Skip to content

Commit 4b890a1

Browse files
committed
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)
1 parent 8255b2c commit 4b890a1

6 files changed

Lines changed: 206 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- **Breaking**: Async paginated return types now use `AsyncIterator[...]` instead of `AsyncGenerator[...]`.
1919
- **Breaking**: API errors now raise `httpx.HTTPStatusError` instead of `requests.exceptions.HTTPError`.
2020
- **Breaking**: Authentication helpers now accept optional `httpx.Client` / `httpx.AsyncClient` instances instead of `session: requests.Session`.
21+
- **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.
2122

2223
## [3.2.1] - 2026-01-22
2324

tests/test_api_projects.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
from typing import TYPE_CHECKING, Any
45

56
import pytest
@@ -202,35 +203,83 @@ async def test_update_project(
202203
todoist_api_async: TodoistAPIAsync,
203204
respx_mock: respx.MockRouter,
204205
default_project: Project,
206+
default_project_response: dict[str, Any],
205207
) -> None:
206208
args: dict[str, Any] = {
207209
"name": "An updated project",
208210
"color": "red",
209211
"is_favorite": False,
210212
}
211-
updated_project_dict = default_project.to_dict() | args
213+
updated_project_response = dict(default_project_response) | args
212214

213215
mock_route(
214216
respx_mock,
215217
method="POST",
216218
url=f"{DEFAULT_API_URL}/projects/{default_project.id}",
217219
request_headers=api_headers(),
218220
request_json=args,
219-
response_json=updated_project_dict,
221+
response_json=updated_project_response,
222+
response_status=200,
223+
)
224+
225+
response = todoist_api.update_project(project_id=default_project.id, **args)
226+
227+
assert len(respx_mock.calls) == 1
228+
assert response == Project.from_dict(updated_project_response)
229+
230+
response = await todoist_api_async.update_project(
231+
project_id=default_project.id, **args
232+
)
233+
234+
assert len(respx_mock.calls) == 2
235+
assert response == Project.from_dict(updated_project_response)
236+
237+
238+
@pytest.mark.asyncio
239+
async def test_update_project_payload_mapping(
240+
todoist_api: TodoistAPI,
241+
todoist_api_async: TodoistAPIAsync,
242+
respx_mock: respx.MockRouter,
243+
default_project: Project,
244+
default_project_response: dict[str, Any],
245+
) -> None:
246+
args: dict[str, Any] = {
247+
"order": 3,
248+
"collapsed": True,
249+
}
250+
expected_payload: dict[str, Any] = {
251+
"child_order": 3,
252+
"is_collapsed": True,
253+
}
254+
updated_project_response = dict(default_project_response) | {
255+
"child_order": args["order"],
256+
"is_collapsed": args["collapsed"],
257+
}
258+
259+
mock_route(
260+
respx_mock,
261+
method="POST",
262+
url=f"{DEFAULT_API_URL}/projects/{default_project.id}",
263+
request_headers=api_headers(),
264+
response_json=updated_project_response,
220265
response_status=200,
221266
)
222267

223268
response = todoist_api.update_project(project_id=default_project.id, **args)
224269

225270
assert len(respx_mock.calls) == 1
226-
assert response == Project.from_dict(updated_project_dict)
271+
assert response == Project.from_dict(updated_project_response)
272+
actual_payload = json.loads(respx_mock.calls[0].request.content)
273+
assert actual_payload == expected_payload
227274

228275
response = await todoist_api_async.update_project(
229276
project_id=default_project.id, **args
230277
)
231278

232279
assert len(respx_mock.calls) == 2
233-
assert response == Project.from_dict(updated_project_dict)
280+
assert response == Project.from_dict(updated_project_response)
281+
actual_payload = json.loads(respx_mock.calls[1].request.content)
282+
assert actual_payload == expected_payload
234283

235284

236285
@pytest.mark.asyncio

tests/test_api_sections.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
from typing import TYPE_CHECKING, Any
45

56
import pytest
@@ -259,33 +260,81 @@ async def test_update_section(
259260
todoist_api_async: TodoistAPIAsync,
260261
respx_mock: respx.MockRouter,
261262
default_section: Section,
263+
default_section_response: dict[str, Any],
262264
) -> None:
263-
args = {
265+
args: dict[str, Any] = {
264266
"name": "An updated section",
265267
}
266-
updated_section_dict = default_section.to_dict() | args
268+
updated_section_response = dict(default_section_response) | args
267269

268270
mock_route(
269271
respx_mock,
270272
method="POST",
271273
url=f"{DEFAULT_API_URL}/sections/{default_section.id}",
272274
request_headers=api_headers(),
273275
request_json=args,
274-
response_json=updated_section_dict,
276+
response_json=updated_section_response,
277+
response_status=200,
278+
)
279+
280+
response = todoist_api.update_section(section_id=default_section.id, **args)
281+
282+
assert len(respx_mock.calls) == 1
283+
assert response == Section.from_dict(updated_section_response)
284+
285+
response = await todoist_api_async.update_section(
286+
section_id=default_section.id, **args
287+
)
288+
289+
assert len(respx_mock.calls) == 2
290+
assert response == Section.from_dict(updated_section_response)
291+
292+
293+
@pytest.mark.asyncio
294+
async def test_update_section_payload_mapping(
295+
todoist_api: TodoistAPI,
296+
todoist_api_async: TodoistAPIAsync,
297+
respx_mock: respx.MockRouter,
298+
default_section: Section,
299+
default_section_response: dict[str, Any],
300+
) -> None:
301+
args: dict[str, Any] = {
302+
"order": 2,
303+
"collapsed": False,
304+
}
305+
expected_payload: dict[str, Any] = {
306+
"section_order": 2,
307+
"is_collapsed": False,
308+
}
309+
updated_section_response = dict(default_section_response) | {
310+
"section_order": args["order"],
311+
"is_collapsed": args["collapsed"],
312+
}
313+
314+
mock_route(
315+
respx_mock,
316+
method="POST",
317+
url=f"{DEFAULT_API_URL}/sections/{default_section.id}",
318+
request_headers=api_headers(),
319+
response_json=updated_section_response,
275320
response_status=200,
276321
)
277322

278323
response = todoist_api.update_section(section_id=default_section.id, **args)
279324

280325
assert len(respx_mock.calls) == 1
281-
assert response == Section.from_dict(updated_section_dict)
326+
assert response == Section.from_dict(updated_section_response)
327+
actual_payload = json.loads(respx_mock.calls[0].request.content)
328+
assert actual_payload == expected_payload
282329

283330
response = await todoist_api_async.update_section(
284331
section_id=default_section.id, **args
285332
)
286333

287334
assert len(respx_mock.calls) == 2
288-
assert response == Section.from_dict(updated_section_dict)
335+
assert response == Section.from_dict(updated_section_response)
336+
actual_payload = json.loads(respx_mock.calls[1].request.content)
337+
assert actual_payload == expected_payload
289338

290339

291340
@pytest.mark.asyncio

tests/test_api_tasks.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import sys
45
from datetime import datetime, timezone
56
from typing import TYPE_CHECKING, Any
@@ -353,35 +354,83 @@ async def test_update_task(
353354
todoist_api_async: TodoistAPIAsync,
354355
respx_mock: respx.MockRouter,
355356
default_task: Task,
357+
default_task_response: dict[str, Any],
356358
) -> None:
357359
args: dict[str, Any] = {
358360
"content": "Updated content",
359361
"description": "Updated description",
360362
"labels": ["label1", "label2"],
361363
"priority": 2,
362-
"order": 3,
363364
}
364-
updated_task_dict = default_task.to_dict() | args
365+
updated_task_response = dict(default_task_response) | args
365366

366367
mock_route(
367368
respx_mock,
368369
method="POST",
369370
url=f"{DEFAULT_API_URL}/tasks/{default_task.id}",
370371
request_headers=api_headers(),
371372
request_json=args,
372-
response_json=updated_task_dict,
373+
response_json=updated_task_response,
374+
response_status=200,
375+
)
376+
377+
response = todoist_api.update_task(task_id=default_task.id, **args)
378+
379+
assert len(respx_mock.calls) == 1
380+
assert response == Task.from_dict(updated_task_response)
381+
382+
response = await todoist_api_async.update_task(task_id=default_task.id, **args)
383+
384+
assert len(respx_mock.calls) == 2
385+
assert response == Task.from_dict(updated_task_response)
386+
387+
388+
@pytest.mark.asyncio
389+
async def test_update_task_payload_mapping(
390+
todoist_api: TodoistAPI,
391+
todoist_api_async: TodoistAPIAsync,
392+
respx_mock: respx.MockRouter,
393+
default_task: Task,
394+
default_task_response: dict[str, Any],
395+
) -> None:
396+
args: dict[str, Any] = {
397+
"order": 3,
398+
"day_order": 2,
399+
"collapsed": True,
400+
}
401+
expected_payload: dict[str, Any] = {
402+
"child_order": 3,
403+
"day_order": 2,
404+
"is_collapsed": True,
405+
}
406+
updated_task_response = dict(default_task_response) | {
407+
"child_order": args["order"],
408+
"day_order": args["day_order"],
409+
"is_collapsed": args["collapsed"],
410+
}
411+
412+
mock_route(
413+
respx_mock,
414+
method="POST",
415+
url=f"{DEFAULT_API_URL}/tasks/{default_task.id}",
416+
request_headers=api_headers(),
417+
response_json=updated_task_response,
373418
response_status=200,
374419
)
375420

376421
response = todoist_api.update_task(task_id=default_task.id, **args)
377422

378423
assert len(respx_mock.calls) == 1
379-
assert response == Task.from_dict(updated_task_dict)
424+
assert response == Task.from_dict(updated_task_response)
425+
actual_payload = json.loads(respx_mock.calls[0].request.content)
426+
assert actual_payload == expected_payload
380427

381428
response = await todoist_api_async.update_task(task_id=default_task.id, **args)
382429

383430
assert len(respx_mock.calls) == 2
384-
assert response == Task.from_dict(updated_task_dict)
431+
assert response == Task.from_dict(updated_task_response)
432+
actual_payload = json.loads(respx_mock.calls[1].request.content)
433+
assert actual_payload == expected_payload
385434

386435

387436
@pytest.mark.asyncio

todoist_api_python/api.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -403,9 +403,9 @@ def update_task(
403403
format_datetime(due_datetime) if due_datetime is not None else None
404404
),
405405
assignee_id=assignee_id,
406-
order=order,
406+
child_order=order,
407407
day_order=day_order,
408-
collapsed=collapsed,
408+
is_collapsed=collapsed,
409409
duration=duration,
410410
duration_unit=duration_unit,
411411
deadline_date=(
@@ -771,6 +771,8 @@ def update_project(
771771
color: ColorString | None = None,
772772
is_favorite: bool | None = None,
773773
view_style: ViewStyle | None = None,
774+
order: int | None = None,
775+
collapsed: bool | None = None,
774776
) -> Project:
775777
"""
776778
Update an existing project.
@@ -783,6 +785,8 @@ def update_project(
783785
:param color: The color of the project icon.
784786
:param is_favorite: Whether the project is a favorite.
785787
:param view_style: A string value (either 'list' or 'board').
788+
:param order: Position of the project among projects with the same parent.
789+
:param collapsed: Whether the project's sub-projects are collapsed.
786790
:return: the updated Project.
787791
:raises httpx.HTTPStatusError: If the API request fails.
788792
"""
@@ -794,6 +798,8 @@ def update_project(
794798
color=color,
795799
is_favorite=is_favorite,
796800
view_style=view_style,
801+
child_order=order,
802+
is_collapsed=collapsed,
797803
)
798804

799805
response = post(
@@ -1026,25 +1032,35 @@ def add_section(
10261032
def update_section(
10271033
self,
10281034
section_id: str,
1029-
name: Annotated[str, MinLen(1), MaxLen(2048)],
1035+
*,
1036+
name: Annotated[str, MinLen(1), MaxLen(2048)] | None = None,
1037+
order: int | None = None,
1038+
collapsed: bool | None = None,
10301039
) -> Section:
10311040
"""
10321041
Update an existing section.
10331042
1034-
Currently, only `name` can be updated.
1035-
10361043
:param section_id: The ID of the section to update.
10371044
:param name: The new name for the section.
1045+
:param order: Position of the section among sections in the project.
1046+
:param collapsed: Whether the section's tasks are collapsed.
10381047
:return: the updated Section.
10391048
:raises httpx.HTTPStatusError: If the API request fails.
10401049
"""
10411050
endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}")
1051+
1052+
data = kwargs_without_none(
1053+
name=name,
1054+
section_order=order,
1055+
is_collapsed=collapsed,
1056+
)
1057+
10421058
response = post(
10431059
self._client,
10441060
endpoint,
10451061
self._token,
10461062
self._request_id_fn() if self._request_id_fn else None,
1047-
data={"name": name},
1063+
data=data,
10481064
)
10491065
data = response_json_dict(response)
10501066
return Section.from_dict(data)

0 commit comments

Comments
 (0)