From dff40386a22c71fea704b305033809398ae442ab Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Tue, 26 Mar 2024 10:59:07 -0400 Subject: [PATCH] feat: adds query params to find and find_one (#130) --- requirements-dev.txt | 3 +- src/posit/connect/paginator.py | 9 +- src/posit/connect/users.py | 26 +- tests/posit/connect/__api__/v1/users.json | 19 -- ...> users?page_number=1&page_size=500.jsonc} | 6 +- ...> users?page_number=2&page_size=500.jsonc} | 6 +- tests/posit/connect/api.py | 25 +- tests/posit/connect/test_users.py | 224 ++++++++++-------- 8 files changed, 193 insertions(+), 125 deletions(-) delete mode 100644 tests/posit/connect/__api__/v1/users.json rename tests/posit/connect/__api__/v1/{users?page_number=1&page_size=500.json => users?page_number=1&page_size=500.jsonc} (86%) rename tests/posit/connect/__api__/v1/{users?page_number=2&page_size=500.json => users?page_number=2&page_size=500.jsonc} (76%) diff --git a/requirements-dev.txt b/requirements-dev.txt index 17beaabf..ff1a036f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,8 +3,9 @@ coverage mypy pandas pre-commit +pyjson5 pytest responses ruff -setuptools-scm setuptools +setuptools-scm diff --git a/src/posit/connect/paginator.py b/src/posit/connect/paginator.py index 96ee3082..e0271065 100644 --- a/src/posit/connect/paginator.py +++ b/src/posit/connect/paginator.py @@ -37,9 +37,10 @@ class Paginator: url (str): The URL of the paginated API endpoint. """ - def __init__(self, session: requests.Session, url: str) -> None: + def __init__(self, session: requests.Session, url: str, params: dict = {}) -> None: self.session = session self.url = url + self.params = params def fetch_results(self) -> List[dict]: """ @@ -90,6 +91,10 @@ def fetch_page(self, page_number: int) -> Page: Page: The fetched page object. """ - params = {"page_number": page_number, "page_size": _MAX_PAGE_SIZE} + params = { + **self.params, + "page_number": page_number, + "page_size": _MAX_PAGE_SIZE, + } response = self.session.get(self.url, params=params) return Page(**response.json()) diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index 67fcbdaf..ec290451 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -152,8 +152,17 @@ def __init__(self, config: Config, session: requests.Session) -> None: self.config = config self.session = session - def find(self) -> List[User]: - paginator = Paginator(self.session, self.url) + @overload + def find( + self, prefix: str = ..., user_role: str = ..., account_status: str = ... + ) -> List[User]: ... + + @overload + def find(self, *args, **kwargs) -> List[User]: ... + + def find(self, *args, **kwargs): + params = dict(*args, **kwargs) + paginator = Paginator(self.session, self.url, params=params) results = paginator.fetch_results() return [ User( @@ -164,8 +173,17 @@ def find(self) -> List[User]: for user in results ] - def find_one(self) -> User | None: - paginator = Paginator(self.session, self.url) + @overload + def find_one( + self, prefix: str = ..., user_role: str = ..., account_status: str = ... + ) -> User | None: ... + + @overload + def find_one(self, *args, **kwargs) -> User | None: ... + + def find_one(self, *args, **kwargs) -> User | None: + params = dict(*args, **kwargs) + paginator = Paginator(self.session, self.url, params=params) pages = paginator.fetch_pages() results = (result for page in pages for result in page.results) users = ( diff --git a/tests/posit/connect/__api__/v1/users.json b/tests/posit/connect/__api__/v1/users.json deleted file mode 100644 index 7affdbaf..00000000 --- a/tests/posit/connect/__api__/v1/users.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "results": [ - { - "email": "testuser@example.com", - "username": "testuser123", - "first_name": "Test", - "last_name": "User", - "user_role": "tester", - "created_time": "2021-01-01T10:00:00Z", - "updated_time": "2022-03-02T20:25:06Z", - "active_time": "2021-01-01T10:00:00Z", - "confirmed": true, - "locked": false, - "guid": "12345678-abcd-1234-efgh-1234567890ab" - } - ], - "current_page": 1, - "total": 1 -} diff --git a/tests/posit/connect/__api__/v1/users?page_number=1&page_size=500.json b/tests/posit/connect/__api__/v1/users?page_number=1&page_size=500.jsonc similarity index 86% rename from tests/posit/connect/__api__/v1/users?page_number=1&page_size=500.json rename to tests/posit/connect/__api__/v1/users?page_number=1&page_size=500.jsonc index 39ae2c6c..73612594 100644 --- a/tests/posit/connect/__api__/v1/users?page_number=1&page_size=500.json +++ b/tests/posit/connect/__api__/v1/users?page_number=1&page_size=500.jsonc @@ -1,3 +1,7 @@ +// A single page response from the '/v1/users' endpoint. +// +// This file is typically used in conjunction with v1/users?page_number=2&page_size=500.jsonc + { "results": [ { @@ -29,4 +33,4 @@ ], "current_page": 1, "total": 3 -} \ No newline at end of file +} diff --git a/tests/posit/connect/__api__/v1/users?page_number=2&page_size=500.json b/tests/posit/connect/__api__/v1/users?page_number=2&page_size=500.jsonc similarity index 76% rename from tests/posit/connect/__api__/v1/users?page_number=2&page_size=500.json rename to tests/posit/connect/__api__/v1/users?page_number=2&page_size=500.jsonc index 79498e66..c5da6c42 100644 --- a/tests/posit/connect/__api__/v1/users?page_number=2&page_size=500.json +++ b/tests/posit/connect/__api__/v1/users?page_number=2&page_size=500.jsonc @@ -1,3 +1,7 @@ +// A subsequent single page response from the '/v1/users' endpoint. +// +// This file is typically used in conjunction with v1/users?page_number=1&page_size=500.jsonc + { "results": [ { @@ -16,4 +20,4 @@ ], "current_page": 2, "total": 3 -} \ No newline at end of file +} diff --git a/tests/posit/connect/api.py b/tests/posit/connect/api.py index 4188c712..ad89713b 100644 --- a/tests/posit/connect/api.py +++ b/tests/posit/connect/api.py @@ -1,10 +1,31 @@ -import json +import pyjson5 as json from pathlib import Path def load_mock(path: str) -> dict: """ - Read a JSON object from `path` + Load mock data from a file. + + Reads a JSON or JSONC (JSON with Comments) file and returns the parsed data. + + It's primarily used for loading mock data for tests. + + The file names for mock data should match the query path that they represent. + + Parameters + ---------- + path : str + The relative path to the JSONC file. + + Returns + ------- + dict + The parsed data from the JSONC file. + + Examples + -------- + >>> data = load_mock("v1/example.json") + >>> data = load_mock("v1/example.jsonc") """ return json.loads((Path(__file__).parent / "__api__" / path).read_text()) diff --git a/tests/posit/connect/test_users.py b/tests/posit/connect/test_users.py index c61e8c32..b30ce4e6 100644 --- a/tests/posit/connect/test_users.py +++ b/tests/posit/connect/test_users.py @@ -1,6 +1,5 @@ from unittest.mock import Mock -import pandas as pd import pytest import requests import responses @@ -14,7 +13,7 @@ url = Mock() -class TestUser: +class TestUserAttributes: def test_guid(self): user = User(session, url) assert hasattr(user, "guid") @@ -92,6 +91,8 @@ def test_locked(self): user = User(session, url, locked=False) assert user.locked is False + +class TestUserLock: @responses.activate def test_lock(self): responses.get( @@ -156,6 +157,8 @@ def test_lock_self_false(self): user.lock(force=False) assert not user.locked + +class TestUserUnlock: @responses.activate def test_unlock(self): responses.get( @@ -175,97 +178,6 @@ def test_unlock(self): class TestUsers: - @responses.activate - def test_users_find(self): - responses.get( - "https://connect.example/__api__/v1/users", - match=[ - responses.matchers.query_param_matcher( - {"page_size": 500, "page_number": 1} - ) - ], - json=load_mock("v1/users?page_number=1&page_size=500.json"), - ) - responses.get( - "https://connect.example/__api__/v1/users", - match=[ - responses.matchers.query_param_matcher( - {"page_size": 500, "page_number": 2} - ) - ], - json=load_mock("v1/users?page_number=2&page_size=500.json"), - ) - - con = Client(api_key="12345", url="https://connect.example/") - all_users = con.users.find() - assert len(all_users) == 3 - - df = pd.DataFrame(all_users) - assert isinstance(df, pd.DataFrame) - assert df.shape == (3, 11) - assert df.columns.to_list() == [ - "email", - "username", - "first_name", - "last_name", - "user_role", - "created_time", - "updated_time", - "active_time", - "confirmed", - "locked", - "guid", - ] - assert df["username"].to_list() == ["al", "robert", "carlos12"] - - @responses.activate - def test_users_find_one(self): - responses.get( - "https://connect.example/__api__/v1/users", - match=[ - responses.matchers.query_param_matcher( - {"page_size": 500, "page_number": 1} - ) - ], - json=load_mock("v1/users?page_number=1&page_size=500.json"), - ) - responses.get( - "https://connect.example/__api__/v1/users", - match=[ - responses.matchers.query_param_matcher( - {"page_size": 500, "page_number": 2} - ) - ], - json=load_mock("v1/users?page_number=2&page_size=500.json"), - ) - - con = Client(api_key="12345", url="https://connect.example/") - c = con.users.find_one() - assert c.username == "al" - - @responses.activate - def test_users_find_one_only_gets_necessary_pages(self): - responses.get( - "https://connect.example/__api__/v1/users", - json=load_mock("v1/users?page_number=1&page_size=500.json"), - ) - - con = Client(api_key="12345", url="https://connect.example/") - user = con.users.find_one() - assert user.username == "al" - assert len(responses.calls) == 1 - - @responses.activate - def test_users_find_one_finds_nothing(self): - responses.get( - "https://connect.example/__api__/v1/users", - json={"total": 0, "current_page": 1, "results": []}, - ) - - con = Client(api_key="12345", url="https://connect.example/") - user = con.users.find_one() - assert user is None - @responses.activate def test_users_get(self): responses.get( @@ -354,9 +266,131 @@ def test_user_cant_setattr(self): def test_count(self): responses.get( "https://connect.example/__api__/v1/users", - json=load_mock("v1/users.json"), + json=load_mock("v1/users?page_number=1&page_size=500.jsonc"), match=[responses.matchers.query_param_matcher({"page_size": 1})], ) con = Client(api_key="12345", url="https://connect.example/") count = con.users.count() - assert count == 1 + assert count == 3 + + +class TestUsersFindOne: + @responses.activate + def test_default(self): + # validate first result returned + responses.get( + "https://connect.example/__api__/v1/users", + json=load_mock("v1/users?page_number=1&page_size=500.jsonc"), + ) + con = Client(api_key="12345", url="https://connect.example/") + user = con.users.find_one() + assert user.username == "al" + assert len(responses.calls) == 1 + + @responses.activate + def test_params(self): + # validate input params are propagated to the query params + params = {"key1": "value1", "key2": "value2", "key3": "value3"} + responses.get( + "https://connect.example/__api__/v1/users", + match=[ + responses.matchers.query_param_matcher( + {"page_size": 500, "page_number": 1, **params} + ) + ], + json=load_mock("v1/users?page_number=1&page_size=500.jsonc"), + ) + con = Client(api_key="12345", url="https://connect.example/") + con.users.find_one(**params) + responses.assert_call_count( + "https://connect.example/__api__/v1/users?key1=value1&key2=value2&key3=value3&page_number=1&page_size=500", + 1, + ) + + @responses.activate + def test_empty_results(self): + responses.get( + "https://connect.example/__api__/v1/users", + json={"total": 0, "current_page": 1, "results": []}, + ) + + con = Client(api_key="12345", url="https://connect.example/") + user = con.users.find_one() + assert user is None + + +class TestUsersFind: + @responses.activate + def test_default(self): + # validate response body is parsed and returned + responses.get( + "https://connect.example/__api__/v1/users", + match=[ + responses.matchers.query_param_matcher( + {"page_size": 500, "page_number": 1} + ) + ], + json=load_mock("v1/users?page_number=1&page_size=500.jsonc"), + ) + responses.get( + "https://connect.example/__api__/v1/users", + match=[ + responses.matchers.query_param_matcher( + {"page_size": 500, "page_number": 2} + ) + ], + json=load_mock("v1/users?page_number=2&page_size=500.jsonc"), + ) + con = Client(api_key="12345", url="https://connect.example/") + users = con.users.find() + assert len(users) == 3 + assert users[0] == { + "email": "alice@connect.example", + "username": "al", + "first_name": "Alice", + "last_name": "User", + "user_role": "publisher", + "created_time": "2017-08-08T15:24:32Z", + "updated_time": "2023-03-02T20:25:06Z", + "active_time": "2018-05-09T16:58:45Z", + "confirmed": True, + "locked": False, + "guid": "a01792e3-2e67-402e-99af-be04a48da074", + } + + @responses.activate + def test_params(self): + # validate input params are propagated to the query params + params = {"key1": "value1", "key2": "value2", "key3": "value3"} + responses.get( + "https://connect.example/__api__/v1/users", + match=[ + responses.matchers.query_param_matcher( + {"page_size": 500, "page_number": 1, **params} + ) + ], + json=load_mock("v1/users?page_number=1&page_size=500.jsonc"), + ) + responses.get( + "https://connect.example/__api__/v1/users", + match=[ + responses.matchers.query_param_matcher( + {"page_size": 500, "page_number": 2, **params} + ) + ], + json=load_mock("v1/users?page_number=2&page_size=500.jsonc"), + ) + con = Client(api_key="12345", url="https://connect.example/") + con.users.find(**params) + responses.assert_call_count( + "https://connect.example/__api__/v1/users?key1=value1&key2=value2&key3=value3&page_number=1&page_size=500", + 1, + ) + + @responses.activate + def test_params_not_dict_like(self): + # validate input params are propagated to the query params + con = Client(api_key="12345", url="https://connect.example/") + not_dict_like = "string" + with pytest.raises(ValueError): + con.users.find(not_dict_like)