Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions tests/test_schemas_base.py
Original file line number Diff line number Diff line change
@@ -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"}
37 changes: 36 additions & 1 deletion toggl_python/api.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand All @@ -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
17 changes: 8 additions & 9 deletions toggl_python/entities/report_time_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Loading