diff --git a/tests/test_api_tasks.py b/tests/test_api_tasks.py index 99459de..53fc9ff 100644 --- a/tests/test_api_tasks.py +++ b/tests/test_api_tasks.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any import pytest @@ -244,6 +245,7 @@ async def test_add_task_full( default_task: Task, ) -> None: content = "Some content" + due_datetime = datetime(2021, 1, 1, 11, 0, 0, tzinfo=UTC) args: dict[str, Any] = { "description": "A description", "project_id": "123", @@ -252,8 +254,6 @@ async def test_add_task_full( "labels": ["label1", "label2"], "priority": 4, "due_string": "today", - "due_date": "2021-01-01", - "due_datetime": "2021-01-01T11:00:00Z", "due_lang": "en", "assignee_id": "321", "order": 3, @@ -268,15 +268,26 @@ async def test_add_task_full( url=f"{DEFAULT_API_URL}/tasks", json=default_task_response, status=200, - match=[auth_matcher(), data_matcher({"content": content} | args)], + match=[ + auth_matcher(), + data_matcher( + { + "content": content, + "due_datetime": due_datetime.strftime("%Y-%m-%dT%H:%M:%SZ"), + } + | args + ), + ], ) - new_task = todoist_api.add_task(content=content, **args) + new_task = todoist_api.add_task(content=content, due_datetime=due_datetime, **args) assert len(requests_mock.calls) == 1 assert new_task == default_task - new_task = await todoist_api_async.add_task(content=content, **args) + new_task = await todoist_api_async.add_task( + content=content, due_datetime=due_datetime, **args + ) assert len(requests_mock.calls) == 2 assert new_task == default_task diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index a9d510b..02bb37b 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator +from datetime import UTC from typing import TYPE_CHECKING, Annotated, Any, Literal, Self, TypeVar from weakref import finalize @@ -33,12 +34,10 @@ ) if TYPE_CHECKING: + from datetime import date, datetime from types import TracebackType -DateFormat = Annotated[str, Predicate(lambda x: len(x) == 10 and x.count("-") == 2)] # noqa: PLR2004 -DateTimeFormat = Annotated[ - str, Predicate(lambda x: len(x) >= 20 and "T" in x and ":" in x) # noqa: PLR2004 -] + LanguageCode = Annotated[str, Predicate(lambda x: len(x) == 2)] # noqa: PLR2004 ColorString = Annotated[ str, @@ -230,16 +229,16 @@ def add_task( # noqa: PLR0912 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_date: DateFormat | None = None, - due_datetime: DateTimeFormat | None = None, due_lang: LanguageCode | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, assignee_id: str | None = None, order: int | None = None, auto_reminder: bool | None = None, auto_parse_labels: bool | None = None, duration: Annotated[int, Ge(1)] | None = None, duration_unit: Literal["minute", "day"] | None = None, - deadline_date: DateFormat | None = None, + deadline_date: date | None = None, deadline_lang: LanguageCode | None = None, ) -> Task: """ @@ -252,9 +251,9 @@ def add_task( # noqa: PLR0912 :param labels: The task's labels (a list of names). :param priority: The priority of the task (4 for very urgent). :param due_string: The due date in natural language format. - :param due_date: The due date in YYYY-MM-DD format. - :param due_datetime: The due date and time in RFC 3339 format. :param due_lang: Language for parsing the due date (e.g., 'en'). + :param due_date: The due date as a date object. + :param due_datetime: The due date and time as a datetime object. :param assignee_id: User ID to whom the task is assigned. :param description: Description for the task. :param order: The order of task in the project or section. @@ -262,7 +261,7 @@ def add_task( # noqa: PLR0912 :param auto_parse_labels: Whether to parse labels from task content. :param duration: The amount of time the task will take. :param duration_unit: The unit of time for duration. - :param deadline_date: The deadline date in YYYY-MM-DD format. + :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. @@ -285,12 +284,12 @@ def add_task( # noqa: PLR0912 data["priority"] = priority if due_string is not None: data["due_string"] = due_string - if due_date is not None: - data["due_date"] = due_date - if due_datetime is not None: - data["due_datetime"] = due_datetime 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: @@ -304,7 +303,7 @@ def add_task( # noqa: PLR0912 if duration_unit is not None: data["duration_unit"] = duration_unit if deadline_date is not None: - data["deadline_date"] = deadline_date + data["deadline_date"] = _format_date(deadline_date) if deadline_lang is not None: data["deadline_lang"] = deadline_lang @@ -362,15 +361,15 @@ def update_task( # noqa: PLR0912 labels: list[Annotated[str, MaxLen(60)]] | None = None, priority: Annotated[int, Ge(1), Le(4)] | None = None, due_string: Annotated[str, MaxLen(150)] | None = None, - due_date: DateFormat | None = None, - due_datetime: DateTimeFormat | None = None, due_lang: LanguageCode | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, assignee_id: str | None = None, day_order: int | None = None, collapsed: bool | None = None, duration: Annotated[int, Ge(1)] | None = None, duration_unit: Literal["minute", "day"] | None = None, - deadline_date: DateFormat | None = None, + deadline_date: date | None = None, deadline_lang: LanguageCode | None = None, ) -> Task: """ @@ -384,15 +383,15 @@ def update_task( # noqa: PLR0912 :param labels: The task's labels (a list of names). :param priority: The priority of the task (4 for very urgent). :param due_string: The due date in natural language format. - :param due_date: The due date in YYYY-MM-DD format. - :param due_datetime: The due date and time in RFC 3339 format. :param due_lang: Language for parsing the due date (e.g., 'en'). + :param due_date: The due date as a date object. + :param due_datetime: The due date and time as a datetime object. :param assignee_id: User ID to whom the task is assigned. :param day_order: The order of the task inside Today or Next 7 days view. :param collapsed: Whether the task's sub-tasks are collapsed. :param duration: The amount of time the task will take. :param duration_unit: The unit of time for duration. - :param deadline_date: The deadline date in YYYY-MM-DD format. + :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. @@ -410,12 +409,12 @@ def update_task( # noqa: PLR0912 data["priority"] = priority if due_string is not None: data["due_string"] = due_string - if due_date is not None: - data["due_date"] = due_date - if due_datetime is not None: - data["due_datetime"] = due_datetime 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: @@ -427,7 +426,7 @@ def update_task( # noqa: PLR0912 if duration_unit is not None: data["duration_unit"] = duration_unit if deadline_date is not None: - data["deadline_date"] = deadline_date + data["deadline_date"] = _format_date(deadline_date) if deadline_lang is not None: data["deadline_lang"] = deadline_lang @@ -1152,3 +1151,19 @@ def __next__(self) -> list[T]: results: list[Any] = data.get(self._results_field, []) return [self._results_inst(result) for result in results] + + +def _format_date(d: date) -> str: + """Format a date object as YYYY-MM-DD.""" + return d.isoformat() + + +def _format_datetime(dt: datetime) -> str: + """ + Format a datetime object. + + YYYY-MM-DDTHH:MM:SS for naive datetimes; YYYY-MM-DDTHH:MM:SSZ for aware datetimes. + """ + if dt.tzinfo is None: + return dt.isoformat() + return dt.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index 519d26a..7045196 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from collections.abc import AsyncGenerator + from datetime import date, datetime from types import TracebackType import requests @@ -25,8 +26,6 @@ from todoist_api_python.api import ( ColorString, - DateFormat, - DateTimeFormat, LanguageCode, ViewStyle, ) @@ -154,8 +153,8 @@ 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_date: DateFormat | None = None, - due_datetime: DateTimeFormat | 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, @@ -163,7 +162,7 @@ async def add_task( auto_parse_labels: bool | None = None, duration: Annotated[int, Ge(1)] | None = None, duration_unit: Literal["minute", "day"] | None = None, - deadline_date: DateFormat | None = None, + deadline_date: date | None = None, deadline_lang: LanguageCode | None = None, ) -> Task: """ @@ -176,9 +175,9 @@ async def add_task( :param labels: The task's labels (a list of names). :param priority: The priority of the task (4 for very urgent). :param due_string: The due date in natural language format. - :param due_date: The due date in YYYY-MM-DD format. - :param due_datetime: The due date and time in RFC 3339 format. :param due_lang: Language for parsing the due date (e.g., 'en'). + :param due_date: The due date as a date object. + :param due_datetime: The due date and time as a datetime object. :param assignee_id: User ID to whom the task is assigned. :param description: Description for the task. :param order: The order of task in the project or section. @@ -186,7 +185,7 @@ async def add_task( :param auto_parse_labels: Whether to parse labels from task content. :param duration: The amount of time the task will take. :param duration_unit: The unit of time for duration. - :param deadline_date: The deadline date in YYYY-MM-DD format. + :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. @@ -202,9 +201,9 @@ async def add_task( labels=labels, priority=priority, due_string=due_string, + due_lang=due_lang, due_date=due_date, due_datetime=due_datetime, - due_lang=due_lang, assignee_id=assignee_id, order=order, auto_reminder=auto_reminder, @@ -216,6 +215,34 @@ async def add_task( ) ) + async def add_task_quick( + self, + text: str, + *, + note: str | None = None, + reminder: str | None = None, + auto_reminder: bool = True, + ) -> Task: + """ + Create a new task using Todoist's Quick Add syntax. + + This automatically parses dates, deadlines, projects, labels, priorities, etc, + from the provided text (e.g., "Buy milk #Shopping @groceries tomorrow p1"). + + :param text: The task text using Quick Add syntax. + :param note: Optional note to be added to the task. + :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 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 + ) + ) + async def update_task( self, task_id: str, @@ -225,15 +252,15 @@ async def update_task( labels: list[Annotated[str, MaxLen(60)]] | None = None, priority: Annotated[int, Ge(1), Le(4)] | None = None, due_string: Annotated[str, MaxLen(150)] | None = None, - due_date: DateFormat | None = None, - due_datetime: DateTimeFormat | None = None, due_lang: LanguageCode | None = None, + due_date: date | None = None, + due_datetime: datetime | None = None, assignee_id: str | None = None, day_order: int | None = None, collapsed: bool | None = None, duration: Annotated[int, Ge(1)] | None = None, duration_unit: Literal["minute", "day"] | None = None, - deadline_date: DateFormat | None = None, + deadline_date: date | None = None, deadline_lang: LanguageCode | None = None, ) -> Task: """ @@ -247,15 +274,15 @@ async def update_task( :param labels: The task's labels (a list of names). :param priority: The priority of the task (4 for very urgent). :param due_string: The due date in natural language format. - :param due_date: The due date in YYYY-MM-DD format. - :param due_datetime: The due date and time in RFC 3339 format. :param due_lang: Language for parsing the due date (e.g., 'en'). + :param due_date: The due date as a date object. + :param due_datetime: The due date and time as a datetime object. :param assignee_id: User ID to whom the task is assigned. :param day_order: The order of the task inside Today or Next 7 days view. :param collapsed: Whether the task's sub-tasks are collapsed. :param duration: The amount of time the task will take. :param duration_unit: The unit of time for duration. - :param deadline_date: The deadline date in YYYY-MM-DD format. + :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. @@ -320,34 +347,6 @@ async def delete_task(self, task_id: str) -> bool: """ return await run_async(lambda: self._api.delete_task(task_id)) - async def add_task_quick( - self, - text: str, - *, - note: str | None = None, - reminder: str | None = None, - auto_reminder: bool = True, - ) -> Task: - """ - Create a new task using Todoist's Quick Add syntax. - - This automatically parses dates, deadlines, projects, labels, priorities, etc, - from the provided text (e.g., "Buy milk #Shopping @groceries tomorrow p1"). - - :param text: The task text using Quick Add syntax. - :param note: Optional note to be added to the task. - :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 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 - ) - ) - async def get_project(self, project_id: str) -> Project: """ Get a project by its ID.