diff --git a/pyproject.toml b/pyproject.toml index c4f7d57..d3ad1b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,10 +86,13 @@ ignore = [ "tests/*" = [ "S101", "S106", # Possible hardcoded password assigned to argument + "SLF001", # Private member accessed - needed to unit test ApiWrapper's protected helpers ] # Move standard/third-party library import into a type-checking block # Necessary for valid Pydantic schemas "toggl_python/schemas/*" = ["TCH002", "TCH003"] +# httpx.Client.request() itself types `json` and other passthrough kwargs as `Any` +"toggl_python/api.py" = ["ANN401"] [tool.ruff.lint.isort] lines-after-imports = 2 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..4081451 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from httpx import Response as HttpxResponse +from pydantic import BaseModel +from toggl_python.api import ApiWrapper +from toggl_python.auth import TokenAuth +from toggl_python.exceptions import BadRequest + +from tests.responses.me_get import FAKE_TOKEN + + +if TYPE_CHECKING: + from respx import MockRouter + + +class SampleSchema(BaseModel): + id: int + name: str + + +@pytest.fixture() +def api_wrapper() -> ApiWrapper: + auth = TokenAuth(token=FAKE_TOKEN) + + return ApiWrapper(auth=auth) + + +def test_request_and_validate(response_mock: MockRouter, api_wrapper: ApiWrapper) -> None: + payload = {"id": 1, "name": "sample"} + mocked_route = response_mock.get("/sample").mock( + return_value=HttpxResponse(status_code=200, json=payload), + ) + + result = api_wrapper._request_and_validate("GET", "/sample", SampleSchema) + + assert mocked_route.called is True + assert result == SampleSchema.model_validate(payload) + + +def test_request_and_validate_list(response_mock: MockRouter, api_wrapper: ApiWrapper) -> None: + payload = [{"id": 1, "name": "first"}, {"id": 2, "name": "second"}] + mocked_route = response_mock.get("/sample").mock( + return_value=HttpxResponse(status_code=200, json=payload), + ) + + result = api_wrapper._request_and_validate_list("GET", "/sample", SampleSchema) + + assert mocked_route.called is True + assert result == [SampleSchema.model_validate(item) for item in payload] + + +def test_request_and_check_success(response_mock: MockRouter, api_wrapper: ApiWrapper) -> None: + mocked_route = response_mock.delete("/sample/1").mock( + return_value=HttpxResponse(status_code=200, json={}), + ) + + result = api_wrapper._request_and_check_success("DELETE", "/sample/1") + + assert mocked_route.called is True + assert result is True + + +def test_request_raises_bad_request_on_error_status( + response_mock: MockRouter, api_wrapper: ApiWrapper +) -> None: + error_message = "Bad request text" + mocked_route = response_mock.get("/sample").mock( + return_value=HttpxResponse(status_code=400, text=error_message), + ) + + with pytest.raises(BadRequest, match=error_message): + _ = api_wrapper._request("GET", "/sample") + + assert mocked_route.called is True diff --git a/tests/test_schemas_base.py b/tests/test_schemas_base.py new file mode 100644 index 0000000..f93d67f --- /dev/null +++ b/tests/test_schemas_base.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Optional + +from toggl_python.schemas.base import BaseSchema, dump_payload + + +class SamplePayloadSchema(BaseSchema): + a: Optional[int] = None + b: str = "default" + + +def test_dump_payload__excludes_none_fields() -> None: + schema = SamplePayloadSchema(a=None, b="value") + + assert dump_payload(schema) == {"b": "value"} + + +def test_dump_payload__keeps_unset_fields_with_non_none_default_by_default() -> None: + schema = SamplePayloadSchema(a=1) + + assert dump_payload(schema) == {"a": 1, "b": "default"} + + +def test_dump_payload__exclude_unset_drops_fields_not_explicitly_passed() -> None: + schema = SamplePayloadSchema(a=1) + + assert dump_payload(schema, exclude_unset=True) == {"a": 1} + + +def test_dump_payload__exclude_unset_keeps_explicitly_passed_default_value() -> None: + schema = SamplePayloadSchema(a=1, b="default") + + assert dump_payload(schema, exclude_unset=True) == {"a": 1, "b": "default"} diff --git a/toggl_python/api.py b/toggl_python/api.py index feed985..e226a5f 100644 --- a/toggl_python/api.py +++ b/toggl_python/api.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, List, Type, TypeVar from httpx import Client, HTTPStatusError, Response +from pydantic import BaseModel from toggl_python.exceptions import BadRequest @@ -13,6 +14,8 @@ COMMON_HEADERS: dict[str, str] = {"content-type": "application/json"} ROOT_URL: str = "https://api.track.toggl.com/api/v9" +ResponseSchema = TypeVar("ResponseSchema", bound=BaseModel) + class ApiWrapper: def __init__(self, auth: BasicAuth | TokenAuth, base_url: str = ROOT_URL) -> None: @@ -29,3 +32,35 @@ def raise_for_status(self, response: Response) -> None: _ = response.raise_for_status() except HTTPStatusError as base_exception: raise BadRequest(base_exception.response.text) from None + + def _request(self, method: str, url: str, **kwargs: Any) -> Response: + """Perform HTTP request and raise an exception on non-2xx status.""" + response = self.client.request(method, url, **kwargs) + self.raise_for_status(response) + + return response + + def _request_and_validate( + self, method: str, url: str, schema: Type[ResponseSchema], **kwargs: Any + ) -> ResponseSchema: + """Perform request and validate response body against a single `schema`.""" + response = self._request(method, url, **kwargs) + + return schema.model_validate(response.json()) + + def _request_and_validate_list( + self, method: str, url: str, schema: Type[ResponseSchema], **kwargs: Any + ) -> List[ResponseSchema]: + """Perform request and validate every item of response body against `schema`.""" + response = self._request(method, url, **kwargs) + + return [schema.model_validate(item) for item in response.json()] + + def _request_and_check_success(self, method: str, url: str, **kwargs: Any) -> bool: + """Perform request and return whether it was successful. + + Used for endpoints whose response body carries no useful data. + """ + response = self._request(method, url, **kwargs) + + return response.is_success diff --git a/toggl_python/entities/report_time_entry.py b/toggl_python/entities/report_time_entry.py index 9d17ee1..24799c8 100644 --- a/toggl_python/entities/report_time_entry.py +++ b/toggl_python/entities/report_time_entry.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, List, Optional, Union from toggl_python.api import ApiWrapper +from toggl_python.schemas.base import dump_payload from toggl_python.schemas.report_time_entry import ( SearchReportTimeEntriesRequest, SearchReportTimeEntriesResponse, @@ -49,13 +50,11 @@ def search( page_size=page_size, first_row_number=first_row_number, ) - payload = payload_schema.model_dump(mode="json", exclude_none=True, exclude_unset=True) + payload = dump_payload(payload_schema, exclude_unset=True) - response = self.client.post(url=f"/{workspace_id}/search/time_entries", json=payload) - self.raise_for_status(response) - - response_body = response.json() - return [ - SearchReportTimeEntriesResponse.model_validate(report_time_entry_data) - for report_time_entry_data in response_body - ] + return self._request_and_validate_list( + "POST", + f"/{workspace_id}/search/time_entries", + SearchReportTimeEntriesResponse, + json=payload, + ) diff --git a/toggl_python/entities/user.py b/toggl_python/entities/user.py index 1e34a69..9df8b98 100644 --- a/toggl_python/entities/user.py +++ b/toggl_python/entities/user.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, List, Optional, Union from toggl_python.api import ApiWrapper +from toggl_python.schemas.base import dump_payload from toggl_python.schemas.current_user import ( DateFormat, DurationFormat, @@ -39,23 +40,18 @@ class CurrentUser(ApiWrapper): prefix: str = "/me" def logged(self) -> bool: - response = self.client.get(url=f"{self.prefix}/logged") - self.raise_for_status(response) - # Returns 200 OK and empty response body - return response.is_success + return self._request_and_check_success("GET", f"{self.prefix}/logged") def me(self, with_related_data: bool = False) -> MeResponse: response_schema = MeResponseWithRelatedData if with_related_data else MeResponse - response = self.client.get( - url=self.prefix, + + return self._request_and_validate( + "GET", + self.prefix, + response_schema, params={"with_related_data": with_related_data}, ) - self.raise_for_status(response) - - response_body = response.json() - - return response_schema.model_validate(response_body) def update_me( self, @@ -78,13 +74,9 @@ def update_me( fullname=fullname, timezone=timezone, ) - payload = payload_schema.model_dump(mode="json", exclude_none=True, exclude_unset=True) + payload = dump_payload(payload_schema, exclude_unset=True) - response = self.client.put(url=self.prefix, json=payload) - self.raise_for_status(response) - - response_body = response.json() - return UpdateMeResponse.model_validate(response_body) + return self._request_and_validate("PUT", self.prefix, UpdateMeResponse, json=payload) def change_password(self, current_password: str, new_password: str) -> bool: """Validate and change user password. @@ -97,27 +89,17 @@ def change_password(self, current_password: str, new_password: str) -> bool: ) payload = payload_schema.model_dump_json() - response = self.client.put(url=self.prefix, content=payload) - self.raise_for_status(response) - - return response.is_success + return self._request_and_check_success("PUT", self.prefix, content=payload) def features(self) -> List[MeFeaturesResponse]: - response = self.client.get(url=f"{self.prefix}/features") - self.raise_for_status(response) - response_body = response.json() - - return [ - MeFeaturesResponse.model_validate(workspace_features) - for workspace_features in response_body - ] + return self._request_and_validate_list( + "GET", f"{self.prefix}/features", MeFeaturesResponse + ) def preferences(self) -> MePreferencesResponse: - response = self.client.get(url=f"{self.prefix}/preferences") - self.raise_for_status(response) - response_body = response.json() - - return MePreferencesResponse.model_validate(response_body) + return self._request_and_validate( + "GET", f"{self.prefix}/preferences", MePreferencesResponse + ) def update_preferences( self, @@ -136,10 +118,9 @@ def update_preferences( ) payload = payload_schema.model_dump_json(exclude_none=True, exclude_unset=True) - response = self.client.post(url=f"{self.prefix}/preferences", content=payload) - self.raise_for_status(response) - - return response.is_success + return self._request_and_check_success( + "POST", f"{self.prefix}/preferences", content=payload + ) def get_time_entry( self, time_entry_id: int, meta: bool = False @@ -149,23 +130,20 @@ def get_time_entry( Tested responses do not differ from requests with `include_sharing=false` that is why there is no `include_sharing` method argument. """ - response = self.client.get( - url=f"{self.prefix}/time_entries/{time_entry_id}", - params={"meta": meta}, - ) - self.raise_for_status(response) - response_schema = MeTimeEntryWithMetaResponse if meta else MeTimeEntryResponse - response_body = response.json() - return response_schema.model_validate(response_body) + return self._request_and_validate( + "GET", + f"{self.prefix}/time_entries/{time_entry_id}", + response_schema, + params={"meta": meta}, + ) def get_current_time_entry(self) -> Optional[MeTimeEntryResponse]: """Return empty response if there is no running TimeEntry.""" - response = self.client.get(url=f"{self.prefix}/time_entries/current") - self.raise_for_status(response) - + response = self._request("GET", f"{self.prefix}/time_entries/current") response_body = response.json() + return MeTimeEntryResponse.model_validate(response_body) if response_body else None def get_time_entries( @@ -188,22 +166,16 @@ def get_time_entries( start_date=start_date, end_date=end_date, ) - payload = payload_schema.model_dump(mode="json", exclude_none=True) - - response = self.client.get(url=f"{self.prefix}/time_entries", params=payload) - self.raise_for_status(response) + payload = dump_payload(payload_schema) response_schema = MeTimeEntryWithMetaResponse if meta else MeTimeEntryResponse - response_body = response.json() - return [response_schema.model_validate(time_entry) for time_entry in response_body] + return self._request_and_validate_list( + "GET", f"{self.prefix}/time_entries", response_schema, params=payload + ) def get_web_timer(self) -> MeWebTimerResponse: - response = self.client.get(url=f"{self.prefix}/web-timer") - self.raise_for_status(response) - - response_body = response.json() - return MeWebTimerResponse.model_validate(response_body) + return self._request_and_validate("GET", f"{self.prefix}/web-timer", MeWebTimerResponse) def get_projects( self, @@ -211,13 +183,11 @@ def get_projects( since: Union[int, datetime, None] = None, ) -> List[ProjectResponse]: payload_schema = MeProjectsQueryParams(include_archived=include_archived, since=since) - payload = payload_schema.model_dump(mode="json", exclude_none=True) + payload = dump_payload(payload_schema) - response = self.client.get(url=f"{self.prefix}/projects", params=payload) - self.raise_for_status(response) - - response_body = response.json() - return [ProjectResponse.model_validate(project) for project in response_body] + return self._request_and_validate_list( + "GET", f"{self.prefix}/projects", ProjectResponse, params=payload + ) def get_paginated_projects( self, @@ -228,10 +198,8 @@ def get_paginated_projects( query_params_schema = MePaginatedProjectsQueryParams( since=since, start_project_id=start_project_id, per_page=per_page ) - query_params = query_params_schema.model_dump(mode="json", exclude_none=True) - - response = self.client.get(url=f"{self.prefix}/projects/paginated", params=query_params) - self.raise_for_status(response) + query_params = dump_payload(query_params_schema) - response_body = response.json() - return [ProjectResponse.model_validate(project) for project in response_body] + return self._request_and_validate_list( + "GET", f"{self.prefix}/projects/paginated", ProjectResponse, params=query_params + ) diff --git a/toggl_python/entities/workspace.py b/toggl_python/entities/workspace.py index c5740c0..dc192f1 100644 --- a/toggl_python/entities/workspace.py +++ b/toggl_python/entities/workspace.py @@ -7,6 +7,7 @@ BulkEditMethodParams, BulkEditOperation, BulkEditResponse, + dump_payload, ) from toggl_python.schemas.project import CreateProjectRequest, ProjectQueryParams, ProjectResponse from toggl_python.schemas.time_entry import ( @@ -29,25 +30,17 @@ class Workspace(ApiWrapper): prefix: str = "/workspaces" def get(self, workspace_id: int) -> WorkspaceResponse: - response = self.client.get(url=f"{self.prefix}/{workspace_id}") - self.raise_for_status(response) - - response_body = response.json() - - return WorkspaceResponse.model_validate(response_body) + return self._request_and_validate( + "GET", f"{self.prefix}/{workspace_id}", WorkspaceResponse + ) def list(self, since: Union[int, datetime, None] = None) -> List[WorkspaceResponse]: payload_schema = GetWorkspacesQueryParams(since=since) - params = payload_schema.model_dump(mode="json", exclude_none=True) - - response = self.client.get(url=self.prefix, params=params) - self.raise_for_status(response) - - response_body = response.json() + params = dump_payload(payload_schema) - return [ - WorkspaceResponse.model_validate(workspace_data) for workspace_data in response_body - ] + return self._request_and_validate_list( + "GET", self.prefix, WorkspaceResponse, params=params + ) def update( self, @@ -72,15 +65,11 @@ def update( reports_collapse=reports_collapse, name=name, ) - request_body = request_body_schema.model_dump( - mode="json", exclude_none=True, exclude_unset=True - ) + request_body = dump_payload(request_body_schema, exclude_unset=True) - response = self.client.put(url=f"{self.prefix}/{workspace_id}", json=request_body) - self.raise_for_status(response) - - response_body = response.json() - return WorkspaceResponse.model_validate(response_body) + return self._request_and_validate( + "PUT", f"{self.prefix}/{workspace_id}", WorkspaceResponse, json=request_body + ) def create_project( self, @@ -118,25 +107,16 @@ def create_project( name=name, start_date=start_date, ) - request_body = request_body_schema.model_dump( - mode="json", exclude_none=True, exclude_unset=True - ) + request_body = dump_payload(request_body_schema, exclude_unset=True) - response = self.client.post( - url=f"{self.prefix}/{workspace_id}/projects", json=request_body + return self._request_and_validate( + "POST", f"{self.prefix}/{workspace_id}/projects", ProjectResponse, json=request_body ) - self.raise_for_status(response) - - response_body = response.json() - return ProjectResponse.model_validate(response_body) def get_project(self, workspace_id: int, project_id: int) -> ProjectResponse: - response = self.client.get(url=f"{self.prefix}/{workspace_id}/projects/{project_id}") - self.raise_for_status(response) - - response_body = response.json() - - return ProjectResponse.model_validate(response_body) + return self._request_and_validate( + "GET", f"{self.prefix}/{workspace_id}/projects/{project_id}", ProjectResponse + ) def get_projects( # noqa: PLR0913 - Too many arguments in function definition (15 > 12) self, @@ -172,14 +152,11 @@ def get_projects( # noqa: PLR0913 - Too many arguments in function definition ( only_templates=only_templates, only_me=only_me, ) - payload = payload_schema.model_dump(mode="json", exclude_none=True) - - response = self.client.get(url=f"{self.prefix}/{workspace_id}/projects", params=payload) - self.raise_for_status(response) + payload = dump_payload(payload_schema) - response_body = response.json() - - return [ProjectResponse.model_validate(project_data) for project_data in response_body] + return self._request_and_validate_list( + "GET", f"{self.prefix}/{workspace_id}/projects", ProjectResponse, params=payload + ) def update_project( # noqa: PLR0913 - Too many arguments in function definition self, @@ -222,17 +199,14 @@ def update_project( # noqa: PLR0913 - Too many arguments in function definition template=template, template_id=template_id, ) - request_body = request_body_schema.model_dump( - mode="json", exclude_none=True, exclude_unset=True - ) + request_body = dump_payload(request_body_schema, exclude_unset=True) - response = self.client.put( - url=f"{self.prefix}/{workspace_id}/projects/{project_id}", json=request_body + return self._request_and_validate( + "PUT", + f"{self.prefix}/{workspace_id}/projects/{project_id}", + ProjectResponse, + json=request_body, ) - self.raise_for_status(response) - - response_body = response.json() - return ProjectResponse.model_validate(response_body) def bulk_edit_projects( self, @@ -249,24 +223,19 @@ def bulk_edit_projects( validated_args_schema = BulkEditMethodParams(ids=project_ids, operations=operations) validated_args = validated_args_schema.model_dump(mode="json") ids = validated_args["ids"] - request_body = [ - operation.model_dump(mode="json", exclude_none=True) for operation in operations - ] + request_body = [dump_payload(operation) for operation in operations] - response = self.client.patch( - url=f"{self.prefix}/{workspace_id}/projects/{ids}", json=request_body + return self._request_and_validate( + "PATCH", + f"{self.prefix}/{workspace_id}/projects/{ids}", + BulkEditResponse, + json=request_body, ) - self.raise_for_status(response) - - response_body = response.json() - - return BulkEditResponse.model_validate(response_body) def delete_project(self, workspace_id: int, project_id: int) -> bool: - response = self.client.delete(url=f"{self.prefix}/{workspace_id}/projects/{project_id}") - self.raise_for_status(response) - - return response.is_success + return self._request_and_check_success( + "DELETE", f"{self.prefix}/{workspace_id}/projects/{project_id}" + ) def create_time_entry( self, @@ -297,18 +266,14 @@ def create_time_entry( task_id=task_id, user_id=user_id, ) - request_body = request_body_schema.model_dump( - mode="json", exclude_none=True, exclude_unset=True - ) + request_body = dump_payload(request_body_schema, exclude_unset=True) - response = self.client.post( - url=f"{self.prefix}/{workspace_id}/time_entries", json=request_body + return self._request_and_validate( + "POST", + f"{self.prefix}/{workspace_id}/time_entries", + MeTimeEntryResponse, + json=request_body, ) - self.raise_for_status(response) - - response_body = response.json() - - return MeTimeEntryResponse.model_validate(response_body) def update_time_entry( # noqa: PLR0913 - Too many arguments in function definition (13 > 12) self, @@ -340,24 +305,19 @@ def update_time_entry( # noqa: PLR0913 - Too many arguments in function definit task_id=task_id, user_id=user_id, ) - request_body = request_body_schema.model_dump(mode="json", exclude_none=True) + request_body = dump_payload(request_body_schema) - response = self.client.put( - url=f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}", json=request_body + return self._request_and_validate( + "PUT", + f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}", + MeTimeEntryResponse, + json=request_body, ) - self.raise_for_status(response) - - response_body = response.json() - - return MeTimeEntryResponse.model_validate(response_body) def delete_time_entry(self, workspace_id: int, time_entry_id: int) -> bool: - response = self.client.delete( - url=f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}" + return self._request_and_check_success( + "DELETE", f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}" ) - self.raise_for_status(response) - - return response.is_success def bulk_edit_time_entries( self, @@ -369,25 +329,18 @@ def bulk_edit_time_entries( validated_args = validated_args_schema.model_dump(mode="json") ids = validated_args["ids"] - request_body = [ - operation.model_dump(mode="json", exclude_none=True) for operation in operations - ] + request_body = [dump_payload(operation) for operation in operations] - response = self.client.patch( - url=f"{self.prefix}/{workspace_id}/time_entries/{ids}", json=request_body + return self._request_and_validate( + "PATCH", + f"{self.prefix}/{workspace_id}/time_entries/{ids}", + BulkEditResponse, + json=request_body, ) - self.raise_for_status(response) - - response_body = response.json() - - return BulkEditResponse.model_validate(response_body) def stop_time_entry(self, workspace_id: int, time_entry_id: int) -> MeTimeEntryResponse: - response = self.client.patch( - url=f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}/stop" + return self._request_and_validate( + "PATCH", + f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}/stop", + MeTimeEntryResponse, ) - self.raise_for_status(response) - - response_body = response.json() - - return MeTimeEntryResponse.model_validate(response_body) diff --git a/toggl_python/schemas/base.py b/toggl_python/schemas/base.py index fe06849..c108590 100644 --- a/toggl_python/schemas/base.py +++ b/toggl_python/schemas/base.py @@ -18,6 +18,16 @@ class BaseSchema(BaseModel): pass +def dump_payload(schema: BaseSchema, *, exclude_unset: bool = False) -> Dict[str, object]: + """Serialize a query params / request body schema the way entity methods need it. + + `None` fields are always dropped since the API treats an omitted field + differently from an explicit `null`. `exclude_unset` additionally drops + fields that were not passed at all, which matters for partial PUT/PATCH bodies. + """ + return schema.model_dump(mode="json", exclude_none=True, exclude_unset=exclude_unset) + + class SinceParamSchemaMixin(BaseSchema): since: Optional[AwareDatetime]