Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions tests/data/test_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 7 additions & 6 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"])
Expand Down
36 changes: 36 additions & 0 deletions todoist_api_python/_core/utils.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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)
30 changes: 7 additions & 23 deletions todoist_api_python/api.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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")
30 changes: 18 additions & 12 deletions todoist_api_python/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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[
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -196,4 +202,4 @@ class _(JSONPyWizard.Meta): # noqa:N801
v1 = True

amount: int
unit: DURATION_UNIT
unit: DurationUnit