diff --git a/CHANGELOG.md b/CHANGELOG.md index 92385c3..2bda79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support for moving tasks, courtesy of @radiant-tangent +- Re-add support for `X-Request-ID` + - Configurable via `request_id_fn` API constructor argument + - Defaults to random UUID v4 - Automatic testing across all supported Python versions ### Fixed diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 75c5b0c..99adc08 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -18,6 +18,8 @@ class PaginatedItems(TypedDict): DEFAULT_TOKEN = "some-default-token" +DEFAULT_REQUEST_ID = "f00dbeef-cafe-4bad-a555-deadc0decafe" + DEFAULT_DUE_RESPONSE = { "date": "2016-09-01", "timezone": "Europe/Moscow", diff --git a/tests/test_api_async.py b/tests/test_api_async.py deleted file mode 100644 index deff88d..0000000 --- a/tests/test_api_async.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import requests - -from tests.data.test_defaults import DEFAULT_TOKEN -from tests.utils.test_utils import get_todoist_api_patch -from todoist_api_python.api import TodoistAPI -from todoist_api_python.api_async import TodoistAPIAsync - - -@patch(get_todoist_api_patch(TodoistAPI.__init__)) -def test_constructs_api_with_token(sync_api_constructor: MagicMock) -> None: - sync_api_constructor.return_value = None - TodoistAPIAsync(DEFAULT_TOKEN) - - sync_api_constructor.assert_called_once_with(DEFAULT_TOKEN, None) - - -@patch(get_todoist_api_patch(TodoistAPI.__init__)) -def test_constructs_api_with_token_and_session(sync_api_constructor: MagicMock) -> None: - sync_api_constructor.return_value = None - session = requests.Session() - TodoistAPIAsync(DEFAULT_TOKEN, session) - sync_api_constructor.assert_called_once_with(DEFAULT_TOKEN, session) diff --git a/tests/test_api_comments.py b/tests/test_api_comments.py index 7e1426e..53b888f 100644 --- a/tests/test_api_comments.py +++ b/tests/test_api_comments.py @@ -14,6 +14,7 @@ data_matcher, enumerate_async, param_matcher, + request_id_matcher, ) from todoist_api_python.models import Attachment @@ -40,7 +41,7 @@ async def test_get_comment( url=endpoint, json=default_comment_response, status=200, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) comment = todoist_api.get_comment(comment_id) @@ -74,6 +75,7 @@ async def test_get_comments( status=200, match=[ auth_matcher(), + request_id_matcher(), param_matcher({"task_id": task_id}, cursor), ], ) @@ -120,6 +122,7 @@ async def test_add_comment( status=200, match=[ auth_matcher(), + request_id_matcher(), data_matcher( { "content": content, @@ -166,7 +169,7 @@ async def test_update_comment( url=f"{DEFAULT_API_URL}/comments/{default_comment.id}", json=updated_comment_dict, status=200, - match=[auth_matcher(), data_matcher(args)], + match=[auth_matcher(), request_id_matcher(), data_matcher(args)], ) response = todoist_api.update_comment(comment_id=default_comment.id, **args) @@ -195,7 +198,7 @@ async def test_delete_comment( method=responses.DELETE, url=endpoint, status=204, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) response = todoist_api.delete_comment(comment_id) diff --git a/tests/test_api_completed_tasks.py b/tests/test_api_completed_tasks.py index 331d1a2..66faa09 100644 --- a/tests/test_api_completed_tasks.py +++ b/tests/test_api_completed_tasks.py @@ -13,7 +13,12 @@ import responses from tests.data.test_defaults import DEFAULT_API_URL, PaginatedItems -from tests.utils.test_utils import auth_matcher, enumerate_async, param_matcher +from tests.utils.test_utils import ( + auth_matcher, + enumerate_async, + param_matcher, + request_id_matcher, +) from todoist_api_python._core.utils import format_datetime if TYPE_CHECKING: @@ -51,7 +56,7 @@ async def test_get_completed_tasks_by_due_date( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher(params, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher(params, cursor)], ) cursor = page["next_cursor"] @@ -111,7 +116,7 @@ async def test_get_completed_tasks_by_completion_date( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher(params, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher(params, cursor)], ) cursor = page["next_cursor"] diff --git a/tests/test_api_labels.py b/tests/test_api_labels.py index 193a3ee..94e3282 100644 --- a/tests/test_api_labels.py +++ b/tests/test_api_labels.py @@ -11,6 +11,7 @@ data_matcher, enumerate_async, param_matcher, + request_id_matcher, ) if TYPE_CHECKING: @@ -66,7 +67,7 @@ async def test_get_labels( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher({}, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], ) cursor = page["next_cursor"] @@ -102,7 +103,11 @@ async def test_add_label_minimal( url=f"{DEFAULT_API_URL}/labels", json=default_label_response, status=200, - match=[auth_matcher(), data_matcher({"name": label_name})], + match=[ + auth_matcher(), + request_id_matcher(), + data_matcher({"name": label_name}), + ], ) new_label = todoist_api.add_label(name=label_name) @@ -136,7 +141,11 @@ async def test_add_label_full( url=f"{DEFAULT_API_URL}/labels", json=default_label_response, status=200, - match=[auth_matcher(), data_matcher({"name": label_name} | args)], + match=[ + auth_matcher(), + request_id_matcher(), + data_matcher({"name": label_name} | args), + ], ) new_label = todoist_api.add_label(name=label_name, **args) @@ -167,7 +176,7 @@ async def test_update_label( url=f"{DEFAULT_API_URL}/labels/{default_label.id}", json=updated_label_dict, status=200, - match=[auth_matcher(), data_matcher(args)], + match=[auth_matcher(), request_id_matcher(), data_matcher(args)], ) response = todoist_api.update_label(label_id=default_label.id, **args) diff --git a/tests/test_api_projects.py b/tests/test_api_projects.py index 03dc61f..e89f8dc 100644 --- a/tests/test_api_projects.py +++ b/tests/test_api_projects.py @@ -11,6 +11,7 @@ data_matcher, enumerate_async, param_matcher, + request_id_matcher, ) if TYPE_CHECKING: @@ -66,7 +67,7 @@ async def test_get_projects( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher({}, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], ) cursor = page["next_cursor"] @@ -102,7 +103,11 @@ async def test_add_project_minimal( url=f"{DEFAULT_API_URL}/projects", json=default_project_response, status=200, - match=[auth_matcher(), data_matcher({"name": project_name})], + match=[ + auth_matcher(), + request_id_matcher(), + data_matcher({"name": project_name}), + ], ) new_project = todoist_api.add_project(name=project_name) @@ -131,7 +136,11 @@ async def test_add_project_full( url=f"{DEFAULT_API_URL}/projects", json=default_project_response, status=200, - match=[auth_matcher(), data_matcher({"name": project_name})], + match=[ + auth_matcher(), + request_id_matcher(), + data_matcher({"name": project_name}), + ], ) new_project = todoist_api.add_project(name=project_name) @@ -164,7 +173,7 @@ async def test_update_project( url=f"{DEFAULT_API_URL}/projects/{default_project.id}", json=updated_project_dict, status=200, - match=[auth_matcher(), data_matcher(args)], + match=[auth_matcher(), request_id_matcher(), data_matcher(args)], ) response = todoist_api.update_project(project_id=default_project.id, **args) @@ -198,7 +207,7 @@ async def test_archive_project( url=endpoint, json=archived_project_dict, status=200, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) project = todoist_api.archive_project(project_id) @@ -230,7 +239,7 @@ async def test_unarchive_project( url=endpoint, json=unarchived_project_dict, status=200, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) project = todoist_api.unarchive_project(project_id) @@ -257,7 +266,7 @@ async def test_delete_project( method=responses.DELETE, url=endpoint, status=204, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) response = todoist_api.delete_project(project_id) @@ -289,7 +298,7 @@ async def test_get_collaborators( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher({}, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], ) cursor = page["next_cursor"] diff --git a/tests/test_api_sections.py b/tests/test_api_sections.py index e422122..b37ef02 100644 --- a/tests/test_api_sections.py +++ b/tests/test_api_sections.py @@ -11,6 +11,7 @@ data_matcher, enumerate_async, param_matcher, + request_id_matcher, ) if TYPE_CHECKING: @@ -66,7 +67,7 @@ async def test_get_sections( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher({}, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], ) cursor = page["next_cursor"] @@ -107,6 +108,7 @@ async def test_get_sections_by_project( status=200, match=[ auth_matcher(), + request_id_matcher(), param_matcher({"project_id": project_id}, cursor), ], ) @@ -150,6 +152,7 @@ async def test_add_section( status=200, match=[ auth_matcher(), + request_id_matcher(), data_matcher({"name": section_name, "project_id": project_id} | args), ], ) @@ -186,7 +189,7 @@ async def test_update_section( url=f"{DEFAULT_API_URL}/sections/{default_section.id}", json=updated_section_dict, status=200, - match=[auth_matcher(), data_matcher(args)], + match=[auth_matcher(), request_id_matcher(), data_matcher(args)], ) response = todoist_api.update_section(section_id=default_section.id, **args) @@ -215,7 +218,7 @@ async def test_delete_section( method=responses.DELETE, url=endpoint, status=204, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) response = todoist_api.delete_section(section_id) diff --git a/tests/test_api_tasks.py b/tests/test_api_tasks.py index c813aea..81f3d83 100644 --- a/tests/test_api_tasks.py +++ b/tests/test_api_tasks.py @@ -18,6 +18,7 @@ data_matcher, enumerate_async, param_matcher, + request_id_matcher, ) if TYPE_CHECKING: @@ -41,7 +42,7 @@ async def test_get_task( method=responses.GET, url=endpoint, json=default_task_response, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) task = todoist_api.get_task(task_id) @@ -72,7 +73,7 @@ async def test_get_tasks( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher({}, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], ) cursor = page["next_cursor"] @@ -126,7 +127,7 @@ async def test_get_tasks_with_filters( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher(params, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher(params, cursor)], ) cursor = page["next_cursor"] @@ -185,7 +186,7 @@ async def test_filter_tasks( url=endpoint, json=page, status=200, - match=[auth_matcher(), param_matcher(params, cursor)], + match=[auth_matcher(), request_id_matcher(), param_matcher(params, cursor)], ) cursor = page["next_cursor"] @@ -228,7 +229,11 @@ async def test_add_task_minimal( url=f"{DEFAULT_API_URL}/tasks", json=default_task_response, status=200, - match=[auth_matcher(), data_matcher({"content": content})], + match=[ + auth_matcher(), + request_id_matcher(), + data_matcher({"content": content}), + ], ) new_task = todoist_api.add_task(content=content) @@ -276,6 +281,7 @@ async def test_add_task_full( status=200, match=[ auth_matcher(), + request_id_matcher(), data_matcher( { "content": content, @@ -318,6 +324,7 @@ async def test_add_task_quick( status=200, match=[ auth_matcher(), + request_id_matcher(), data_matcher( { "meta": True, @@ -368,7 +375,7 @@ async def test_update_task( url=f"{DEFAULT_API_URL}/tasks/{default_task.id}", json=updated_task_dict, status=200, - match=[auth_matcher(), data_matcher(args)], + match=[auth_matcher(), request_id_matcher(), data_matcher(args)], ) response = todoist_api.update_task(task_id=default_task.id, **args) @@ -395,7 +402,7 @@ async def test_complete_task( method=responses.POST, url=endpoint, status=204, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) response = todoist_api.complete_task(task_id) @@ -422,7 +429,7 @@ async def test_uncomplete_task( method=responses.POST, url=endpoint, status=204, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) response = todoist_api.uncomplete_task(task_id) @@ -449,7 +456,7 @@ async def test_move_task( method=responses.POST, url=endpoint, status=204, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) response = todoist_api.move_task(task_id, project_id="123") @@ -487,7 +494,7 @@ async def test_delete_task( method=responses.DELETE, url=endpoint, status=204, - match=[auth_matcher()], + match=[auth_matcher(), request_id_matcher()], ) response = todoist_api.delete_task(task_id) diff --git a/tests/test_http_headers.py b/tests/test_http_headers.py index 98a2d33..cb8aa74 100644 --- a/tests/test_http_headers.py +++ b/tests/test_http_headers.py @@ -3,7 +3,7 @@ from todoist_api_python._core.http_headers import create_headers -def test_create_headers_none() -> None: +def test_create_headers_default() -> None: headers = create_headers() assert headers == {} @@ -17,3 +17,9 @@ def test_create_headers_authorization() -> None: 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) + assert headers["X-Request-Id"] == request_id diff --git a/tests/test_http_requests.py b/tests/test_http_requests.py index 3920289..a34d778 100644 --- a/tests/test_http_requests.py +++ b/tests/test_http_requests.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from typing import Any import pytest @@ -8,8 +7,13 @@ from requests import HTTPError, Session from responses.matchers import query_param_matcher -from tests.data.test_defaults import DEFAULT_TOKEN -from tests.utils.test_utils import auth_matcher, 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 todoist_api_python._core.http_requests import delete, get, post EXAMPLE_URL = "https://example.com/" @@ -25,11 +29,19 @@ def test_get_with_params(default_task_response: dict[str, Any]) -> None: url=EXAMPLE_URL, json=EXAMPLE_RESPONSE, status=200, - match=[auth_matcher(), param_matcher(EXAMPLE_PARAMS)], + match=[ + auth_matcher(), + request_id_matcher(DEFAULT_REQUEST_ID), + param_matcher(EXAMPLE_PARAMS), + ], ) response: dict[str, Any] = get( - session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN, params=EXAMPLE_PARAMS + session=Session(), + url=EXAMPLE_URL, + token=DEFAULT_TOKEN, + request_id=DEFAULT_REQUEST_ID, + params=EXAMPLE_PARAMS, ) assert len(responses.calls) == 1 @@ -58,22 +70,22 @@ def test_post_with_data(default_task_response: dict[str, Any]) -> None: url=EXAMPLE_URL, json=EXAMPLE_RESPONSE, status=200, + match=[ + auth_matcher(), + request_id_matcher(DEFAULT_REQUEST_ID), + data_matcher(EXAMPLE_DATA), + ], ) response: dict[str, Any] = post( - session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN, data=EXAMPLE_DATA + session=Session(), + url=EXAMPLE_URL, + token=DEFAULT_TOKEN, + request_id=DEFAULT_REQUEST_ID, + data=EXAMPLE_DATA, ) assert len(responses.calls) == 1 - assert responses.calls[0].request.url == EXAMPLE_URL - assert ( - responses.calls[0].request.headers["Authorization"] == f"Bearer {DEFAULT_TOKEN}" - ) - assert ( - responses.calls[0].request.headers["Content-Type"] - == "application/json; charset=utf-8" - ) - assert responses.calls[0].request.body == json.dumps(EXAMPLE_DATA) assert response == EXAMPLE_RESPONSE @@ -107,13 +119,18 @@ def test_delete_with_params() -> None: method=responses.DELETE, url=EXAMPLE_URL, status=204, - match=[auth_matcher(), query_param_matcher(EXAMPLE_PARAMS)], + match=[ + auth_matcher(), + request_id_matcher(DEFAULT_REQUEST_ID), + query_param_matcher(EXAMPLE_PARAMS), + ], ) result = delete( session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN, + request_id=DEFAULT_REQUEST_ID, params=EXAMPLE_PARAMS, ) diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 9060c78..79396df 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -14,13 +14,17 @@ from collections.abc import AsyncIterable, AsyncIterator, Callable -MATCH_ANY_REGEX = re.compile(".*") +RE_UUID = re.compile(r"^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}$", re.IGNORECASE) def auth_matcher() -> Callable[..., Any]: return matchers.header_matcher({"Authorization": f"Bearer {DEFAULT_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 param_matcher( params: dict[str, str], cursor: str | None = None ) -> Callable[..., Any]: diff --git a/todoist_api_python/_core/http_headers.py b/todoist_api_python/_core/http_headers.py index 77972ec..b9751a7 100644 --- a/todoist_api_python/_core/http_headers.py +++ b/todoist_api_python/_core/http_headers.py @@ -2,11 +2,13 @@ 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] = {} @@ -14,5 +16,7 @@ def create_headers( 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)]) return headers diff --git a/todoist_api_python/_core/http_requests.py b/todoist_api_python/_core/http_requests.py index fd098c5..9c0f10f 100644 --- a/todoist_api_python/_core/http_requests.py +++ b/todoist_api_python/_core/http_requests.py @@ -27,10 +27,16 @@ def get( session: Session, url: str, token: str | None = None, + request_id: str | None = None, params: dict[str, Any] | None = None, ) -> T: # type: ignore[type-var] + headers = create_headers(token=token, request_id=request_id) + response = session.get( - url, params=params, headers=create_headers(token=token), timeout=TIMEOUT + url, + params=params, + headers=headers, + timeout=TIMEOUT, ) if response.status_code == codes.OK: @@ -44,11 +50,14 @@ def post( session: Session, 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)) + headers = create_headers( + token=token, with_content=bool(data), request_id=request_id + ) response = session.post( url, @@ -69,9 +78,10 @@ def delete( session: Session, url: str, token: str | None = None, + request_id: str | None = None, params: dict[str, Any] | None = None, ) -> bool: - headers = create_headers(token=token) + headers = create_headers(token=token, request_id=request_id) response = session.delete(url, params=params, headers=headers, timeout=TIMEOUT) diff --git a/todoist_api_python/_core/utils.py b/todoist_api_python/_core/utils.py index 0415931..c612f6e 100644 --- a/todoist_api_python/_core/utils.py +++ b/todoist_api_python/_core/utils.py @@ -2,6 +2,7 @@ import asyncio import sys +import uuid from datetime import date, datetime, timezone from typing import TYPE_CHECKING, TypeVar, cast @@ -69,3 +70,8 @@ def parse_datetime(datetime_str: str) -> datetime: datetime_str = datetime_str[:-1] + "+00:00" return datetime.fromisoformat(datetime_str) return datetime.fromisoformat(datetime_str) + + +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 2e6e044..a297bf5 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -27,7 +27,11 @@ get_api_url, ) from todoist_api_python._core.http_requests import delete, get, post -from todoist_api_python._core.utils import format_date, format_datetime +from todoist_api_python._core.utils import ( + default_request_id_fn, + format_date, + format_datetime, +) from todoist_api_python.models import ( Attachment, Collaborator, @@ -91,14 +95,21 @@ class TodoistAPI: to ensure the session is closed properly. """ - def __init__(self, token: str, session: requests.Session | None = None) -> None: + def __init__( + self, + token: str, + request_id_fn: Callable[[], str] | None = default_request_id_fn, + session: requests.Session | 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. """ - self._token: str = token + self._token = token + self._request_id_fn = request_id_fn self._session = session or requests.Session() self._finalizer = finalize(self, self._session.close) @@ -132,7 +143,12 @@ def get_task(self, task_id: str) -> Task: :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, endpoint, self._token) + task_data: dict[str, Any] = get( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) return Task.from_dict(task_data) def get_tasks( @@ -184,6 +200,7 @@ def get_tasks( "results", Task.from_dict, self._token, + self._request_id_fn, params, ) @@ -224,6 +241,7 @@ def filter_tasks( "results", Task.from_dict, self._token, + self._request_id_fn, params, ) @@ -317,7 +335,11 @@ def add_task( # noqa: PLR0912 data["deadline_lang"] = deadline_lang task_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Task.from_dict(task_data) @@ -357,7 +379,11 @@ def add_task_quick( data["reminder"] = reminder task_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Task.from_dict(task_data) @@ -440,7 +466,11 @@ def update_task( # noqa: PLR0912 data["deadline_lang"] = deadline_lang task_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Task.from_dict(task_data) @@ -457,7 +487,12 @@ def complete_task(self, task_id: str) -> bool: :raises requests.exceptions.HTTPError: If the API request fails. """ endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/close") - return post(self._session, endpoint, self._token) + return post( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) def uncomplete_task(self, task_id: str) -> bool: """ @@ -471,7 +506,12 @@ def uncomplete_task(self, task_id: str) -> bool: :raises requests.exceptions.HTTPError: If the API request fails. """ endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/reopen") - return post(self._session, endpoint, self._token) + return post( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) def move_task( self, @@ -510,7 +550,13 @@ def move_task( if parent_id is not None: data["parent_id"] = parent_id endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/move") - return post(self._session, endpoint, self._token, data=data) + return post( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) def delete_task(self, task_id: str) -> bool: """ @@ -522,7 +568,12 @@ def delete_task(self, task_id: str) -> bool: :raises requests.exceptions.HTTPError: If the API request fails. """ endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") - return delete(self._session, endpoint, self._token) + return delete( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) def get_completed_tasks_by_due_date( self, @@ -582,7 +633,13 @@ def get_completed_tasks_by_due_date( params["limit"] = limit return ResultsPaginator( - self._session, endpoint, "items", Task.from_dict, self._token, params + self._session, + endpoint, + "items", + Task.from_dict, + self._token, + self._request_id_fn, + params, ) def get_completed_tasks_by_completion_date( @@ -631,7 +688,13 @@ def get_completed_tasks_by_completion_date( params["limit"] = limit return ResultsPaginator( - self._session, endpoint, "items", Task.from_dict, self._token, params + self._session, + endpoint, + "items", + Task.from_dict, + self._token, + self._request_id_fn, + params, ) def get_project(self, project_id: str) -> Project: @@ -644,7 +707,12 @@ def get_project(self, project_id: str) -> Project: :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, endpoint, self._token) + project_data: dict[str, Any] = get( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) return Project.from_dict(project_data) def get_projects( @@ -668,7 +736,13 @@ def get_projects( if limit is not None: params["limit"] = limit return ResultsPaginator( - self._session, endpoint, "results", Project.from_dict, self._token, params + self._session, + endpoint, + "results", + Project.from_dict, + self._token, + self._request_id_fn, + params, ) def add_project( @@ -709,7 +783,11 @@ def add_project( data["view_style"] = view_style project_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Project.from_dict(project_data) @@ -753,7 +831,11 @@ def update_project( data["view_style"] = view_style project_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Project.from_dict(project_data) @@ -772,7 +854,12 @@ def archive_project(self, project_id: str) -> Project: endpoint = get_api_url( f"{PROJECTS_PATH}/{project_id}/{PROJECT_ARCHIVE_PATH_SUFFIX}" ) - project_data: dict[str, Any] = post(self._session, endpoint, self._token) + project_data: dict[str, Any] = post( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) return Project.from_dict(project_data) def unarchive_project(self, project_id: str) -> Project: @@ -789,7 +876,12 @@ def unarchive_project(self, project_id: str) -> Project: endpoint = get_api_url( f"{PROJECTS_PATH}/{project_id}/{PROJECT_UNARCHIVE_PATH_SUFFIX}" ) - project_data: dict[str, Any] = post(self._session, endpoint, self._token) + project_data: dict[str, Any] = post( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) return Project.from_dict(project_data) def delete_project(self, project_id: str) -> bool: @@ -804,7 +896,12 @@ def delete_project(self, project_id: str) -> bool: :raises requests.exceptions.HTTPError: If the API request fails. """ endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") - return delete(self._session, endpoint, self._token) + return delete( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) def get_collaborators( self, @@ -834,6 +931,7 @@ def get_collaborators( "results", Collaborator.from_dict, self._token, + self._request_id_fn, params, ) @@ -847,7 +945,12 @@ def get_section(self, section_id: str) -> Section: :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, endpoint, self._token) + section_data: dict[str, Any] = get( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) return Section.from_dict(section_data) def get_sections( @@ -880,7 +983,13 @@ def get_sections( params["limit"] = limit return ResultsPaginator( - self._session, endpoint, "results", Section.from_dict, self._token, params + self._session, + endpoint, + "results", + Section.from_dict, + self._token, + self._request_id_fn, + params, ) def add_section( @@ -907,7 +1016,11 @@ def add_section( data["order"] = order section_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Section.from_dict(section_data) @@ -928,7 +1041,11 @@ def update_section( """ endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") section_data: dict[str, Any] = post( - self._session, endpoint, self._token, data={"name": name} + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data={"name": name}, ) return Section.from_dict(section_data) @@ -944,7 +1061,12 @@ def delete_section(self, section_id: str) -> bool: :raises requests.exceptions.HTTPError: If the API request fails. """ endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") - return delete(self._session, endpoint, self._token) + return delete( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) def get_comment(self, comment_id: str) -> Comment: """ @@ -956,7 +1078,12 @@ def get_comment(self, comment_id: str) -> Comment: :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, endpoint, self._token) + comment_data: dict[str, Any] = get( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) return Comment.from_dict(comment_data) def get_comments( @@ -997,7 +1124,13 @@ def get_comments( params["limit"] = limit return ResultsPaginator( - self._session, endpoint, "results", Comment.from_dict, self._token, params + self._session, + endpoint, + "results", + Comment.from_dict, + self._token, + self._request_id_fn, + params, ) def add_comment( @@ -1041,7 +1174,11 @@ def add_comment( data["uids_to_notify"] = uids_to_notify comment_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Comment.from_dict(comment_data) @@ -1060,7 +1197,11 @@ def update_comment( """ endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") comment_data: dict[str, Any] = post( - self._session, endpoint, self._token, data={"content": content} + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data={"content": content}, ) return Comment.from_dict(comment_data) @@ -1074,7 +1215,12 @@ def delete_comment(self, comment_id: str) -> bool: :raises requests.exceptions.HTTPError: If the API request fails. """ endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") - return delete(self._session, endpoint, self._token) + return delete( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) def get_label(self, label_id: str) -> Label: """ @@ -1086,7 +1232,12 @@ def get_label(self, label_id: str) -> Label: :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, endpoint, self._token) + label_data: dict[str, Any] = get( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) return Label.from_dict(label_data) def get_labels( @@ -1115,7 +1266,13 @@ def get_labels( params["limit"] = limit return ResultsPaginator( - self._session, endpoint, "results", Label.from_dict, self._token, params + self._session, + endpoint, + "results", + Label.from_dict, + self._token, + self._request_id_fn, + params, ) def add_label( @@ -1149,7 +1306,11 @@ def add_label( data["is_favorite"] = is_favorite label_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Label.from_dict(label_data) @@ -1188,7 +1349,11 @@ def update_label( data["is_favorite"] = is_favorite label_data: dict[str, Any] = post( - self._session, endpoint, self._token, data=data + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, ) return Label.from_dict(label_data) @@ -1204,7 +1369,12 @@ def delete_label(self, label_id: str) -> bool: :raises requests.exceptions.HTTPError: If the API request fails. """ endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") - return delete(self._session, endpoint, self._token) + return delete( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) def get_shared_labels( self, @@ -1236,7 +1406,13 @@ def get_shared_labels( params["limit"] = limit return ResultsPaginator( - self._session, endpoint, "results", str, self._token, params + self._session, + endpoint, + "results", + str, + self._token, + self._request_id_fn, + params, ) def rename_shared_label( @@ -1274,7 +1450,13 @@ def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: """ endpoint = get_api_url(SHARED_LABELS_REMOVE_PATH) data = {"name": name} - return post(self._session, endpoint, self._token, data=data) + return post( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) T = TypeVar("T") @@ -1303,6 +1485,7 @@ def __init__( results_field: str, results_inst: Callable[[Any], T], token: str, + request_id_fn: Callable[[], str] | None, params: dict[str, Any], ) -> None: """ @@ -1320,6 +1503,7 @@ def __init__( 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 @@ -1338,7 +1522,13 @@ def __next__(self) -> list[T]: if self._cursor != "": params["cursor"] = self._cursor - data: dict[str, Any] = get(self._session, self._url, self._token, params) + data: dict[str, Any] = get( + self._session, + 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, []) diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index 3c45273..02aa84d 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -1,11 +1,15 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Annotated, Literal, TypeVar +from typing import TYPE_CHECKING, Annotated, Callable, Literal, TypeVar from annotated_types import Ge, Le, MaxLen, MinLen -from todoist_api_python._core.utils import generate_async, run_async +from todoist_api_python._core.utils import ( + default_request_id_fn, + generate_async, + run_async, +) from todoist_api_python.api import TodoistAPI if TYPE_CHECKING: @@ -48,14 +52,19 @@ class TodoistAPIAsync: manager to ensure the session is closed properly. """ - def __init__(self, token: str, session: requests.Session | None = None) -> None: + def __init__( + self, + token: str, + request_id_fn: Callable[[], str] | None = default_request_id_fn, + session: requests.Session | None = None, + ) -> None: """ Initialize the TodoistAPIAsync client. :param token: Authentication token for the Todoist API. :param session: An optional pre-configured requests `Session` object. """ - self._api = TodoistAPI(token, session) + self._api = TodoistAPI(token, request_id_fn, session) async def __aenter__(self) -> Self: """