Skip to content

Commit 7d2b521

Browse files
Merge pull request #184 from Doist/goncalossilva/date-datetime-fields
2 parents 83d8e23 + f202f88 commit 7d2b521

File tree

5 files changed

+72
-45
lines changed

5 files changed

+72
-45
lines changed

tests/data/test_defaults.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ class PaginatedResults(TypedDict):
4949
"is_inbox_project": True,
5050
"can_assign_tasks": False,
5151
"view_style": "list",
52-
"created_at": "2023-02-01T00:00:00000Z",
53-
"updated_at": "2025-04-03T03:14:15926Z",
52+
"created_at": "2023-02-01T00:00:00.000000Z",
53+
"updated_at": "2025-04-03T03:14:15.926536Z",
5454
}
5555

5656
DEFAULT_PROJECT_RESPONSE_2 = dict(DEFAULT_PROJECT_RESPONSE)
@@ -90,8 +90,8 @@ class PaginatedResults(TypedDict):
9090
"assigned_by_uid": "2971358",
9191
"completed_at": None,
9292
"added_by_uid": "34567",
93-
"added_at": "2016-01-02T21:00:30.00000Z",
94-
"updated_at": None,
93+
"added_at": "2014-09-26T08:25:05.000000Z",
94+
"updated_at": "2016-01-02T21:00:30.000000Z",
9595
}
9696

9797
DEFAULT_TASK_RESPONSE_2 = dict(DEFAULT_TASK_RESPONSE)

tests/test_models.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DEFAULT_SECTION_RESPONSE,
1313
DEFAULT_TASK_RESPONSE,
1414
)
15+
from todoist_api_python._core.utils import parse_date, parse_datetime
1516
from todoist_api_python.models import (
1617
Attachment,
1718
AuthResult,
@@ -34,7 +35,7 @@ def test_due_from_dict() -> None:
3435

3536
due = Due.from_dict(sample_data)
3637

37-
assert due.date == sample_data["date"]
38+
assert due.date == parse_date(str(sample_data["date"]))
3839
assert due.timezone == sample_data["timezone"]
3940
assert due.string == sample_data["string"]
4041
assert due.lang == sample_data["lang"]
@@ -71,8 +72,8 @@ def test_project_from_dict() -> None:
7172
assert project.is_inbox_project == sample_data["is_inbox_project"]
7273
assert project.can_assign_tasks == sample_data["can_assign_tasks"]
7374
assert project.view_style == sample_data["view_style"]
74-
assert project.created_at == sample_data["created_at"]
75-
assert project.updated_at == sample_data["updated_at"]
75+
assert project.created_at == parse_datetime(str(sample_data["created_at"]))
76+
assert project.updated_at == parse_datetime(str(sample_data["updated_at"]))
7677

7778

7879
def test_project_url() -> None:
@@ -104,8 +105,8 @@ def test_task_from_dict() -> None:
104105
assert task.assigner_id == sample_data["assigned_by_uid"]
105106
assert task.completed_at == sample_data["completed_at"]
106107
assert task.creator_id == sample_data["added_by_uid"]
107-
assert task.created_at == sample_data["added_at"]
108-
assert task.updated_at == sample_data["updated_at"]
108+
assert task.created_at == parse_datetime(sample_data["added_at"])
109+
assert task.updated_at == parse_datetime(sample_data["updated_at"])
109110

110111

111112
def test_task_url() -> None:
@@ -167,7 +168,7 @@ def test_comment_from_dict() -> None:
167168
assert comment.id == sample_data["id"]
168169
assert comment.content == sample_data["content"]
169170
assert comment.poster_id == sample_data["posted_uid"]
170-
assert comment.posted_at == sample_data["posted_at"]
171+
assert comment.posted_at == parse_datetime(sample_data["posted_at"])
171172
assert comment.task_id == sample_data["task_id"]
172173
assert comment.project_id == sample_data["project_id"]
173174
assert comment.attachment == Attachment.from_dict(sample_data["attachment"])

todoist_api_python/_core/utils.py

+36
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
from collections.abc import AsyncGenerator, Callable, Iterator
3+
from datetime import UTC, date, datetime
34
from typing import TypeVar, cast
45

56
T = TypeVar("T")
@@ -23,3 +24,38 @@ def get_next_item() -> tuple[bool, T | None]:
2324
yield cast("T", item)
2425
else:
2526
break
27+
28+
29+
def format_date(d: date) -> str:
30+
"""Format a date object as YYYY-MM-DD."""
31+
return d.isoformat()
32+
33+
34+
def format_datetime(dt: datetime) -> str:
35+
"""
36+
Format a datetime object.
37+
38+
YYYY-MM-DDTHH:MM:SS for naive datetimes; YYYY-MM-DDTHH:MM:SSZ for aware datetimes.
39+
"""
40+
if dt.tzinfo is None:
41+
return dt.isoformat()
42+
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
43+
44+
45+
def parse_date(date_str: str) -> date:
46+
"""Parse a YYYY-MM-DD string into a date object."""
47+
return date.fromisoformat(date_str)
48+
49+
50+
def parse_datetime(datetime_str: str) -> datetime:
51+
"""
52+
Parse a string into a datetime object.
53+
54+
YYYY-MM-DDTHH:MM:SS for naive datetimes; YYYY-MM-DDTHH:MM:SSZ for aware datetimes.
55+
"""
56+
from datetime import datetime
57+
58+
if datetime_str.endswith("Z"):
59+
datetime_str = datetime_str[:-1] + "+00:00"
60+
return datetime.fromisoformat(datetime_str)
61+
return datetime.fromisoformat(datetime_str)

todoist_api_python/api.py

+7-23
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable, Iterator
4-
from datetime import UTC
54
from typing import TYPE_CHECKING, Annotated, Any, Literal, Self, TypeVar
65
from weakref import finalize
76

@@ -23,6 +22,7 @@
2322
get_api_url,
2423
)
2524
from todoist_api_python._core.http_requests import delete, get, post
25+
from todoist_api_python._core.utils import format_date, format_datetime
2626
from todoist_api_python.models import (
2727
Attachment,
2828
Collaborator,
@@ -287,9 +287,9 @@ def add_task( # noqa: PLR0912
287287
if due_lang is not None:
288288
data["due_lang"] = due_lang
289289
if due_date is not None:
290-
data["due_date"] = _format_date(due_date)
290+
data["due_date"] = format_date(due_date)
291291
if due_datetime is not None:
292-
data["due_datetime"] = _format_datetime(due_datetime)
292+
data["due_datetime"] = format_datetime(due_datetime)
293293
if assignee_id is not None:
294294
data["assignee_id"] = assignee_id
295295
if order is not None:
@@ -303,7 +303,7 @@ def add_task( # noqa: PLR0912
303303
if duration_unit is not None:
304304
data["duration_unit"] = duration_unit
305305
if deadline_date is not None:
306-
data["deadline_date"] = _format_date(deadline_date)
306+
data["deadline_date"] = format_date(deadline_date)
307307
if deadline_lang is not None:
308308
data["deadline_lang"] = deadline_lang
309309

@@ -412,9 +412,9 @@ def update_task( # noqa: PLR0912
412412
if due_lang is not None:
413413
data["due_lang"] = due_lang
414414
if due_date is not None:
415-
data["due_date"] = _format_date(due_date)
415+
data["due_date"] = format_date(due_date)
416416
if due_datetime is not None:
417-
data["due_datetime"] = _format_datetime(due_datetime)
417+
data["due_datetime"] = format_datetime(due_datetime)
418418
if assignee_id is not None:
419419
data["assignee_id"] = assignee_id
420420
if day_order is not None:
@@ -426,7 +426,7 @@ def update_task( # noqa: PLR0912
426426
if duration_unit is not None:
427427
data["duration_unit"] = duration_unit
428428
if deadline_date is not None:
429-
data["deadline_date"] = _format_date(deadline_date)
429+
data["deadline_date"] = format_date(deadline_date)
430430
if deadline_lang is not None:
431431
data["deadline_lang"] = deadline_lang
432432

@@ -1151,19 +1151,3 @@ def __next__(self) -> list[T]:
11511151

11521152
results: list[Any] = data.get(self._results_field, [])
11531153
return [self._results_inst(result) for result in results]
1154-
1155-
1156-
def _format_date(d: date) -> str:
1157-
"""Format a date object as YYYY-MM-DD."""
1158-
return d.isoformat()
1159-
1160-
1161-
def _format_datetime(dt: datetime) -> str:
1162-
"""
1163-
Format a datetime object.
1164-
1165-
YYYY-MM-DDTHH:MM:SS for naive datetimes; YYYY-MM-DDTHH:MM:SSZ for aware datetimes.
1166-
"""
1167-
if dt.tzinfo is None:
1168-
return dt.isoformat()
1169-
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")

todoist_api_python/models.py

+18-12
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass
4-
from typing import Annotated, Literal
4+
from typing import Annotated, Literal, Union
55

66
from dataclass_wizard import JSONPyWizard
7+
from dataclass_wizard.v1 import DatePattern, DateTimePattern, UTCDateTimePattern
78
from dataclass_wizard.v1.models import Alias
89

910
from todoist_api_python._core.endpoints import INBOX_URL, get_project_url, get_task_url
1011

11-
VIEW_STYLE = Literal["list", "board", "calendar"]
12-
DURATION_UNIT = Literal["minute", "day"]
12+
ViewStyle = Literal["list", "board", "calendar"]
13+
DurationUnit = Literal["minute", "day"]
14+
ApiDate = UTCDateTimePattern["%FT%T.%fZ"] # type: ignore[valid-type]
15+
ApiDue = Union[ # noqa: UP007
16+
# https://github.com/rnag/dataclass-wizard/issues/189
17+
DatePattern["%F"], DateTimePattern["%FT%T"], UTCDateTimePattern["%FT%TZ"] # type: ignore[valid-type] # noqa: F722
18+
]
1319

1420

1521
@dataclass
@@ -25,10 +31,10 @@ class _(JSONPyWizard.Meta): # noqa:N801
2531
is_collapsed: Annotated[bool, Alias(load=("collapsed", "is_collapsed"))]
2632
is_shared: Annotated[bool, Alias(load=("shared", "is_shared"))]
2733
is_favorite: bool
28-
can_assign_tasks: bool | None
29-
view_style: VIEW_STYLE
30-
created_at: str | None = None
31-
updated_at: str | None = None
34+
can_assign_tasks: bool
35+
view_style: ViewStyle
36+
created_at: ApiDate
37+
updated_at: ApiDate
3238

3339
parent_id: str | None = None
3440
is_inbox_project: Annotated[
@@ -62,7 +68,7 @@ class Due(JSONPyWizard):
6268
class _(JSONPyWizard.Meta): # noqa:N801
6369
v1 = True
6470

65-
date: str
71+
date: ApiDue
6672
string: str
6773
lang: str = "en"
6874
is_recurring: bool = False
@@ -102,8 +108,8 @@ class _(JSONPyWizard.Meta): # noqa:N801
102108
assigner_id: Annotated[str | None, Alias(load=("assigned_by_uid", "assigner_id"))]
103109
completed_at: str | None
104110
creator_id: Annotated[str, Alias(load=("added_by_uid", "creator_id"))]
105-
created_at: Annotated[str, Alias(load=("added_at", "created_at"))]
106-
updated_at: str | None
111+
created_at: Annotated[ApiDate, Alias(load=("added_at", "created_at"))]
112+
updated_at: ApiDate
107113

108114
meta: Meta | None = None
109115

@@ -152,7 +158,7 @@ class _(JSONPyWizard.Meta): # noqa:N801
152158
id: str
153159
content: str
154160
poster_id: Annotated[str, Alias(load=("posted_uid", "poster_id"))]
155-
posted_at: str
161+
posted_at: ApiDate
156162
task_id: Annotated[str | None, Alias(load=("item_id", "task_id"))] = None
157163
project_id: str | None = None
158164
attachment: Annotated[
@@ -196,4 +202,4 @@ class _(JSONPyWizard.Meta): # noqa:N801
196202
v1 = True
197203

198204
amount: int
199-
unit: DURATION_UNIT
205+
unit: DurationUnit

0 commit comments

Comments
 (0)