diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 13f8aa0..d49a1e1 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -49,8 +49,8 @@ class PaginatedResults(TypedDict): "is_inbox_project": True, "can_assign_tasks": False, "view_style": "list", - "created_at": "2023-02-01T00:00:00000Z", - "updated_at": "2025-04-03T03:14:15926Z", + "created_at": "2023-02-01T00:00:00.000000Z", + "updated_at": "2025-04-03T03:14:15.926536Z", } DEFAULT_PROJECT_RESPONSE_2 = dict(DEFAULT_PROJECT_RESPONSE) @@ -90,8 +90,8 @@ class PaginatedResults(TypedDict): "assigned_by_uid": "2971358", "completed_at": None, "added_by_uid": "34567", - "added_at": "2016-01-02T21:00:30.00000Z", - "updated_at": None, + "added_at": "2014-09-26T08:25:05.000000Z", + "updated_at": "2016-01-02T21:00:30.000000Z", } DEFAULT_TASK_RESPONSE_2 = dict(DEFAULT_TASK_RESPONSE) diff --git a/tests/test_models.py b/tests/test_models.py index c023f0f..06840d6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,6 +12,7 @@ DEFAULT_SECTION_RESPONSE, DEFAULT_TASK_RESPONSE, ) +from todoist_api_python._core.utils import parse_date, parse_datetime from todoist_api_python.models import ( Attachment, AuthResult, @@ -34,7 +35,7 @@ def test_due_from_dict() -> None: due = Due.from_dict(sample_data) - assert due.date == sample_data["date"] + assert due.date == parse_date(str(sample_data["date"])) assert due.timezone == sample_data["timezone"] assert due.string == sample_data["string"] assert due.lang == sample_data["lang"] @@ -71,8 +72,8 @@ def test_project_from_dict() -> None: assert project.is_inbox_project == sample_data["is_inbox_project"] assert project.can_assign_tasks == sample_data["can_assign_tasks"] assert project.view_style == sample_data["view_style"] - assert project.created_at == sample_data["created_at"] - assert project.updated_at == sample_data["updated_at"] + assert project.created_at == parse_datetime(str(sample_data["created_at"])) + assert project.updated_at == parse_datetime(str(sample_data["updated_at"])) def test_project_url() -> None: @@ -104,8 +105,8 @@ def test_task_from_dict() -> None: assert task.assigner_id == sample_data["assigned_by_uid"] assert task.completed_at == sample_data["completed_at"] assert task.creator_id == sample_data["added_by_uid"] - assert task.created_at == sample_data["added_at"] - assert task.updated_at == sample_data["updated_at"] + assert task.created_at == parse_datetime(sample_data["added_at"]) + assert task.updated_at == parse_datetime(sample_data["updated_at"]) def test_task_url() -> None: @@ -167,7 +168,7 @@ def test_comment_from_dict() -> None: assert comment.id == sample_data["id"] assert comment.content == sample_data["content"] assert comment.poster_id == sample_data["posted_uid"] - assert comment.posted_at == sample_data["posted_at"] + assert comment.posted_at == parse_datetime(sample_data["posted_at"]) assert comment.task_id == sample_data["task_id"] assert comment.project_id == sample_data["project_id"] assert comment.attachment == Attachment.from_dict(sample_data["attachment"]) diff --git a/todoist_api_python/_core/utils.py b/todoist_api_python/_core/utils.py index 26042ca..429fd9a 100644 --- a/todoist_api_python/_core/utils.py +++ b/todoist_api_python/_core/utils.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import AsyncGenerator, Callable, Iterator +from datetime import UTC, date, datetime from typing import TypeVar, cast T = TypeVar("T") @@ -23,3 +24,38 @@ def get_next_item() -> tuple[bool, T | None]: yield cast("T", item) else: break + + +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") + + +def parse_date(date_str: str) -> date: + """Parse a YYYY-MM-DD string into a date object.""" + return date.fromisoformat(date_str) + + +def parse_datetime(datetime_str: str) -> datetime: + """ + Parse a string into a datetime object. + + YYYY-MM-DDTHH:MM:SS for naive datetimes; YYYY-MM-DDTHH:MM:SSZ for aware datetimes. + """ + from datetime import datetime + + if datetime_str.endswith("Z"): + datetime_str = datetime_str[:-1] + "+00:00" + return datetime.fromisoformat(datetime_str) + return datetime.fromisoformat(datetime_str) diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index 02bb37b..8aba06f 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -1,7 +1,6 @@ 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 @@ -23,6 +22,7 @@ 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.models import ( Attachment, Collaborator, @@ -287,9 +287,9 @@ def add_task( # noqa: PLR0912 if due_lang is not None: data["due_lang"] = due_lang if due_date is not None: - data["due_date"] = _format_date(due_date) + data["due_date"] = format_date(due_date) if due_datetime is not None: - data["due_datetime"] = _format_datetime(due_datetime) + data["due_datetime"] = format_datetime(due_datetime) if assignee_id is not None: data["assignee_id"] = assignee_id if order is not None: @@ -303,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"] = _format_date(deadline_date) + data["deadline_date"] = format_date(deadline_date) if deadline_lang is not None: data["deadline_lang"] = deadline_lang @@ -412,9 +412,9 @@ def update_task( # noqa: PLR0912 if due_lang is not None: data["due_lang"] = due_lang if due_date is not None: - data["due_date"] = _format_date(due_date) + data["due_date"] = format_date(due_date) if due_datetime is not None: - data["due_datetime"] = _format_datetime(due_datetime) + data["due_datetime"] = format_datetime(due_datetime) if assignee_id is not None: data["assignee_id"] = assignee_id if day_order is not None: @@ -426,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"] = _format_date(deadline_date) + data["deadline_date"] = format_date(deadline_date) if deadline_lang is not None: data["deadline_lang"] = deadline_lang @@ -1151,19 +1151,3 @@ 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/models.py b/todoist_api_python/models.py index 0425226..a54a04d 100644 --- a/todoist_api_python/models.py +++ b/todoist_api_python/models.py @@ -1,15 +1,21 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Annotated, Literal +from typing import Annotated, Literal, Union from dataclass_wizard import JSONPyWizard +from dataclass_wizard.v1 import DatePattern, DateTimePattern, UTCDateTimePattern from dataclass_wizard.v1.models import Alias from todoist_api_python._core.endpoints import INBOX_URL, get_project_url, get_task_url -VIEW_STYLE = Literal["list", "board", "calendar"] -DURATION_UNIT = Literal["minute", "day"] +ViewStyle = Literal["list", "board", "calendar"] +DurationUnit = Literal["minute", "day"] +ApiDate = UTCDateTimePattern["%FT%T.%fZ"] # type: ignore[valid-type] +ApiDue = Union[ # noqa: UP007 + # https://github.com/rnag/dataclass-wizard/issues/189 + DatePattern["%F"], DateTimePattern["%FT%T"], UTCDateTimePattern["%FT%TZ"] # type: ignore[valid-type] # noqa: F722 +] @dataclass @@ -25,10 +31,10 @@ class _(JSONPyWizard.Meta): # noqa:N801 is_collapsed: Annotated[bool, Alias(load=("collapsed", "is_collapsed"))] is_shared: Annotated[bool, Alias(load=("shared", "is_shared"))] is_favorite: bool - can_assign_tasks: bool | None - view_style: VIEW_STYLE - created_at: str | None = None - updated_at: str | None = None + can_assign_tasks: bool + view_style: ViewStyle + created_at: ApiDate + updated_at: ApiDate parent_id: str | None = None is_inbox_project: Annotated[ @@ -62,7 +68,7 @@ class Due(JSONPyWizard): class _(JSONPyWizard.Meta): # noqa:N801 v1 = True - date: str + date: ApiDue string: str lang: str = "en" is_recurring: bool = False @@ -102,8 +108,8 @@ class _(JSONPyWizard.Meta): # noqa:N801 assigner_id: Annotated[str | None, Alias(load=("assigned_by_uid", "assigner_id"))] completed_at: str | None creator_id: Annotated[str, Alias(load=("added_by_uid", "creator_id"))] - created_at: Annotated[str, Alias(load=("added_at", "created_at"))] - updated_at: str | None + created_at: Annotated[ApiDate, Alias(load=("added_at", "created_at"))] + updated_at: ApiDate meta: Meta | None = None @@ -152,7 +158,7 @@ class _(JSONPyWizard.Meta): # noqa:N801 id: str content: str poster_id: Annotated[str, Alias(load=("posted_uid", "poster_id"))] - posted_at: str + posted_at: ApiDate task_id: Annotated[str | None, Alias(load=("item_id", "task_id"))] = None project_id: str | None = None attachment: Annotated[ @@ -196,4 +202,4 @@ class _(JSONPyWizard.Meta): # noqa:N801 v1 = True amount: int - unit: DURATION_UNIT + unit: DurationUnit