From 8255b2c9b9cf59ac9f487ec811fb65d82cab9e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Silva?= Date: Tue, 3 Mar 2026 14:43:30 +0000 Subject: [PATCH 1/2] fix(api): Align test fixtures with API field names --- tests/data/test_defaults.py | 26 +++++++++++++------------- tests/test_models.py | 17 +++++++++-------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 99adc08..7cf5279 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -56,10 +56,10 @@ class PaginatedItems(TypedDict): "workspace_id": None, "child_order": 1, "color": "red", - "shared": False, - "collapsed": False, + "is_shared": False, + "is_collapsed": False, "is_favorite": False, - "is_inbox_project": True, + "inbox_project": True, "can_assign_tasks": False, "is_archived": False, "view_style": "list", @@ -69,12 +69,12 @@ class PaginatedItems(TypedDict): DEFAULT_PROJECT_RESPONSE_2 = dict(DEFAULT_PROJECT_RESPONSE) DEFAULT_PROJECT_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" -DEFAULT_PROJECT_RESPONSE_2["is_inbox_project"] = False +DEFAULT_PROJECT_RESPONSE_2["inbox_project"] = False DEFAULT_PROJECT_RESPONSE_3 = dict(DEFAULT_PROJECT_RESPONSE) DEFAULT_PROJECT_RESPONSE_3["id"] = "6X7rfEVP8hvv25ZQ" -DEFAULT_PROJECT_RESPONSE_3["is_inbox_project"] = False +DEFAULT_PROJECT_RESPONSE_3["inbox_project"] = False DEFAULT_PROJECTS_RESPONSE: list[PaginatedResults] = [ { @@ -99,8 +99,9 @@ class PaginatedItems(TypedDict): "due": DEFAULT_DUE_RESPONSE, "deadline": DEFAULT_DEADLINE_RESPONSE, "duration": DEFAULT_DURATION_RESPONSE, - "collapsed": False, + "is_collapsed": False, "child_order": 3, + "day_order": -1, "responsible_uid": "2423523", "assigned_by_uid": "2971358", "completed_at": None, @@ -179,8 +180,8 @@ class PaginatedItems(TypedDict): "id": "6X7rM8997g3RQmvh", "project_id": "4567", "name": "A Section", - "collapsed": False, - "order": 1, + "is_collapsed": False, + "section_order": 1, } DEFAULT_SECTION_RESPONSE_2 = dict(DEFAULT_SECTION_RESPONSE) @@ -219,18 +220,17 @@ class PaginatedItems(TypedDict): "content": "A comment", "posted_uid": "34567", "posted_at": "2019-09-22T07:00:00.000000Z", - "task_id": "6X7rM8997g3RQmvh", - "project_id": "6X7rfEVP8hvv25ZQ", - "attachment": DEFAULT_ATTACHMENT_RESPONSE, + "item_id": "6X7rM8997g3RQmvh", + "file_attachment": DEFAULT_ATTACHMENT_RESPONSE, } DEFAULT_COMMENT_RESPONSE_2 = dict(DEFAULT_COMMENT_RESPONSE) DEFAULT_COMMENT_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" -DEFAULT_COMMENT_RESPONSE_2["attachment"] = None +DEFAULT_COMMENT_RESPONSE_2["file_attachment"] = None DEFAULT_COMMENT_RESPONSE_3 = dict(DEFAULT_COMMENT_RESPONSE) DEFAULT_COMMENT_RESPONSE_3["id"] = "6X7rfFVPjhvv65HG" -DEFAULT_COMMENT_RESPONSE_3["attachment"] = None +DEFAULT_COMMENT_RESPONSE_3["file_attachment"] = None DEFAULT_COMMENTS_RESPONSE: list[PaginatedResults] = [ { diff --git a/tests/test_models.py b/tests/test_models.py index 06840d6..3ec24ba 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -66,10 +66,10 @@ def test_project_from_dict() -> None: assert project.workspace_id == sample_data["workspace_id"] assert project.order == sample_data["child_order"] assert project.color == sample_data["color"] - assert project.is_collapsed == sample_data["collapsed"] - assert project.is_shared == sample_data["shared"] + assert project.is_collapsed == sample_data["is_collapsed"] + assert project.is_shared == sample_data["is_shared"] assert project.is_favorite == sample_data["is_favorite"] - assert project.is_inbox_project == sample_data["is_inbox_project"] + assert project.is_inbox_project == sample_data["inbox_project"] assert project.can_assign_tasks == sample_data["can_assign_tasks"] assert project.view_style == sample_data["view_style"] assert project.created_at == parse_datetime(str(sample_data["created_at"])) @@ -99,7 +99,7 @@ def test_task_from_dict() -> None: assert task.priority == sample_data["priority"] assert task.due == Due.from_dict(sample_data["due"]) assert task.duration == Duration.from_dict(sample_data["duration"]) - assert task.is_collapsed == sample_data["collapsed"] + assert task.is_collapsed == sample_data["is_collapsed"] assert task.order == sample_data["child_order"] assert task.assignee_id == sample_data["responsible_uid"] assert task.assigner_id == sample_data["assigned_by_uid"] @@ -126,7 +126,8 @@ def test_section_from_dict() -> None: assert section.id == sample_data["id"] assert section.project_id == sample_data["project_id"] assert section.name == sample_data["name"] - assert section.order == sample_data["order"] + assert section.is_collapsed == sample_data["is_collapsed"] + assert section.order == sample_data["section_order"] def test_collaborator_from_dict() -> None: @@ -169,9 +170,9 @@ def test_comment_from_dict() -> None: assert comment.content == sample_data["content"] assert comment.poster_id == sample_data["posted_uid"] 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"]) + assert comment.task_id == sample_data["item_id"] + assert comment.project_id is None + assert comment.attachment == Attachment.from_dict(sample_data["file_attachment"]) def test_label_from_dict() -> None: From 4b890a12fb8da9c7765c8b95d9cc1fde8dbbd442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Silva?= Date: Tue, 3 Mar 2026 14:46:26 +0000 Subject: [PATCH 2/2] fix(api): Map update order/collapsed to API field names Keep SDK parameters ergonomic by handling the mapping internally: - Map task/project update `order` -> `child_order` - Map section update `order` -> `section_order` - Map update `collapsed` -> `is_collapsed` (task/project/section) --- CHANGELOG.md | 1 + tests/test_api_projects.py | 57 ++++++++++++++++++++++++++++--- tests/test_api_sections.py | 59 ++++++++++++++++++++++++++++++--- tests/test_api_tasks.py | 59 ++++++++++++++++++++++++++++++--- todoist_api_python/api.py | 28 ++++++++++++---- todoist_api_python/api_async.py | 28 ++++++++++++---- 6 files changed, 206 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6264682..1cde80f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking**: Async paginated return types now use `AsyncIterator[...]` instead of `AsyncGenerator[...]`. - **Breaking**: API errors now raise `httpx.HTTPStatusError` instead of `requests.exceptions.HTTPError`. - **Breaking**: Authentication helpers now accept optional `httpx.Client` / `httpx.AsyncClient` instances instead of `session: requests.Session`. +- **Breaking**: `update_section` now accepts only keyword arguments after `section_id`; any one of `name`, `order`, or `collapsed` can be updated in the same call. ## [3.2.1] - 2026-01-22 diff --git a/tests/test_api_projects.py b/tests/test_api_projects.py index d092fc3..212d430 100644 --- a/tests/test_api_projects.py +++ b/tests/test_api_projects.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Any import pytest @@ -202,13 +203,14 @@ async def test_update_project( todoist_api_async: TodoistAPIAsync, respx_mock: respx.MockRouter, default_project: Project, + default_project_response: dict[str, Any], ) -> None: args: dict[str, Any] = { "name": "An updated project", "color": "red", "is_favorite": False, } - updated_project_dict = default_project.to_dict() | args + updated_project_response = dict(default_project_response) | args mock_route( respx_mock, @@ -216,21 +218,68 @@ async def test_update_project( url=f"{DEFAULT_API_URL}/projects/{default_project.id}", request_headers=api_headers(), request_json=args, - response_json=updated_project_dict, + response_json=updated_project_response, + response_status=200, + ) + + response = todoist_api.update_project(project_id=default_project.id, **args) + + assert len(respx_mock.calls) == 1 + assert response == Project.from_dict(updated_project_response) + + response = await todoist_api_async.update_project( + project_id=default_project.id, **args + ) + + assert len(respx_mock.calls) == 2 + assert response == Project.from_dict(updated_project_response) + + +@pytest.mark.asyncio +async def test_update_project_payload_mapping( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_project: Project, + default_project_response: dict[str, Any], +) -> None: + args: dict[str, Any] = { + "order": 3, + "collapsed": True, + } + expected_payload: dict[str, Any] = { + "child_order": 3, + "is_collapsed": True, + } + updated_project_response = dict(default_project_response) | { + "child_order": args["order"], + "is_collapsed": args["collapsed"], + } + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/projects/{default_project.id}", + request_headers=api_headers(), + response_json=updated_project_response, response_status=200, ) response = todoist_api.update_project(project_id=default_project.id, **args) assert len(respx_mock.calls) == 1 - assert response == Project.from_dict(updated_project_dict) + assert response == Project.from_dict(updated_project_response) + actual_payload = json.loads(respx_mock.calls[0].request.content) + assert actual_payload == expected_payload response = await todoist_api_async.update_project( project_id=default_project.id, **args ) assert len(respx_mock.calls) == 2 - assert response == Project.from_dict(updated_project_dict) + assert response == Project.from_dict(updated_project_response) + actual_payload = json.loads(respx_mock.calls[1].request.content) + assert actual_payload == expected_payload @pytest.mark.asyncio diff --git a/tests/test_api_sections.py b/tests/test_api_sections.py index 46210a8..d06bec5 100644 --- a/tests/test_api_sections.py +++ b/tests/test_api_sections.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Any import pytest @@ -259,11 +260,12 @@ async def test_update_section( todoist_api_async: TodoistAPIAsync, respx_mock: respx.MockRouter, default_section: Section, + default_section_response: dict[str, Any], ) -> None: - args = { + args: dict[str, Any] = { "name": "An updated section", } - updated_section_dict = default_section.to_dict() | args + updated_section_response = dict(default_section_response) | args mock_route( respx_mock, @@ -271,21 +273,68 @@ async def test_update_section( url=f"{DEFAULT_API_URL}/sections/{default_section.id}", request_headers=api_headers(), request_json=args, - response_json=updated_section_dict, + response_json=updated_section_response, + response_status=200, + ) + + response = todoist_api.update_section(section_id=default_section.id, **args) + + assert len(respx_mock.calls) == 1 + assert response == Section.from_dict(updated_section_response) + + response = await todoist_api_async.update_section( + section_id=default_section.id, **args + ) + + assert len(respx_mock.calls) == 2 + assert response == Section.from_dict(updated_section_response) + + +@pytest.mark.asyncio +async def test_update_section_payload_mapping( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_section: Section, + default_section_response: dict[str, Any], +) -> None: + args: dict[str, Any] = { + "order": 2, + "collapsed": False, + } + expected_payload: dict[str, Any] = { + "section_order": 2, + "is_collapsed": False, + } + updated_section_response = dict(default_section_response) | { + "section_order": args["order"], + "is_collapsed": args["collapsed"], + } + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/sections/{default_section.id}", + request_headers=api_headers(), + response_json=updated_section_response, response_status=200, ) response = todoist_api.update_section(section_id=default_section.id, **args) assert len(respx_mock.calls) == 1 - assert response == Section.from_dict(updated_section_dict) + assert response == Section.from_dict(updated_section_response) + actual_payload = json.loads(respx_mock.calls[0].request.content) + assert actual_payload == expected_payload response = await todoist_api_async.update_section( section_id=default_section.id, **args ) assert len(respx_mock.calls) == 2 - assert response == Section.from_dict(updated_section_dict) + assert response == Section.from_dict(updated_section_response) + actual_payload = json.loads(respx_mock.calls[1].request.content) + assert actual_payload == expected_payload @pytest.mark.asyncio diff --git a/tests/test_api_tasks.py b/tests/test_api_tasks.py index dd24489..1860e95 100644 --- a/tests/test_api_tasks.py +++ b/tests/test_api_tasks.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import sys from datetime import datetime, timezone from typing import TYPE_CHECKING, Any @@ -353,15 +354,15 @@ async def test_update_task( todoist_api_async: TodoistAPIAsync, respx_mock: respx.MockRouter, default_task: Task, + default_task_response: dict[str, Any], ) -> None: args: dict[str, Any] = { "content": "Updated content", "description": "Updated description", "labels": ["label1", "label2"], "priority": 2, - "order": 3, } - updated_task_dict = default_task.to_dict() | args + updated_task_response = dict(default_task_response) | args mock_route( respx_mock, @@ -369,19 +370,67 @@ async def test_update_task( url=f"{DEFAULT_API_URL}/tasks/{default_task.id}", request_headers=api_headers(), request_json=args, - response_json=updated_task_dict, + response_json=updated_task_response, + response_status=200, + ) + + response = todoist_api.update_task(task_id=default_task.id, **args) + + assert len(respx_mock.calls) == 1 + assert response == Task.from_dict(updated_task_response) + + response = await todoist_api_async.update_task(task_id=default_task.id, **args) + + assert len(respx_mock.calls) == 2 + assert response == Task.from_dict(updated_task_response) + + +@pytest.mark.asyncio +async def test_update_task_payload_mapping( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + respx_mock: respx.MockRouter, + default_task: Task, + default_task_response: dict[str, Any], +) -> None: + args: dict[str, Any] = { + "order": 3, + "day_order": 2, + "collapsed": True, + } + expected_payload: dict[str, Any] = { + "child_order": 3, + "day_order": 2, + "is_collapsed": True, + } + updated_task_response = dict(default_task_response) | { + "child_order": args["order"], + "day_order": args["day_order"], + "is_collapsed": args["collapsed"], + } + + mock_route( + respx_mock, + method="POST", + url=f"{DEFAULT_API_URL}/tasks/{default_task.id}", + request_headers=api_headers(), + response_json=updated_task_response, response_status=200, ) response = todoist_api.update_task(task_id=default_task.id, **args) assert len(respx_mock.calls) == 1 - assert response == Task.from_dict(updated_task_dict) + assert response == Task.from_dict(updated_task_response) + actual_payload = json.loads(respx_mock.calls[0].request.content) + assert actual_payload == expected_payload response = await todoist_api_async.update_task(task_id=default_task.id, **args) assert len(respx_mock.calls) == 2 - assert response == Task.from_dict(updated_task_dict) + assert response == Task.from_dict(updated_task_response) + actual_payload = json.loads(respx_mock.calls[1].request.content) + assert actual_payload == expected_payload @pytest.mark.asyncio diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index 269595f..9b95035 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -403,9 +403,9 @@ def update_task( format_datetime(due_datetime) if due_datetime is not None else None ), assignee_id=assignee_id, - order=order, + child_order=order, day_order=day_order, - collapsed=collapsed, + is_collapsed=collapsed, duration=duration, duration_unit=duration_unit, deadline_date=( @@ -771,6 +771,8 @@ def update_project( color: ColorString | None = None, is_favorite: bool | None = None, view_style: ViewStyle | None = None, + order: int | None = None, + collapsed: bool | None = None, ) -> Project: """ Update an existing project. @@ -783,6 +785,8 @@ def update_project( :param color: The color of the project icon. :param is_favorite: Whether the project is a favorite. :param view_style: A string value (either 'list' or 'board'). + :param order: Position of the project among projects with the same parent. + :param collapsed: Whether the project's sub-projects are collapsed. :return: the updated Project. :raises httpx.HTTPStatusError: If the API request fails. """ @@ -794,6 +798,8 @@ def update_project( color=color, is_favorite=is_favorite, view_style=view_style, + child_order=order, + is_collapsed=collapsed, ) response = post( @@ -1026,25 +1032,35 @@ def add_section( def update_section( self, section_id: str, - name: Annotated[str, MinLen(1), MaxLen(2048)], + *, + name: Annotated[str, MinLen(1), MaxLen(2048)] | None = None, + order: int | None = None, + collapsed: bool | None = None, ) -> Section: """ Update an existing section. - Currently, only `name` can be updated. - :param section_id: The ID of the section to update. :param name: The new name for the section. + :param order: Position of the section among sections in the project. + :param collapsed: Whether the section's tasks are collapsed. :return: the updated Section. :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") + + data = kwargs_without_none( + name=name, + section_order=order, + is_collapsed=collapsed, + ) + response = post( self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, - data={"name": name}, + data=data, ) data = response_json_dict(response) return Section.from_dict(data) diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index 4482ce3..39a6e0b 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -423,9 +423,9 @@ async def update_task( format_datetime(due_datetime) if due_datetime is not None else None ), assignee_id=assignee_id, - order=order, + child_order=order, day_order=day_order, - collapsed=collapsed, + is_collapsed=collapsed, duration=duration, duration_unit=duration_unit, deadline_date=( @@ -791,6 +791,8 @@ async def update_project( color: ColorString | None = None, is_favorite: bool | None = None, view_style: ViewStyle | None = None, + order: int | None = None, + collapsed: bool | None = None, ) -> Project: """ Update an existing project. @@ -803,6 +805,8 @@ async def update_project( :param color: The color of the project icon. :param is_favorite: Whether the project is a favorite. :param view_style: A string value (either 'list' or 'board'). + :param order: Position of the project among projects with the same parent. + :param collapsed: Whether the project's sub-projects are collapsed. :return: the updated Project. :raises httpx.HTTPStatusError: If the API request fails. """ @@ -814,6 +818,8 @@ async def update_project( color=color, is_favorite=is_favorite, view_style=view_style, + child_order=order, + is_collapsed=collapsed, ) response = await post_async( @@ -1046,25 +1052,35 @@ async def add_section( async def update_section( self, section_id: str, - name: Annotated[str, MinLen(1), MaxLen(2048)], + *, + name: Annotated[str, MinLen(1), MaxLen(2048)] | None = None, + order: int | None = None, + collapsed: bool | None = None, ) -> Section: """ Update an existing section. - Currently, only `name` can be updated. - :param section_id: The ID of the section to update. :param name: The new name for the section. + :param order: Position of the section among sections in the project. + :param collapsed: Whether the section's tasks are collapsed. :return: the updated Section. :raises httpx.HTTPStatusError: If the API request fails. """ endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") + + data = kwargs_without_none( + name=name, + section_order=order, + is_collapsed=collapsed, + ) + response = await post_async( self._client, endpoint, self._token, self._request_id_fn() if self._request_id_fn else None, - data={"name": name}, + data=data, ) data = response_json_dict(response) return Section.from_dict(data)