From ee3b227d0d6d1b99d50fe81fc02070e5a156cf9e Mon Sep 17 00:00:00 2001 From: BoggerByte Date: Sat, 22 Feb 2025 17:34:55 +0100 Subject: [PATCH] Use dataclasses_json for object mapping Closes #163 --- CHANGELOG.md | 8 +- poetry.lock | 60 ++++++- pyproject.toml | 1 + tests/data/test_defaults.py | 2 +- todoist_api_python/models.py | 320 +++++------------------------------ 5 files changed, 107 insertions(+), 284 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc680cc..aef1e50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -... +### Changed + +- Use `dataclasses_json` for object mapping + +### Fixes + +- Missing optional `next_cursor` attribute in `CompletedItems` object ## [2.1.7] - 2024-08-13 diff --git a/poetry.lock b/poetry.lock index 0eab43b..cc9671d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -303,6 +303,22 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "dataclasses-json" +version = "0.6.7" +description = "Easily serialize dataclasses to and from JSON." +optional = false +python-versions = "<4.0,>=3.7" +groups = ["main"] +files = [ + {file = "dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a"}, + {file = "dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0"}, +] + +[package.dependencies] +marshmallow = ">=3.18.0,<4.0.0" +typing-inspect = ">=0.4.0,<1" + [[package]] name = "distlib" version = "0.3.9" @@ -539,6 +555,26 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +[[package]] +name = "marshmallow" +version = "3.26.1" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c"}, + {file = "marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6"}, +] + +[package.dependencies] +packaging = ">=17.0" + +[package.extras] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.1.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.0)", "sphinxext-opengraph (==0.9.1)"] +tests = ["pytest", "simplejson"] + [[package]] name = "mdurl" version = "0.1.2" @@ -623,7 +659,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -681,7 +717,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev", "publishing"] +groups = ["main", "dev", "publishing"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -1098,12 +1134,28 @@ version = "4.13.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5"}, {file = "typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b"}, ] +[[package]] +name = "typing-inspect" +version = "0.9.0" +description = "Runtime inspection utilities for typing module." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"}, + {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"}, +] + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + [[package]] name = "urllib3" version = "2.3.0" @@ -1146,4 +1198,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "d4b879b705dfa6a70a928d243ccd9320fa9fb4d646d12ba4292d835dcb0d1476" +content-hash = "b85dd9d79ca8f8d08fd59eae62ea681533e15fa11505c52a16dd62d1ce5c5317" diff --git a/pyproject.toml b/pyproject.toml index f55329f..379be05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ include = ["LICENSE"] [tool.poetry.dependencies] python = "^3.13" requests = "^2.32.3" +dataclasses-json = "^0.6.7" [tool.poetry.group.dev.dependencies] pytest = "^8.0.0" diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 8d5fe85..76bbfcd 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -38,7 +38,7 @@ "comment_count": 0, "creator_id": "0", "created_at": "2019-01-02T21:00:30.00000Z", - "url": "https://todoist.com/showTask?id=2995104339", + "url": "https://todoist.com/showTask?id=1234", "due": DEFAULT_DUE_RESPONSE, "duration": DEFAULT_DURATION_RESPONSE, } diff --git a/todoist_api_python/models.py b/todoist_api_python/models.py index 05df211..7675bfc 100644 --- a/todoist_api_python/models.py +++ b/todoist_api_python/models.py @@ -1,15 +1,17 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import dataclass, field from typing import Any, Literal +from dataclasses_json import DataClassJsonMixin + from todoist_api_python.utils import get_url_for_task VIEW_STYLE = Literal["list", "board"] @dataclass -class Project: +class Project(DataClassJsonMixin): color: str comment_count: int id: str @@ -24,61 +26,17 @@ class Project: url: str view_style: VIEW_STYLE - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Project: - return cls( - color=obj["color"], - comment_count=obj["comment_count"], - id=obj["id"], - is_favorite=obj["is_favorite"], - is_inbox_project=obj.get("is_inbox_project"), - is_shared=obj["is_shared"], - is_team_inbox=obj.get("is_team_inbox"), - can_assign_tasks=obj.get("can_assign_tasks"), - name=obj["name"], - order=obj["order"], - parent_id=obj.get("parent_id"), - url=obj["url"], - view_style=obj["view_style"], - ) - - def to_dict(self) -> dict[str, Any]: - return { - "color": self.color, - "comment_count": self.comment_count, - "id": self.id, - "is_favorite": self.is_favorite, - "is_inbox_project": self.is_inbox_project, - "is_shared": self.is_shared, - "is_team_inbox": self.is_team_inbox, - "can_assign_tasks": self.can_assign_tasks, - "name": self.name, - "order": self.order, - "parent_id": self.parent_id, - "url": self.url, - "view_style": self.view_style, - } - @dataclass -class Section: +class Section(DataClassJsonMixin): id: str name: str order: int project_id: str - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Section: - return cls( - id=obj["id"], - name=obj["name"], - order=obj["order"], - project_id=obj["project_id"], - ) - @dataclass -class Due: +class Due(DataClassJsonMixin): date: str is_recurring: bool string: str @@ -86,25 +44,6 @@ class Due: datetime: str | None = None timezone: str | None = None - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Due: - return cls( - date=obj["date"], - is_recurring=obj["is_recurring"], - string=obj["string"], - datetime=obj.get("datetime"), - timezone=obj.get("timezone"), - ) - - def to_dict(self) -> dict[str, Any]: - return { - "date": self.date, - "is_recurring": self.is_recurring, - "string": self.string, - "datetime": self.datetime, - "timezone": self.timezone, - } - @classmethod def from_quick_add_response(cls, obj: dict[str, Any]) -> Due | None: due = obj.get("due") @@ -113,23 +52,16 @@ def from_quick_add_response(cls, obj: dict[str, Any]) -> Due | None: return None timezone = due.get("timezone") + datetime: str | None = due["date"] if timezone is not None else None - datetime: str | None = None + due["datetime"] = datetime + due["timezone"] = timezone - if timezone: - datetime = due["date"] - - return cls( - date=due["date"], - is_recurring=due["is_recurring"], - string=due["string"], - datetime=datetime, - timezone=timezone, - ) + return cls.from_dict(due) @dataclass -class Task: +class Task(DataClassJsonMixin): assignee_id: str | None assigner_id: str | None comment_count: int @@ -146,111 +78,35 @@ class Task: priority: int project_id: str section_id: str | None - url: str - duration: Duration | None + url: str = field(init=False) + duration: Duration | None = None sync_id: str | None = None - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Task: - due: Due | None = None - duration: Duration | None = None - - if obj.get("due"): - due = Due.from_dict(obj["due"]) - - if obj.get("duration"): - duration = Duration.from_dict(obj["duration"]) - - return cls( - assignee_id=obj.get("assignee_id"), - assigner_id=obj.get("assigner_id"), - comment_count=obj["comment_count"], - is_completed=obj["is_completed"], - content=obj["content"], - created_at=obj["created_at"], - creator_id=obj["creator_id"], - description=obj["description"], - due=due, - id=obj["id"], - labels=obj.get("labels"), - order=obj["order"], - parent_id=obj.get("parent_id"), - priority=obj["priority"], - project_id=obj["project_id"], - section_id=obj.get("section_id"), - url=obj["url"], - duration=duration, + def __post_init__(self) -> None: + self.url = get_url_for_task( + int(self.id), int(self.sync_id) if self.sync_id else None ) - def to_dict(self) -> dict[str, Any]: - due: dict[str, Any] | None = None - duration: dict[str, Any] | None = None - - if self.due: - due = self.due.to_dict() - - if self.duration: - duration = self.duration.to_dict() - - return { - "assignee_id": self.assignee_id, - "assigner_id": self.assigner_id, - "comment_count": self.comment_count, - "is_completed": self.is_completed, - "content": self.content, - "created_at": self.created_at, - "creator_id": self.creator_id, - "description": self.description, - "due": due, - "id": self.id, - "labels": self.labels, - "order": self.order, - "parent_id": self.parent_id, - "priority": self.priority, - "project_id": self.project_id, - "section_id": self.section_id, - "sync_id": self.sync_id, - "url": self.url, - "duration": duration, - } - @classmethod def from_quick_add_response(cls, obj: dict[str, Any]) -> Task: - due: Due | None = None - duration: Duration | None = None - - if obj.get("due"): - due = Due.from_quick_add_response(obj) - - if obj.get("duration"): - duration = Duration.from_dict(obj["duration"]) - - return cls( - assignee_id=obj.get("responsible_uid"), - assigner_id=obj.get("assigned_by_uid"), - comment_count=0, - is_completed=False, - content=obj["content"], - created_at=obj["added_at"], - creator_id=obj["added_by_uid"], - description=obj["description"], - due=due, - duration=duration, - id=obj["id"], - labels=obj["labels"], - order=obj["child_order"], - parent_id=obj["parent_id"] or None, - priority=obj["priority"], - project_id=obj["project_id"], - section_id=obj["section_id"] or None, - sync_id=obj["sync_id"], - url=get_url_for_task(obj["id"], obj["sync_id"]), + obj_copy = obj.copy() + obj_copy["due"] = ( + Due.from_quick_add_response(obj) if obj.get("due") is not None else None ) + obj_copy["comment_count"] = 0 + obj_copy["is_completed"] = False + obj_copy["created_at"] = obj_copy.pop("added_at", None) + obj_copy["creator_id"] = obj_copy.pop("added_by_uid", None) + obj_copy["assignee_id"] = obj_copy.pop("responsible_uid", None) + obj_copy["assigner_id"] = obj_copy.pop("assigned_by_uid", None) + obj_copy["order"] = obj_copy.pop("child_order", None) + + return cls.from_dict(obj_copy) @dataclass -class QuickAddResult: +class QuickAddResult(DataClassJsonMixin): task: Task resolved_project_name: str | None = None @@ -277,32 +133,26 @@ def from_quick_add_response(cls, obj: dict[str, Any]) -> QuickAddResult: if section_data and len(section_data) == 2: # noqa: PLR2004 resolved_section_name = obj["meta"]["section"][1] + resolved_label_names = list(obj["meta"]["labels"].values()) + return cls( task=Task.from_quick_add_response(obj), resolved_project_name=resolved_project_name, resolved_assignee_name=resolved_assignee_name, - resolved_label_names=list(obj["meta"]["labels"].values()), + resolved_label_names=resolved_label_names, resolved_section_name=resolved_section_name, ) @dataclass -class Collaborator: +class Collaborator(DataClassJsonMixin): id: str email: str name: str - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Collaborator: - return cls( - id=obj["id"], - email=obj["email"], - name=obj["name"], - ) - @dataclass -class Attachment: +class Attachment(DataClassJsonMixin): resource_type: str | None = None file_name: str | None = None @@ -319,83 +169,34 @@ class Attachment: url: str | None = None title: str | None = None - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Attachment: - return cls( - resource_type=obj.get("resource_type"), - file_name=obj.get("file_name"), - file_size=obj.get("file_size"), - file_type=obj.get("file_type"), - file_url=obj.get("file_url"), - upload_state=obj.get("upload_state"), - image=obj.get("image"), - image_width=obj.get("image_width"), - image_height=obj.get("image_height"), - url=obj.get("url"), - title=obj.get("title"), - ) - @dataclass -class Comment: - attachment: Attachment | None +class Comment(DataClassJsonMixin): content: str id: str posted_at: str project_id: str | None task_id: str | None - - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Comment: - attachment: Attachment | None = None - - if "attachment" in obj and obj["attachment"] is not None: - attachment = Attachment.from_dict(obj["attachment"]) - - return cls( - attachment=attachment, - content=obj["content"], - id=obj["id"], - posted_at=obj["posted_at"], - project_id=obj.get("project_id"), - task_id=obj.get("task_id"), - ) + attachment: Attachment | None = None @dataclass -class Label: +class Label(DataClassJsonMixin): id: str name: str color: str order: int is_favorite: bool - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Label: - return cls( - id=obj["id"], - name=obj["name"], - color=obj["color"], - order=obj["order"], - is_favorite=obj["is_favorite"], - ) - @dataclass -class AuthResult: +class AuthResult(DataClassJsonMixin): access_token: str state: str | None - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> AuthResult: - return cls( - access_token=obj["access_token"], - state=obj.get("state"), - ) - @dataclass -class Item: +class Item(DataClassJsonMixin): id: str user_id: str project_id: str @@ -418,60 +219,23 @@ class Item: sync_id: str | None = None completed_at: str | None = None - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Item: - params = {f.name: obj[f.name] for f in fields(cls) if f.name in obj} - if (due := obj.get("due")) is not None: - params["due"] = Due.from_dict(due) - - return cls(**params) - @dataclass -class ItemCompletedInfo: +class ItemCompletedInfo(DataClassJsonMixin): item_id: str completed_items: int - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> ItemCompletedInfo: - return cls(**{f.name: obj[f.name] for f in fields(cls)}) - @dataclass -class CompletedItems: +class CompletedItems(DataClassJsonMixin): items: list[Item] total: int completed_info: list[ItemCompletedInfo] has_more: bool next_cursor: str | None = None - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> CompletedItems: - return cls( - items=[Item.from_dict(v) for v in obj["items"]], - total=obj["total"], - completed_info=[ - ItemCompletedInfo.from_dict(v) for v in obj["completed_info"] - ], - has_more=obj["has_more"], - next_cursor=obj.get("next_cursor"), - ) - @dataclass -class Duration: +class Duration(DataClassJsonMixin): amount: int unit: str - - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> Duration: - return cls( - amount=obj["amount"], - unit=obj["unit"], - ) - - def to_dict(self) -> dict[str, Any]: - return { - "amount": self.amount, - "unit": self.unit, - }