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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 13 additions & 13 deletions tests/data/test_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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] = [
{
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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] = [
{
Expand Down
57 changes: 53 additions & 4 deletions tests/test_api_projects.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any

import pytest
Expand Down Expand Up @@ -202,35 +203,83 @@ 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,
method="POST",
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
Expand Down
59 changes: 54 additions & 5 deletions tests/test_api_sections.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any

import pytest
Expand Down Expand Up @@ -259,33 +260,81 @@ 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,
method="POST",
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
Expand Down
59 changes: 54 additions & 5 deletions tests/test_api_tasks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
import sys
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
Expand Down Expand Up @@ -353,35 +354,83 @@ 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,
method="POST",
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
Expand Down
Loading