From 6a97b4693dd96048c62293b7d223df0cfcff86e9 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Tue, 24 Mar 2026 11:43:33 +0000 Subject: [PATCH 1/3] Add reminders and location reminders CRUD support Add full CRUD operations for both regular reminders (relative/absolute) and location reminders, including sync and async API methods, models, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/conftest.py | 52 +++++ tests/data/test_defaults.py | 62 +++++ tests/test_api_location_reminders.py | 211 +++++++++++++++++ tests/test_api_reminders.py | 202 ++++++++++++++++ todoist_api_python/_core/endpoints.py | 2 + todoist_api_python/api.py | 317 +++++++++++++++++++++++++ todoist_api_python/api_async.py | 319 ++++++++++++++++++++++++++ todoist_api_python/models.py | 37 +++ 8 files changed, 1202 insertions(+) create mode 100644 tests/test_api_location_reminders.py create mode 100644 tests/test_api_reminders.py diff --git a/tests/conftest.py b/tests/conftest.py index 42a8cd1..882cea1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,8 +13,12 @@ DEFAULT_COMPLETED_TASKS_RESPONSE, DEFAULT_LABEL_RESPONSE, DEFAULT_LABELS_RESPONSE, + DEFAULT_LOCATION_REMINDER_RESPONSE, + DEFAULT_LOCATION_REMINDERS_RESPONSE, DEFAULT_PROJECT_RESPONSE, DEFAULT_PROJECTS_RESPONSE, + DEFAULT_REMINDER_RESPONSE, + DEFAULT_REMINDERS_RESPONSE, DEFAULT_REQUEST_ID, DEFAULT_SECTION_RESPONSE, DEFAULT_SECTIONS_RESPONSE, @@ -32,7 +36,9 @@ Collaborator, Comment, Label, + LocationReminder, Project, + Reminder, Section, Task, ) @@ -210,6 +216,52 @@ def default_labels_list() -> list[list[Label]]: ] +@pytest.fixture +def default_reminder_response() -> dict[str, Any]: + return DEFAULT_REMINDER_RESPONSE + + +@pytest.fixture +def default_reminder() -> Reminder: + return Reminder.from_dict(DEFAULT_REMINDER_RESPONSE) + + +@pytest.fixture +def default_reminders_response() -> list[PaginatedResults]: + return DEFAULT_REMINDERS_RESPONSE + + +@pytest.fixture +def default_reminders_list() -> list[list[Reminder]]: + return [ + [Reminder.from_dict(result) for result in response["results"]] + for response in DEFAULT_REMINDERS_RESPONSE + ] + + +@pytest.fixture +def default_location_reminder_response() -> dict[str, Any]: + return DEFAULT_LOCATION_REMINDER_RESPONSE + + +@pytest.fixture +def default_location_reminder() -> LocationReminder: + return LocationReminder.from_dict(DEFAULT_LOCATION_REMINDER_RESPONSE) + + +@pytest.fixture +def default_location_reminders_response() -> list[PaginatedResults]: + return DEFAULT_LOCATION_REMINDERS_RESPONSE + + +@pytest.fixture +def default_location_reminders_list() -> list[list[LocationReminder]]: + return [ + [LocationReminder.from_dict(result) for result in response["results"]] + for response in DEFAULT_LOCATION_REMINDERS_RESPONSE + ] + + @pytest.fixture def default_quick_add_response() -> dict[str, Any]: return DEFAULT_TASK_RESPONSE diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 7cf5279..d445221 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -268,6 +268,68 @@ class PaginatedItems(TypedDict): }, ] +DEFAULT_REMINDER_RESPONSE: dict[str, Any] = { + "id": "6X7rM8997g3RQmvh", + "item_id": "6X7rM8997g3RQmvh", + "notify_uid": "34567", + "type": "relative", + "is_deleted": False, + "minute_offset": 30, + "due": DEFAULT_DUE_RESPONSE, + "service": "push", +} + +DEFAULT_REMINDER_RESPONSE_2 = dict(DEFAULT_REMINDER_RESPONSE) +DEFAULT_REMINDER_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" + +DEFAULT_REMINDER_RESPONSE_3 = dict(DEFAULT_REMINDER_RESPONSE) +DEFAULT_REMINDER_RESPONSE_3["id"] = "6X7rfEVP8hvv25ZQ" + +DEFAULT_REMINDERS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_REMINDER_RESPONSE, DEFAULT_REMINDER_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_REMINDER_RESPONSE_3], + "next_cursor": None, + }, +] + +DEFAULT_LOCATION_REMINDER_RESPONSE: dict[str, Any] = { + "id": "6X7rM8997g3RQmvh", + "item_id": "6X7rM8997g3RQmvh", + "project_id": "6Jf8VQXxpwv56VQ7", + "notify_uid": "34567", + "name": "Office", + "loc_lat": "51.5074", + "loc_long": "-0.1278", + "loc_trigger": "on_enter", + "radius": 100, + "type": "location", + "is_deleted": False, +} + +DEFAULT_LOCATION_REMINDER_RESPONSE_2 = dict(DEFAULT_LOCATION_REMINDER_RESPONSE) +DEFAULT_LOCATION_REMINDER_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" + +DEFAULT_LOCATION_REMINDER_RESPONSE_3 = dict(DEFAULT_LOCATION_REMINDER_RESPONSE) +DEFAULT_LOCATION_REMINDER_RESPONSE_3["id"] = "6X7rfEVP8hvv25ZQ" + +DEFAULT_LOCATION_REMINDERS_RESPONSE: list[PaginatedResults] = [ + { + "results": [ + DEFAULT_LOCATION_REMINDER_RESPONSE, + DEFAULT_LOCATION_REMINDER_RESPONSE_2, + ], + "next_cursor": "next", + }, + { + "results": [DEFAULT_LOCATION_REMINDER_RESPONSE_3], + "next_cursor": None, + }, +] + DEFAULT_AUTH_RESPONSE = { "access_token": "123456789", "state": "somestate", diff --git a/tests/test_api_location_reminders.py b/tests/test_api_location_reminders.py new file mode 100644 index 0000000..437fa73 --- /dev/null +++ b/tests/test_api_location_reminders.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from tests.data.test_defaults import ( + DEFAULT_API_URL, + PaginatedResults, +) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route +from todoist_api_python.models import LocationReminder + +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_get_location_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_location_reminder_response: dict[str, Any], + default_location_reminder: LocationReminder, +) -> None: + location_reminder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/location_reminders/{location_reminder_id}" + + mock_route( + respx_mock, + method="GET", + url=endpoint, + request_headers=api_headers(), + response_json=default_location_reminder_response, + response_status=200, + ) + + reminder = todoist_api.get_location_reminder(location_reminder_id) + + assert len(respx_mock.calls) == 1 + assert reminder == default_location_reminder + + reminder = await todoist_api_async.get_location_reminder(location_reminder_id) + + assert len(respx_mock.calls) == 2 + assert reminder == default_location_reminder + + +@pytest.mark.asyncio +async def test_get_location_reminders( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_location_reminders_response: list[PaginatedResults], + default_location_reminders_list: list[list[LocationReminder]], +) -> None: + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/location_reminders" + + cursor: str | None = None + for page in default_location_reminders_response: + mock_route( + respx_mock, + method="GET", + url=endpoint, + 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"] + + count = 0 + + reminders_iter = todoist_api.get_location_reminders(task_id=task_id) + + for i, reminders in enumerate(reminders_iter): + assert len(respx_mock.calls) == count + 1 + assert reminders == default_location_reminders_list[i] + count += 1 + + reminders_async_iter = await todoist_api_async.get_location_reminders( + task_id=task_id + ) + + async for i, reminders in enumerate_async(reminders_async_iter): + assert len(respx_mock.calls) == count + 1 + assert reminders == default_location_reminders_list[i] + count += 1 + + +@pytest.mark.asyncio +async def test_add_location_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_location_reminder_response: dict[str, Any], + default_location_reminder: LocationReminder, +) -> None: + task_id = "6X7rM8997g3RQmvh" + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/location_reminders", + request_headers=api_headers(), + request_json={ + "task_id": task_id, + "name": "Office", + "loc_lat": "51.5074", + "loc_long": "-0.1278", + "loc_trigger": "on_enter", + "radius": 200, + }, + response_json=default_location_reminder_response, + response_status=200, + ) + + new_reminder = todoist_api.add_location_reminder( + task_id=task_id, + name="Office", + loc_lat="51.5074", + loc_long="-0.1278", + loc_trigger="on_enter", + radius=200, + ) + + assert len(respx_mock.calls) == 1 + assert new_reminder == default_location_reminder + + new_reminder = await todoist_api_async.add_location_reminder( + task_id=task_id, + name="Office", + loc_lat="51.5074", + loc_long="-0.1278", + loc_trigger="on_enter", + radius=200, + ) + + assert len(respx_mock.calls) == 2 + assert new_reminder == default_location_reminder + + +@pytest.mark.asyncio +async def test_update_location_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_location_reminder: LocationReminder, +) -> None: + args = { + "name": "Home Office", + "radius": 150, + } + updated_dict = default_location_reminder.to_dict() | args + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/location_reminders/{default_location_reminder.id}", + request_headers=api_headers(), + request_json=args, + response_json=updated_dict, + response_status=200, + ) + + response = todoist_api.update_location_reminder( + location_reminder_id=default_location_reminder.id, **args + ) + + assert len(respx_mock.calls) == 1 + assert response == LocationReminder.from_dict(updated_dict) + + response = await todoist_api_async.update_location_reminder( + location_reminder_id=default_location_reminder.id, **args + ) + + assert len(respx_mock.calls) == 2 + assert response == LocationReminder.from_dict(updated_dict) + + +@pytest.mark.asyncio +async def test_delete_location_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, +) -> None: + location_reminder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/location_reminders/{location_reminder_id}" + + mock_route( + respx_mock, + method="DELETE", + url=endpoint, + request_headers=api_headers(), + response_status=204, + ) + + response = todoist_api.delete_location_reminder(location_reminder_id) + + assert len(respx_mock.calls) == 1 + assert response is True + + response = await todoist_api_async.delete_location_reminder(location_reminder_id) + + assert len(respx_mock.calls) == 2 + assert response is True diff --git a/tests/test_api_reminders.py b/tests/test_api_reminders.py new file mode 100644 index 0000000..e368a15 --- /dev/null +++ b/tests/test_api_reminders.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from tests.data.test_defaults import ( + DEFAULT_API_URL, + DEFAULT_DUE_RESPONSE, + PaginatedResults, +) +from tests.utils.test_utils import api_headers, enumerate_async, mock_route +from todoist_api_python.models import Due, Reminder + +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_get_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_reminder_response: dict[str, Any], + default_reminder: Reminder, +) -> None: + reminder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/reminders/{reminder_id}" + + mock_route( + respx_mock, + method="GET", + url=endpoint, + request_headers=api_headers(), + response_json=default_reminder_response, + response_status=200, + ) + + reminder = todoist_api.get_reminder(reminder_id) + + assert len(respx_mock.calls) == 1 + assert reminder == default_reminder + + reminder = await todoist_api_async.get_reminder(reminder_id) + + assert len(respx_mock.calls) == 2 + assert reminder == default_reminder + + +@pytest.mark.asyncio +async def test_get_reminders( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_reminders_response: list[PaginatedResults], + default_reminders_list: list[list[Reminder]], +) -> None: + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/reminders" + + cursor: str | None = None + for page in default_reminders_response: + mock_route( + respx_mock, + method="GET", + url=endpoint, + 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"] + + count = 0 + + reminders_iter = todoist_api.get_reminders(task_id=task_id) + + for i, reminders in enumerate(reminders_iter): + assert len(respx_mock.calls) == count + 1 + assert reminders == default_reminders_list[i] + count += 1 + + reminders_async_iter = await todoist_api_async.get_reminders(task_id=task_id) + + async for i, reminders in enumerate_async(reminders_async_iter): + assert len(respx_mock.calls) == count + 1 + assert reminders == default_reminders_list[i] + count += 1 + + +@pytest.mark.asyncio +async def test_add_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_reminder_response: dict[str, Any], + default_reminder: Reminder, +) -> None: + task_id = "6X7rM8997g3RQmvh" + due = Due.from_dict(DEFAULT_DUE_RESPONSE) + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/reminders", + request_headers=api_headers(), + request_json={ + "task_id": task_id, + "reminder_type": "absolute", + "due": due.to_dict(), + "service": "push", + }, + response_json=default_reminder_response, + response_status=200, + ) + + new_reminder = todoist_api.add_reminder( + task_id=task_id, + reminder_type="absolute", + due=due, + service="push", + ) + + assert len(respx_mock.calls) == 1 + assert new_reminder == default_reminder + + new_reminder = await todoist_api_async.add_reminder( + task_id=task_id, + reminder_type="absolute", + due=due, + service="push", + ) + + assert len(respx_mock.calls) == 2 + assert new_reminder == default_reminder + + +@pytest.mark.asyncio +async def test_update_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_reminder: Reminder, +) -> None: + args = { + "minute_offset": 45, + } + updated_reminder_dict = default_reminder.to_dict() | args + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/reminders/{default_reminder.id}", + request_headers=api_headers(), + request_json=args, + response_json=updated_reminder_dict, + response_status=200, + ) + + response = todoist_api.update_reminder(reminder_id=default_reminder.id, **args) + + assert len(respx_mock.calls) == 1 + assert response == Reminder.from_dict(updated_reminder_dict) + + response = await todoist_api_async.update_reminder( + reminder_id=default_reminder.id, **args + ) + + assert len(respx_mock.calls) == 2 + assert response == Reminder.from_dict(updated_reminder_dict) + + +@pytest.mark.asyncio +async def test_delete_reminder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, +) -> None: + reminder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/reminders/{reminder_id}" + + mock_route( + respx_mock, + method="DELETE", + url=endpoint, + request_headers=api_headers(), + response_status=204, + ) + + response = todoist_api.delete_reminder(reminder_id) + + assert len(respx_mock.calls) == 1 + assert response is True + + response = await todoist_api_async.delete_reminder(reminder_id) + + assert len(respx_mock.calls) == 2 + assert response is True diff --git a/todoist_api_python/_core/endpoints.py b/todoist_api_python/_core/endpoints.py index e10d0ae..435264f 100644 --- a/todoist_api_python/_core/endpoints.py +++ b/todoist_api_python/_core/endpoints.py @@ -30,6 +30,8 @@ SHARED_LABELS_PATH = "labels/shared" SHARED_LABELS_RENAME_PATH = f"{SHARED_LABELS_PATH}/rename" SHARED_LABELS_REMOVE_PATH = f"{SHARED_LABELS_PATH}/remove" +REMINDERS_PATH = "reminders" +LOCATION_REMINDERS_PATH = "location_reminders" AUTHORIZE_PATH = "authorize" ACCESS_TOKEN_PATH = "access_token" # noqa: S105 diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index 9b95035..c1a06a1 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -13,10 +13,12 @@ COMMENTS_PATH, LABELS_PATH, LABELS_SEARCH_PATH_SUFFIX, + LOCATION_REMINDERS_PATH, PROJECT_ARCHIVE_PATH_SUFFIX, PROJECT_UNARCHIVE_PATH_SUFFIX, PROJECTS_PATH, PROJECTS_SEARCH_PATH_SUFFIX, + REMINDERS_PATH, SECTIONS_PATH, SECTIONS_SEARCH_PATH_SUFFIX, SHARED_LABELS_PATH, @@ -45,8 +47,11 @@ Attachment, Collaborator, Comment, + Due, Label, + LocationReminder, Project, + Reminder, Section, Task, ) @@ -1506,6 +1511,318 @@ def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: ) return response.is_success + # ── Reminders ────────────────────────────────────────────────────── + + def get_reminder(self, reminder_id: str) -> Reminder: + """ + Get a specific reminder by its ID. + + :param reminder_id: The ID of the reminder to retrieve. + :return: The requested reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + response = get( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + def get_reminders( + self, + *, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Reminder]]: + """ + Get an iterable of lists of reminders. + + The response is an iterable of lists of reminders. + 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 task_id: Optional task ID to filter reminders by. + :param limit: Maximum number of reminders per page. + :return: An iterable of lists of reminders. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(REMINDERS_PATH) + + params = kwargs_without_none( + task_id=task_id, + limit=limit, + ) + + return ResultsPaginator( + self._client, + endpoint, + "results", + Reminder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + def add_reminder( + self, + task_id: str, + *, + reminder_type: Literal["relative", "absolute"] = "relative", + minute_offset: int | None = None, + due: Due | None = None, + service: Literal["email", "push"] | None = None, + ) -> Reminder: + """ + Create a new reminder. + + For relative reminders, provide `minute_offset`. + For absolute reminders, provide `due`. + + :param task_id: The ID of the task to add the reminder to. + :param reminder_type: The type of reminder ("relative" or "absolute"). + :param minute_offset: Minutes before the due date/time to trigger (relative). + :param due: The absolute due date/time for the reminder (absolute). + :param service: The notification service ("email" or "push"). + :return: The newly created reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(REMINDERS_PATH) + + data = kwargs_without_none( + task_id=task_id, + reminder_type=reminder_type, + minute_offset=minute_offset, + due=due.to_dict() if due is not None else None, + service=service, + ) + + response = post( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + def update_reminder( + self, + reminder_id: str, + *, + minute_offset: int | None = None, + due: Due | None = None, + service: Literal["email", "push"] | None = None, + ) -> Reminder: + """ + Update an existing reminder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param reminder_id: The ID of the reminder to update. + :param minute_offset: Minutes before the due date/time to trigger. + :param due: The absolute due date/time for the reminder. + :param service: The notification service ("email" or "push"). + :return: The updated reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + + data = kwargs_without_none( + minute_offset=minute_offset, + due=due.to_dict() if due is not None else None, + service=service, + ) + + response = post( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + def delete_reminder(self, reminder_id: str) -> bool: + """ + Delete a reminder. + + :param reminder_id: The ID of the reminder to delete. + :return: True if the reminder was deleted successfully. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + response = delete( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return response.is_success + + # ── Location Reminders ───────────────────────────────────────────── + + def get_location_reminder(self, location_reminder_id: str) -> LocationReminder: + """ + Get a specific location reminder by its ID. + + :param location_reminder_id: The ID of the location reminder to retrieve. + :return: The requested location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + response = get( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + def get_location_reminders( + self, + *, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[LocationReminder]]: + """ + Get an iterable of lists of location reminders. + + The response is an iterable of lists of location reminders. + 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 task_id: Optional task ID to filter location reminders by. + :param limit: Maximum number of location reminders per page. + :return: An iterable of lists of location reminders. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(LOCATION_REMINDERS_PATH) + + params = kwargs_without_none( + task_id=task_id, + limit=limit, + ) + + return ResultsPaginator( + self._client, + endpoint, + "results", + LocationReminder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + def add_location_reminder( + self, + task_id: str, + name: str, + loc_lat: str, + loc_long: str, + loc_trigger: Literal["on_enter", "on_leave"], + *, + radius: int | None = None, + ) -> LocationReminder: + """ + Create a new location reminder. + + :param task_id: The ID of the task to add the reminder to. + :param name: The name of the location. + :param loc_lat: The latitude coordinate. + :param loc_long: The longitude coordinate. + :param loc_trigger: When to trigger ("on_enter" or "on_leave"). + :param radius: The radius in meters (default: 100). + :return: The newly created location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(LOCATION_REMINDERS_PATH) + + data = kwargs_without_none( + task_id=task_id, + name=name, + loc_lat=loc_lat, + loc_long=loc_long, + loc_trigger=loc_trigger, + radius=radius, + ) + + response = post( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + def update_location_reminder( + self, + location_reminder_id: str, + *, + name: str | None = None, + loc_lat: str | None = None, + loc_long: str | None = None, + loc_trigger: Literal["on_enter", "on_leave"] | None = None, + radius: int | None = None, + ) -> LocationReminder: + """ + Update an existing location reminder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param location_reminder_id: The ID of the location reminder to update. + :param name: The name of the location. + :param loc_lat: The latitude coordinate. + :param loc_long: The longitude coordinate. + :param loc_trigger: When to trigger ("on_enter" or "on_leave"). + :param radius: The radius in meters. + :return: The updated location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + + data = kwargs_without_none( + name=name, + loc_lat=loc_lat, + loc_long=loc_long, + loc_trigger=loc_trigger, + radius=radius, + ) + + response = post( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + def delete_location_reminder(self, location_reminder_id: str) -> bool: + """ + Delete a location reminder. + + :param location_reminder_id: The ID of the location reminder to delete. + :return: True if the location reminder was deleted successfully. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + response = delete( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return response.is_success + T = TypeVar("T") diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index 39a6e0b..8cb9c07 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -13,10 +13,12 @@ COMMENTS_PATH, LABELS_PATH, LABELS_SEARCH_PATH_SUFFIX, + LOCATION_REMINDERS_PATH, PROJECT_ARCHIVE_PATH_SUFFIX, PROJECT_UNARCHIVE_PATH_SUFFIX, PROJECTS_PATH, PROJECTS_SEARCH_PATH_SUFFIX, + REMINDERS_PATH, SECTIONS_PATH, SECTIONS_SEARCH_PATH_SUFFIX, SHARED_LABELS_PATH, @@ -45,8 +47,11 @@ Attachment, Collaborator, Comment, + Due, Label, + LocationReminder, Project, + Reminder, Section, Task, ) @@ -1526,6 +1531,320 @@ async def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: ) return response.is_success + # ── Reminders ────────────────────────────────────────────────────── + + async def get_reminder(self, reminder_id: str) -> Reminder: + """ + Get a specific reminder by its ID. + + :param reminder_id: The ID of the reminder to retrieve. + :return: The requested reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + response = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + async def get_reminders( + self, + *, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncIterator[list[Reminder]]: + """ + Get an async iterable of lists of reminders. + + The response is an async iterable of lists of reminders. + 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 task_id: Optional task ID to filter reminders by. + :param limit: Maximum number of reminders per page. + :return: An async iterable of lists of reminders. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(REMINDERS_PATH) + + params = kwargs_without_none( + task_id=task_id, + limit=limit, + ) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + Reminder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + async def add_reminder( + self, + task_id: str, + *, + reminder_type: Literal["relative", "absolute"] = "relative", + minute_offset: int | None = None, + due: Due | None = None, + service: Literal["email", "push"] | None = None, + ) -> Reminder: + """ + Create a new reminder. + + For relative reminders, provide `minute_offset`. + For absolute reminders, provide `due`. + + :param task_id: The ID of the task to add the reminder to. + :param reminder_type: The type of reminder ("relative" or "absolute"). + :param minute_offset: Minutes before the due date/time to trigger (relative). + :param due: The absolute due date/time for the reminder (absolute). + :param service: The notification service ("email" or "push"). + :return: The newly created reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(REMINDERS_PATH) + + data = kwargs_without_none( + task_id=task_id, + reminder_type=reminder_type, + minute_offset=minute_offset, + due=due.to_dict() if due is not None else None, + service=service, + ) + + response = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + async def update_reminder( + self, + reminder_id: str, + *, + minute_offset: int | None = None, + due: Due | None = None, + service: Literal["email", "push"] | None = None, + ) -> Reminder: + """ + Update an existing reminder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param reminder_id: The ID of the reminder to update. + :param minute_offset: Minutes before the due date/time to trigger. + :param due: The absolute due date/time for the reminder. + :param service: The notification service ("email" or "push"). + :return: The updated reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + + data = kwargs_without_none( + minute_offset=minute_offset, + due=due.to_dict() if due is not None else None, + service=service, + ) + + response = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return Reminder.from_dict(data) + + async def delete_reminder(self, reminder_id: str) -> bool: + """ + Delete a reminder. + + :param reminder_id: The ID of the reminder to delete. + :return: True if the reminder was deleted successfully. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + response = await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return response.is_success + + # ── Location Reminders ───────────────────────────────────────────── + + async def get_location_reminder( + self, location_reminder_id: str + ) -> LocationReminder: + """ + Get a specific location reminder by its ID. + + :param location_reminder_id: The ID of the location reminder to retrieve. + :return: The requested location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + response = await get_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + async def get_location_reminders( + self, + *, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncIterator[list[LocationReminder]]: + """ + Get an async iterable of lists of location reminders. + + The response is an async iterable of lists of location reminders. + 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 task_id: Optional task ID to filter location reminders by. + :param limit: Maximum number of location reminders per page. + :return: An async iterable of lists of location reminders. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(LOCATION_REMINDERS_PATH) + + params = kwargs_without_none( + task_id=task_id, + limit=limit, + ) + + return AsyncResultsPaginator( + self._client, + endpoint, + "results", + LocationReminder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + async def add_location_reminder( + self, + task_id: str, + name: str, + loc_lat: str, + loc_long: str, + loc_trigger: Literal["on_enter", "on_leave"], + *, + radius: int | None = None, + ) -> LocationReminder: + """ + Create a new location reminder. + + :param task_id: The ID of the task to add the reminder to. + :param name: The name of the location. + :param loc_lat: The latitude coordinate. + :param loc_long: The longitude coordinate. + :param loc_trigger: When to trigger ("on_enter" or "on_leave"). + :param radius: The radius in meters (default: 100). + :return: The newly created location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(LOCATION_REMINDERS_PATH) + + data = kwargs_without_none( + task_id=task_id, + name=name, + loc_lat=loc_lat, + loc_long=loc_long, + loc_trigger=loc_trigger, + radius=radius, + ) + + response = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + async def update_location_reminder( + self, + location_reminder_id: str, + *, + name: str | None = None, + loc_lat: str | None = None, + loc_long: str | None = None, + loc_trigger: Literal["on_enter", "on_leave"] | None = None, + radius: int | None = None, + ) -> LocationReminder: + """ + Update an existing location reminder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param location_reminder_id: The ID of the location reminder to update. + :param name: The name of the location. + :param loc_lat: The latitude coordinate. + :param loc_long: The longitude coordinate. + :param loc_trigger: When to trigger ("on_enter" or "on_leave"). + :param radius: The radius in meters. + :return: The updated location reminder. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + + data = kwargs_without_none( + name=name, + loc_lat=loc_lat, + loc_long=loc_long, + loc_trigger=loc_trigger, + radius=radius, + ) + + response = await post_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + data = response_json_dict(response) + return LocationReminder.from_dict(data) + + async def delete_location_reminder(self, location_reminder_id: str) -> bool: + """ + Delete a location reminder. + + :param location_reminder_id: The ID of the location reminder to delete. + :return: True if the location reminder was deleted successfully. + :raises httpx.HTTPStatusError: If the API request fails. + """ + endpoint = get_api_url(f"{LOCATION_REMINDERS_PATH}/{location_reminder_id}") + response = await delete_async( + self._client, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return response.is_success + T = TypeVar("T") diff --git a/todoist_api_python/models.py b/todoist_api_python/models.py index 24bd33c..7ba7f15 100644 --- a/todoist_api_python/models.py +++ b/todoist_api_python/models.py @@ -11,6 +11,9 @@ ViewStyle = Literal["list", "board", "calendar"] DurationUnit = Literal["minute", "day"] +ReminderType = Literal["relative", "absolute"] +ReminderService = Literal["email", "push"] +LocationTrigger = Literal["on_enter", "on_leave"] ApiDate = UTCDateTimePattern["%FT%T.%fZ"] # type: ignore[valid-type] ApiDue = Union[ # https://github.com/rnag/dataclass-wizard/issues/189 DatePattern["%F"], DateTimePattern["%FT%T"], UTCDateTimePattern["%FT%TZ"] # type: ignore[valid-type] # noqa: F722 @@ -218,3 +221,37 @@ class _(JSONPyWizard.Meta): # noqa:N801 amount: int unit: DurationUnit + + +@dataclass +class Reminder(JSONPyWizard): + class _(JSONPyWizard.Meta): # noqa:N801 + v1 = True + + id: str + task_id: Annotated[str, Alias(load=("item_id", "task_id"))] + notify_uid: str + type: ReminderType + is_deleted: bool + + minute_offset: int | None = None + due: Due | None = None + service: ReminderService | None = None + + +@dataclass +class LocationReminder(JSONPyWizard): + class _(JSONPyWizard.Meta): # noqa:N801 + v1 = True + + id: str + task_id: Annotated[str, Alias(load=("item_id", "task_id"))] + project_id: str + notify_uid: str + name: str + loc_lat: str + loc_long: str + loc_trigger: LocationTrigger + radius: int + type: str + is_deleted: bool From 85267e18bbe33511683da9edd28bb320aa7a235d Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Tue, 24 Mar 2026 11:50:25 +0000 Subject: [PATCH 2/3] Add is_urgent field to Reminder model Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/data/test_defaults.py | 1 + todoist_api_python/models.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index d445221..97e4783 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -274,6 +274,7 @@ class PaginatedItems(TypedDict): "notify_uid": "34567", "type": "relative", "is_deleted": False, + "is_urgent": False, "minute_offset": 30, "due": DEFAULT_DUE_RESPONSE, "service": "push", diff --git a/todoist_api_python/models.py b/todoist_api_python/models.py index 7ba7f15..5185a3c 100644 --- a/todoist_api_python/models.py +++ b/todoist_api_python/models.py @@ -233,6 +233,7 @@ class _(JSONPyWizard.Meta): # noqa:N801 notify_uid: str type: ReminderType is_deleted: bool + is_urgent: bool minute_offset: int | None = None due: Due | None = None From 33a6894db74cd74b116b1c05c79da06897f48d43 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Tue, 24 Mar 2026 12:05:09 +0000 Subject: [PATCH 3/3] Address review feedback: flatten due params, fix fixture aliasing - Replace Due model input param with flat fields (due_string, due_date, due_datetime, due_lang, due_timezone) matching add_task pattern - Use distinct id/item_id values in test fixtures to properly exercise the item_id -> task_id alias Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/data/test_defaults.py | 4 +-- tests/test_api_reminders.py | 12 ++++---- todoist_api_python/api.py | 49 +++++++++++++++++++++++++++------ todoist_api_python/api_async.py | 49 +++++++++++++++++++++++++++------ 4 files changed, 90 insertions(+), 24 deletions(-) diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 97e4783..8d14794 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -270,7 +270,7 @@ class PaginatedItems(TypedDict): DEFAULT_REMINDER_RESPONSE: dict[str, Any] = { "id": "6X7rM8997g3RQmvh", - "item_id": "6X7rM8997g3RQmvh", + "item_id": "6Jf8VQXxpwv56VQ7", "notify_uid": "34567", "type": "relative", "is_deleted": False, @@ -299,7 +299,7 @@ class PaginatedItems(TypedDict): DEFAULT_LOCATION_REMINDER_RESPONSE: dict[str, Any] = { "id": "6X7rM8997g3RQmvh", - "item_id": "6X7rM8997g3RQmvh", + "item_id": "6Jf8VQXxpwv56VQ7", "project_id": "6Jf8VQXxpwv56VQ7", "notify_uid": "34567", "name": "Office", diff --git a/tests/test_api_reminders.py b/tests/test_api_reminders.py index e368a15..dca6b1b 100644 --- a/tests/test_api_reminders.py +++ b/tests/test_api_reminders.py @@ -6,11 +6,10 @@ from tests.data.test_defaults import ( DEFAULT_API_URL, - DEFAULT_DUE_RESPONSE, PaginatedResults, ) from tests.utils.test_utils import api_headers, enumerate_async, mock_route -from todoist_api_python.models import Due, Reminder +from todoist_api_python.models import Reminder if TYPE_CHECKING: import respx @@ -101,7 +100,6 @@ async def test_add_reminder( default_reminder: Reminder, ) -> None: task_id = "6X7rM8997g3RQmvh" - due = Due.from_dict(DEFAULT_DUE_RESPONSE) mock_route( respx_mock, @@ -111,7 +109,7 @@ async def test_add_reminder( request_json={ "task_id": task_id, "reminder_type": "absolute", - "due": due.to_dict(), + "due": {"string": "tomorrow at 12", "lang": "en"}, "service": "push", }, response_json=default_reminder_response, @@ -121,7 +119,8 @@ async def test_add_reminder( new_reminder = todoist_api.add_reminder( task_id=task_id, reminder_type="absolute", - due=due, + due_string="tomorrow at 12", + due_lang="en", service="push", ) @@ -131,7 +130,8 @@ async def test_add_reminder( new_reminder = await todoist_api_async.add_reminder( task_id=task_id, reminder_type="absolute", - due=due, + due_string="tomorrow at 12", + due_lang="en", service="push", ) diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index c1a06a1..20b59cc 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -47,7 +47,6 @@ Attachment, Collaborator, Comment, - Due, Label, LocationReminder, Project, @@ -1572,30 +1571,47 @@ def add_reminder( *, reminder_type: Literal["relative", "absolute"] = "relative", minute_offset: int | None = None, - due: Due | None = None, + due_string: str | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, + due_lang: LanguageCode | None = None, + due_timezone: str | None = None, service: Literal["email", "push"] | None = None, ) -> Reminder: """ Create a new reminder. For relative reminders, provide `minute_offset`. - For absolute reminders, provide `due`. + For absolute reminders, provide due date fields. :param task_id: The ID of the task to add the reminder to. :param reminder_type: The type of reminder ("relative" or "absolute"). :param minute_offset: Minutes before the due date/time to trigger (relative). - :param due: The absolute due date/time for the reminder (absolute). + :param due_string: The due date in natural language format (absolute). + :param due_date: The due date as a date object (absolute). + :param due_datetime: The due date and time as a datetime object (absolute). + :param due_lang: Language for parsing the due date. + :param due_timezone: Timezone for the due date. :param service: The notification service ("email" or "push"). :return: The newly created reminder. :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(REMINDERS_PATH) + due = kwargs_without_none( + string=due_string, + date=format_date(due_date) + if due_date is not None + else (format_datetime(due_datetime) if due_datetime is not None else None), + lang=due_lang, + timezone=due_timezone, + ) + data = kwargs_without_none( task_id=task_id, reminder_type=reminder_type, minute_offset=minute_offset, - due=due.to_dict() if due is not None else None, + due=due or None, service=service, ) @@ -1614,7 +1630,11 @@ def update_reminder( reminder_id: str, *, minute_offset: int | None = None, - due: Due | None = None, + due_string: str | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, + due_lang: LanguageCode | None = None, + due_timezone: str | None = None, service: Literal["email", "push"] | None = None, ) -> Reminder: """ @@ -1624,16 +1644,29 @@ def update_reminder( :param reminder_id: The ID of the reminder to update. :param minute_offset: Minutes before the due date/time to trigger. - :param due: The absolute due date/time for the reminder. + :param due_string: The due date in natural language format. + :param due_date: The due date as a date object. + :param due_datetime: The due date and time as a datetime object. + :param due_lang: Language for parsing the due date. + :param due_timezone: Timezone for the due date. :param service: The notification service ("email" or "push"). :return: The updated reminder. :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + due = kwargs_without_none( + string=due_string, + date=format_date(due_date) + if due_date is not None + else (format_datetime(due_datetime) if due_datetime is not None else None), + lang=due_lang, + timezone=due_timezone, + ) + data = kwargs_without_none( minute_offset=minute_offset, - due=due.to_dict() if due is not None else None, + due=due or None, service=service, ) diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index 8cb9c07..18150bb 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -47,7 +47,6 @@ Attachment, Collaborator, Comment, - Due, Label, LocationReminder, Project, @@ -1592,30 +1591,47 @@ async def add_reminder( *, reminder_type: Literal["relative", "absolute"] = "relative", minute_offset: int | None = None, - due: Due | None = None, + due_string: str | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, + due_lang: LanguageCode | None = None, + due_timezone: str | None = None, service: Literal["email", "push"] | None = None, ) -> Reminder: """ Create a new reminder. For relative reminders, provide `minute_offset`. - For absolute reminders, provide `due`. + For absolute reminders, provide due date fields. :param task_id: The ID of the task to add the reminder to. :param reminder_type: The type of reminder ("relative" or "absolute"). :param minute_offset: Minutes before the due date/time to trigger (relative). - :param due: The absolute due date/time for the reminder (absolute). + :param due_string: The due date in natural language format (absolute). + :param due_date: The due date as a date object (absolute). + :param due_datetime: The due date and time as a datetime object (absolute). + :param due_lang: Language for parsing the due date. + :param due_timezone: Timezone for the due date. :param service: The notification service ("email" or "push"). :return: The newly created reminder. :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(REMINDERS_PATH) + due = kwargs_without_none( + string=due_string, + date=format_date(due_date) + if due_date is not None + else (format_datetime(due_datetime) if due_datetime is not None else None), + lang=due_lang, + timezone=due_timezone, + ) + data = kwargs_without_none( task_id=task_id, reminder_type=reminder_type, minute_offset=minute_offset, - due=due.to_dict() if due is not None else None, + due=due or None, service=service, ) @@ -1634,7 +1650,11 @@ async def update_reminder( reminder_id: str, *, minute_offset: int | None = None, - due: Due | None = None, + due_string: str | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, + due_lang: LanguageCode | None = None, + due_timezone: str | None = None, service: Literal["email", "push"] | None = None, ) -> Reminder: """ @@ -1644,16 +1664,29 @@ async def update_reminder( :param reminder_id: The ID of the reminder to update. :param minute_offset: Minutes before the due date/time to trigger. - :param due: The absolute due date/time for the reminder. + :param due_string: The due date in natural language format. + :param due_date: The due date as a date object. + :param due_datetime: The due date and time as a datetime object. + :param due_lang: Language for parsing the due date. + :param due_timezone: Timezone for the due date. :param service: The notification service ("email" or "push"). :return: The updated reminder. :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{REMINDERS_PATH}/{reminder_id}") + due = kwargs_without_none( + string=due_string, + date=format_date(due_date) + if due_date is not None + else (format_datetime(due_datetime) if due_datetime is not None else None), + lang=due_lang, + timezone=due_timezone, + ) + data = kwargs_without_none( minute_offset=minute_offset, - due=due.to_dict() if due is not None else None, + due=due or None, service=service, )