diff --git a/CHANGELOG.md b/CHANGELOG.md index 2670d3f..e0f20e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `TodoistAPIAsync` now performs true async HTTP I/O with `httpx.AsyncClient`. + +### Changed + +- **Breaking**: `TodoistAPI` now accepts an optional `client: httpx.Client` instead of `session: requests.Session`. +- **Breaking**: `TodoistAPIAsync` now accepts an optional `client: httpx.AsyncClient` instead of `session: requests.Session`. +- **Breaking**: API errors now raise `httpx.HTTPStatusError` instead of `requests.exceptions.HTTPError`. +- **Breaking**: Authentication helpers now accept optional `httpx.Client` / `httpx.AsyncClient` instances instead of `session: requests.Session`. + ## [3.2.1] - 2026-01-22 ### Fixed diff --git a/README.md b/README.md index 8f0bcdb..0cc9db6 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,30 @@ for comments in comments_iter: print(f"Comment: {comment.content}") ``` +### Async usage + +Always close `TodoistAPIAsync` explicitly, either via `async with` (recommended) or by calling `await api.close()`. + +```python +from todoist_api_python.api_async import TodoistAPIAsync + +async with TodoistAPIAsync("YOUR_API_TOKEN") as api: + task = await api.get_task("6X4Vw2Hfmg73Q2XR") + print(task.content) +``` + ## Documentation For more detailed reference documentation, have a look at the [SDK documentation](https://doist.github.io/todoist-api-python/) and the [API documentation](https://developer.todoist.com). +## Migrating from 3.x + +Version `4.x` introduces a breaking HTTP stack migration from `requests` to `httpx`. + +- `TodoistAPI(..., session=...)` is now `TodoistAPI(..., client=...)` with `httpx.Client`. +- `TodoistAPIAsync(..., session=...)` is now `TodoistAPIAsync(..., client=...)` with `httpx.AsyncClient`. +- Error handling should catch `httpx.HTTPStatusError` instead of `requests.exceptions.HTTPError`. + ## Development To install Python dependencies: diff --git a/docs/authentication.md b/docs/authentication.md index 6902e8a..1920353 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,36 +1,37 @@ # Authentication -This module provides functions to help authenticate with Todoist using the OAuth protocol. +This module provides helpers to authenticate with Todoist using OAuth. ## Quick start ```python import uuid -from todoist_api_python.authentication import get_access_token, get_authentication_url + +from todoist_api_python.authentication import get_auth_token, get_authentication_url # 1. Generate a random state -state = uuid.uuid4() +state = str(uuid.uuid4()) -# 2. Get authorization url +# 2. Build the authorization URL url = get_authentication_url( client_id="YOUR_CLIENT_ID", scopes=["data:read", "task:add"], - state=uuid.uuid4() + state=state, ) -# 3.Redirect user to url -# 4. Handle OAuth callback and get code +# 3. Redirect the user to `url` +# 4. Handle the OAuth callback and obtain the auth code code = "CODE_YOU_OBTAINED" -# 5. Exchange code for access token -auth_result = get_access_token( +# 5. Exchange code for an access token +auth_result = get_auth_token( client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", code=code, ) # 6. Ensure state is consistent, and done! -assert(auth_result.state == state) +assert auth_result.state == state access_token = auth_result.access_token ``` diff --git a/docs/index.md b/docs/index.md index d18df0f..382a485 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,6 +34,11 @@ for comments in comments_iter: print(f"Comment: {comment.content}") ``` +### Async usage + +Use `TodoistAPIAsync` with `async with` (or call `await api.close()` manually) +to ensure the underlying `httpx.AsyncClient` is closed. + ## Quick start - [Authentication](authentication.md) diff --git a/pyproject.toml b/pyproject.toml index cac9b02..390de7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ classifiers = [ ] dependencies = [ - "requests>=2.32.3,<3", + "httpx>=0.28.1,<1", "dataclass-wizard>=0.35.0,<1.0", "annotated-types", ] @@ -32,8 +32,7 @@ dev = [ "tox-uv>=1.25.0,<2", "mypy~=1.11", "ruff>=0.11.0,<0.12", - "responses>=0.25.3,<0.26", - "types-requests~=2.32", + "respx>=0.22.0,<0.23", ] docs = [ diff --git a/tests/conftest.py b/tests/conftest.py index 1e3864a..42a8cd1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any import pytest -import responses +import pytest_asyncio from tests.data.test_defaults import ( DEFAULT_AUTH_RESPONSE, @@ -15,6 +15,7 @@ DEFAULT_LABELS_RESPONSE, DEFAULT_PROJECT_RESPONSE, DEFAULT_PROJECTS_RESPONSE, + DEFAULT_REQUEST_ID, DEFAULT_SECTION_RESPONSE, DEFAULT_SECTIONS_RESPONSE, DEFAULT_TASK_META_RESPONSE, @@ -37,23 +38,25 @@ ) if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import AsyncIterator, Iterator @pytest.fixture -def requests_mock() -> Iterator[responses.RequestsMock]: - with responses.RequestsMock() as requests_mock: - yield requests_mock +def todoist_api() -> Iterator[TodoistAPI]: + with TodoistAPI( + DEFAULT_TOKEN, + request_id_fn=lambda: DEFAULT_REQUEST_ID, + ) as api: + yield api -@pytest.fixture -def todoist_api() -> TodoistAPI: - return TodoistAPI(DEFAULT_TOKEN) - - -@pytest.fixture -def todoist_api_async() -> TodoistAPIAsync: - return TodoistAPIAsync(DEFAULT_TOKEN) +@pytest_asyncio.fixture +async def todoist_api_async() -> AsyncIterator[TodoistAPIAsync]: + async with TodoistAPIAsync( + DEFAULT_TOKEN, + request_id_fn=lambda: DEFAULT_REQUEST_ID, + ) as api: + yield api @pytest.fixture diff --git a/tests/test_api_async_client.py b/tests/test_api_async_client.py new file mode 100644 index 0000000..38b06b1 --- /dev/null +++ b/tests/test_api_async_client.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import asyncio +import warnings + +from tests.data.test_defaults import DEFAULT_TOKEN +from todoist_api_python.api_async import TodoistAPIAsync + + +def test_warns_if_async_client_is_not_closed() -> None: + api = TodoistAPIAsync(DEFAULT_TOKEN) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", ResourceWarning) + api.__del__() + + assert any(item.category is ResourceWarning for item in caught) + + asyncio.run(api.close()) diff --git a/tests/test_api_comments.py b/tests/test_api_comments.py index 53b888f..c2bcf72 100644 --- a/tests/test_api_comments.py +++ b/tests/test_api_comments.py @@ -3,22 +3,17 @@ from typing import TYPE_CHECKING, Any import pytest -import responses from tests.data.test_defaults import ( DEFAULT_API_URL, PaginatedResults, ) -from tests.utils.test_utils import ( - auth_matcher, - data_matcher, - enumerate_async, - param_matcher, - request_id_matcher, -) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route from todoist_api_python.models import Attachment if TYPE_CHECKING: + import respx + from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync @@ -29,29 +24,30 @@ async def test_get_comment( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_comment_response: dict[str, Any], default_comment: Comment, ) -> None: comment_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/comments/{comment_id}" - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=default_comment_response, - status=200, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_json=default_comment_response, + response_status=200, ) comment = todoist_api.get_comment(comment_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert comment == default_comment comment = await todoist_api_async.get_comment(comment_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert comment == default_comment @@ -59,7 +55,7 @@ async def test_get_comment( async def test_get_comments( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_comments_response: list[PaginatedResults], default_comments_list: list[list[Comment]], ) -> None: @@ -68,16 +64,15 @@ async def test_get_comments( cursor: str | None = None for page in default_comments_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - param_matcher({"task_id": task_id}, cursor), - ], + request_params={"task_id": task_id} + | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -86,14 +81,14 @@ async def test_get_comments( comments_iter = todoist_api.get_comments(task_id=task_id) for i, comments in enumerate(comments_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert comments == default_comments_list[i] count += 1 comments_async_iter = await todoist_api_async.get_comments(task_id=task_id) async for i, comments in enumerate_async(comments_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert comments == default_comments_list[i] count += 1 @@ -102,7 +97,7 @@ async def test_get_comments( async def test_add_comment( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_comment_response: dict[str, Any], default_comment: Comment, ) -> None: @@ -115,22 +110,18 @@ async def test_add_comment( file_name="File.pdf", ) - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/comments", - json=default_comment_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher( - { - "content": content, - "project_id": project_id, - "attachment": attachment.to_dict(), - } - ), - ], + request_headers=api_headers(), + request_json={ + "content": content, + "project_id": project_id, + "attachment": attachment.to_dict(), + }, + response_json=default_comment_response, + response_status=200, ) new_comment = todoist_api.add_comment( @@ -139,7 +130,7 @@ async def test_add_comment( attachment=attachment, ) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert new_comment == default_comment new_comment = await todoist_api_async.add_comment( @@ -148,7 +139,7 @@ async def test_add_comment( attachment=attachment, ) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert new_comment == default_comment @@ -156,7 +147,7 @@ async def test_add_comment( async def test_update_comment( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_comment: Comment, ) -> None: args = { @@ -164,24 +155,26 @@ async def test_update_comment( } updated_comment_dict = default_comment.to_dict() | args - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/comments/{default_comment.id}", - json=updated_comment_dict, - status=200, - match=[auth_matcher(), request_id_matcher(), data_matcher(args)], + request_headers=api_headers(), + request_json=args, + response_json=updated_comment_dict, + response_status=200, ) response = todoist_api.update_comment(comment_id=default_comment.id, **args) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response == Comment.from_dict(updated_comment_dict) response = await todoist_api_async.update_comment( comment_id=default_comment.id, **args ) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response == Comment.from_dict(updated_comment_dict) @@ -189,24 +182,25 @@ async def test_update_comment( async def test_delete_comment( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: comment_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/comments/{comment_id}" - requests_mock.add( - method=responses.DELETE, + mock_route( + respx_mock, + method="DELETE", url=endpoint, - status=204, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_status=204, ) response = todoist_api.delete_comment(comment_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response is True response = await todoist_api_async.delete_comment(comment_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response is True diff --git a/tests/test_api_completed_tasks.py b/tests/test_api_completed_tasks.py index 66faa09..eec1bc8 100644 --- a/tests/test_api_completed_tasks.py +++ b/tests/test_api_completed_tasks.py @@ -10,18 +10,14 @@ UTC = timezone.utc import pytest -import responses from tests.data.test_defaults import DEFAULT_API_URL, PaginatedItems -from tests.utils.test_utils import ( - auth_matcher, - enumerate_async, - param_matcher, - request_id_matcher, -) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route from todoist_api_python._core.utils import format_datetime if TYPE_CHECKING: + import respx + from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Task @@ -31,7 +27,7 @@ async def test_get_completed_tasks_by_due_date( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_completed_tasks_response: list[PaginatedItems], default_completed_tasks_list: list[list[Task]], ) -> None: @@ -51,12 +47,14 @@ async def test_get_completed_tasks_by_due_date( cursor: str | None = None for page in default_completed_tasks_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher(params, cursor)], + request_params=params | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -70,7 +68,7 @@ async def test_get_completed_tasks_by_due_date( ) for i, tasks in enumerate(tasks_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_completed_tasks_list[i] count += 1 @@ -82,7 +80,7 @@ async def test_get_completed_tasks_by_due_date( ) async for i, tasks in enumerate_async(tasks_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_completed_tasks_list[i] count += 1 @@ -91,7 +89,7 @@ async def test_get_completed_tasks_by_due_date( async def test_get_completed_tasks_by_completion_date( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_completed_tasks_response: list[PaginatedItems], default_completed_tasks_list: list[list[Task]], ) -> None: @@ -111,12 +109,14 @@ async def test_get_completed_tasks_by_completion_date( cursor: str | None = None for page in default_completed_tasks_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher(params, cursor)], + request_params=params | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -130,7 +130,7 @@ async def test_get_completed_tasks_by_completion_date( ) for i, tasks in enumerate(tasks_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_completed_tasks_list[i] count += 1 @@ -142,6 +142,6 @@ async def test_get_completed_tasks_by_completion_date( ) async for i, tasks in enumerate_async(tasks_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_completed_tasks_list[i] count += 1 diff --git a/tests/test_api_labels.py b/tests/test_api_labels.py index fbf090b..f63c350 100644 --- a/tests/test_api_labels.py +++ b/tests/test_api_labels.py @@ -3,18 +3,13 @@ from typing import TYPE_CHECKING, Any import pytest -import responses from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults -from tests.utils.test_utils import ( - auth_matcher, - data_matcher, - enumerate_async, - param_matcher, - request_id_matcher, -) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route if TYPE_CHECKING: + import respx + from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Label @@ -24,29 +19,30 @@ async def test_get_label( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_label_response: dict[str, Any], default_label: Label, ) -> None: label_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/labels/{label_id}" - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=default_label_response, - status=200, - match=[auth_matcher()], + request_headers=api_headers(), + response_json=default_label_response, + response_status=200, ) label = todoist_api.get_label(label_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert label == default_label label = await todoist_api_async.get_label(label_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert label == default_label @@ -54,7 +50,7 @@ async def test_get_label( async def test_get_labels( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_labels_response: list[PaginatedResults], default_labels_list: list[list[Label]], ) -> None: @@ -62,12 +58,14 @@ async def test_get_labels( cursor: str | None = None for page in default_labels_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], + request_params={"cursor": cursor} if cursor else {}, + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -76,14 +74,14 @@ async def test_get_labels( labels_iter = todoist_api.get_labels() for i, labels in enumerate(labels_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert labels == default_labels_list[i] count += 1 labels_async_iter = await todoist_api_async.get_labels() async for i, labels in enumerate_async(labels_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert labels == default_labels_list[i] count += 1 @@ -92,7 +90,7 @@ async def test_get_labels( async def test_search_labels( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_labels_response: list[PaginatedResults], default_labels_list: list[list[Label]], ) -> None: @@ -101,16 +99,14 @@ async def test_search_labels( cursor: str | None = None for page in default_labels_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - param_matcher({"query": query}, cursor), - ], + request_params={"query": query} | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -119,14 +115,14 @@ async def test_search_labels( labels_iter = todoist_api.search_labels(query) for i, labels in enumerate(labels_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert labels == default_labels_list[i] count += 1 labels_async_iter = await todoist_api_async.search_labels(query) async for i, labels in enumerate_async(labels_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert labels == default_labels_list[i] count += 1 @@ -135,32 +131,30 @@ async def test_search_labels( async def test_add_label_minimal( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_label_response: dict[str, Any], default_label: Label, ) -> None: label_name = "A Label" - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/labels", - json=default_label_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher({"name": label_name}), - ], + request_headers=api_headers(), + request_json={"name": label_name}, + response_json=default_label_response, + response_status=200, ) new_label = todoist_api.add_label(name=label_name) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert new_label == default_label new_label = await todoist_api_async.add_label(name=label_name) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert new_label == default_label @@ -168,7 +162,7 @@ async def test_add_label_minimal( async def test_add_label_full( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_label_response: dict[str, Any], default_label: Label, ) -> None: @@ -179,26 +173,24 @@ async def test_add_label_full( "is_favorite": True, } - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/labels", - json=default_label_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher({"name": label_name} | args), - ], + request_headers=api_headers(), + request_json={"name": label_name} | args, + response_json=default_label_response, + response_status=200, ) new_label = todoist_api.add_label(name=label_name, **args) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert new_label == default_label new_label = await todoist_api_async.add_label(name=label_name, **args) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert new_label == default_label @@ -206,7 +198,7 @@ async def test_add_label_full( async def test_update_label( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_label: Label, ) -> None: args: dict[str, Any] = { @@ -214,22 +206,24 @@ async def test_update_label( } updated_label_dict = default_label.to_dict() | args - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/labels/{default_label.id}", - json=updated_label_dict, - status=200, - match=[auth_matcher(), request_id_matcher(), data_matcher(args)], + request_headers=api_headers(), + request_json=args, + response_json=updated_label_dict, + response_status=200, ) response = todoist_api.update_label(label_id=default_label.id, **args) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response == Label.from_dict(updated_label_dict) response = await todoist_api_async.update_label(label_id=default_label.id, **args) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response == Label.from_dict(updated_label_dict) @@ -237,23 +231,25 @@ async def test_update_label( async def test_delete_label( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: label_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/labels/{label_id}" - requests_mock.add( - method=responses.DELETE, + mock_route( + respx_mock, + method="DELETE", url=endpoint, - status=204, + request_headers=api_headers(), + response_status=204, ) response = todoist_api.delete_label(label_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response is True response = await todoist_api_async.delete_label(label_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response is True diff --git a/tests/test_api_projects.py b/tests/test_api_projects.py index 01002cf..d092fc3 100644 --- a/tests/test_api_projects.py +++ b/tests/test_api_projects.py @@ -3,18 +3,13 @@ from typing import TYPE_CHECKING, Any import pytest -import responses from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults -from tests.utils.test_utils import ( - auth_matcher, - data_matcher, - enumerate_async, - param_matcher, - request_id_matcher, -) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route if TYPE_CHECKING: + import respx + from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Collaborator, Project @@ -24,29 +19,30 @@ async def test_get_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_project_response: dict[str, Any], default_project: Project, ) -> None: project_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/projects/{project_id}" - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=default_project_response, - status=200, - match=[auth_matcher()], + request_headers=api_headers(), + response_json=default_project_response, + response_status=200, ) project = todoist_api.get_project(project_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert project == default_project project = await todoist_api_async.get_project(project_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert project == default_project @@ -54,7 +50,7 @@ async def test_get_project( async def test_get_projects( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_projects_response: list[PaginatedResults], default_projects_list: list[list[Project]], ) -> None: @@ -62,12 +58,14 @@ async def test_get_projects( cursor: str | None = None for page in default_projects_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], + request_params={"cursor": cursor} if cursor else {}, + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -76,14 +74,14 @@ async def test_get_projects( projects_iter = todoist_api.get_projects() for i, projects in enumerate(projects_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert projects == default_projects_list[i] count += 1 projects_async_iter = await todoist_api_async.get_projects() async for i, projects in enumerate_async(projects_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert projects == default_projects_list[i] count += 1 @@ -92,7 +90,7 @@ async def test_get_projects( async def test_search_projects( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_projects_response: list[PaginatedResults], default_projects_list: list[list[Project]], ) -> None: @@ -101,16 +99,14 @@ async def test_search_projects( cursor: str | None = None for page in default_projects_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - param_matcher({"query": query}, cursor), - ], + request_params={"query": query} | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -119,14 +115,14 @@ async def test_search_projects( projects_iter = todoist_api.search_projects(query) for i, projects in enumerate(projects_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert projects == default_projects_list[i] count += 1 projects_async_iter = await todoist_api_async.search_projects(query) async for i, projects in enumerate_async(projects_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert projects == default_projects_list[i] count += 1 @@ -135,32 +131,30 @@ async def test_search_projects( async def test_add_project_minimal( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_project_response: dict[str, Any], default_project: Project, ) -> None: project_name = "A Project" - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/projects", - json=default_project_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher({"name": project_name}), - ], + request_headers=api_headers(), + request_json={"name": project_name}, + response_json=default_project_response, + response_status=200, ) new_project = todoist_api.add_project(name=project_name) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert new_project == default_project new_project = await todoist_api_async.add_project(name=project_name) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert new_project == default_project @@ -168,32 +162,37 @@ async def test_add_project_minimal( async def test_add_project_full( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_project_response: dict[str, Any], default_project: Project, ) -> None: project_name = "A Project" + args: dict[str, Any] = { + "description": "A project description", + "parent_id": "3Ty8VQXxpwv28PK3", + "color": "red", + "is_favorite": True, + "view_style": "board", + } - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/projects", - json=default_project_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher({"name": project_name}), - ], + request_headers=api_headers(), + request_json={"name": project_name} | args, + response_json=default_project_response, + response_status=200, ) - new_project = todoist_api.add_project(name=project_name) + new_project = todoist_api.add_project(name=project_name, **args) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert new_project == default_project - new_project = await todoist_api_async.add_project(name=project_name) + new_project = await todoist_api_async.add_project(name=project_name, **args) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert new_project == default_project @@ -201,7 +200,7 @@ async def test_add_project_full( async def test_update_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_project: Project, ) -> None: args: dict[str, Any] = { @@ -211,24 +210,26 @@ async def test_update_project( } updated_project_dict = default_project.to_dict() | args - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/projects/{default_project.id}", - json=updated_project_dict, - status=200, - match=[auth_matcher(), request_id_matcher(), data_matcher(args)], + request_headers=api_headers(), + request_json=args, + response_json=updated_project_dict, + response_status=200, ) response = todoist_api.update_project(project_id=default_project.id, **args) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response == Project.from_dict(updated_project_dict) response = await todoist_api_async.update_project( project_id=default_project.id, **args ) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response == Project.from_dict(updated_project_dict) @@ -236,7 +237,7 @@ async def test_update_project( async def test_archive_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_project: Project, ) -> None: project_id = default_project.id @@ -245,22 +246,23 @@ async def test_archive_project( archived_project_dict = default_project.to_dict() archived_project_dict["is_archived"] = True - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=endpoint, - json=archived_project_dict, - status=200, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_json=archived_project_dict, + response_status=200, ) project = todoist_api.archive_project(project_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert project == Project.from_dict(archived_project_dict) project = await todoist_api_async.archive_project(project_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert project == Project.from_dict(archived_project_dict) @@ -268,7 +270,7 @@ async def test_archive_project( async def test_unarchive_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_project: Project, ) -> None: project_id = default_project.id @@ -277,22 +279,23 @@ async def test_unarchive_project( unarchived_project_dict = default_project.to_dict() unarchived_project_dict["is_archived"] = False - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=endpoint, - json=unarchived_project_dict, - status=200, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_json=unarchived_project_dict, + response_status=200, ) project = todoist_api.unarchive_project(project_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert project == Project.from_dict(unarchived_project_dict) project = await todoist_api_async.unarchive_project(project_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert project == Project.from_dict(unarchived_project_dict) @@ -300,26 +303,27 @@ async def test_unarchive_project( async def test_delete_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: project_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/projects/{project_id}" - requests_mock.add( - method=responses.DELETE, + mock_route( + respx_mock, + method="DELETE", url=endpoint, - status=204, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_status=204, ) response = todoist_api.delete_project(project_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response is True response = await todoist_api_async.delete_project(project_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response is True @@ -327,7 +331,7 @@ async def test_delete_project( async def test_get_collaborators( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_collaborators_response: list[PaginatedResults], default_collaborators_list: list[list[Collaborator]], ) -> None: @@ -336,12 +340,14 @@ async def test_get_collaborators( cursor: str | None = None for page in default_collaborators_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], + request_params={"cursor": cursor} if cursor else {}, + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -350,13 +356,13 @@ async def test_get_collaborators( collaborators_iter = todoist_api.get_collaborators(project_id) for i, collaborators in enumerate(collaborators_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert collaborators == default_collaborators_list[i] count += 1 collaborators_async_iter = await todoist_api_async.get_collaborators(project_id) async for i, collaborators in enumerate_async(collaborators_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert collaborators == default_collaborators_list[i] count += 1 diff --git a/tests/test_api_sections.py b/tests/test_api_sections.py index 8fbbcda..46210a8 100644 --- a/tests/test_api_sections.py +++ b/tests/test_api_sections.py @@ -3,18 +3,13 @@ from typing import TYPE_CHECKING, Any import pytest -import responses from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults -from tests.utils.test_utils import ( - auth_matcher, - data_matcher, - enumerate_async, - param_matcher, - request_id_matcher, -) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route if TYPE_CHECKING: + import respx + from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Section @@ -24,29 +19,30 @@ async def test_get_section( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_section_response: dict[str, Any], default_section: Section, ) -> None: section_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/sections/{section_id}" - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=default_section_response, - status=200, - match=[auth_matcher()], + request_headers=api_headers(), + response_json=default_section_response, + response_status=200, ) section = todoist_api.get_section(section_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert section == default_section section = await todoist_api_async.get_section(section_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert section == default_section @@ -54,7 +50,7 @@ async def test_get_section( async def test_get_sections( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_sections_response: list[PaginatedResults], default_sections_list: list[list[Section]], ) -> None: @@ -62,12 +58,14 @@ async def test_get_sections( cursor: str | None = None for page in default_sections_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], + request_params={"cursor": cursor} if cursor else {}, + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -76,14 +74,14 @@ async def test_get_sections( sections_iter = todoist_api.get_sections() for i, sections in enumerate(sections_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert sections == default_sections_list[i] count += 1 sections_async_iter = await todoist_api_async.get_sections() async for i, sections in enumerate_async(sections_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert sections == default_sections_list[i] count += 1 @@ -92,7 +90,7 @@ async def test_get_sections( async def test_get_sections_by_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_sections_response: list[PaginatedResults], default_sections_list: list[list[Section]], ) -> None: @@ -101,16 +99,15 @@ async def test_get_sections_by_project( cursor: str | None = None for page in default_sections_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - param_matcher({"project_id": project_id}, cursor), - ], + request_params={"project_id": project_id} + | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -119,14 +116,14 @@ async def test_get_sections_by_project( sections_iter = todoist_api.get_sections(project_id=project_id) for i, sections in enumerate(sections_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert sections == default_sections_list[i] count += 1 sections_async_iter = await todoist_api_async.get_sections(project_id=project_id) async for i, sections in enumerate_async(sections_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert sections == default_sections_list[i] count += 1 @@ -135,7 +132,7 @@ async def test_get_sections_by_project( async def test_search_sections( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_sections_response: list[PaginatedResults], default_sections_list: list[list[Section]], ) -> None: @@ -144,16 +141,14 @@ async def test_search_sections( cursor: str | None = None for page in default_sections_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - param_matcher({"query": query}, cursor), - ], + request_params={"query": query} | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -162,14 +157,14 @@ async def test_search_sections( sections_iter = todoist_api.search_sections(query) for i, sections in enumerate(sections_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert sections == default_sections_list[i] count += 1 sections_async_iter = await todoist_api_async.search_sections(query) async for i, sections in enumerate_async(sections_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert sections == default_sections_list[i] count += 1 @@ -178,7 +173,7 @@ async def test_search_sections( async def test_search_sections_by_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_sections_response: list[PaginatedResults], default_sections_list: list[list[Section]], ) -> None: @@ -188,16 +183,15 @@ async def test_search_sections_by_project( cursor: str | None = None for page in default_sections_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - param_matcher({"query": query, "project_id": project_id}, cursor), - ], + request_params={"query": query, "project_id": project_id} + | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -206,7 +200,7 @@ async def test_search_sections_by_project( sections_iter = todoist_api.search_sections(query, project_id=project_id) for i, sections in enumerate(sections_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert sections == default_sections_list[i] count += 1 @@ -215,7 +209,7 @@ async def test_search_sections_by_project( ) async for i, sections in enumerate_async(sections_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert sections == default_sections_list[i] count += 1 @@ -224,7 +218,7 @@ async def test_search_sections_by_project( async def test_add_section( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_section_response: dict[str, Any], default_section: Section, ) -> None: @@ -234,30 +228,28 @@ async def test_add_section( "order": 3, } - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/sections", - json=default_section_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher({"name": section_name, "project_id": project_id} | args), - ], + request_headers=api_headers(), + request_json={"name": section_name, "project_id": project_id} | args, + response_json=default_section_response, + response_status=200, ) new_section = todoist_api.add_section( name=section_name, project_id=project_id, **args ) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert new_section == default_section new_section = await todoist_api_async.add_section( name=section_name, project_id=project_id, **args ) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert new_section == default_section @@ -265,7 +257,7 @@ async def test_add_section( async def test_update_section( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_section: Section, ) -> None: args = { @@ -273,24 +265,26 @@ async def test_update_section( } updated_section_dict = default_section.to_dict() | args - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/sections/{default_section.id}", - json=updated_section_dict, - status=200, - match=[auth_matcher(), request_id_matcher(), data_matcher(args)], + request_headers=api_headers(), + request_json=args, + response_json=updated_section_dict, + response_status=200, ) response = todoist_api.update_section(section_id=default_section.id, **args) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response == Section.from_dict(updated_section_dict) response = await todoist_api_async.update_section( section_id=default_section.id, **args ) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response == Section.from_dict(updated_section_dict) @@ -298,24 +292,25 @@ async def test_update_section( async def test_delete_section( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: section_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/sections/{section_id}" - requests_mock.add( - method=responses.DELETE, + mock_route( + respx_mock, + method="DELETE", url=endpoint, - status=204, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_status=204, ) response = todoist_api.delete_section(section_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response is True response = await todoist_api_async.delete_section(section_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response is True diff --git a/tests/test_api_shared_labels.py b/tests/test_api_shared_labels.py new file mode 100644 index 0000000..167ee29 --- /dev/null +++ b/tests/test_api_shared_labels.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.data.test_defaults import DEFAULT_API_URL +from tests.utils.test_utils import api_headers, mock_route + +if TYPE_CHECKING: + import respx + + from todoist_api_python.api import TodoistAPI + from todoist_api_python.api_async import TodoistAPIAsync + + +@pytest.mark.asyncio +async def test_rename_shared_label( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, +) -> None: + name = "old-shared-label" + new_name = "new-shared-label" + endpoint = f"{DEFAULT_API_URL}/labels/shared/rename" + + mock_route( + respx_mock, + method="POST", + url=endpoint, + request_params={"name": name}, + request_headers=api_headers(), + request_json={"new_name": new_name}, + response_status=204, + ) + + result = todoist_api.rename_shared_label(name, new_name) + + assert len(respx_mock.calls) == 1 + assert result is True + + result = await todoist_api_async.rename_shared_label(name, new_name) + + assert len(respx_mock.calls) == 2 + assert result is True + + +@pytest.mark.asyncio +async def test_remove_shared_label( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, +) -> None: + name = "Shared Label" + endpoint = f"{DEFAULT_API_URL}/labels/shared/remove" + + mock_route( + respx_mock, + method="POST", + url=endpoint, + request_headers=api_headers(), + request_json={"name": name}, + response_status=204, + ) + + result = todoist_api.remove_shared_label(name) + + assert len(respx_mock.calls) == 1 + assert result is True + + result = await todoist_api_async.remove_shared_label(name) + + assert len(respx_mock.calls) == 2 + assert result is True diff --git a/tests/test_api_tasks.py b/tests/test_api_tasks.py index 81f3d83..2787a37 100644 --- a/tests/test_api_tasks.py +++ b/tests/test_api_tasks.py @@ -10,18 +10,13 @@ UTC = timezone.utc import pytest -import responses from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults -from tests.utils.test_utils import ( - auth_matcher, - data_matcher, - enumerate_async, - param_matcher, - request_id_matcher, -) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route if TYPE_CHECKING: + import respx + from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Task @@ -31,28 +26,29 @@ async def test_get_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_task_response: dict[str, Any], default_task: Task, ) -> None: task_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}" - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=default_task_response, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_json=default_task_response, ) task = todoist_api.get_task(task_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert task == default_task task = await todoist_api_async.get_task(task_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert task == default_task @@ -60,7 +56,7 @@ async def test_get_task( async def test_get_tasks( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_tasks_response: list[PaginatedResults], default_tasks_list: list[list[Task]], ) -> None: @@ -68,12 +64,14 @@ async def test_get_tasks( cursor: str | None = None for page in default_tasks_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], + request_params={"cursor": cursor} if cursor else {}, + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -82,14 +80,14 @@ async def test_get_tasks( tasks_iter = todoist_api.get_tasks() for i, tasks in enumerate(tasks_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_tasks_list[i] count += 1 tasks_async_iter = await todoist_api_async.get_tasks() async for i, tasks in enumerate_async(tasks_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_tasks_list[i] count += 1 @@ -98,7 +96,7 @@ async def test_get_tasks( async def test_get_tasks_with_filters( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_tasks_response: list[PaginatedResults], default_tasks_list: list[list[Task]], ) -> None: @@ -122,12 +120,14 @@ async def test_get_tasks_with_filters( cursor: str | None = None for page in default_tasks_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher(params, cursor)], + request_params=params | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -143,7 +143,7 @@ async def test_get_tasks_with_filters( ) for i, tasks in enumerate(tasks_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_tasks_list[i] count += 1 @@ -157,7 +157,7 @@ async def test_get_tasks_with_filters( ) async for i, tasks in enumerate_async(tasks_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_tasks_list[i] count += 1 @@ -166,7 +166,7 @@ async def test_get_tasks_with_filters( async def test_filter_tasks( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_tasks_response: list[PaginatedResults], default_tasks_list: list[list[Task]], ) -> None: @@ -181,12 +181,14 @@ async def test_filter_tasks( cursor: str | None = None for page in default_tasks_response: - requests_mock.add( - method=responses.GET, + mock_route( + respx_mock, + method="GET", url=endpoint, - json=page, - status=200, - match=[auth_matcher(), request_id_matcher(), param_matcher(params, cursor)], + request_params=params | ({"cursor": cursor} if cursor else {}), + request_headers=api_headers(), + response_json=page, + response_status=200, ) cursor = page["next_cursor"] @@ -198,7 +200,7 @@ async def test_filter_tasks( ) for i, tasks in enumerate(tasks_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_tasks_list[i] count += 1 @@ -209,7 +211,7 @@ async def test_filter_tasks( ) async for i, tasks in enumerate_async(tasks_async_iter): - assert len(requests_mock.calls) == count + 1 + assert len(respx_mock.calls) == count + 1 assert tasks == default_tasks_list[i] count += 1 @@ -218,32 +220,30 @@ async def test_filter_tasks( async def test_add_task_minimal( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_task_response: dict[str, Any], default_task: Task, ) -> None: content = "Some content" - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/tasks", - json=default_task_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher({"content": content}), - ], + request_headers=api_headers(), + request_json={"content": content}, + response_json=default_task_response, + response_status=200, ) new_task = todoist_api.add_task(content=content) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert new_task == default_task new_task = await todoist_api_async.add_task(content=content) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert new_task == default_task @@ -251,7 +251,7 @@ async def test_add_task_minimal( async def test_add_task_full( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_task_response: dict[str, Any], default_task: Task, ) -> None: @@ -274,34 +274,30 @@ async def test_add_task_full( "duration_unit": "minute", } - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/tasks", - json=default_task_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher( - { - "content": content, - "due_datetime": due_datetime.strftime("%Y-%m-%dT%H:%M:%SZ"), - } - | args - ), - ], + request_headers=api_headers(), + request_json={ + "content": content, + "due_datetime": due_datetime.strftime("%Y-%m-%dT%H:%M:%SZ"), + } + | args, + response_json=default_task_response, + response_status=200, ) new_task = todoist_api.add_task(content=content, due_datetime=due_datetime, **args) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert new_task == default_task new_task = await todoist_api_async.add_task( content=content, due_datetime=due_datetime, **args ) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert new_task == default_task @@ -309,7 +305,7 @@ async def test_add_task_full( async def test_add_task_quick( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_task_meta_response: dict[str, Any], default_task_meta: Task, ) -> None: @@ -317,23 +313,19 @@ async def test_add_task_quick( note = "Whole milk x6" auto_reminder = True - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/tasks/quick", - json=default_task_meta_response, - status=200, - match=[ - auth_matcher(), - request_id_matcher(), - data_matcher( - { - "meta": True, - "text": text, - "auto_reminder": auto_reminder, - "note": note, - } - ), - ], + request_headers=api_headers(), + request_json={ + "meta": True, + "text": text, + "auto_reminder": auto_reminder, + "note": note, + }, + response_json=default_task_meta_response, + response_status=200, ) task = todoist_api.add_task_quick( @@ -342,7 +334,7 @@ async def test_add_task_quick( auto_reminder=auto_reminder, ) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert task == default_task_meta task = await todoist_api_async.add_task_quick( @@ -351,7 +343,7 @@ async def test_add_task_quick( auto_reminder=auto_reminder, ) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert task == default_task_meta @@ -359,7 +351,7 @@ async def test_add_task_quick( async def test_update_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_task: Task, ) -> None: args: dict[str, Any] = { @@ -370,22 +362,24 @@ async def test_update_task( } updated_task_dict = default_task.to_dict() | args - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=f"{DEFAULT_API_URL}/tasks/{default_task.id}", - json=updated_task_dict, - status=200, - match=[auth_matcher(), request_id_matcher(), data_matcher(args)], + request_headers=api_headers(), + request_json=args, + response_json=updated_task_dict, + response_status=200, ) response = todoist_api.update_task(task_id=default_task.id, **args) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response == Task.from_dict(updated_task_dict) response = await todoist_api_async.update_task(task_id=default_task.id, **args) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response == Task.from_dict(updated_task_dict) @@ -393,26 +387,27 @@ async def test_update_task( async def test_complete_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: task_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}/close" - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=endpoint, - status=204, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_status=204, ) response = todoist_api.complete_task(task_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response is True response = await todoist_api_async.complete_task(task_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response is True @@ -420,26 +415,27 @@ async def test_complete_task( async def test_uncomplete_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: task_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}/reopen" - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=endpoint, - status=204, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_status=204, ) response = todoist_api.uncomplete_task(task_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response is True response = await todoist_api_async.uncomplete_task(task_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response is True @@ -447,31 +443,32 @@ async def test_uncomplete_task( async def test_move_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: task_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}/move" - requests_mock.add( - method=responses.POST, + mock_route( + respx_mock, + method="POST", url=endpoint, - status=204, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_status=204, ) response = todoist_api.move_task(task_id, project_id="123") - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response is True response = await todoist_api_async.move_task(task_id, section_id="456") - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response is True response = await todoist_api_async.move_task(task_id, parent_id="789") - assert len(requests_mock.calls) == 3 + assert len(respx_mock.calls) == 3 assert response is True with pytest.raises( @@ -485,24 +482,25 @@ async def test_move_task( async def test_delete_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: task_id = "6X7rM8997g3RQmvh" endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}" - requests_mock.add( - method=responses.DELETE, + mock_route( + respx_mock, + method="DELETE", url=endpoint, - status=204, - match=[auth_matcher(), request_id_matcher()], + request_headers=api_headers(), + response_status=204, ) response = todoist_api.delete_task(task_id) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert response is True response = await todoist_api_async.delete_task(task_id) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert response is True diff --git a/tests/test_authentication.py b/tests/test_authentication.py index cadefbd..92b7815 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -4,10 +4,9 @@ from urllib.parse import quote import pytest -import responses from tests.data.test_defaults import DEFAULT_OAUTH_URL -from tests.utils.test_utils import data_matcher, param_matcher +from tests.utils.test_utils import mock_route from todoist_api_python._core.endpoints import API_URL # Use new base URL from todoist_api_python.authentication import ( get_auth_token, @@ -18,12 +17,15 @@ ) if TYPE_CHECKING: + import respx + + from todoist_api_python.authentication import Scope from todoist_api_python.models import AuthResult def test_get_authentication_url() -> None: client_id = "123" - scopes = ["task:add", "data:read", "project:delete"] + scopes: list[Scope] = ["task:add", "data:read", "project:delete"] state = "456" params = ( f"client_id={client_id}&scope={scopes[0]},{scopes[1]},{scopes[2]}&state={state}" @@ -38,7 +40,7 @@ def test_get_authentication_url() -> None: @pytest.mark.asyncio async def test_get_auth_token( - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, default_auth_response: dict[str, Any], default_auth_result: AuthResult, ) -> None: @@ -46,58 +48,56 @@ async def test_get_auth_token( client_secret = "456" code = "789" - requests_mock.add( - responses.POST, + mock_route( + respx_mock, + "POST", f"{DEFAULT_OAUTH_URL}/access_token", - json=default_auth_response, - status=200, - match=[ - data_matcher( - {"client_id": client_id, "client_secret": client_secret, "code": code} - ) - ], + request_json={ + "client_id": client_id, + "client_secret": client_secret, + "code": code, + }, + response_json=default_auth_response, + response_status=200, ) auth_result = get_auth_token(client_id, client_secret, code) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert auth_result == default_auth_result auth_result = await get_auth_token_async(client_id, client_secret, code) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert auth_result == default_auth_result @pytest.mark.asyncio async def test_revoke_auth_token( - requests_mock: responses.RequestsMock, + respx_mock: respx.MockRouter, ) -> None: client_id = "123" client_secret = "456" token = "AToken" - requests_mock.add( - responses.DELETE, + mock_route( + respx_mock, + "DELETE", f"{API_URL}/access_tokens", - match=[ - param_matcher( - { - "client_id": client_id, - "client_secret": client_secret, - "access_token": token, - } - ) - ], - status=200, + request_params={ + "client_id": client_id, + "client_secret": client_secret, + "access_token": token, + }, + response_status=200, ) result = revoke_auth_token(client_id, client_secret, token) - assert len(requests_mock.calls) == 1 + assert len(respx_mock.calls) == 1 assert result is True result = await revoke_auth_token_async(client_id, client_secret, token) - assert len(requests_mock.calls) == 2 + assert len(respx_mock.calls) == 2 assert result is True diff --git a/tests/test_http_headers.py b/tests/test_http_headers.py index cb8aa74..79ae566 100644 --- a/tests/test_http_headers.py +++ b/tests/test_http_headers.py @@ -14,11 +14,6 @@ def test_create_headers_authorization() -> None: assert headers["Authorization"] == f"Bearer {token}" -def test_create_headers_content_type() -> None: - headers = create_headers(with_content=True) - assert headers["Content-Type"] == "application/json; charset=utf-8" - - def test_create_headers_request_id() -> None: request_id = "12345" headers = create_headers(request_id=request_id) diff --git a/tests/test_http_requests.py b/tests/test_http_requests.py index a34d778..b5829e6 100644 --- a/tests/test_http_requests.py +++ b/tests/test_http_requests.py @@ -1,150 +1,166 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any +import httpx import pytest -import responses -from requests import HTTPError, Session -from responses.matchers import query_param_matcher from tests.data.test_defaults import DEFAULT_REQUEST_ID, DEFAULT_TOKEN -from tests.utils.test_utils import ( - auth_matcher, - data_matcher, - param_matcher, - request_id_matcher, -) +from tests.utils.test_utils import api_headers, mock_route from todoist_api_python._core.http_requests import delete, get, post +if TYPE_CHECKING: + import respx + EXAMPLE_URL = "https://example.com/" EXAMPLE_PARAMS = {"param1": "value1", "param2": "value2"} EXAMPLE_DATA = {"param3": "value31", "param4": "value4"} EXAMPLE_RESPONSE = {"result": "ok"} -@responses.activate -def test_get_with_params(default_task_response: dict[str, Any]) -> None: - responses.add( - method=responses.GET, +def test_get_with_params(respx_mock: respx.MockRouter) -> None: + mock_route( + respx_mock, + method="GET", url=EXAMPLE_URL, - json=EXAMPLE_RESPONSE, - status=200, - match=[ - auth_matcher(), - request_id_matcher(DEFAULT_REQUEST_ID), - param_matcher(EXAMPLE_PARAMS), - ], + request_params=EXAMPLE_PARAMS, + request_headers=api_headers(request_id=DEFAULT_REQUEST_ID), + response_json=EXAMPLE_RESPONSE, + response_status=200, ) - response: dict[str, Any] = get( - session=Session(), - url=EXAMPLE_URL, - token=DEFAULT_TOKEN, - request_id=DEFAULT_REQUEST_ID, - params=EXAMPLE_PARAMS, - ) + with httpx.Client() as client: + response: dict[str, Any] = get( + client=client, + url=EXAMPLE_URL, + token=DEFAULT_TOKEN, + request_id=DEFAULT_REQUEST_ID, + params=EXAMPLE_PARAMS, + ) - assert len(responses.calls) == 1 + assert len(respx_mock.calls) == 1 assert response == EXAMPLE_RESPONSE -@responses.activate -def test_get_raise_for_status() -> None: - responses.add( - method=responses.GET, +def test_get_raise_for_status(respx_mock: respx.MockRouter) -> None: + mock_route( + respx_mock, + method="GET", url=EXAMPLE_URL, - json="", - status=500, + response_json="", + response_status=500, ) - with pytest.raises(HTTPError) as error_info: - get(Session(), EXAMPLE_URL, DEFAULT_TOKEN) + with httpx.Client() as client, pytest.raises(httpx.HTTPStatusError) as error_info: + get(client, EXAMPLE_URL, DEFAULT_TOKEN) assert error_info.value.response.content == b'""' -@responses.activate -def test_post_with_data(default_task_response: dict[str, Any]) -> None: - responses.add( - method=responses.POST, +def test_post_with_data(respx_mock: respx.MockRouter) -> None: + mock_route( + respx_mock, + method="POST", url=EXAMPLE_URL, - json=EXAMPLE_RESPONSE, - status=200, - match=[ - auth_matcher(), - request_id_matcher(DEFAULT_REQUEST_ID), - data_matcher(EXAMPLE_DATA), - ], + request_headers=api_headers(request_id=DEFAULT_REQUEST_ID), + request_json=EXAMPLE_DATA, + response_json=EXAMPLE_RESPONSE, + response_status=200, ) - response: dict[str, Any] = post( - session=Session(), + with httpx.Client() as client: + response: dict[str, Any] = post( + client=client, + url=EXAMPLE_URL, + token=DEFAULT_TOKEN, + request_id=DEFAULT_REQUEST_ID, + data=EXAMPLE_DATA, + ) + + assert len(respx_mock.calls) == 1 + assert response == EXAMPLE_RESPONSE + + +def test_post_with_empty_data(respx_mock: respx.MockRouter) -> None: + mock_route( + respx_mock, + method="POST", url=EXAMPLE_URL, - token=DEFAULT_TOKEN, - request_id=DEFAULT_REQUEST_ID, - data=EXAMPLE_DATA, + request_headers=api_headers(request_id=DEFAULT_REQUEST_ID), + request_json={}, + response_json=EXAMPLE_RESPONSE, + response_status=200, ) - assert len(responses.calls) == 1 + with httpx.Client() as client: + response: dict[str, Any] = post( + client=client, + url=EXAMPLE_URL, + token=DEFAULT_TOKEN, + request_id=DEFAULT_REQUEST_ID, + data={}, + ) + + assert len(respx_mock.calls) == 1 assert response == EXAMPLE_RESPONSE -@responses.activate -def test_post_return_ok_when_no_response_body() -> None: - responses.add( - method=responses.POST, +def test_post_return_ok_when_no_response_body(respx_mock: respx.MockRouter) -> None: + mock_route( + respx_mock, + method="POST", url=EXAMPLE_URL, - status=204, + response_status=204, ) - result: bool = post(session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN) + with httpx.Client() as client: + result: bool = post(client=client, url=EXAMPLE_URL, token=DEFAULT_TOKEN) + assert result is True -@responses.activate -def test_post_raise_for_status() -> None: - responses.add( - method=responses.POST, +def test_post_raise_for_status(respx_mock: respx.MockRouter) -> None: + mock_route( + respx_mock, + method="POST", url=EXAMPLE_URL, - status=500, + response_status=500, ) - with pytest.raises(HTTPError): - post(session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN) + with httpx.Client() as client, pytest.raises(httpx.HTTPStatusError): + post(client=client, url=EXAMPLE_URL, token=DEFAULT_TOKEN) -@responses.activate -def test_delete_with_params() -> None: - responses.add( - method=responses.DELETE, +def test_delete_with_params(respx_mock: respx.MockRouter) -> None: + mock_route( + respx_mock, + method="DELETE", url=EXAMPLE_URL, - status=204, - match=[ - auth_matcher(), - request_id_matcher(DEFAULT_REQUEST_ID), - query_param_matcher(EXAMPLE_PARAMS), - ], + request_params=EXAMPLE_PARAMS, + request_headers=api_headers(request_id=DEFAULT_REQUEST_ID), + response_status=204, ) - result = delete( - session=Session(), - url=EXAMPLE_URL, - token=DEFAULT_TOKEN, - request_id=DEFAULT_REQUEST_ID, - params=EXAMPLE_PARAMS, - ) + with httpx.Client() as client: + result = delete( + client=client, + url=EXAMPLE_URL, + token=DEFAULT_TOKEN, + request_id=DEFAULT_REQUEST_ID, + params=EXAMPLE_PARAMS, + ) - assert len(responses.calls) == 1 + assert len(respx_mock.calls) == 1 assert result is True -@responses.activate -def test_delete_raise_for_status() -> None: - responses.add( - method=responses.DELETE, +def test_delete_raise_for_status(respx_mock: respx.MockRouter) -> None: + mock_route( + respx_mock, + method="DELETE", url=EXAMPLE_URL, - status=500, + response_status=500, ) - with pytest.raises(HTTPError): - delete(session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN) + with httpx.Client() as client, pytest.raises(httpx.HTTPStatusError): + delete(client=client, url=EXAMPLE_URL, token=DEFAULT_TOKEN) diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 79396df..e9159d6 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -1,45 +1,61 @@ from __future__ import annotations -import re -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast -from responses import matchers - -from tests.data.test_defaults import ( - DEFAULT_TOKEN, -) -from todoist_api_python.api import TodoistAPI +from tests.data.test_defaults import DEFAULT_REQUEST_ID, DEFAULT_TOKEN if TYPE_CHECKING: - from collections.abc import AsyncIterable, AsyncIterator, Callable + from collections.abc import AsyncIterable, AsyncIterator + + import respx +_UNSET = object() -RE_UUID = re.compile(r"^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}$", re.IGNORECASE) +JSONValue = Union[dict[str, object], list[object], str, int, float, bool, None] -def auth_matcher() -> Callable[..., Any]: - return matchers.header_matcher({"Authorization": f"Bearer {DEFAULT_TOKEN}"}) +def auth_headers(token: str = DEFAULT_TOKEN) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} -def request_id_matcher(request_id: str | None = None) -> Callable[..., Any]: - return matchers.header_matcher({"X-Request-Id": request_id or RE_UUID}) +def request_id_headers(request_id: str = DEFAULT_REQUEST_ID) -> dict[str, str]: + return {"X-Request-Id": request_id} -def param_matcher( - params: dict[str, str], cursor: str | None = None -) -> Callable[..., Any]: - return matchers.query_param_matcher(params | ({"cursor": cursor} if cursor else {})) +def api_headers( + token: str = DEFAULT_TOKEN, + request_id: str = DEFAULT_REQUEST_ID, +) -> dict[str, str]: + return auth_headers(token) | request_id_headers(request_id) -def data_matcher(data: dict[str, Any]) -> Callable[..., Any]: - return matchers.json_params_matcher(data) +def mock_route( + router: respx.MockRouter, + method: str, + url: str, + *, + response_status: int = 200, + response_json: JSONValue | object = _UNSET, + request_params: dict[str, Any] | None = None, + request_headers: dict[str, str] | None = None, + request_json: JSONValue | object = _UNSET, +) -> None: + """Register a route with declarative request lookups and mocked response data.""" + route_lookups: dict[str, Any] = {"method": method, "url": url} + if request_params is not None: + route_lookups["params__eq"] = _normalize_params(request_params) or {} + if request_headers is not None: + route_lookups["headers__contains"] = request_headers + if request_json is not _UNSET: + route_lookups["json__eq"] = cast("JSONValue", request_json) -def get_todoist_api_patch(method: Callable[..., Any] | None) -> str: - module = TodoistAPI.__module__ - name = TodoistAPI.__qualname__ + route = router.route(**route_lookups) - return f"{module}.{name}.{method.__name__}" if method else f"{module}.{name}" + if response_json is _UNSET: + route.respond(status_code=response_status) + else: + route.respond(status_code=response_status, json=cast("Any", response_json)) T = TypeVar("T") @@ -52,3 +68,16 @@ async def enumerate_async( async for value in iterable: yield index, value index += 1 + + +def _normalize_params(params: dict[str, Any] | None) -> dict[str, str] | None: + if params is None: + return None + + return {key: _normalize_param_value(value) for key, value in params.items()} + + +def _normalize_param_value(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + return str(value) diff --git a/todoist_api_python/_core/http_headers.py b/todoist_api_python/_core/http_headers.py index b9751a7..7be0927 100644 --- a/todoist_api_python/_core/http_headers.py +++ b/todoist_api_python/_core/http_headers.py @@ -1,21 +1,17 @@ from __future__ import annotations -CONTENT_TYPE = ("Content-Type", "application/json; charset=utf-8") AUTHORIZATION = ("Authorization", "Bearer %s") X_REQUEST_ID = ("X-Request-Id", "%s") def create_headers( token: str | None = None, - with_content: bool = False, request_id: str | None = None, ) -> dict[str, str]: headers: dict[str, str] = {} if token: headers.update([(AUTHORIZATION[0], AUTHORIZATION[1] % token)]) - if with_content: - headers.update([CONTENT_TYPE]) if request_id: headers.update([(X_REQUEST_ID[0], X_REQUEST_ID[1] % request_id)]) diff --git a/todoist_api_python/_core/http_requests.py b/todoist_api_python/_core/http_requests.py index 9c0f10f..0d777c8 100644 --- a/todoist_api_python/_core/http_requests.py +++ b/todoist_api_python/_core/http_requests.py @@ -1,81 +1,138 @@ from __future__ import annotations -import json -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import Any, TypeVar, cast -from requests.status_codes import codes +import httpx from todoist_api_python._core.http_headers import create_headers -if TYPE_CHECKING: - from requests import Session - - # Timeouts for requests. # # 10 seconds for connecting is a recurring default and adheres to python-requests's # recommendation of picking a value slightly larger than a multiple of 3. # -# 60 seconds for reading aligns with Todoist's own internal timeout. All requests are -# forcefully terminated after this time, so there is no point waiting any longer. -TIMEOUT = (10, 60) +# 60 seconds for reading aligns with Todoist's own internal timeout. All requests +# are forcefully terminated after this time, so there is no point waiting longer. +TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=60.0, pool=10.0) T = TypeVar("T") +def _parse_response( + response: httpx.Response, + _result_type: type[T] | None = None, +) -> T: + response.raise_for_status() + + if response.status_code == httpx.codes.NO_CONTENT: + return cast("T", response.is_success) + + return cast("T", response.json()) + + def get( - session: Session, + client: httpx.Client, url: str, token: str | None = None, request_id: str | None = None, params: dict[str, Any] | None = None, -) -> T: # type: ignore[type-var] + result_type: type[T] | None = None, +) -> T: headers = create_headers(token=token, request_id=request_id) - response = session.get( + response = client.get( url, params=params, headers=headers, timeout=TIMEOUT, ) - if response.status_code == codes.OK: - return cast("T", response.json()) + return _parse_response(response, result_type) - response.raise_for_status() - return cast("T", response.ok) + +async def get_async( + client: httpx.AsyncClient, + url: str, + token: str | None = None, + request_id: str | None = None, + params: dict[str, Any] | None = None, + result_type: type[T] | None = None, +) -> T: + headers = create_headers(token=token, request_id=request_id) + + response = await client.get( + url, + params=params, + headers=headers, + timeout=TIMEOUT, + ) + + return _parse_response(response, result_type) def post( - session: Session, + client: httpx.Client, url: str, token: str | None = None, request_id: str | None = None, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, -) -> T: # type: ignore[type-var] - headers = create_headers( - token=token, with_content=bool(data), request_id=request_id + result_type: type[T] | None = None, +) -> T: + headers = create_headers(token=token, request_id=request_id) + + response = client.post( + url, + headers=headers, + json=data if data is not None else None, + params=params, + timeout=TIMEOUT, ) - response = session.post( + return _parse_response(response, result_type) + + +async def post_async( + client: httpx.AsyncClient, + url: str, + token: str | None = None, + request_id: str | None = None, + *, + params: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + result_type: type[T] | None = None, +) -> T: + headers = create_headers(token=token, request_id=request_id) + + response = await client.post( url, headers=headers, - data=json.dumps(data) if data else None, + json=data if data is not None else None, params=params, timeout=TIMEOUT, ) - if response.status_code == codes.OK: - return cast("T", response.json()) + return _parse_response(response, result_type) + + +def delete( + client: httpx.Client, + url: str, + token: str | None = None, + request_id: str | None = None, + params: dict[str, Any] | None = None, +) -> bool: + headers = create_headers(token=token, request_id=request_id) + + response = client.delete(url, params=params, headers=headers, timeout=TIMEOUT) response.raise_for_status() - return cast("T", response.ok) + return response.is_success -def delete( - session: Session, +async def delete_async( + client: httpx.AsyncClient, url: str, token: str | None = None, request_id: str | None = None, @@ -83,7 +140,7 @@ def delete( ) -> bool: headers = create_headers(token=token, request_id=request_id) - response = session.delete(url, params=params, headers=headers, timeout=TIMEOUT) + response = await client.delete(url, params=params, headers=headers, timeout=TIMEOUT) response.raise_for_status() - return response.ok + return response.is_success diff --git a/todoist_api_python/_core/type_aliases.py b/todoist_api_python/_core/type_aliases.py new file mode 100644 index 0000000..75eb59d --- /dev/null +++ b/todoist_api_python/_core/type_aliases.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from todoist_api_python.types import ColorString, LanguageCode, ViewStyle + +__all__ = ["ColorString", "LanguageCode", "ViewStyle"] diff --git a/todoist_api_python/_core/utils.py b/todoist_api_python/_core/utils.py index c612f6e..9d5dab3 100644 --- a/todoist_api_python/_core/utils.py +++ b/todoist_api_python/_core/utils.py @@ -1,41 +1,15 @@ from __future__ import annotations -import asyncio import sys import uuid from datetime import date, datetime, timezone -from typing import TYPE_CHECKING, TypeVar, cast - -if TYPE_CHECKING: - from collections.abc import AsyncGenerator, Callable, Iterator +from typing import TypeVar if sys.version_info >= (3, 11): from datetime import UTC else: UTC = timezone.utc -T = TypeVar("T") - - -async def run_async(func: Callable[[], T]) -> T: - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, func) - - -async def generate_async(iterator: Iterator[T]) -> AsyncGenerator[T]: - def get_next_item() -> tuple[bool, T | None]: - try: - return True, next(iterator) - except StopIteration: - return False, None - - while True: - has_more, item = await run_async(get_next_item) - if has_more is True: - yield cast("T", item) - else: - break - def format_date(d: date) -> str: """Format a date object as YYYY-MM-DD.""" @@ -64,14 +38,20 @@ def parse_datetime(datetime_str: str) -> datetime: YYYY-MM-DDTHH:MM:SS for naive datetimes; YYYY-MM-DDTHH:MM:SSZ for aware datetimes. """ - from datetime import datetime - if datetime_str.endswith("Z"): datetime_str = datetime_str[:-1] + "+00:00" return datetime.fromisoformat(datetime_str) return datetime.fromisoformat(datetime_str) +V = TypeVar("V") + + +def kwargs_without_none(**values: V | None) -> dict[str, V]: + """Return a dictionary without keys whose value is `None`.""" + return {key: value for key, value in values.items() if value is not None} + + def default_request_id_fn() -> str: """Generate random UUIDv4s as the default request ID.""" return str(uuid.uuid4()) diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index 85343cf..44c59a6 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -5,8 +5,8 @@ from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeVar from weakref import finalize -import requests -from annotated_types import Ge, Le, MaxLen, MinLen, Predicate +import httpx +from annotated_types import Ge, Le, MaxLen, MinLen from todoist_api_python._core.endpoints import ( COLLABORATORS_PATH, @@ -34,6 +34,7 @@ default_request_id_fn, format_date, format_datetime, + kwargs_without_none, ) from todoist_api_python.models import ( Attachment, @@ -49,44 +50,14 @@ from datetime import date, datetime from types import TracebackType + from todoist_api_python.types import ColorString, LanguageCode, ViewStyle + if sys.version_info >= (3, 11): from typing import Self else: Self = TypeVar("Self", bound="TodoistAPI") -LanguageCode = Annotated[str, Predicate(lambda x: len(x) == 2)] # noqa: PLR2004 -ColorString = Annotated[ - str, - Predicate( - lambda x: x - in ( - "berry_red", - "red", - "orange", - "yellow", - "olive_green", - "lime_green", - "green", - "mint_green", - "teal", - "sky_blue", - "light_blue", - "blue", - "grape", - "violet", - "lavender", - "magenta", - "salmon", - "charcoal", - "grey", - "taupe", - ) - ), -] -ViewStyle = Annotated[str, Predicate(lambda x: x in ("list", "board", "calendar"))] - - class TodoistAPI: """ Client for the Todoist API. @@ -94,27 +65,27 @@ class TodoistAPI: Provides methods for interacting with Todoist resources like tasks, projects, labels, comments, etc. - Manages an HTTP session and handles authentication. Can be used as a context manager - to ensure the session is closed properly. + Manages an HTTP client and handles authentication. Can be used as a context manager + to ensure the client is closed properly. """ def __init__( self, token: str, request_id_fn: Callable[[], str] | None = default_request_id_fn, - session: requests.Session | None = None, + client: httpx.Client | None = None, ) -> None: """ Initialize the TodoistAPI client. :param token: Authentication token for the Todoist API. :param request_id_fn: Generator of request IDs for the `X-Request-ID` header. - :param session: An optional pre-configured requests `Session` object. + :param client: An optional pre-configured `httpx.Client` object. """ self._token = token self._request_id_fn = request_id_fn - self._session = session or requests.Session() - self._finalizer = finalize(self, self._session.close) + self._client = client or httpx.Client() + self._finalizer = finalize(self, self._client.close) def __enter__(self) -> Self: """ @@ -133,7 +104,7 @@ def __exit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - """Exit the runtime context and closes the underlying requests session.""" + """Exit the runtime context and close the underlying httpx client.""" self._finalizer() def get_task(self, task_id: str) -> Task: @@ -142,12 +113,12 @@ def get_task(self, task_id: str) -> Task: :param task_id: The ID of the task to retrieve. :return: The requested task. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Task dictionary. """ endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") task_data: dict[str, Any] = get( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -178,27 +149,22 @@ def get_tasks( :param ids: A list of the IDs of the tasks to retrieve. :param limit: Maximum number of tasks per page. :return: An iterable of lists of tasks. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(TASKS_PATH) - params: dict[str, Any] = {} - if project_id is not None: - params["project_id"] = project_id - if section_id is not None: - params["section_id"] = section_id - if parent_id is not None: - params["parent_id"] = parent_id - if label is not None: - params["label"] = label - if ids is not None: - params["ids"] = ",".join(str(i) for i in ids) - if limit is not None: - params["limit"] = limit + params = kwargs_without_none( + project_id=project_id, + section_id=section_id, + parent_id=parent_id, + label=label, + ids=",".join(str(i) for i in ids) if ids is not None else None, + limit=limit, + ) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Task.from_dict, @@ -225,21 +191,15 @@ def filter_tasks( :param lang: Language for task content (e.g., 'en'). :param limit: Maximum number of tasks per page. :return: An iterable of lists of tasks. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(TASKS_FILTER_PATH) - params: dict[str, Any] = {} - if query is not None: - params["query"] = query - if lang is not None: - params["lang"] = lang - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(query=query, lang=lang, limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Task.from_dict, @@ -248,7 +208,7 @@ def filter_tasks( params, ) - def add_task( # noqa: PLR0912 + def add_task( self, content: Annotated[str, MinLen(1), MaxLen(500)], *, @@ -294,51 +254,39 @@ def add_task( # noqa: PLR0912 :param deadline_date: The deadline date as a date object. :param deadline_lang: Language for parsing the deadline date. :return: The newly created task. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Task dictionary. """ endpoint = get_api_url(TASKS_PATH) - data: dict[str, Any] = {"content": content} - if description is not None: - data["description"] = description - if project_id is not None: - data["project_id"] = project_id - if section_id is not None: - data["section_id"] = section_id - if parent_id is not None: - data["parent_id"] = parent_id - if labels is not None: - data["labels"] = labels - if priority is not None: - data["priority"] = priority - if due_string is not None: - data["due_string"] = due_string - if due_lang is not None: - data["due_lang"] = due_lang - if due_date is not None: - data["due_date"] = format_date(due_date) - if due_datetime is not None: - data["due_datetime"] = format_datetime(due_datetime) - if assignee_id is not None: - data["assignee_id"] = assignee_id - if order is not None: - data["order"] = order - if auto_reminder is not None: - data["auto_reminder"] = auto_reminder - if auto_parse_labels is not None: - data["auto_parse_labels"] = auto_parse_labels - if duration is not None: - data["duration"] = duration - if duration_unit is not None: - data["duration_unit"] = duration_unit - if deadline_date is not None: - data["deadline_date"] = format_date(deadline_date) - if deadline_lang is not None: - data["deadline_lang"] = deadline_lang + data = kwargs_without_none( + content=content, + description=description, + project_id=project_id, + section_id=section_id, + parent_id=parent_id, + labels=labels, + priority=priority, + due_string=due_string, + due_lang=due_lang, + due_date=format_date(due_date) if due_date is not None else None, + due_datetime=( + format_datetime(due_datetime) if due_datetime is not None else None + ), + assignee_id=assignee_id, + order=order, + auto_reminder=auto_reminder, + auto_parse_labels=auto_parse_labels, + duration=duration, + duration_unit=duration_unit, + deadline_date=( + format_date(deadline_date) if deadline_date is not None else None + ), + deadline_lang=deadline_lang, + ) task_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -365,24 +313,21 @@ def add_task_quick( :param reminder: Optional reminder date in free form text. :param auto_reminder: Whether to add default reminder if date with time is set. :return: A result object containing the parsed task data and metadata. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response cannot be parsed into a QuickAddResult. """ endpoint = get_api_url(TASKS_QUICK_ADD_PATH) - data = { - "meta": True, - "text": text, - "auto_reminder": auto_reminder, - } - - if note is not None: - data["note"] = note - if reminder is not None: - data["reminder"] = reminder + data = kwargs_without_none( + meta=True, + text=text, + auto_reminder=auto_reminder, + note=note, + reminder=reminder, + ) task_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -390,7 +335,7 @@ def add_task_quick( ) return Task.from_dict(task_data) - def update_task( # noqa: PLR0912 + def update_task( self, task_id: str, *, @@ -432,44 +377,34 @@ def update_task( # noqa: PLR0912 :param deadline_date: The deadline date as a date object. :param deadline_lang: Language for parsing the deadline date. :return: the updated Task. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") - data: dict[str, Any] = {} - if content is not None: - data["content"] = content - if description is not None: - data["description"] = description - if labels is not None: - data["labels"] = labels - if priority is not None: - data["priority"] = priority - if due_string is not None: - data["due_string"] = due_string - if due_lang is not None: - data["due_lang"] = due_lang - if due_date is not None: - data["due_date"] = format_date(due_date) - if due_datetime is not None: - data["due_datetime"] = format_datetime(due_datetime) - if assignee_id is not None: - data["assignee_id"] = assignee_id - if day_order is not None: - data["day_order"] = day_order - if collapsed is not None: - data["collapsed"] = collapsed - if duration is not None: - data["duration"] = duration - if duration_unit is not None: - data["duration_unit"] = duration_unit - if deadline_date is not None: - data["deadline_date"] = format_date(deadline_date) - if deadline_lang is not None: - data["deadline_lang"] = deadline_lang + data = kwargs_without_none( + content=content, + description=description, + labels=labels, + priority=priority, + due_string=due_string, + due_lang=due_lang, + due_date=format_date(due_date) if due_date is not None else None, + due_datetime=( + format_datetime(due_datetime) if due_datetime is not None else None + ), + assignee_id=assignee_id, + day_order=day_order, + collapsed=collapsed, + duration=duration, + duration_unit=duration_unit, + deadline_date=( + format_date(deadline_date) if deadline_date is not None else None + ), + deadline_lang=deadline_lang, + ) task_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -487,11 +422,11 @@ def complete_task(self, task_id: str) -> bool: :param task_id: The ID of the task to close. :return: True if the task was closed successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/close") return post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -506,11 +441,11 @@ def uncomplete_task(self, task_id: str) -> bool: :param task_id: The ID of the task to reopen. :return: True if the task was uncompleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/reopen") return post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -536,7 +471,7 @@ def move_task( :param parent_id: The ID of the parent to move the task to. :return: True if the task was moved successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises ValueError: If neither `project_id`, `section_id`, nor `parent_id` is provided. """ @@ -545,16 +480,14 @@ def move_task( "Either `project_id`, `section_id`, or `parent_id` must be provided." ) - data: dict[str, Any] = {} - if project_id is not None: - data["project_id"] = project_id - if section_id is not None: - data["section_id"] = section_id - if parent_id is not None: - data["parent_id"] = parent_id + data = kwargs_without_none( + project_id=project_id, + section_id=section_id, + parent_id=parent_id, + ) endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/move") return post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -568,11 +501,11 @@ def delete_task(self, task_id: str) -> bool: :param task_id: The ID of the task to delete. :return: True if the task was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") return delete( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -611,32 +544,25 @@ def get_completed_tasks_by_due_date( :param filter_lang: Language for the filter query (e.g., 'en'). :param limit: Maximum number of tasks per page (default 50). :return: An iterable of lists of completed tasks. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(TASKS_COMPLETED_BY_DUE_DATE_PATH) - params: dict[str, Any] = { - "since": format_datetime(since), - "until": format_datetime(until), - } - if workspace_id is not None: - params["workspace_id"] = workspace_id - if project_id is not None: - params["project_id"] = project_id - if section_id is not None: - params["section_id"] = section_id - if parent_id is not None: - params["parent_id"] = parent_id - if filter_query is not None: - params["filter_query"] = filter_query - if filter_lang is not None: - params["filter_lang"] = filter_lang - if limit is not None: - params["limit"] = limit + params = kwargs_without_none( + since=format_datetime(since), + until=format_datetime(until), + workspace_id=workspace_id, + project_id=project_id, + section_id=section_id, + parent_id=parent_id, + filter_query=filter_query, + filter_lang=filter_lang, + limit=limit, + ) return ResultsPaginator( - self._session, + self._client, endpoint, "items", Task.from_dict, @@ -672,26 +598,22 @@ def get_completed_tasks_by_completion_date( :param filter_lang: Language for the filter query (e.g., 'en'). :param limit: Maximum number of tasks per page (default 50). :return: An iterable of lists of completed tasks. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(TASKS_COMPLETED_BY_COMPLETION_DATE_PATH) - params: dict[str, Any] = { - "since": format_datetime(since), - "until": format_datetime(until), - } - if workspace_id is not None: - params["workspace_id"] = workspace_id - if filter_query is not None: - params["filter_query"] = filter_query - if filter_lang is not None: - params["filter_lang"] = filter_lang - if limit is not None: - params["limit"] = limit + params = kwargs_without_none( + since=format_datetime(since), + until=format_datetime(until), + workspace_id=workspace_id, + filter_query=filter_query, + filter_lang=filter_lang, + limit=limit, + ) return ResultsPaginator( - self._session, + self._client, endpoint, "items", Task.from_dict, @@ -706,12 +628,12 @@ def get_project(self, project_id: str) -> Project: :param project_id: The ID of the project to retrieve. :return: The requested project. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Project dictionary. """ endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") project_data: dict[str, Any] = get( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -731,15 +653,13 @@ def get_projects( :param limit: Maximum number of projects per page. :return: An iterable of lists of projects. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(PROJECTS_PATH) - params: dict[str, Any] = {} - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Project.from_dict, @@ -764,17 +684,15 @@ def search_projects( :param query: Query string for project names. :param limit: Maximum number of projects per page. :return: An iterable of lists of projects. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(f"{PROJECTS_PATH}/{PROJECTS_SEARCH_PATH_SUFFIX}") - params: dict[str, Any] = {"query": query} - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(query=query, limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Project.from_dict, @@ -803,25 +721,22 @@ def add_project( :param is_favorite: Whether the project is a favorite. :param view_style: A string value (either 'list' or 'board', default is 'list'). :return: The newly created project. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Project dictionary. """ endpoint = get_api_url(PROJECTS_PATH) - data: dict[str, Any] = {"name": name} - if parent_id is not None: - data["parent_id"] = parent_id - if description is not None: - data["description"] = description - if color is not None: - data["color"] = color - if is_favorite is not None: - data["is_favorite"] = is_favorite - if view_style is not None: - data["view_style"] = view_style + data = kwargs_without_none( + name=name, + parent_id=parent_id, + description=description, + color=color, + is_favorite=is_favorite, + view_style=view_style, + ) project_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -851,25 +766,20 @@ def update_project( :param is_favorite: Whether the project is a favorite. :param view_style: A string value (either 'list' or 'board'). :return: the updated Project. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") - data: dict[str, Any] = {} - - if name is not None: - data["name"] = name - if description is not None: - data["description"] = description - if color is not None: - data["color"] = color - if is_favorite is not None: - data["is_favorite"] = is_favorite - if view_style is not None: - data["view_style"] = view_style + data = kwargs_without_none( + name=name, + description=description, + color=color, + is_favorite=is_favorite, + view_style=view_style, + ) project_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -886,14 +796,14 @@ def archive_project(self, project_id: str) -> Project: :param project_id: The ID of the project to archive. :return: The archived project object. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Project dictionary. """ endpoint = get_api_url( f"{PROJECTS_PATH}/{project_id}/{PROJECT_ARCHIVE_PATH_SUFFIX}" ) project_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -908,14 +818,14 @@ def unarchive_project(self, project_id: str) -> Project: :param project_id: The ID of the project to unarchive. :return: The unarchived project object. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Project dictionary. """ endpoint = get_api_url( f"{PROJECTS_PATH}/{project_id}/{PROJECT_UNARCHIVE_PATH_SUFFIX}" ) project_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -931,11 +841,11 @@ def delete_project(self, project_id: str) -> bool: :param project_id: The ID of the project to delete. :return: True if the project was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") return delete( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -956,15 +866,13 @@ def get_collaborators( :param project_id: The ID of the project. :param limit: Maximum number of collaborators per page. :return: An iterable of lists of collaborators. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}/{COLLABORATORS_PATH}") - params: dict[str, Any] = {} - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Collaborator.from_dict, @@ -979,12 +887,12 @@ def get_section(self, section_id: str) -> Section: :param section_id: The ID of the section to retrieve. :return: The requested section. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Section dictionary. """ endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") section_data: dict[str, Any] = get( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1009,19 +917,15 @@ def get_sections( :param project_id: Filter sections by project ID. :param limit: Maximum number of sections per page. :return: An iterable of lists of sections. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(SECTIONS_PATH) - params: dict[str, Any] = {} - if project_id is not None: - params["project_id"] = project_id - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(project_id=project_id, limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Section.from_dict, @@ -1048,19 +952,15 @@ def search_sections( :param project_id: If set, search sections within the given project only. :param limit: Maximum number of sections per page. :return: An iterable of lists of sections. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(f"{SECTIONS_PATH}/{SECTIONS_SEARCH_PATH_SUFFIX}") - params: dict[str, Any] = {"query": query} - if project_id is not None: - params["project_id"] = project_id - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(query=query, project_id=project_id, limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Section.from_dict, @@ -1083,17 +983,15 @@ def add_section( :param project_id: The ID of the project to add the section to. :param order: The order of the section among all sections in the project. :return: The newly created section. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Section dictionary. """ endpoint = get_api_url(SECTIONS_PATH) - data: dict[str, Any] = {"name": name, "project_id": project_id} - if order is not None: - data["order"] = order + data = kwargs_without_none(name=name, project_id=project_id, order=order) section_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1114,11 +1012,11 @@ def update_section( :param section_id: The ID of the section to update. :param name: The new name for the section. :return: the updated Section. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") section_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1135,11 +1033,11 @@ def delete_section(self, section_id: str) -> bool: :param section_id: The ID of the section to delete. :return: True if the section was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") return delete( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1151,12 +1049,12 @@ def get_comment(self, comment_id: str) -> Comment: :param comment_id: The ID of the comment to retrieve. :return: The requested comment. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Comment dictionary. """ endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") comment_data: dict[str, Any] = get( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1184,7 +1082,7 @@ def get_comments( :param limit: Maximum number of comments per page. :return: An iterable of lists of comments. :raises ValueError: If neither `project_id` nor `task_id` is provided. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ if project_id is None and task_id is None: @@ -1192,16 +1090,14 @@ def get_comments( endpoint = get_api_url(COMMENTS_PATH) - params: dict[str, Any] = {} - if project_id is not None: - params["project_id"] = project_id - if task_id is not None: - params["task_id"] = task_id - if limit is not None: - params["limit"] = limit + params = kwargs_without_none( + project_id=project_id, + task_id=task_id, + limit=limit, + ) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Comment.from_dict, @@ -1232,7 +1128,7 @@ def add_comment( :param uids_to_notify: A list of user IDs to notify. :return: The newly created comment. :raises ValueError: If neither `project_id` nor `task_id` is provided. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Comment dictionary. """ if project_id is None and task_id is None: @@ -1240,18 +1136,16 @@ def add_comment( endpoint = get_api_url(COMMENTS_PATH) - data: dict[str, Any] = {"content": content} - if project_id is not None: - data["project_id"] = project_id - if task_id is not None: - data["task_id"] = task_id - if attachment is not None: - data["attachment"] = attachment.to_dict() - if uids_to_notify is not None: - data["uids_to_notify"] = uids_to_notify + data = kwargs_without_none( + content=content, + project_id=project_id, + task_id=task_id, + attachment=attachment.to_dict() if attachment is not None else None, + uids_to_notify=uids_to_notify, + ) comment_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1270,11 +1164,11 @@ def update_comment( :param comment_id: The ID of the comment to update. :param content: The new text content for the comment. :return: the updated Comment. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") comment_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1289,11 +1183,11 @@ def delete_comment(self, comment_id: str) -> bool: :param comment_id: The ID of the comment to delete. :return: True if the comment was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") return delete( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1305,12 +1199,12 @@ def get_label(self, label_id: str) -> Label: :param label_id: The ID of the label to retrieve. :return: The requested label. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Label dictionary. """ endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") label_data: dict[str, Any] = get( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1333,17 +1227,15 @@ def get_labels( :param limit: Maximum number of labels per page. :return: An iterable of lists of personal labels. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(LABELS_PATH) - params: dict[str, Any] = {} - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Label.from_dict, @@ -1368,17 +1260,15 @@ def search_labels( :param query: Query string for label names. :param limit: Maximum number of labels per page. :return: An iterable of lists of labels. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(f"{LABELS_PATH}/{LABELS_SEARCH_PATH_SUFFIX}") - params: dict[str, Any] = {"query": query} - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(query=query, limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", Label.from_dict, @@ -1403,22 +1293,20 @@ def add_label( :param item_order: Label's order in the label list. :param is_favorite: Whether the label is a favorite. :return: The newly created label. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Label dictionary. """ endpoint = get_api_url(LABELS_PATH) - data: dict[str, Any] = {"name": name} - - if color is not None: - data["color"] = color - if item_order is not None: - data["item_order"] = item_order - if is_favorite is not None: - data["is_favorite"] = is_favorite + data = kwargs_without_none( + name=name, + color=color, + item_order=item_order, + is_favorite=is_favorite, + ) label_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1446,22 +1334,19 @@ def update_label( :param item_order: Label's order in the label list. :param is_favorite: Whether the label is a favorite. :return: the updated Label. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") - data: dict[str, Any] = {} - if name is not None: - data["name"] = name - if color is not None: - data["color"] = color - if item_order is not None: - data["item_order"] = item_order - if is_favorite is not None: - data["is_favorite"] = is_favorite + data = kwargs_without_none( + name=name, + color=color, + item_order=item_order, + is_favorite=is_favorite, + ) label_data: dict[str, Any] = post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1478,11 +1363,11 @@ def delete_label(self, label_id: str) -> bool: :param label_id: The ID of the label to delete. :return: True if the label was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") return delete( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1508,17 +1393,15 @@ def get_shared_labels( :param omit_personal: Optional boolean flag to omit personal label names. :param limit: Maximum number of labels per page. :return: An iterable of lists of shared label names (strings). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ endpoint = get_api_url(SHARED_LABELS_PATH) - params: dict[str, Any] = {"omit_personal": omit_personal} - if limit is not None: - params["limit"] = limit + params = kwargs_without_none(omit_personal=omit_personal, limit=limit) return ResultsPaginator( - self._session, + self._client, endpoint, "results", str, @@ -1539,13 +1422,14 @@ def rename_shared_label( :param new_name: The new name for the shared label. :return: True if the rename was successful, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(SHARED_LABELS_RENAME_PATH) return post( - self._session, + self._client, endpoint, self._token, + self._request_id_fn() if self._request_id_fn else None, params={"name": name}, data={"new_name": new_name}, ) @@ -1558,12 +1442,12 @@ def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: :param name: The name of the shared label to remove. :return: True if the removal was successful, - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(SHARED_LABELS_REMOVE_PATH) data = {"name": name} return post( - self._session, + self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, @@ -1583,7 +1467,7 @@ class ResultsPaginator(Iterator[list[T]]): requesting new pages as needed when iterating. """ - _session: requests.Session + _client: httpx.Client _url: str _results_field: str _results_inst: Callable[[Any], T] @@ -1592,7 +1476,7 @@ class ResultsPaginator(Iterator[list[T]]): def __init__( self, - session: requests.Session, + client: httpx.Client, url: str, results_field: str, results_inst: Callable[[Any], T], @@ -1603,14 +1487,14 @@ def __init__( """ Initialize the ResultsPaginator. - :param session: The requests Session to use for API calls. + :param client: The httpx client to use for API calls. :param url: The API endpoint URL to fetch results from. :param results_field: The key in the API response that contains the results. :param results_inst: A callable that converts result items to objects of type T. :param token: The authentication token for the Todoist API. :param params: Query parameters to include in API requests. """ - self._session = session + self._client = client self._url = url self._results_field = results_field self._results_inst = results_inst @@ -1624,7 +1508,7 @@ def __next__(self) -> list[T]: Fetch and return the next page of results from the Todoist API. :return: A list of results. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ if self._cursor is None: @@ -1635,7 +1519,7 @@ def __next__(self) -> list[T]: params["cursor"] = self._cursor data: dict[str, Any] = get( - self._session, + self._client, self._url, self._token, self._request_id_fn() if self._request_id_fn else None, diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index cb6945b..2a4e05e 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -1,39 +1,60 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Annotated, Callable, Literal, TypeVar +import warnings +from collections.abc import AsyncIterator, Callable +from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeVar +import httpx from annotated_types import Ge, Le, MaxLen, MinLen +from todoist_api_python._core.endpoints import ( + COLLABORATORS_PATH, + COMMENTS_PATH, + LABELS_PATH, + LABELS_SEARCH_PATH_SUFFIX, + PROJECT_ARCHIVE_PATH_SUFFIX, + PROJECT_UNARCHIVE_PATH_SUFFIX, + PROJECTS_PATH, + PROJECTS_SEARCH_PATH_SUFFIX, + SECTIONS_PATH, + SECTIONS_SEARCH_PATH_SUFFIX, + SHARED_LABELS_PATH, + SHARED_LABELS_REMOVE_PATH, + SHARED_LABELS_RENAME_PATH, + TASKS_COMPLETED_BY_COMPLETION_DATE_PATH, + TASKS_COMPLETED_BY_DUE_DATE_PATH, + TASKS_FILTER_PATH, + TASKS_PATH, + TASKS_QUICK_ADD_PATH, + get_api_url, +) +from todoist_api_python._core.http_requests import ( + delete_async, + get_async, + post_async, +) from todoist_api_python._core.utils import ( default_request_id_fn, - generate_async, - run_async, + format_date, + format_datetime, + kwargs_without_none, +) +from todoist_api_python.models import ( + Attachment, + Collaborator, + Comment, + Label, + Project, + Section, + Task, ) -from todoist_api_python.api import TodoistAPI if TYPE_CHECKING: - from collections.abc import AsyncGenerator from datetime import date, datetime from types import TracebackType - import requests - - from todoist_api_python.models import ( - Attachment, - Collaborator, - Comment, - Label, - Project, - Section, - Task, - ) - -from todoist_api_python.api import ( - ColorString, - LanguageCode, - ViewStyle, -) + from todoist_api_python.types import ColorString, LanguageCode, ViewStyle if sys.version_info >= (3, 11): from typing import Self @@ -45,30 +66,37 @@ class TodoistAPIAsync: """ Async client for the Todoist API. - Provides asynchronous methods for interacting with Todoist resources like tasks, - projects,labels, comments, etc. + Provides asynchronous methods for interacting with Todoist resources like + tasks, projects, labels, comments, etc. + + Manages an HTTP client and handles authentication. - Manages an HTTP session and handles authentication. Can be used as an async context - manager to ensure the session is closed properly. + Prefer using this class as an async context manager to ensure the underlying + `httpx.AsyncClient` is always closed. If you do not use `async with`, call + `await close()` explicitly. """ def __init__( self, token: str, request_id_fn: Callable[[], str] | None = default_request_id_fn, - session: requests.Session | None = None, + client: httpx.AsyncClient | None = None, ) -> None: """ Initialize the TodoistAPIAsync client. :param token: Authentication token for the Todoist API. - :param session: An optional pre-configured requests `Session` object. + :param request_id_fn: Generator of request IDs for the `X-Request-ID` header. + :param client: An optional pre-configured `httpx.AsyncClient` object, to be + fully managed by `TodoistAPIAsync`. """ - self._api = TodoistAPI(token, request_id_fn, session) + self._token = token + self._request_id_fn = request_id_fn + self._client = client or httpx.AsyncClient() async def __aenter__(self) -> Self: """ - Enters the async runtime context related to this object. + Enters the runtime context related to this object. The with statement will bind this method's return value to the target(s) specified in the as clause of the statement, if any. @@ -77,13 +105,31 @@ async def __aenter__(self) -> Self: """ return self - def __aexit__( + async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - """Exit the async runtime context and closes the underlying requests session.""" + """Exit the async runtime context and close the underlying httpx client.""" + await self.close() + + async def close(self) -> None: + """Close the underlying `httpx.AsyncClient`.""" + await self._client.aclose() + + def __del__(self) -> None: + """Warn when the async client was not explicitly closed.""" + client = getattr(self, "_client", None) + if client is None or client.is_closed: + return + + warnings.warn( + "TodoistAPIAsync client was not closed. " + "Use `async with TodoistAPIAsync(...)` or call `await api.close()`.", + ResourceWarning, + stacklevel=2, + ) async def get_task(self, task_id: str) -> Task: """ @@ -91,10 +137,17 @@ async def get_task(self, task_id: str) -> Task: :param task_id: The ID of the task to retrieve. :return: The requested task. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Task dictionary. """ - return await run_async(lambda: self._api.get_task(task_id)) + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") + task_data: dict[str, Any] = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return Task.from_dict(task_data) async def get_tasks( self, @@ -105,30 +158,44 @@ async def get_tasks( label: str | None = None, ids: list[str] | None = None, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Task]]: + ) -> AsyncIterator[list[Task]]: """ - Get a list of active tasks. + Get an iterable of lists of active tasks. + + The response is an iterable of lists of active tasks matching the criteria. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. :param project_id: Filter tasks by project ID. :param section_id: Filter tasks by section ID. :param parent_id: Filter tasks by parent task ID. :param label: Filter tasks by label name. :param ids: A list of the IDs of the tasks to retrieve. - :param limit: Maximum number of tasks per page (between 1 and 200). - :return: A list of tasks. - :raises requests.exceptions.HTTPError: If the API request fails. + :param limit: Maximum number of tasks per page. + :return: An iterable of lists of tasks. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_tasks( + endpoint = get_api_url(TASKS_PATH) + + params = kwargs_without_none( project_id=project_id, section_id=section_id, parent_id=parent_id, label=label, - ids=ids, + ids=",".join(str(i) for i in ids) if ids is not None else None, limit=limit, ) - return generate_async(paginator) + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Task.from_dict, + self._token, + self._request_id_fn, + params, + ) async def filter_tasks( self, @@ -136,9 +203,9 @@ async def filter_tasks( query: Annotated[str, MaxLen(1024)] | None = None, lang: str | None = None, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Task]]: + ) -> AsyncIterator[list[Task]]: """ - Get a lists of active tasks matching the filter. + Get an iterable of lists of active tasks matching the filter. The response is an iterable of lists of active tasks matching the criteria. Be aware that each iteration fires off a network request to the Todoist API, @@ -146,17 +213,24 @@ async def filter_tasks( :param query: Query tasks using Todoist's filter language. :param lang: Language for task content (e.g., 'en'). - :param limit: Maximum number of tasks per page (between 1 and 200). + :param limit: Maximum number of tasks per page. :return: An iterable of lists of tasks. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.filter_tasks( - query=query, - lang=lang, - limit=limit, + endpoint = get_api_url(TASKS_FILTER_PATH) + + params = kwargs_without_none(query=query, lang=lang, limit=limit) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Task.from_dict, + self._token, + self._request_id_fn, + params, ) - return generate_async(paginator) async def add_task( self, @@ -169,9 +243,9 @@ async def add_task( labels: list[Annotated[str, MaxLen(100)]] | None = None, priority: Annotated[int, Ge(1), Le(4)] | None = None, due_string: Annotated[str, MaxLen(150)] | None = None, + due_lang: LanguageCode | None = None, due_date: date | None = None, due_datetime: datetime | None = None, - due_lang: LanguageCode | None = None, assignee_id: str | None = None, order: int | None = None, auto_reminder: bool | None = None, @@ -204,32 +278,45 @@ async def add_task( :param deadline_date: The deadline date as a date object. :param deadline_lang: Language for parsing the deadline date. :return: The newly created task. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Task dictionary. """ - return await run_async( - lambda: self._api.add_task( - content, - description=description, - project_id=project_id, - section_id=section_id, - parent_id=parent_id, - labels=labels, - priority=priority, - due_string=due_string, - due_lang=due_lang, - due_date=due_date, - due_datetime=due_datetime, - assignee_id=assignee_id, - order=order, - auto_reminder=auto_reminder, - auto_parse_labels=auto_parse_labels, - duration=duration, - duration_unit=duration_unit, - deadline_date=deadline_date, - deadline_lang=deadline_lang, - ) + endpoint = get_api_url(TASKS_PATH) + + data = kwargs_without_none( + content=content, + description=description, + project_id=project_id, + section_id=section_id, + parent_id=parent_id, + labels=labels, + priority=priority, + due_string=due_string, + due_lang=due_lang, + due_date=format_date(due_date) if due_date is not None else None, + due_datetime=( + format_datetime(due_datetime) if due_datetime is not None else None + ), + assignee_id=assignee_id, + order=order, + auto_reminder=auto_reminder, + auto_parse_labels=auto_parse_labels, + duration=duration, + duration_unit=duration_unit, + deadline_date=( + format_date(deadline_date) if deadline_date is not None else None + ), + deadline_lang=deadline_lang, + ) + + task_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) + return Task.from_dict(task_data) async def add_task_quick( self, @@ -250,15 +337,28 @@ async def add_task_quick( :param reminder: Optional reminder date in free form text. :param auto_reminder: Whether to add default reminder if date with time is set. :return: A result object containing the parsed task data and metadata. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response cannot be parsed into a QuickAddResult. """ - return await run_async( - lambda: self._api.add_task_quick( - text, note=note, reminder=reminder, auto_reminder=auto_reminder - ) + endpoint = get_api_url(TASKS_QUICK_ADD_PATH) + + data = kwargs_without_none( + meta=True, + text=text, + auto_reminder=auto_reminder, + note=note, + reminder=reminder, ) + task_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + return Task.from_dict(task_data) + async def update_task( self, task_id: str, @@ -301,28 +401,40 @@ async def update_task( :param deadline_date: The deadline date as a date object. :param deadline_lang: Language for parsing the deadline date. :return: the updated Task. - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async( - lambda: self._api.update_task( - task_id, - content=content, - description=description, - labels=labels, - priority=priority, - due_string=due_string, - due_date=due_date, - due_datetime=due_datetime, - due_lang=due_lang, - assignee_id=assignee_id, - day_order=day_order, - collapsed=collapsed, - duration=duration, - duration_unit=duration_unit, - deadline_date=deadline_date, - deadline_lang=deadline_lang, - ) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") + + data = kwargs_without_none( + content=content, + description=description, + labels=labels, + priority=priority, + due_string=due_string, + due_lang=due_lang, + due_date=format_date(due_date) if due_date is not None else None, + due_datetime=( + format_datetime(due_datetime) if due_datetime is not None else None + ), + assignee_id=assignee_id, + day_order=day_order, + collapsed=collapsed, + duration=duration, + duration_unit=duration_unit, + deadline_date=( + format_date(deadline_date) if deadline_date is not None else None + ), + deadline_lang=deadline_lang, + ) + + task_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) + return Task.from_dict(task_data) async def complete_task(self, task_id: str) -> bool: """ @@ -334,9 +446,15 @@ async def complete_task(self, task_id: str) -> bool: :param task_id: The ID of the task to close. :return: True if the task was closed successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.complete_task(task_id)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/close") + return await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) async def uncomplete_task(self, task_id: str) -> bool: """ @@ -347,9 +465,15 @@ async def uncomplete_task(self, task_id: str) -> bool: :param task_id: The ID of the task to reopen. :return: True if the task was uncompleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.uncomplete_task(task_id)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/reopen") + return await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) async def move_task( self, @@ -371,17 +495,27 @@ async def move_task( :param parent_id: The ID of the parent to move the task to. :return: True if the task was moved successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises ValueError: If neither `project_id`, `section_id`, nor `parent_id` is provided. """ - return await run_async( - lambda: self._api.move_task( - task_id, - project_id=project_id, - section_id=section_id, - parent_id=parent_id, + if project_id is None and section_id is None and parent_id is None: + raise ValueError( + "Either `project_id`, `section_id`, or `parent_id` must be provided." ) + + data = kwargs_without_none( + project_id=project_id, + section_id=section_id, + parent_id=parent_id, + ) + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/move") + return await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) async def delete_task(self, task_id: str) -> bool: @@ -391,9 +525,15 @@ async def delete_task(self, task_id: str) -> bool: :param task_id: The ID of the task to delete. :return: True if the task was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.delete_task(task_id)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") + return await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) async def get_completed_tasks_by_due_date( self, @@ -407,7 +547,7 @@ async def get_completed_tasks_by_due_date( filter_query: str | None = None, filter_lang: str | None = None, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Task]]: + ) -> AsyncIterator[list[Task]]: """ Get an iterable of lists of completed tasks within a due date range. @@ -428,12 +568,14 @@ async def get_completed_tasks_by_due_date( :param filter_lang: Language for the filter query (e.g., 'en'). :param limit: Maximum number of tasks per page (default 50). :return: An iterable of lists of completed tasks. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_completed_tasks_by_due_date( - since=since, - until=until, + endpoint = get_api_url(TASKS_COMPLETED_BY_DUE_DATE_PATH) + + params = kwargs_without_none( + since=format_datetime(since), + until=format_datetime(until), workspace_id=workspace_id, project_id=project_id, section_id=section_id, @@ -442,7 +584,16 @@ async def get_completed_tasks_by_due_date( filter_lang=filter_lang, limit=limit, ) - return generate_async(paginator) + + return AsyncResultsPaginator( + self._client, + endpoint, + "items", + Task.from_dict, + self._token, + self._request_id_fn, + params, + ) async def get_completed_tasks_by_completion_date( self, @@ -453,7 +604,7 @@ async def get_completed_tasks_by_completion_date( filter_query: str | None = None, filter_lang: str | None = None, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Task]]: + ) -> AsyncIterator[list[Task]]: """ Get an iterable of lists of completed tasks within a date range. @@ -471,18 +622,29 @@ async def get_completed_tasks_by_completion_date( :param filter_lang: Language for the filter query (e.g., 'en'). :param limit: Maximum number of tasks per page (default 50). :return: An iterable of lists of completed tasks. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_completed_tasks_by_completion_date( - since=since, - until=until, + endpoint = get_api_url(TASKS_COMPLETED_BY_COMPLETION_DATE_PATH) + + params = kwargs_without_none( + since=format_datetime(since), + until=format_datetime(until), workspace_id=workspace_id, filter_query=filter_query, filter_lang=filter_lang, limit=limit, ) - return generate_async(paginator) + + return AsyncResultsPaginator( + self._client, + endpoint, + "items", + Task.from_dict, + self._token, + self._request_id_fn, + params, + ) async def get_project(self, project_id: str) -> Project: """ @@ -490,32 +652,52 @@ async def get_project(self, project_id: str) -> Project: :param project_id: The ID of the project to retrieve. :return: The requested project. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Project dictionary. """ - return await run_async(lambda: self._api.get_project(project_id)) + endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") + project_data: dict[str, Any] = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return Project.from_dict(project_data) async def get_projects( self, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Project]]: + ) -> AsyncIterator[list[Project]]: """ - Get a list of active projects. + Get an iterable of lists of active projects. + + The response is an iterable of lists of active projects. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. :param limit: Maximum number of projects per page. - :return: A list of projects. - :raises requests.exceptions.HTTPError: If the API request fails. + :return: An iterable of lists of projects. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_projects(limit=limit) - return generate_async(paginator) + endpoint = get_api_url(PROJECTS_PATH) + params = kwargs_without_none(limit=limit) + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Project.from_dict, + self._token, + self._request_id_fn, + params, + ) async def search_projects( self, query: Annotated[str, MinLen(1), MaxLen(1024)], *, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Project]]: + ) -> AsyncIterator[list[Project]]: """ Search active projects by name. @@ -526,11 +708,22 @@ async def search_projects( :param query: Query string for project names. :param limit: Maximum number of projects per page. :return: An iterable of lists of projects. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.search_projects(query, limit=limit) - return generate_async(paginator) + endpoint = get_api_url(f"{PROJECTS_PATH}/{PROJECTS_SEARCH_PATH_SUFFIX}") + + params = kwargs_without_none(query=query, limit=limit) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Project.from_dict, + self._token, + self._request_id_fn, + params, + ) async def add_project( self, @@ -552,19 +745,28 @@ async def add_project( :param is_favorite: Whether the project is a favorite. :param view_style: A string value (either 'list' or 'board', default is 'list'). :return: The newly created project. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Project dictionary. """ - return await run_async( - lambda: self._api.add_project( - name, - description=description, - parent_id=parent_id, - color=color, - is_favorite=is_favorite, - view_style=view_style, - ) + endpoint = get_api_url(PROJECTS_PATH) + + data = kwargs_without_none( + name=name, + parent_id=parent_id, + description=description, + color=color, + is_favorite=is_favorite, + view_style=view_style, + ) + + project_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) + return Project.from_dict(project_data) async def update_project( self, @@ -588,18 +790,26 @@ async def update_project( :param is_favorite: Whether the project is a favorite. :param view_style: A string value (either 'list' or 'board'). :return: the updated Project. - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async( - lambda: self._api.update_project( - project_id, - name=name, - description=description, - color=color, - is_favorite=is_favorite, - view_style=view_style, - ) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") + + data = kwargs_without_none( + name=name, + description=description, + color=color, + is_favorite=is_favorite, + view_style=view_style, + ) + + project_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) + return Project.from_dict(project_data) async def archive_project(self, project_id: str) -> Project: """ @@ -610,10 +820,19 @@ async def archive_project(self, project_id: str) -> Project: :param project_id: The ID of the project to archive. :return: The archived project object. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Project dictionary. """ - return await run_async(lambda: self._api.archive_project(project_id)) + endpoint = get_api_url( + f"{PROJECTS_PATH}/{project_id}/{PROJECT_ARCHIVE_PATH_SUFFIX}" + ) + project_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return Project.from_dict(project_data) async def unarchive_project(self, project_id: str) -> Project: """ @@ -623,10 +842,19 @@ async def unarchive_project(self, project_id: str) -> Project: :param project_id: The ID of the project to unarchive. :return: The unarchived project object. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Project dictionary. """ - return await run_async(lambda: self._api.unarchive_project(project_id)) + endpoint = get_api_url( + f"{PROJECTS_PATH}/{project_id}/{PROJECT_UNARCHIVE_PATH_SUFFIX}" + ) + project_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return Project.from_dict(project_data) async def delete_project(self, project_id: str) -> bool: """ @@ -637,26 +865,45 @@ async def delete_project(self, project_id: str) -> bool: :param project_id: The ID of the project to delete. :return: True if the project was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.delete_project(project_id)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") + return await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) async def get_collaborators( self, project_id: str, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Collaborator]]: + ) -> AsyncIterator[list[Collaborator]]: """ - Get a list of collaborators in shared projects. + Get an iterable of lists of collaborators in shared projects. + + The response is an iterable of lists of collaborators in shared projects, + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. :param project_id: The ID of the project. :param limit: Maximum number of collaborators per page. - :return: A list of collaborators. - :raises requests.exceptions.HTTPError: If the API request fails. + :return: An iterable of lists of collaborators. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_collaborators(project_id, limit=limit) - return generate_async(paginator) + endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}/{COLLABORATORS_PATH}") + params = kwargs_without_none(limit=limit) + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Collaborator.from_dict, + self._token, + self._request_id_fn, + params, + ) async def get_section(self, section_id: str) -> Section: """ @@ -664,30 +911,52 @@ async def get_section(self, section_id: str) -> Section: :param section_id: The ID of the section to retrieve. :return: The requested section. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Section dictionary. """ - return await run_async(lambda: self._api.get_section(section_id)) + endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") + section_data: dict[str, Any] = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return Section.from_dict(section_data) async def get_sections( self, project_id: str | None = None, *, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Section]]: + ) -> AsyncIterator[list[Section]]: """ - Get a list of active sections. + Get an iterable of lists of active sections. Supports filtering by `project_id` and pagination arguments. + The response is an iterable of lists of active sections. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + :param project_id: Filter sections by project ID. - :param limit: Maximum number of sections per page (between 1 and 200). - :return: A list of sections. - :raises requests.exceptions.HTTPError: If the API request fails. + :param limit: Maximum number of sections per page. + :return: An iterable of lists of sections. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_sections(project_id=project_id, limit=limit) - return generate_async(paginator) + endpoint = get_api_url(SECTIONS_PATH) + + params = kwargs_without_none(project_id=project_id, limit=limit) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Section.from_dict, + self._token, + self._request_id_fn, + params, + ) async def search_sections( self, @@ -695,7 +964,7 @@ async def search_sections( *, project_id: str | None = None, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Section]]: + ) -> AsyncIterator[list[Section]]: """ Search active sections by name. @@ -707,15 +976,22 @@ async def search_sections( :param project_id: If set, search sections within the given project only. :param limit: Maximum number of sections per page. :return: An iterable of lists of sections. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.search_sections( - query, - project_id=project_id, - limit=limit, + endpoint = get_api_url(f"{SECTIONS_PATH}/{SECTIONS_SEARCH_PATH_SUFFIX}") + + params = kwargs_without_none(query=query, project_id=project_id, limit=limit) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Section.from_dict, + self._token, + self._request_id_fn, + params, ) - return generate_async(paginator) async def add_section( self, @@ -731,12 +1007,21 @@ async def add_section( :param project_id: The ID of the project to add the section to. :param order: The order of the section among all sections in the project. :return: The newly created section. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Section dictionary. """ - return await run_async( - lambda: self._api.add_section(name, project_id, order=order) + endpoint = get_api_url(SECTIONS_PATH) + + data = kwargs_without_none(name=name, project_id=project_id, order=order) + + section_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) + return Section.from_dict(section_data) async def update_section( self, @@ -751,9 +1036,17 @@ async def update_section( :param section_id: The ID of the section to update. :param name: The new name for the section. :return: the updated Section. - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.update_section(section_id, name)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") + section_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data={"name": name}, + ) + return Section.from_dict(section_data) async def delete_section(self, section_id: str) -> bool: """ @@ -764,9 +1057,15 @@ async def delete_section(self, section_id: str) -> bool: :param section_id: The ID of the section to delete. :return: True if the section was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.delete_section(section_id)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") + return await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) async def get_comment(self, comment_id: str) -> Comment: """ @@ -774,10 +1073,17 @@ async def get_comment(self, comment_id: str) -> Comment: :param comment_id: The ID of the comment to retrieve. :return: The requested comment. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Comment dictionary. """ - return await run_async(lambda: self._api.get_comment(comment_id)) + endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") + comment_data: dict[str, Any] = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return Comment.from_dict(comment_data) async def get_comments( self, @@ -785,24 +1091,44 @@ async def get_comments( project_id: str | None = None, task_id: str | None = None, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Comment]]: + ) -> AsyncIterator[list[Comment]]: """ - Get a list of comments for a task or project. + Get an iterable of lists of comments for a task or project. Requires either `project_id` or `task_id` to be set. + The response is an iterable of lists of comments. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + :param project_id: The ID of the project to retrieve comments for. :param task_id: The ID of the task to retrieve comments for. - :param limit: Maximum number of comments per page (between 1 and 200). - :return: A list of comments. + :param limit: Maximum number of comments per page. + :return: An iterable of lists of comments. :raises ValueError: If neither `project_id` nor `task_id` is provided. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_comments( - project_id=project_id, task_id=task_id, limit=limit + if project_id is None and task_id is None: + raise ValueError("Either `project_id` or `task_id` must be provided.") + + endpoint = get_api_url(COMMENTS_PATH) + + params = kwargs_without_none( + project_id=project_id, + task_id=task_id, + limit=limit, + ) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Comment.from_dict, + self._token, + self._request_id_fn, + params, ) - return generate_async(paginator) async def add_comment( self, @@ -826,19 +1152,31 @@ async def add_comment( :param uids_to_notify: A list of user IDs to notify. :return: The newly created comment. :raises ValueError: If neither `project_id` nor `task_id` is provided. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Comment dictionary. """ - return await run_async( - lambda: self._api.add_comment( - content, - project_id=project_id, - task_id=task_id, - attachment=attachment, - uids_to_notify=uids_to_notify, - ) + if project_id is None and task_id is None: + raise ValueError("Either `project_id` or `task_id` must be provided.") + + endpoint = get_api_url(COMMENTS_PATH) + + data = kwargs_without_none( + content=content, + project_id=project_id, + task_id=task_id, + attachment=attachment.to_dict() if attachment is not None else None, + uids_to_notify=uids_to_notify, ) + comment_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + return Comment.from_dict(comment_data) + async def update_comment( self, comment_id: str, content: Annotated[str, MaxLen(15000)] ) -> Comment: @@ -850,9 +1188,17 @@ async def update_comment( :param comment_id: The ID of the comment to update. :param content: The new text content for the comment. :return: the updated Comment. - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.update_comment(comment_id, content)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") + comment_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data={"content": content}, + ) + return Comment.from_dict(comment_data) async def delete_comment(self, comment_id: str) -> bool: """ @@ -861,9 +1207,15 @@ async def delete_comment(self, comment_id: str) -> bool: :param comment_id: The ID of the comment to delete. :return: True if the comment was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.delete_comment(comment_id)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") + return await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) async def get_label(self, label_id: str) -> Label: """ @@ -871,35 +1223,57 @@ async def get_label(self, label_id: str) -> Label: :param label_id: The ID of the label to retrieve. :return: The requested label. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Label dictionary. """ - return await run_async(lambda: self._api.get_label(label_id)) + endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") + label_data: dict[str, Any] = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return Label.from_dict(label_data) async def get_labels( self, *, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Label]]: + ) -> AsyncIterator[list[Label]]: """ - Get a list of personal labels. + Get an iterable of lists of personal labels. Supports pagination arguments. - :param limit: Maximum number of labels per page (between 1 and 200). - :return: A list of personal labels. - :raises requests.exceptions.HTTPError: If the API request fails. + The response is an iterable of lists of personal labels. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param limit: Maximum number of labels per page. + :return: An iterable of lists of personal labels. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_labels(limit=limit) - return generate_async(paginator) + endpoint = get_api_url(LABELS_PATH) + + params = kwargs_without_none(limit=limit) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Label.from_dict, + self._token, + self._request_id_fn, + params, + ) async def search_labels( self, query: Annotated[str, MinLen(1), MaxLen(1024)], *, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[Label]]: + ) -> AsyncIterator[list[Label]]: """ Search personal labels by name. @@ -910,11 +1284,22 @@ async def search_labels( :param query: Query string for label names. :param limit: Maximum number of labels per page. :return: An iterable of lists of labels. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.search_labels(query, limit=limit) - return generate_async(paginator) + endpoint = get_api_url(f"{LABELS_PATH}/{LABELS_SEARCH_PATH_SUFFIX}") + + params = kwargs_without_none(query=query, limit=limit) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Label.from_dict, + self._token, + self._request_id_fn, + params, + ) async def add_label( self, @@ -932,14 +1317,26 @@ async def add_label( :param item_order: Label's order in the label list. :param is_favorite: Whether the label is a favorite. :return: The newly created label. - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response is not a valid Label dictionary. """ - return await run_async( - lambda: self._api.add_label( - name, color=color, item_order=item_order, is_favorite=is_favorite - ) + endpoint = get_api_url(LABELS_PATH) + + data = kwargs_without_none( + name=name, + color=color, + item_order=item_order, + is_favorite=is_favorite, + ) + + label_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) + return Label.from_dict(label_data) async def update_label( self, @@ -961,18 +1358,26 @@ async def update_label( :param item_order: Label's order in the label list. :param is_favorite: Whether the label is a favorite. :return: the updated Label. - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async( - lambda: self._api.update_label( - label_id, - name=name, - color=color, - item_order=item_order, - is_favorite=is_favorite, - ) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") + + data = kwargs_without_none( + name=name, + color=color, + item_order=item_order, + is_favorite=is_favorite, ) + label_data: dict[str, Any] = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + return Label.from_dict(label_data) + async def delete_label(self, label_id: str) -> bool: """ Delete a personal label. @@ -982,33 +1387,52 @@ async def delete_label(self, label_id: str) -> bool: :param label_id: The ID of the label to delete. :return: True if the label was deleted successfully, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.delete_label(label_id)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") + return await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) async def get_shared_labels( self, *, omit_personal: bool = False, limit: Annotated[int, Ge(1), Le(200)] | None = None, - ) -> AsyncGenerator[list[str]]: + ) -> AsyncIterator[list[str]]: """ - Get a list of shared label names. + Get an iterable of lists of shared label names. Includes labels from collaborators on shared projects that are not in the user's personal labels. Can optionally exclude personal label names using `omit_personal=True`. Supports pagination arguments. + The response is an iterable of lists of shared label names. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + :param omit_personal: Optional boolean flag to omit personal label names. - :param limit: Maximum number of labels per page (between 1 and 200). - :return: A list of shared label names (strings). - :raises requests.exceptions.HTTPError: If the API request fails. + :param limit: Maximum number of labels per page. + :return: An iterable of lists of shared label names (strings). + :raises httpx.HTTPStatusError: If the API request fails. :raises TypeError: If the API response structure is unexpected. """ - paginator = self._api.get_shared_labels( - omit_personal=omit_personal, limit=limit + endpoint = get_api_url(SHARED_LABELS_PATH) + + params = kwargs_without_none(omit_personal=omit_personal, limit=limit) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + str, + self._token, + self._request_id_fn, + params, ) - return generate_async(paginator) async def rename_shared_label( self, @@ -1022,9 +1446,17 @@ async def rename_shared_label( :param new_name: The new name for the shared label. :return: True if the rename was successful, False otherwise (possibly raise `HTTPError` instead). - :raises requests.exceptions.HTTPError: If the API request fails. - """ - return await run_async(lambda: self._api.rename_shared_label(name, new_name)) + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(SHARED_LABELS_RENAME_PATH) + return await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + params={"name": name}, + data={"new_name": new_name}, + ) async def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: """ @@ -1034,6 +1466,90 @@ async def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: :param name: The name of the shared label to remove. :return: True if the removal was successful, - :raises requests.exceptions.HTTPError: If the API request fails. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(SHARED_LABELS_REMOVE_PATH) + data = {"name": name} + return await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + + +T = TypeVar("T") + + +class AsyncResultsPaginator(AsyncIterator[list[T]]): + """ + Iterator for paginated results from the Todoist API. + + It encapsulates the logic for fetching and iterating through paginated results + from Todoist API endpoints. It handles cursor-based pagination automatically, + requesting new pages as needed when iterating. + """ + + _client: httpx.AsyncClient + _url: str + _results_field: str + _results_inst: Callable[[Any], T] + _token: str + _cursor: str | None + + def __init__( + self, + client: httpx.AsyncClient, + url: str, + results_field: str, + results_inst: Callable[[Any], T], + token: str, + request_id_fn: Callable[[], str] | None, + params: dict[str, Any], + ) -> None: """ - return await run_async(lambda: self._api.remove_shared_label(name)) + Initialize the ResultsPaginator. + + :param client: The httpx client to use for API calls. + :param url: The API endpoint URL to fetch results from. + :param results_field: The key in the API response that contains the results. + :param results_inst: A callable that converts result items to objects of type T. + :param token: The authentication token for the Todoist API. + :param params: Query parameters to include in API requests. + """ + self._client = client + self._url = url + self._results_field = results_field + self._results_inst = results_inst + self._token = token + self._request_id_fn = request_id_fn + self._params = params + self._cursor = "" # empty string for first page + + async def __anext__(self) -> list[T]: + """ + Fetch and return the next page of results from the Todoist API. + + :return: A list of results. + :raises httpx.HTTPStatusError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + if self._cursor is None: + raise StopAsyncIteration + + params = self._params.copy() + if self._cursor != "": + params["cursor"] = self._cursor + + data: dict[str, Any] = await get_async( + self._client, + self._url, + self._token, + self._request_id_fn() if self._request_id_fn else None, + params, + ) + self._cursor = data.get("next_cursor") + + results: list[Any] = data.get(self._results_field, []) + return [self._results_inst(result) for result in results] diff --git a/todoist_api_python/authentication.py b/todoist_api_python/authentication.py index 0e11b70..0c79fb8 100644 --- a/todoist_api_python/authentication.py +++ b/todoist_api_python/authentication.py @@ -1,10 +1,13 @@ from __future__ import annotations -from typing import Any, Literal +from contextlib import asynccontextmanager, contextmanager +from typing import TYPE_CHECKING, Any, Literal from urllib.parse import urlencode -import requests -from requests import Session +import httpx + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterator from todoist_api_python._core.endpoints import ( ACCESS_TOKEN_PATH, @@ -13,8 +16,12 @@ get_api_url, get_oauth_url, ) -from todoist_api_python._core.http_requests import delete, post -from todoist_api_python._core.utils import run_async +from todoist_api_python._core.http_requests import ( + delete, + delete_async, + post, + post_async, +) from todoist_api_python.models import AuthResult """ @@ -52,42 +59,123 @@ def get_authentication_url(client_id: str, scopes: list[Scope], state: str) -> s def get_auth_token( - client_id: str, client_secret: str, code: str, session: Session | None = None + client_id: str, + client_secret: str, + code: str, + client: httpx.Client | None = None, ) -> AuthResult: """Get access token using provided client ID, client secret, and auth code.""" - endpoint = get_oauth_url(ACCESS_TOKEN_PATH) - session = session or requests.Session() - data = { - "client_id": client_id, - "client_secret": client_secret, - "code": code, - } - response: dict[str, Any] = post(session=session, url=endpoint, data=data) + endpoint = _get_access_token_url() + data = _build_auth_token_data(client_id, client_secret, code) + + with _managed_client(client) as managed_client: + response: dict[str, Any] = post( + client=managed_client, + url=endpoint, + data=data, + ) + return AuthResult.from_dict(response) async def get_auth_token_async( - client_id: str, client_secret: str, code: str + client_id: str, + client_secret: str, + code: str, + client: httpx.AsyncClient | None = None, ) -> AuthResult: - return await run_async(lambda: get_auth_token(client_id, client_secret, code)) + """Get access token asynchronously.""" + endpoint = _get_access_token_url() + data = _build_auth_token_data(client_id, client_secret, code) + + async with _managed_async_client(client) as managed_client: + response: dict[str, Any] = await post_async( + client=managed_client, + url=endpoint, + data=data, + ) + + return AuthResult.from_dict(response) def revoke_auth_token( - client_id: str, client_secret: str, token: str, session: Session | None = None + client_id: str, + client_secret: str, + token: str, + client: httpx.Client | None = None, ) -> bool: """Revoke an access token.""" - # `get_api_url` is not a typo. Deleting access tokens is done using the regular API. - endpoint = get_api_url(ACCESS_TOKENS_PATH) - session = session or requests.Session() - params = { + endpoint = _get_access_tokens_url() + params = _build_revoke_auth_token_params(client_id, client_secret, token) + + with _managed_client(client) as managed_client: + return delete(client=managed_client, url=endpoint, params=params) + + +async def revoke_auth_token_async( + client_id: str, + client_secret: str, + token: str, + client: httpx.AsyncClient | None = None, +) -> bool: + """Revoke an access token asynchronously.""" + endpoint = _get_access_tokens_url() + params = _build_revoke_auth_token_params(client_id, client_secret, token) + + async with _managed_async_client(client) as managed_client: + return await delete_async(client=managed_client, url=endpoint, params=params) + + +@contextmanager +def _managed_client(client: httpx.Client | None) -> Iterator[httpx.Client]: + if client is not None: + yield client + return + + with httpx.Client() as default_client: + yield default_client + + +@asynccontextmanager +async def _managed_async_client( + client: httpx.AsyncClient | None, +) -> AsyncIterator[httpx.AsyncClient]: + if client is not None: + yield client + return + + async with httpx.AsyncClient() as default_client: + yield default_client + + +def _build_auth_token_data( + client_id: str, + client_secret: str, + code: str, +) -> dict[str, str]: + return { + "client_id": client_id, + "client_secret": client_secret, + "code": code, + } + + +def _build_revoke_auth_token_params( + client_id: str, + client_secret: str, + token: str, +) -> dict[str, str]: + return { "client_id": client_id, "client_secret": client_secret, "access_token": token, } - return delete(session=session, url=endpoint, params=params) -async def revoke_auth_token_async( - client_id: str, client_secret: str, token: str -) -> bool: - return await run_async(lambda: revoke_auth_token(client_id, client_secret, token)) +def _get_access_token_url() -> str: + return get_oauth_url(ACCESS_TOKEN_PATH) + + +def _get_access_tokens_url() -> str: + # `get_api_url` is not a typo. Deleting access tokens is done using the regular API. + return get_api_url(ACCESS_TOKENS_PATH) diff --git a/todoist_api_python/types.py b/todoist_api_python/types.py new file mode 100644 index 0000000..02c0d7c --- /dev/null +++ b/todoist_api_python/types.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Annotated + +from annotated_types import Predicate + +LanguageCode = Annotated[str, Predicate(lambda value: len(value) == 2)] # noqa: PLR2004 + +_SUPPORTED_PROJECT_COLORS = ( + "berry_red", + "red", + "orange", + "yellow", + "olive_green", + "lime_green", + "green", + "mint_green", + "teal", + "sky_blue", + "light_blue", + "blue", + "grape", + "violet", + "lavender", + "magenta", + "salmon", + "charcoal", + "grey", + "taupe", +) + +ColorString = Annotated[ + str, + Predicate(lambda value: value in _SUPPORTED_PROJECT_COLORS), +] +ViewStyle = Annotated[ + str, + Predicate(lambda value: value in ("list", "board", "calendar")), +] + +__all__ = ["ColorString", "LanguageCode", "ViewStyle"] diff --git a/uv.lock b/uv.lock index 30c2445..d91c542 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, +] + [[package]] name = "babel" version = "2.17.0" @@ -246,6 +260,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/5e/38b408f41064c9fcdbb0ea27c1bd13a1c8657c4846e04dab9f5ea770602c/griffe-1.7.2-py3-none-any.whl", hash = "sha256:1ed9c2e338a75741fc82083fe5a1bc89cb6142efe126194cc313e34ee6af5423", size = 129187, upload-time = "2025-04-01T14:38:43.227Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + [[package]] name = "identify" version = "2.6.9" @@ -790,17 +841,15 @@ wheels = [ ] [[package]] -name = "responses" -version = "0.25.7" +name = "respx" +version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyyaml" }, - { name = "requests" }, - { name = "urllib3" }, + { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/7e/2345ac3299bd62bd7163216702bbc88976c099cfceba5b889f2a457727a1/responses-0.25.7.tar.gz", hash = "sha256:8ebae11405d7a5df79ab6fd54277f6f2bc29b2d002d0dd2d5c632594d1ddcedb", size = 79203, upload-time = "2025-03-11T15:36:16.624Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/fc/1d20b64fa90e81e4fa0a34c9b0240a6cfb1326b7e06d18a5432a9917c316/responses-0.25.7-py3-none-any.whl", hash = "sha256:92ca17416c90fe6b35921f52179bff29332076bb32694c0df02dcac2c6bc043c", size = 34732, upload-time = "2025-03-11T15:36:14.589Z" }, + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127 }, ] [[package]] @@ -844,7 +893,7 @@ source = { editable = "." } dependencies = [ { name = "annotated-types" }, { name = "dataclass-wizard" }, - { name = "requests" }, + { name = "httpx" }, ] [package.dev-dependencies] @@ -853,11 +902,10 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, - { name = "responses" }, + { name = "respx" }, { name = "ruff" }, { name = "tox" }, { name = "tox-uv" }, - { name = "types-requests" }, ] docs = [ { name = "mkdocs" }, @@ -869,7 +917,7 @@ docs = [ requires-dist = [ { name = "annotated-types" }, { name = "dataclass-wizard", specifier = ">=0.35.0,<1.0" }, - { name = "requests", specifier = ">=2.32.3,<3" }, + { name = "httpx", specifier = ">=0.28.1,<1" }, ] [package.metadata.requires-dev] @@ -878,11 +926,10 @@ dev = [ { name = "pre-commit", specifier = ">=4.0.0,<5" }, { name = "pytest", specifier = ">=8.0.0,<9" }, { name = "pytest-asyncio", specifier = ">=0.26.0,<0.27" }, - { name = "responses", specifier = ">=0.25.3,<0.26" }, + { name = "respx", specifier = ">=0.22.0,<0.23" }, { name = "ruff", specifier = ">=0.11.0,<0.12" }, { name = "tox", specifier = ">=4.15.1,<5" }, { name = "tox-uv", specifier = ">=1.25.0,<2" }, - { name = "types-requests", specifier = "~=2.32" }, ] docs = [ { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, @@ -967,18 +1014,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/a7/f5c29e0e6faaccefcab607f672b176927144e9412c8183d21301ea2a6f6c/tox_uv-1.25.0-py3-none-any.whl", hash = "sha256:50cfe7795dcd49b2160d7d65b5ece8717f38cfedc242c852a40ec0a71e159bf7", size = 16431, upload-time = "2025-02-21T16:37:49.657Z" }, ] -[[package]] -name = "types-requests" -version = "2.32.0.20250328" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/7d/eb174f74e3f5634eaacb38031bbe467dfe2e545bc255e5c90096ec46bc46/types_requests-2.32.0.20250328.tar.gz", hash = "sha256:c9e67228ea103bd811c96984fac36ed2ae8da87a36a633964a21f199d60baf32", size = 22995, upload-time = "2025-03-28T02:55:13.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/15/3700282a9d4ea3b37044264d3e4d1b1f0095a4ebf860a99914fd544e3be3/types_requests-2.32.0.20250328-py3-none-any.whl", hash = "sha256:72ff80f84b15eb3aa7a8e2625fffb6a93f2ad5a0c20215fc1dcfa61117bcb2a2", size = 20663, upload-time = "2025-03-28T02:55:11.946Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0"