diff --git a/CHANGELOG.md b/CHANGELOG.md index 191deb5..87b2599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Use `dataclass-wizard` for object mapping +- Update SDK to use the Todoist API v1 and corresponding changes + - Remove deprecated `Task.sync_id`, `Task.comment_count`, and `Project.comment_count` + - Replace `Task.is_completed` with `Task.completed_at` + - Add support for `calendar` in `Project.view_style` + - Rename `quick_add_task` to `add_task_quick` + - Add `filter_tasks`, extracting that workflow from `get_tasks` + - Paginate results via an `Iterator` in `get_tasks`, `filter_task`, `get_projects`, + `get_collaborators`, `get_sections`, `get_comments`, `get_labels`, `get_shared_labels` + - Remove support for `X-Request-Id` header, unused on the API level +- Improve type hints and documentation +- Hide internal modules and functions +- Support for `note`, `reminder`, and `auto_reminder` in `add_task_quick` ### Fixes diff --git a/README.md b/README.md index c008957..7fd206d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ It is best to integrate to a release tag to ensure a stable dependency: ```toml dependencies = [ ... - "todoist-api-python>=8.0.0,<9", + "todoist-api-python>=3.0.0,<4", ... ] ``` @@ -27,20 +27,20 @@ An example of initializing the API client and fetching a user's tasks: from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.api import TodoistAPI -# Fetch tasks asynchronously -async def get_tasks_async(): - api = TodoistAPIAsync("YOURTOKEN") +# Fetch tasks synchronously +def get_tasks_sync(): + api = TodoistAPI("my token") try: - tasks = await api.get_tasks() + tasks = api.get_tasks() print(tasks) except Exception as error: print(error) -# Fetch tasks synchronously -def get_tasks_sync(): - api = TodoistAPI("my token") +# Fetch tasks asynchronously +async def get_tasks_async(): + api = TodoistAPIAsync("YOURTOKEN") try: - tasks = api.get_tasks() + tasks = await api.get_tasks() print(tasks) except Exception as error: print(error) diff --git a/pyproject.toml b/pyproject.toml index d3003f9..0e64cc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,11 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["requests>=2.32.3,<3", "dataclass-wizard>=0.35.0,<1.0"] +dependencies = [ + "requests>=2.32.3,<3", + "dataclass-wizard>=0.35.0,<1.0", + "annotated-types", +] [project.urls] Homepage = "https://github.com/Doist/todoist-api-python" @@ -118,16 +122,14 @@ ignore = [ "D203", # incorrect-blank-line-before-class "D212", # multi-line-summary-first-line "PLR0913", # too-many-arguments + "TRY00", # raise-vanilla-args # All listed below are not intentional and should be fixed - "D100", # undocumented-public-module - "D101", # undocumented-public-class - "D102", # undocumented-public-method - "D103", # undocumented-public-function - "D104", # undocumented-public-package - "D105", # undocumented-magic-method - "D107", # undocumented-public-init - "ANN003", # missing-type-kwargs + "D100", # undocumented-public-module + "D101", # undocumented-public-class + "D102", # undocumented-public-method + "D103", # undocumented-public-function + "D104", # undocumented-public-package ] [tool.ruff.lint.extend-per-file-ignores] diff --git a/tests/conftest.py b/tests/conftest.py index c579990..197bdd3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,22 +5,22 @@ import pytest import responses -from tests.data.quick_add_responses import QUICK_ADD_RESPONSE_FULL from tests.data.test_defaults import ( DEFAULT_AUTH_RESPONSE, DEFAULT_COLLABORATORS_RESPONSE, DEFAULT_COMMENT_RESPONSE, DEFAULT_COMMENTS_RESPONSE, - DEFAULT_COMPLETED_ITEMS_RESPONSE, DEFAULT_LABEL_RESPONSE, DEFAULT_LABELS_RESPONSE, DEFAULT_PROJECT_RESPONSE, DEFAULT_PROJECTS_RESPONSE, DEFAULT_SECTION_RESPONSE, DEFAULT_SECTIONS_RESPONSE, + DEFAULT_TASK_META_RESPONSE, DEFAULT_TASK_RESPONSE, DEFAULT_TASKS_RESPONSE, DEFAULT_TOKEN, + PaginatedResults, ) from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync @@ -28,10 +28,8 @@ AuthResult, Collaborator, Comment, - CompletedItems, Label, Project, - QuickAddResult, Section, Task, ) @@ -61,19 +59,32 @@ def default_task_response() -> dict[str, Any]: return DEFAULT_TASK_RESPONSE +@pytest.fixture +def default_task_meta() -> Task: + return Task.from_dict(DEFAULT_TASK_META_RESPONSE) + + +@pytest.fixture +def default_task_meta_response() -> dict[str, Any]: + return DEFAULT_TASK_META_RESPONSE + + @pytest.fixture def default_task() -> Task: return Task.from_dict(DEFAULT_TASK_RESPONSE) @pytest.fixture -def default_tasks_response() -> list[dict[str, Any]]: +def default_tasks_response() -> list[PaginatedResults]: return DEFAULT_TASKS_RESPONSE @pytest.fixture -def default_tasks_list() -> list[Task]: - return [Task.from_dict(obj) for obj in DEFAULT_TASKS_RESPONSE] +def default_tasks_list() -> list[list[Task]]: + return [ + [Task.from_dict(result) for result in response["results"]] + for response in DEFAULT_TASKS_RESPONSE + ] @pytest.fixture @@ -87,23 +98,29 @@ def default_project() -> Project: @pytest.fixture -def default_projects_response() -> list[dict[str, Any]]: +def default_projects_response() -> list[PaginatedResults]: return DEFAULT_PROJECTS_RESPONSE @pytest.fixture -def default_projects_list() -> list[Project]: - return [Project.from_dict(obj) for obj in DEFAULT_PROJECTS_RESPONSE] +def default_projects_list() -> list[list[Project]]: + return [ + [Project.from_dict(result) for result in response["results"]] + for response in DEFAULT_PROJECTS_RESPONSE + ] @pytest.fixture -def default_collaborators_response() -> list[dict[str, Any]]: +def default_collaborators_response() -> list[PaginatedResults]: return DEFAULT_COLLABORATORS_RESPONSE @pytest.fixture -def default_collaborators_list() -> list[Collaborator]: - return [Collaborator.from_dict(obj) for obj in DEFAULT_COLLABORATORS_RESPONSE] +def default_collaborators_list() -> list[list[Collaborator]]: + return [ + [Collaborator.from_dict(result) for result in response["results"]] + for response in DEFAULT_COLLABORATORS_RESPONSE + ] @pytest.fixture @@ -117,13 +134,16 @@ def default_section() -> Section: @pytest.fixture -def default_sections_response() -> list[dict[str, Any]]: +def default_sections_response() -> list[PaginatedResults]: return DEFAULT_SECTIONS_RESPONSE @pytest.fixture -def default_sections_list() -> list[Section]: - return [Section.from_dict(obj) for obj in DEFAULT_SECTIONS_RESPONSE] +def default_sections_list() -> list[list[Section]]: + return [ + [Section.from_dict(result) for result in response["results"]] + for response in DEFAULT_SECTIONS_RESPONSE + ] @pytest.fixture @@ -137,13 +157,16 @@ def default_comment() -> Comment: @pytest.fixture -def default_comments_response() -> list[dict[str, Any]]: +def default_comments_response() -> list[PaginatedResults]: return DEFAULT_COMMENTS_RESPONSE @pytest.fixture -def default_comments_list() -> list[Comment]: - return [Comment.from_dict(obj) for obj in DEFAULT_COMMENTS_RESPONSE] +def default_comments_list() -> list[list[Comment]]: + return [ + [Comment.from_dict(result) for result in response["results"]] + for response in DEFAULT_COMMENTS_RESPONSE + ] @pytest.fixture @@ -157,23 +180,26 @@ def default_label() -> Label: @pytest.fixture -def default_labels_response() -> list[dict[str, Any]]: +def default_labels_response() -> list[PaginatedResults]: return DEFAULT_LABELS_RESPONSE @pytest.fixture -def default_labels_list() -> list[Label]: - return [Label.from_dict(obj) for obj in DEFAULT_LABELS_RESPONSE] +def default_labels_list() -> list[list[Label]]: + return [ + [Label.from_dict(result) for result in response["results"]] + for response in DEFAULT_LABELS_RESPONSE + ] @pytest.fixture def default_quick_add_response() -> dict[str, Any]: - return QUICK_ADD_RESPONSE_FULL + return DEFAULT_TASK_RESPONSE @pytest.fixture -def default_quick_add_result() -> QuickAddResult: - return QuickAddResult.from_quick_add_response(QUICK_ADD_RESPONSE_FULL) +def default_quick_add_result() -> Task: + return Task.from_dict(DEFAULT_TASK_RESPONSE) @pytest.fixture @@ -184,13 +210,3 @@ def default_auth_response() -> dict[str, Any]: @pytest.fixture def default_auth_result() -> AuthResult: return AuthResult.from_dict(DEFAULT_AUTH_RESPONSE) - - -@pytest.fixture -def default_completed_items_response() -> dict[str, Any]: - return DEFAULT_COMPLETED_ITEMS_RESPONSE - - -@pytest.fixture -def default_completed_items() -> CompletedItems: - return CompletedItems.from_dict(DEFAULT_COMPLETED_ITEMS_RESPONSE) diff --git a/tests/data/quick_add_responses.py b/tests/data/quick_add_responses.py deleted file mode 100644 index cac6c8d..0000000 --- a/tests/data/quick_add_responses.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -from typing import Any - -QUICK_ADD_RESPONSE_MINIMAL: dict[str, Any] = { - "added_by_uid": "21180723", - "assigned_by_uid": None, - "checked": 0, - "child_order": 6, - "collapsed": 0, - "content": "some task", - "description": "", - "added_at": "2021-02-05T11:02:56.00000Z", - "date_completed": None, - "due": None, - "id": "4554989047", - "in_history": 0, - "is_deleted": 0, - "labels": [], - "meta": { - "assignee": [None, None], - "due": { - "date_local": None, - "datetime_local": None, - "datetime_utc": None, - "is_recurring": False, - "lang": None, - "object_type": "null", - "string": None, - "timezone": None, - "timezone_name": None, - }, - "labels": {}, - "priority": 1, - "project": [None, None], - "section": [None, None], - "text": "some task", - }, - "parent_id": None, - "priority": 1, - "project_id": "2203108698", - "responsible_uid": None, - "section_id": None, - "sync_id": None, - "user_id": "21180723", -} - -QUICK_ADD_RESPONSE_FULL: dict[str, Any] = { - "added_by_uid": "21180723", - "assigned_by_uid": "21180723", - "checked": 0, - "child_order": 1, - "collapsed": 0, - "content": "some task", - "description": "a description", - "added_at": "2021-02-05T11:04:54.00000Z", - "date_completed": None, - "due": { - "date": "2021-02-06T11:00:00.00000Z", - "is_recurring": False, - "lang": "en", - "string": "Feb 6 11:00 AM", - "timezone": "Europe/London", - }, - "id": "4554993687", - "in_history": 0, - "is_deleted": 0, - "labels": ["Label1", "Label2"], - "meta": { - "assignee": ["29172386", "Some Guy"], - "due": { - "date_local": "2021-02-06T00:00:00.00000Z", - "datetime_local": "2021-02-06T11:00:00.00000Z", - "datetime_utc": "2021-02-06T11:00:00.00000Z", - "is_recurring": False, - "lang": "en", - "object_type": "fixed_datetime", - "string": "Feb 6 11:00 AM", - "timezone": {"zone": "Europe/London"}, - "timezone_name": "Europe/London", - }, - "labels": {"2156154810": "Label1", "2156154812": "Label2"}, - "priority": 1, - "project": ["2257514220", "test"], - "section": ["2232454220", "A section"], - "text": "some task", - }, - "parent_id": None, - "priority": 1, - "project_id": "2257514220", - "responsible_uid": "29172386", - "section_id": "2232454220", - "sync_id": "4554993687", - "user_id": "21180723", -} diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 76bbfcd..13f8aa0 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -1,19 +1,24 @@ from __future__ import annotations -from typing import Any +from typing import Any, TypedDict -REST_API_BASE_URL = "https://api.todoist.com/rest/v2" -SYNC_API_BASE_URL = "https://api.todoist.com/sync/v9" -AUTH_BASE_URL = "https://todoist.com" -DEFAULT_TOKEN = "A TOKEN" -DEFAULT_REQUEST_ID = "REQUEST12345" + +class PaginatedResults(TypedDict): + results: list[dict[str, Any]] + next_cursor: str | None + + +DEFAULT_API_URL = "https://api.todoist.com/api/v1" +DEFAULT_OAUTH_URL = "https://todoist.com/oauth" + +DEFAULT_TOKEN = "some-default-token" DEFAULT_DUE_RESPONSE = { "date": "2016-09-01", - "is_recurring": True, - "datetime": "2016-09-01T09:00:00.00000Z", - "string": "tomorrow at 12", "timezone": "Europe/Moscow", + "string": "tomorrow at 12", + "lang": "en", + "is_recurring": True, } DEFAULT_DURATION_RESPONSE = { @@ -21,87 +26,141 @@ "unit": "minute", } -DEFAULT_TASK_RESPONSE = { - "id": "1234", - "assigner_id": "2971358", - "assignee_id": "2423523", - "project_id": "2203306141", - "parent_id": "8686843758", - "section_id": "7025", - "order": 3, - "content": "Some Task Content", - "description": "A description", - "is_completed": False, - "is_shared": False, - "labels": [], - "priority": 1, - "comment_count": 0, - "creator_id": "0", - "created_at": "2019-01-02T21:00:30.00000Z", - "url": "https://todoist.com/showTask?id=1234", - "due": DEFAULT_DUE_RESPONSE, - "duration": DEFAULT_DURATION_RESPONSE, +DEFAULT_META_RESPONSE: dict[str, Any] = { + "project": ["6X7rM8997g3RQmvh", "Inbox"], + "section": [None, None], + "assignee": [None, None], + "labels": {}, + "due": None, } -DEFAULT_TASK_RESPONSE_2 = dict(DEFAULT_TASK_RESPONSE) -DEFAULT_TASK_RESPONSE_2["id"] = "5678" - -DEFAULT_TASKS_RESPONSE = [ - DEFAULT_TASK_RESPONSE, - DEFAULT_TASK_RESPONSE_2, -] - DEFAULT_PROJECT_RESPONSE = { - "id": "1234", + "id": "6X7rM8997g3RQmvh", "name": "Inbox", - "comment_count": 10, - "order": 1, + "description": "", + "parent_id": "6X7rfFVPjhvv84XG", + "folder_id": None, + "workspace_id": None, + "child_order": 1, "color": "red", - "is_shared": False, - "parent_id": "5678", + "shared": False, + "collapsed": False, "is_favorite": False, "is_inbox_project": True, - "is_team_inbox": True, "can_assign_tasks": False, - "url": "https://todoist.com/showProject?id=1234", "view_style": "list", + "created_at": "2023-02-01T00:00:00000Z", + "updated_at": "2025-04-03T03:14:15926Z", } DEFAULT_PROJECT_RESPONSE_2 = dict(DEFAULT_PROJECT_RESPONSE) -DEFAULT_PROJECT_RESPONSE_2["id"] = "5678" +DEFAULT_PROJECT_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" +DEFAULT_PROJECT_RESPONSE_2["is_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_PROJECTS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_PROJECT_RESPONSE, DEFAULT_PROJECT_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_PROJECT_RESPONSE_3], + "next_cursor": None, + }, +] + +DEFAULT_TASK_RESPONSE: dict[str, Any] = { + "id": "6X7rM8997g3RQmvh", + "content": "Some task content", + "description": "Some task description", + "project_id": "6Jf8VQXxpwv56VQ7", + "section_id": "3Ty8VQXxpwv28PK3", + "parent_id": "6X7rf9x6pv2FGghW", + "labels": [], + "priority": 1, + "due": DEFAULT_DUE_RESPONSE, + "duration": DEFAULT_DURATION_RESPONSE, + "collapsed": False, + "child_order": 3, + "responsible_uid": "2423523", + "assigned_by_uid": "2971358", + "completed_at": None, + "added_by_uid": "34567", + "added_at": "2016-01-02T21:00:30.00000Z", + "updated_at": None, +} -DEFAULT_PROJECTS_RESPONSE = [ - DEFAULT_PROJECT_RESPONSE, - DEFAULT_PROJECT_RESPONSE_2, +DEFAULT_TASK_RESPONSE_2 = dict(DEFAULT_TASK_RESPONSE) +DEFAULT_TASK_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" + +DEFAULT_TASK_RESPONSE_3 = dict(DEFAULT_TASK_RESPONSE) +DEFAULT_TASK_RESPONSE_3["id"] = "6X7rF9xvX25jTxm5" + +DEFAULT_TASKS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_TASK_RESPONSE, DEFAULT_TASK_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_TASK_RESPONSE_3], + "next_cursor": None, + }, ] +DEFAULT_TASK_META_RESPONSE = dict(DEFAULT_TASK_RESPONSE) +DEFAULT_TASK_META_RESPONSE["meta"] = DEFAULT_META_RESPONSE + + DEFAULT_COLLABORATOR_RESPONSE = { - "id": "1234", + "id": "6X7rM8997g3RQmvh", "name": "Alice", "email": "alice@example.com", } DEFAULT_COLLABORATOR_RESPONSE_2 = dict(DEFAULT_COLLABORATOR_RESPONSE) -DEFAULT_COLLABORATOR_RESPONSE_2["id"] = "5678" - -DEFAULT_COLLABORATORS_RESPONSE = [ - DEFAULT_COLLABORATOR_RESPONSE, - DEFAULT_COLLABORATOR_RESPONSE_2, +DEFAULT_COLLABORATOR_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" + +DEFAULT_COLLABORATOR_RESPONSE_3 = dict(DEFAULT_COLLABORATOR_RESPONSE) +DEFAULT_COLLABORATOR_RESPONSE_3["id"] = "6X7rjKtP98vG84rK" + +DEFAULT_COLLABORATORS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_COLLABORATOR_RESPONSE, DEFAULT_COLLABORATOR_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_COLLABORATOR_RESPONSE_3], + "next_cursor": None, + }, ] DEFAULT_SECTION_RESPONSE = { - "id": "1234", + "id": "6X7rM8997g3RQmvh", "project_id": "4567", "name": "A Section", + "collapsed": False, "order": 1, } DEFAULT_SECTION_RESPONSE_2 = dict(DEFAULT_SECTION_RESPONSE) -DEFAULT_SECTION_RESPONSE_2["id"] = 5678 - -DEFAULT_SECTIONS_RESPONSE = [ - DEFAULT_SECTION_RESPONSE, - DEFAULT_SECTION_RESPONSE_2, +DEFAULT_SECTION_RESPONSE_2["id"] = "6X7FxXvX84jHphx" + +DEFAULT_SECTION_RESPONSE_3 = dict(DEFAULT_SECTION_RESPONSE) +DEFAULT_SECTION_RESPONSE_3["id"] = "6X7rF9xvX25jTzm7" + +DEFAULT_SECTIONS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_SECTION_RESPONSE, DEFAULT_SECTION_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_SECTION_RESPONSE_3], + "next_cursor": None, + }, ] DEFAULT_ATTACHMENT_RESPONSE = { @@ -112,28 +171,39 @@ "file_url": "https://cdn-domain.tld/path/to/file.pdf", "upload_state": "completed", "image": "https://cdn-domain.tld/path/to/some_image.jpg", - "image_width": 1234, - "image_height": 5678, + "image_width": 800, + "image_height": 600, "url": "https://todoist.com", "title": "Todoist Website", } DEFAULT_COMMENT_RESPONSE: dict[str, Any] = { - "id": "1234", + "id": "6X7rM8997g3RQmvh", "content": "A comment", - "posted_at": "2016-09-22T07:00:00.00000Z", - "task_id": "2345", - "project_id": "4567", + "posted_uid": "34567", + "posted_at": "2019-09-22T07:00:00.00000Z", + "task_id": "6X7rM8997g3RQmvh", + "project_id": "6X7rfEVP8hvv25ZQ", "attachment": DEFAULT_ATTACHMENT_RESPONSE, } DEFAULT_COMMENT_RESPONSE_2 = dict(DEFAULT_COMMENT_RESPONSE) -DEFAULT_COMMENT_RESPONSE_2["id"] = "5678" +DEFAULT_COMMENT_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" DEFAULT_COMMENT_RESPONSE_2["attachment"] = None -DEFAULT_COMMENTS_RESPONSE = [ - DEFAULT_COMMENT_RESPONSE, - DEFAULT_COMMENT_RESPONSE_2, +DEFAULT_COMMENT_RESPONSE_3 = dict(DEFAULT_COMMENT_RESPONSE) +DEFAULT_COMMENT_RESPONSE_3["id"] = "6X7rfFVPjhvv65HG" +DEFAULT_COMMENT_RESPONSE_3["attachment"] = None + +DEFAULT_COMMENTS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_COMMENT_RESPONSE, DEFAULT_COMMENT_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_COMMENT_RESPONSE_3], + "next_cursor": None, + }, ] DEFAULT_LABEL_RESPONSE = { @@ -147,41 +217,21 @@ DEFAULT_LABEL_RESPONSE_2 = dict(DEFAULT_LABEL_RESPONSE) DEFAULT_LABEL_RESPONSE_2["id"] = "4567" -DEFAULT_LABELS_RESPONSE = [ - DEFAULT_LABEL_RESPONSE, - DEFAULT_LABEL_RESPONSE_2, +DEFAULT_LABEL_RESPONSE_3 = dict(DEFAULT_LABEL_RESPONSE) +DEFAULT_LABEL_RESPONSE_3["id"] = "6789" + +DEFAULT_LABELS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_LABEL_RESPONSE, DEFAULT_LABEL_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_LABEL_RESPONSE_3], + "next_cursor": None, + }, ] DEFAULT_AUTH_RESPONSE = { - "access_token": "1234", + "access_token": "123456789", "state": "somestate", } - -DEFAULT_ITEM_RESPONSE = { - "id": "2995104339", - "user_id": "2671355", - "project_id": "2203306141", - "content": "Buy Milk", - "description": "", - "priority": 1, - "due": DEFAULT_DUE_RESPONSE, - "child_order": 1, - "day_order": -1, - "collapsed": False, - "labels": ["Food", "Shopping"], - "added_by_uid": "2671355", - "assigned_by_uid": "2671355", - "checked": False, - "is_deleted": False, - "added_at": "2014-09-26T08:25:05.000000Z", -} - -DEFAULT_ITEM_COMPLETED_INFO_RESPONSE = {"item_id": "2995104339", "completed_items": 12} - -DEFAULT_COMPLETED_ITEMS_RESPONSE = { - "items": [DEFAULT_ITEM_RESPONSE], - "completed_info": [DEFAULT_ITEM_COMPLETED_INFO_RESPONSE], - "total": 22, - "next_cursor": "k85gVI5ZAs8AAAABFoOzAQ", - "has_more": True, -} diff --git a/tests/test_api_comments.py b/tests/test_api_comments.py index 442c134..7e1426e 100644 --- a/tests/test_api_comments.py +++ b/tests/test_api_comments.py @@ -1,18 +1,27 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any import pytest import responses -from tests.data.test_defaults import DEFAULT_REQUEST_ID, REST_API_BASE_URL -from tests.utils.test_utils import assert_auth_header, assert_request_id_header +from tests.data.test_defaults import ( + DEFAULT_API_URL, + PaginatedResults, +) +from tests.utils.test_utils import ( + auth_matcher, + data_matcher, + enumerate_async, + param_matcher, +) +from todoist_api_python.models import Attachment if TYPE_CHECKING: from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync - from todoist_api_python.models import Comment + +from todoist_api_python.models import Comment @pytest.mark.asyncio @@ -23,26 +32,25 @@ async def test_get_comment( default_comment_response: dict[str, Any], default_comment: Comment, ) -> None: - comment_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/comments/{comment_id}" + comment_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/comments/{comment_id}" requests_mock.add( - responses.GET, - expected_endpoint, + method=responses.GET, + url=endpoint, json=default_comment_response, status=200, + match=[auth_matcher()], ) comment = todoist_api.get_comment(comment_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert comment == default_comment comment = await todoist_api_async.get_comment(comment_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert comment == default_comment @@ -51,29 +59,41 @@ async def test_get_comments( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_comments_response: list[dict[str, Any]], - default_comments_list: list[Comment], + default_comments_response: list[PaginatedResults], + default_comments_list: list[list[Comment]], ) -> None: - task_id = "1234" + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/comments" - requests_mock.add( - responses.GET, - f"{REST_API_BASE_URL}/comments?task_id={task_id}", - json=default_comments_response, - status=200, - ) + cursor: str | None = None + for page in default_comments_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[ + auth_matcher(), + param_matcher({"task_id": task_id}, cursor), + ], + ) + cursor = page["next_cursor"] - comments = todoist_api.get_comments(task_id=task_id) + count = 0 - assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert comments == default_comments_list + comments_iter = todoist_api.get_comments(task_id=task_id) - comments = await todoist_api_async.get_comments(task_id=task_id) + for i, comments in enumerate(comments_iter): + assert len(requests_mock.calls) == count + 1 + assert comments == default_comments_list[i] + count += 1 - assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert comments == default_comments_list + comments_async_iter = await todoist_api_async.get_comments(task_id=task_id) + + async for i, comments in enumerate_async(comments_async_iter): + assert len(requests_mock.calls) == count + 1 + assert comments == default_comments_list[i] + count += 1 @pytest.mark.asyncio @@ -85,51 +105,47 @@ async def test_add_comment( default_comment: Comment, ) -> None: content = "A Comment" - project_id = 123 - attachment_data = { - "resource_type": "file", - "file_url": "https://s3.amazonaws.com/domorebetter/Todoist+Setup+Guide.pdf", - "file_type": "application/pdf", - "file_name": "File.pdf", - } - - expected_payload: dict[str, Any] = { - "content": content, - "project_id": project_id, - "attachment": attachment_data, - } + project_id = "6HWcc9PJCvPjCxC9" + attachment = Attachment( + resource_type="file", + file_url="https://s3.amazonaws.com/domorebetter/Todoist+Setup+Guide.pdf", + file_type="application/pdf", + file_name="File.pdf", + ) requests_mock.add( - responses.POST, - f"{REST_API_BASE_URL}/comments", + method=responses.POST, + url=f"{DEFAULT_API_URL}/comments", json=default_comment_response, status=200, + match=[ + auth_matcher(), + data_matcher( + { + "content": content, + "project_id": project_id, + "attachment": attachment.to_dict(), + } + ), + ], ) new_comment = todoist_api.add_comment( content=content, project_id=project_id, - attachment=attachment_data, - request_id=DEFAULT_REQUEST_ID, + attachment=attachment, ) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) assert new_comment == default_comment new_comment = await todoist_api_async.add_comment( content=content, project_id=project_id, - attachment=attachment_data, - request_id=DEFAULT_REQUEST_ID, + attachment=attachment, ) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) assert new_comment == default_comment @@ -138,36 +154,32 @@ async def test_update_comment( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, + default_comment: Comment, ) -> None: - comment_id = "1234" - args = { "content": "An updated comment", } + updated_comment_dict = default_comment.to_dict() | args requests_mock.add( - responses.POST, f"{REST_API_BASE_URL}/comments/{comment_id}", status=204 + method=responses.POST, + url=f"{DEFAULT_API_URL}/comments/{default_comment.id}", + json=updated_comment_dict, + status=200, + match=[auth_matcher(), data_matcher(args)], ) - response = todoist_api.update_comment( - comment_id=comment_id, request_id=DEFAULT_REQUEST_ID, **args - ) + response = todoist_api.update_comment(comment_id=default_comment.id, **args) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(args) - assert response is True + assert response == Comment.from_dict(updated_comment_dict) response = await todoist_api_async.update_comment( - comment_id=comment_id, request_id=DEFAULT_REQUEST_ID, **args + comment_id=default_comment.id, **args ) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(args) - assert response is True + assert response == Comment.from_dict(updated_comment_dict) @pytest.mark.asyncio @@ -176,23 +188,22 @@ async def test_delete_comment( todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, ) -> None: - comment_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/comments/{comment_id}" + comment_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/comments/{comment_id}" requests_mock.add( - responses.DELETE, - expected_endpoint, + method=responses.DELETE, + url=endpoint, status=204, + match=[auth_matcher()], ) response = todoist_api.delete_comment(comment_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert response is True response = await todoist_api_async.delete_comment(comment_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert response is True diff --git a/tests/test_api_items.py b/tests/test_api_items.py deleted file mode 100644 index 1f4e476..0000000 --- a/tests/test_api_items.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qs, urlparse - -import pytest -import responses - -from tests.data.test_defaults import SYNC_API_BASE_URL -from tests.utils.test_utils import assert_auth_header -from todoist_api_python.endpoints import COMPLETED_ITEMS_ENDPOINT - -if TYPE_CHECKING: - from todoist_api_python.api import TodoistAPI - from todoist_api_python.api_async import TodoistAPIAsync - from todoist_api_python.models import CompletedItems - - -@pytest.mark.asyncio -async def test_get_completed_items( - todoist_api: TodoistAPI, - todoist_api_async: TodoistAPIAsync, - requests_mock: responses.RequestsMock, - default_completed_items_response: dict[str, Any], - default_completed_items: CompletedItems, -) -> None: - project_id = "1234" - section_id = "5678" - item_id = "90ab" - last_seen_id = "cdef" - limit = 30 - cursor = "ghij" - - def assert_query(url: str) -> None: - queries = parse_qs(urlparse(url).query) - assert queries.get("project_id") == [project_id] - assert queries.get("section_id") == [section_id] - assert queries.get("item_id") == [item_id] - assert queries.get("last_seen_id") == [last_seen_id] - assert queries.get("limit") == [str(limit)] - assert queries.get("cursor") == [cursor] - - expected_endpoint = f"{SYNC_API_BASE_URL}/{COMPLETED_ITEMS_ENDPOINT}" - - requests_mock.add( - responses.GET, - expected_endpoint, - json=default_completed_items_response, - status=200, - ) - - completed_items = todoist_api.get_completed_items( - project_id, section_id, item_id, last_seen_id, limit, cursor - ) - - assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_query(requests_mock.calls[0].request.url) - assert completed_items == default_completed_items - - completed_items = await todoist_api_async.get_completed_items( - project_id, section_id, item_id, last_seen_id, limit, cursor - ) - - assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_query(requests_mock.calls[1].request.url) - assert completed_items == default_completed_items diff --git a/tests/test_api_labels.py b/tests/test_api_labels.py index cbedf10..193a3ee 100644 --- a/tests/test_api_labels.py +++ b/tests/test_api_labels.py @@ -1,18 +1,22 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any import pytest import responses -from tests.data.test_defaults import DEFAULT_REQUEST_ID, REST_API_BASE_URL -from tests.utils.test_utils import assert_auth_header, assert_request_id_header +from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults +from tests.utils.test_utils import ( + auth_matcher, + data_matcher, + enumerate_async, + param_matcher, +) if TYPE_CHECKING: from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync - from todoist_api_python.models import Label +from todoist_api_python.models import Label @pytest.mark.asyncio @@ -23,26 +27,25 @@ async def test_get_label( default_label_response: dict[str, Any], default_label: Label, ) -> None: - label_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/labels/{label_id}" + label_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/labels/{label_id}" requests_mock.add( - responses.GET, - expected_endpoint, + method=responses.GET, + url=endpoint, json=default_label_response, status=200, + match=[auth_matcher()], ) label = todoist_api.get_label(label_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert label == default_label label = await todoist_api_async.get_label(label_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert label == default_label @@ -51,27 +54,37 @@ async def test_get_labels( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_labels_response: list[dict[str, Any]], - default_labels_list: list[Label], + default_labels_response: list[PaginatedResults], + default_labels_list: list[list[Label]], ) -> None: - requests_mock.add( - responses.GET, - f"{REST_API_BASE_URL}/labels", - json=default_labels_response, - status=200, - ) + endpoint = f"{DEFAULT_API_URL}/labels" - labels = todoist_api.get_labels() + cursor: str | None = None + for page in default_labels_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[auth_matcher(), param_matcher({}, cursor)], + ) + cursor = page["next_cursor"] - assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert labels == default_labels_list + count = 0 - labels = await todoist_api_async.get_labels() + labels_iter = todoist_api.get_labels() - assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert labels == default_labels_list + for i, labels in enumerate(labels_iter): + assert len(requests_mock.calls) == count + 1 + assert labels == default_labels_list[i] + count += 1 + + labels_async_iter = await todoist_api_async.get_labels() + + async for i, labels in enumerate_async(labels_async_iter): + assert len(requests_mock.calls) == count + 1 + assert labels == default_labels_list[i] + count += 1 @pytest.mark.asyncio @@ -83,31 +96,23 @@ async def test_add_label_minimal( default_label: Label, ) -> None: label_name = "A Label" - expected_payload = {"name": label_name} requests_mock.add( - responses.POST, - f"{REST_API_BASE_URL}/labels", + method=responses.POST, + url=f"{DEFAULT_API_URL}/labels", json=default_label_response, status=200, + match=[auth_matcher(), data_matcher({"name": label_name})], ) - new_label = todoist_api.add_label(name=label_name, request_id=DEFAULT_REQUEST_ID) + new_label = todoist_api.add_label(name=label_name) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) assert new_label == default_label - new_label = await todoist_api_async.add_label( - name=label_name, request_id=DEFAULT_REQUEST_ID - ) + new_label = await todoist_api_async.add_label(name=label_name) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) assert new_label == default_label @@ -120,41 +125,28 @@ async def test_add_label_full( default_label: Label, ) -> None: label_name = "A Label" - - optional_args = { - "color": 30, - "order": 3, - "favorite": True, + args: dict[str, Any] = { + "color": "red", + "item_order": 3, + "is_favorite": True, } - expected_payload: dict[str, Any] = {"name": label_name} - expected_payload.update(optional_args) - requests_mock.add( - responses.POST, - f"{REST_API_BASE_URL}/labels", + method=responses.POST, + url=f"{DEFAULT_API_URL}/labels", json=default_label_response, status=200, + match=[auth_matcher(), data_matcher({"name": label_name} | args)], ) - new_label = todoist_api.add_label( - name=label_name, request_id=DEFAULT_REQUEST_ID, **optional_args - ) + new_label = todoist_api.add_label(name=label_name, **args) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) assert new_label == default_label - new_label = await todoist_api_async.add_label( - name=label_name, request_id=DEFAULT_REQUEST_ID, **optional_args - ) + new_label = await todoist_api_async.add_label(name=label_name, **args) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) assert new_label == default_label @@ -163,39 +155,30 @@ async def test_update_label( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, + default_label: Label, ) -> None: - label_id = "123" - - args = { + args: dict[str, Any] = { "name": "An updated label", - "order": 2, - "color": 31, - "favorite": False, } + updated_label_dict = default_label.to_dict() | args requests_mock.add( - responses.POST, f"{REST_API_BASE_URL}/labels/{label_id}", status=204 + method=responses.POST, + url=f"{DEFAULT_API_URL}/labels/{default_label.id}", + json=updated_label_dict, + status=200, + match=[auth_matcher(), data_matcher(args)], ) - response = todoist_api.update_label( - label_id=label_id, request_id=DEFAULT_REQUEST_ID, **args - ) + response = todoist_api.update_label(label_id=default_label.id, **args) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(args) - assert response is True + assert response == Label.from_dict(updated_label_dict) - response = await todoist_api_async.update_label( - label_id=label_id, request_id=DEFAULT_REQUEST_ID, **args - ) + response = await todoist_api_async.update_label(label_id=default_label.id, **args) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(args) - assert response is True + assert response == Label.from_dict(updated_label_dict) @pytest.mark.asyncio @@ -204,23 +187,21 @@ async def test_delete_label( todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, ) -> None: - label_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/labels/{label_id}" + label_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/labels/{label_id}" requests_mock.add( - responses.DELETE, - expected_endpoint, + method=responses.DELETE, + url=endpoint, status=204, ) response = todoist_api.delete_label(label_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert response is True response = await todoist_api_async.delete_label(label_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert response is True diff --git a/tests/test_api_projects.py b/tests/test_api_projects.py index 92821c9..9fee2da 100644 --- a/tests/test_api_projects.py +++ b/tests/test_api_projects.py @@ -1,18 +1,22 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any import pytest import responses -from tests.data.test_defaults import DEFAULT_REQUEST_ID, REST_API_BASE_URL -from tests.utils.test_utils import assert_auth_header, assert_request_id_header +from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults +from tests.utils.test_utils import ( + auth_matcher, + data_matcher, + enumerate_async, + param_matcher, +) if TYPE_CHECKING: from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync - from todoist_api_python.models import Project +from todoist_api_python.models import Collaborator, Project @pytest.mark.asyncio @@ -23,26 +27,25 @@ async def test_get_project( default_project_response: dict[str, Any], default_project: Project, ) -> None: - project_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/projects/{project_id}" + project_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/projects/{project_id}" requests_mock.add( - responses.GET, - expected_endpoint, + method=responses.GET, + url=endpoint, json=default_project_response, status=200, + match=[auth_matcher()], ) project = todoist_api.get_project(project_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert project == default_project project = await todoist_api_async.get_project(project_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert project == default_project @@ -51,27 +54,37 @@ async def test_get_projects( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_projects_response: list[dict[str, Any]], - default_projects_list: list[Project], + default_projects_response: list[PaginatedResults], + default_projects_list: list[list[Project]], ) -> None: - requests_mock.add( - responses.GET, - f"{REST_API_BASE_URL}/projects", - json=default_projects_response, - status=200, - ) + endpoint = f"{DEFAULT_API_URL}/projects" - projects = todoist_api.get_projects() + cursor: str | None = None + for page in default_projects_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[auth_matcher(), param_matcher({}, cursor)], + ) + cursor = page["next_cursor"] - assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert projects == default_projects_list + count = 0 - projects = await todoist_api_async.get_projects() + projects_iter = todoist_api.get_projects() - assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert projects == default_projects_list + for i, projects in enumerate(projects_iter): + assert len(requests_mock.calls) == count + 1 + assert projects == default_projects_list[i] + count += 1 + + projects_async_iter = await todoist_api_async.get_projects() + + async for i, projects in enumerate_async(projects_async_iter): + assert len(requests_mock.calls) == count + 1 + assert projects == default_projects_list[i] + count += 1 @pytest.mark.asyncio @@ -83,33 +96,23 @@ async def test_add_project_minimal( default_project: Project, ) -> None: project_name = "A Project" - expected_payload = {"name": project_name} requests_mock.add( - responses.POST, - f"{REST_API_BASE_URL}/projects", + method=responses.POST, + url=f"{DEFAULT_API_URL}/projects", json=default_project_response, status=200, + match=[auth_matcher(), data_matcher({"name": project_name})], ) - new_project = todoist_api.add_project( - name=project_name, request_id=DEFAULT_REQUEST_ID - ) + new_project = todoist_api.add_project(name=project_name) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) assert new_project == default_project - new_project = await todoist_api_async.add_project( - name=project_name, request_id=DEFAULT_REQUEST_ID - ) + new_project = await todoist_api_async.add_project(name=project_name) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) assert new_project == default_project @@ -123,41 +126,22 @@ async def test_add_project_full( ) -> None: project_name = "A Project" - optional_args = { - "parent_id": 789, - "color": 30, - "order": 3, - "favorite": True, - } - - expected_payload: dict[str, Any] = {"name": project_name} - expected_payload.update(optional_args) - requests_mock.add( - responses.POST, - f"{REST_API_BASE_URL}/projects", + method=responses.POST, + url=f"{DEFAULT_API_URL}/projects", json=default_project_response, status=200, + match=[auth_matcher(), data_matcher({"name": project_name})], ) - new_project = todoist_api.add_project( - name=project_name, request_id=DEFAULT_REQUEST_ID, **optional_args - ) + new_project = todoist_api.add_project(name=project_name) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) assert new_project == default_project - new_project = await todoist_api_async.add_project( - name=project_name, request_id=DEFAULT_REQUEST_ID, **optional_args - ) + new_project = await todoist_api_async.add_project(name=project_name) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) assert new_project == default_project @@ -166,38 +150,34 @@ async def test_update_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, + default_project: Project, ) -> None: - project_id = "123" - - args = { + args: dict[str, Any] = { "name": "An updated project", - "color": 31, - "favorite": False, + "color": "red", + "is_favorite": False, } + updated_project_dict = default_project.to_dict() | args requests_mock.add( - responses.POST, f"{REST_API_BASE_URL}/projects/{project_id}", status=204 + method=responses.POST, + url=f"{DEFAULT_API_URL}/projects/{default_project.id}", + json=updated_project_dict, + status=200, + match=[auth_matcher(), data_matcher(args)], ) - response = todoist_api.update_project( - project_id=project_id, request_id=DEFAULT_REQUEST_ID, **args - ) + response = todoist_api.update_project(project_id=default_project.id, **args) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(args) - assert response is True + assert response == Project.from_dict(updated_project_dict) response = await todoist_api_async.update_project( - project_id=project_id, request_id=DEFAULT_REQUEST_ID, **args + project_id=default_project.id, **args ) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(args) - assert response is True + assert response == Project.from_dict(updated_project_dict) @pytest.mark.asyncio @@ -206,25 +186,24 @@ async def test_delete_project( todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, ) -> None: - project_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/projects/{project_id}" + project_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/projects/{project_id}" requests_mock.add( - responses.DELETE, - expected_endpoint, + method=responses.DELETE, + url=endpoint, status=204, + match=[auth_matcher()], ) response = todoist_api.delete_project(project_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert response is True response = await todoist_api_async.delete_project(project_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert response is True @@ -233,27 +212,35 @@ async def test_get_collaborators( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_collaborators_response: list[dict[str, Any]], - default_collaborators_list: list[Project], + default_collaborators_response: list[PaginatedResults], + default_collaborators_list: list[list[Collaborator]], ) -> None: - project_id = "123" - expected_endpoint = f"{REST_API_BASE_URL}/projects/{project_id}/collaborators" - - requests_mock.add( - responses.GET, - expected_endpoint, - json=default_collaborators_response, - status=200, - ) - - collaborators = todoist_api.get_collaborators(project_id) - - assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert collaborators == default_collaborators_list - - collaborators = await todoist_api_async.get_collaborators(project_id) - - assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert collaborators == default_collaborators_list + project_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/projects/{project_id}/collaborators" + + cursor: str | None = None + for page in default_collaborators_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[auth_matcher(), param_matcher({}, cursor)], + ) + cursor = page["next_cursor"] + + count = 0 + + collaborators_iter = todoist_api.get_collaborators(project_id) + + for i, collaborators in enumerate(collaborators_iter): + assert len(requests_mock.calls) == count + 1 + assert collaborators == default_collaborators_list[i] + count += 1 + + collaborators_async_iter = await todoist_api_async.get_collaborators(project_id) + + async for i, collaborators in enumerate_async(collaborators_async_iter): + assert len(requests_mock.calls) == count + 1 + assert collaborators == default_collaborators_list[i] + count += 1 diff --git a/tests/test_api_sections.py b/tests/test_api_sections.py index f74c734..e422122 100644 --- a/tests/test_api_sections.py +++ b/tests/test_api_sections.py @@ -1,18 +1,22 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any import pytest import responses -from tests.data.test_defaults import DEFAULT_REQUEST_ID, REST_API_BASE_URL -from tests.utils.test_utils import assert_auth_header, assert_request_id_header +from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults +from tests.utils.test_utils import ( + auth_matcher, + data_matcher, + enumerate_async, + param_matcher, +) if TYPE_CHECKING: from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync - from todoist_api_python.models import Section +from todoist_api_python.models import Section @pytest.mark.asyncio @@ -23,77 +27,106 @@ async def test_get_section( default_section_response: dict[str, Any], default_section: Section, ) -> None: - section_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/sections/{section_id}" + section_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/sections/{section_id}" requests_mock.add( - responses.GET, - expected_endpoint, + method=responses.GET, + url=endpoint, json=default_section_response, status=200, + match=[auth_matcher()], ) section = todoist_api.get_section(section_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert section == default_section section = await todoist_api_async.get_section(section_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert section == default_section @pytest.mark.asyncio -async def test_get_all_sections( +async def test_get_sections( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_sections_response: list[dict[str, Any]], - default_sections_list: list[Section], + default_sections_response: list[PaginatedResults], + default_sections_list: list[list[Section]], ) -> None: - requests_mock.add( - responses.GET, - f"{REST_API_BASE_URL}/sections", - json=default_sections_response, - status=200, - ) + endpoint = f"{DEFAULT_API_URL}/sections" - sections = todoist_api.get_sections() + cursor: str | None = None + for page in default_sections_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[auth_matcher(), param_matcher({}, cursor)], + ) + cursor = page["next_cursor"] - assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert sections == default_sections_list + count = 0 - sections = await todoist_api_async.get_sections() + sections_iter = todoist_api.get_sections() - assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert sections == default_sections_list + for i, sections in enumerate(sections_iter): + assert len(requests_mock.calls) == count + 1 + assert sections == default_sections_list[i] + count += 1 + + sections_async_iter = await todoist_api_async.get_sections() + + async for i, sections in enumerate_async(sections_async_iter): + assert len(requests_mock.calls) == count + 1 + assert sections == default_sections_list[i] + count += 1 @pytest.mark.asyncio -async def test_get_project_sections( +async def test_get_sections_by_project( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_sections_response: list[dict[str, Any]], + default_sections_response: list[PaginatedResults], + default_sections_list: list[list[Section]], ) -> None: project_id = "123" + endpoint = f"{DEFAULT_API_URL}/sections" - requests_mock.add( - responses.GET, - f"{REST_API_BASE_URL}/sections?project_id={project_id}", - json=default_sections_response, - status=200, - ) + cursor: str | None = None + for page in default_sections_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[ + auth_matcher(), + param_matcher({"project_id": project_id}, cursor), + ], + ) + cursor = page["next_cursor"] - todoist_api.get_sections(project_id=project_id) - await todoist_api_async.get_sections(project_id=project_id) + count = 0 - assert len(requests_mock.calls) == 2 + sections_iter = todoist_api.get_sections(project_id=project_id) + + for i, sections in enumerate(sections_iter): + assert len(requests_mock.calls) == count + 1 + assert sections == default_sections_list[i] + count += 1 + + sections_async_iter = await todoist_api_async.get_sections(project_id=project_id) + + async for i, sections in enumerate_async(sections_async_iter): + assert len(requests_mock.calls) == count + 1 + assert sections == default_sections_list[i] + count += 1 @pytest.mark.asyncio @@ -106,45 +139,33 @@ async def test_add_section( ) -> None: section_name = "A Section" project_id = "123" - order = 3 - - expected_payload: dict[str, Any] = { - "name": section_name, - "project_id": project_id, - "order": order, + args = { + "order": 3, } requests_mock.add( - responses.POST, - f"{REST_API_BASE_URL}/sections", + method=responses.POST, + url=f"{DEFAULT_API_URL}/sections", json=default_section_response, status=200, + match=[ + auth_matcher(), + data_matcher({"name": section_name, "project_id": project_id} | args), + ], ) new_section = todoist_api.add_section( - name=section_name, - project_id=project_id, - order=order, - request_id=DEFAULT_REQUEST_ID, + name=section_name, project_id=project_id, **args ) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) assert new_section == default_section new_section = await todoist_api_async.add_section( - name=section_name, - project_id=project_id, - order=order, - request_id=DEFAULT_REQUEST_ID, + name=section_name, project_id=project_id, **args ) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) assert new_section == default_section @@ -153,36 +174,32 @@ async def test_update_section( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, + default_section: Section, ) -> None: - section_id = "123" - args = { "name": "An updated section", } + updated_section_dict = default_section.to_dict() | args requests_mock.add( - responses.POST, f"{REST_API_BASE_URL}/sections/{section_id}", status=204 + method=responses.POST, + url=f"{DEFAULT_API_URL}/sections/{default_section.id}", + json=updated_section_dict, + status=200, + match=[auth_matcher(), data_matcher(args)], ) - response = todoist_api.update_section( - section_id=section_id, request_id=DEFAULT_REQUEST_ID, **args - ) + response = todoist_api.update_section(section_id=default_section.id, **args) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(args) - assert response is True + assert response == Section.from_dict(updated_section_dict) response = await todoist_api_async.update_section( - section_id=section_id, request_id=DEFAULT_REQUEST_ID, **args + section_id=default_section.id, **args ) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(args) - assert response is True + assert response == Section.from_dict(updated_section_dict) @pytest.mark.asyncio @@ -191,23 +208,22 @@ async def test_delete_section( todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, ) -> None: - section_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/sections/{section_id}" + section_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/sections/{section_id}" requests_mock.add( - responses.DELETE, - expected_endpoint, + method=responses.DELETE, + url=endpoint, status=204, + match=[auth_matcher()], ) response = todoist_api.delete_section(section_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert response is True response = await todoist_api_async.delete_section(section_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert response is True diff --git a/tests/test_api_tasks.py b/tests/test_api_tasks.py index 9d152fd..99459de 100644 --- a/tests/test_api_tasks.py +++ b/tests/test_api_tasks.py @@ -1,23 +1,22 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any -from urllib.parse import quote import pytest import responses -from tests.data.test_defaults import ( - DEFAULT_REQUEST_ID, - REST_API_BASE_URL, - SYNC_API_BASE_URL, +from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults +from tests.utils.test_utils import ( + auth_matcher, + data_matcher, + enumerate_async, + param_matcher, ) -from tests.utils.test_utils import assert_auth_header, assert_request_id_header if TYPE_CHECKING: from todoist_api_python.api import TodoistAPI from todoist_api_python.api_async import TodoistAPIAsync - from todoist_api_python.models import QuickAddResult, Task +from todoist_api_python.models import Task @pytest.mark.asyncio @@ -28,105 +27,183 @@ async def test_get_task( default_task_response: dict[str, Any], default_task: Task, ) -> None: - task_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/tasks/{task_id}" + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}" requests_mock.add( - responses.GET, - expected_endpoint, + method=responses.GET, + url=endpoint, json=default_task_response, - status=200, + match=[auth_matcher()], ) task = todoist_api.get_task(task_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert task == default_task task = await todoist_api_async.get_task(task_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert task == default_task @pytest.mark.asyncio -async def test_get_tasks_minimal( +async def test_get_tasks( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_tasks_response: list[dict[str, Any]], - default_tasks_list: list[Task], + default_tasks_response: list[PaginatedResults], + default_tasks_list: list[list[Task]], ) -> None: - requests_mock.add( - responses.GET, - f"{REST_API_BASE_URL}/tasks", - json=default_tasks_response, - status=200, - ) + endpoint = f"{DEFAULT_API_URL}/tasks" - tasks = todoist_api.get_tasks() + cursor: str | None = None + for page in default_tasks_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[auth_matcher(), param_matcher({}, cursor)], + ) + cursor = page["next_cursor"] - assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert tasks == default_tasks_list + count = 0 - tasks = await todoist_api_async.get_tasks() + tasks_iter = todoist_api.get_tasks() - assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert tasks == default_tasks_list + for i, tasks in enumerate(tasks_iter): + assert len(requests_mock.calls) == count + 1 + assert tasks == default_tasks_list[i] + count += 1 + + tasks_async_iter = await todoist_api_async.get_tasks() + + async for i, tasks in enumerate_async(tasks_async_iter): + assert len(requests_mock.calls) == count + 1 + assert tasks == default_tasks_list[i] + count += 1 @pytest.mark.asyncio -async def test_get_tasks_full( +async def test_get_tasks_with_filters( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_tasks_response: list[dict[str, Any]], - default_tasks_list: list[Task], + default_tasks_response: list[PaginatedResults], + default_tasks_list: list[list[Task]], ) -> None: - project_id = "1234" - label_id = 2345 - filter_query = "today" - lang = "en" - ids = [1, 2, 3, 4] + project_id = "123" + section_id = "456" + parent_id = "789" + label = "test-label" + ids = ["1", "2", "3"] + limit = 30 + + params: dict[str, Any] = { + "project_id": project_id, + "section_id": section_id, + "parent_id": parent_id, + "label": label, + "ids": ",".join(ids), + "limit": limit, + } - encoded_ids = quote(",".join(str(x) for x in ids)) - expected_endpoint = ( - f"{REST_API_BASE_URL}/tasks" - f"?project_id={project_id}&label_id={label_id}" - f"&filter={filter_query}&lang={lang}&ids={encoded_ids}" - ) + endpoint = f"{DEFAULT_API_URL}/tasks" - requests_mock.add( - responses.GET, expected_endpoint, json=default_tasks_response, status=200 - ) + cursor: str | None = None + for page in default_tasks_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[auth_matcher(), param_matcher(params, cursor)], + ) + cursor = page["next_cursor"] + + count = 0 - tasks = todoist_api.get_tasks( + tasks_iter = todoist_api.get_tasks( project_id=project_id, - label_id=label_id, - filter=filter_query, - lang=lang, + section_id=section_id, + parent_id=parent_id, + label=label, ids=ids, + limit=limit, ) - assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert tasks == default_tasks_list + for i, tasks in enumerate(tasks_iter): + assert len(requests_mock.calls) == count + 1 + assert tasks == default_tasks_list[i] + count += 1 - tasks = await todoist_api_async.get_tasks( + tasks_async_iter = await todoist_api_async.get_tasks( project_id=project_id, - label_id=label_id, - filter=filter_query, - lang=lang, + section_id=section_id, + parent_id=parent_id, + label=label, ids=ids, + limit=limit, ) - assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert tasks == default_tasks_list + async for i, tasks in enumerate_async(tasks_async_iter): + assert len(requests_mock.calls) == count + 1 + assert tasks == default_tasks_list[i] + count += 1 + + +@pytest.mark.asyncio +async def test_filter_tasks( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_tasks_response: list[PaginatedResults], + default_tasks_list: list[list[Task]], +) -> None: + query = "today or overdue" + lang = "en" + params = { + "query": "today or overdue", + "lang": "en", + } + + endpoint = f"{DEFAULT_API_URL}/tasks/filter" + + cursor: str | None = None + for page in default_tasks_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[auth_matcher(), param_matcher(params, cursor)], + ) + cursor = page["next_cursor"] + + count = 0 + + tasks_iter = todoist_api.filter_tasks( + query=query, + lang=lang, + ) + + for i, tasks in enumerate(tasks_iter): + assert len(requests_mock.calls) == count + 1 + assert tasks == default_tasks_list[i] + count += 1 + + # Test async iterator + tasks_async_iter = await todoist_api_async.filter_tasks( + query=query, + lang=lang, + ) + + async for i, tasks in enumerate_async(tasks_async_iter): + assert len(requests_mock.calls) == count + 1 + assert tasks == default_tasks_list[i] + count += 1 @pytest.mark.asyncio @@ -137,32 +214,24 @@ async def test_add_task_minimal( default_task_response: dict[str, Any], default_task: Task, ) -> None: - task_content = "Some content" - expected_payload = {"content": task_content} + content = "Some content" requests_mock.add( - responses.POST, - f"{REST_API_BASE_URL}/tasks", + method=responses.POST, + url=f"{DEFAULT_API_URL}/tasks", json=default_task_response, status=200, + match=[auth_matcher(), data_matcher({"content": content})], ) - new_task = todoist_api.add_task(content=task_content, request_id=DEFAULT_REQUEST_ID) + new_task = todoist_api.add_task(content=content) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) assert new_task == default_task - new_task = await todoist_api_async.add_task( - content=task_content, request_id=DEFAULT_REQUEST_ID - ) + new_task = await todoist_api_async.add_task(content=content) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) assert new_task == default_task @@ -174,211 +243,204 @@ async def test_add_task_full( default_task_response: dict[str, Any], default_task: Task, ) -> None: - task_content = "Some content" - - optional_args = { + content = "Some content" + args: dict[str, Any] = { "description": "A description", - "project_id": 123, - "section_id": 456, - "parent_id": 789, - "order": 3, - "label_ids": [123, 456], + "project_id": "123", + "section_id": "456", + "parent_id": "789", + "labels": ["label1", "label2"], "priority": 4, "due_string": "today", "due_date": "2021-01-01", "due_datetime": "2021-01-01T11:00:00Z", - "due_lang": "ja", - "assignee": 321, + "due_lang": "en", + "assignee_id": "321", + "order": 3, + "auto_reminder": True, + "auto_parse_labels": True, + "duration": 60, + "duration_unit": "minute", } - expected_payload: dict[str, Any] = {"content": task_content} - expected_payload.update(optional_args) - requests_mock.add( - responses.POST, - f"{REST_API_BASE_URL}/tasks", + method=responses.POST, + url=f"{DEFAULT_API_URL}/tasks", json=default_task_response, status=200, + match=[auth_matcher(), data_matcher({"content": content} | args)], ) - new_task = todoist_api.add_task( - content=task_content, request_id=DEFAULT_REQUEST_ID, **optional_args - ) + new_task = todoist_api.add_task(content=content, **args) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) assert new_task == default_task - new_task = await todoist_api_async.add_task( - content=task_content, request_id=DEFAULT_REQUEST_ID, **optional_args - ) + new_task = await todoist_api_async.add_task(content=content, **args) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) assert new_task == default_task @pytest.mark.asyncio -async def test_update_task( +async def test_add_task_quick( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, + default_task_meta_response: dict[str, Any], + default_task_meta: Task, ) -> None: - task_id = "123" - - args = { - "content": "Some updated content", - "description": "An updated description", - "label_ids": ["123", "456"], - "priority": 4, - "due_string": "today", - "due_date": "2021-01-01", - "due_datetime": "2021-01-01T11:00:00Z", - "due_lang": "ja", - "assignee": "321", - } + text = "Buy milk tomorrow at 9am #Shopping @errands" + note = "Whole milk x6" + auto_reminder = True requests_mock.add( - responses.POST, f"{REST_API_BASE_URL}/tasks/{task_id}", status=204 + method=responses.POST, + url=f"{DEFAULT_API_URL}/tasks/quick", + json=default_task_meta_response, + status=200, + match=[ + auth_matcher(), + data_matcher( + { + "meta": True, + "text": text, + "auto_reminder": auto_reminder, + "note": note, + } + ), + ], ) - response = todoist_api.update_task( - task_id=task_id, request_id=DEFAULT_REQUEST_ID, **args + task = todoist_api.add_task_quick( + text=text, + note=note, + auto_reminder=auto_reminder, ) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert_request_id_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(args) - assert response is True + assert task == default_task_meta - response = await todoist_api_async.update_task( - task_id=task_id, request_id=DEFAULT_REQUEST_ID, **args + task = await todoist_api_async.add_task_quick( + text=text, + note=note, + auto_reminder=auto_reminder, ) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert_request_id_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(args) - assert response is True + assert task == default_task_meta @pytest.mark.asyncio -async def test_close_task( +async def test_update_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, + default_task: Task, ) -> None: - task_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/tasks/{task_id}/close" + args: dict[str, Any] = { + "content": "Updated content", + "description": "Updated description", + "labels": ["label1", "label2"], + "priority": 2, + } + updated_task_dict = default_task.to_dict() | args requests_mock.add( - responses.POST, - expected_endpoint, - status=204, + method=responses.POST, + url=f"{DEFAULT_API_URL}/tasks/{default_task.id}", + json=updated_task_dict, + status=200, + match=[auth_matcher(), data_matcher(args)], ) - response = todoist_api.close_task(task_id) + response = todoist_api.update_task(task_id=default_task.id, **args) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert response is True + assert response == Task.from_dict(updated_task_dict) - response = await todoist_api_async.close_task(task_id) + response = await todoist_api_async.update_task(task_id=default_task.id, **args) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert response is True + assert response == Task.from_dict(updated_task_dict) @pytest.mark.asyncio -async def test_reopen_task( +async def test_complete_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, ) -> None: - task_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/tasks/{task_id}/reopen" + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}/close" requests_mock.add( - responses.POST, - expected_endpoint, + method=responses.POST, + url=endpoint, status=204, + match=[auth_matcher()], ) - response = todoist_api.reopen_task(task_id) + response = todoist_api.complete_task(task_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert response is True - response = await todoist_api_async.reopen_task(task_id) + response = await todoist_api_async.complete_task(task_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert response is True @pytest.mark.asyncio -async def test_delete_task( +async def test_uncomplete_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, ) -> None: - task_id = "1234" - expected_endpoint = f"{REST_API_BASE_URL}/tasks/{task_id}" + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}/reopen" requests_mock.add( - responses.DELETE, - expected_endpoint, + method=responses.POST, + url=endpoint, status=204, + match=[auth_matcher()], ) - response = todoist_api.delete_task(task_id) + response = todoist_api.uncomplete_task(task_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) assert response is True - response = await todoist_api_async.delete_task(task_id) + response = await todoist_api_async.uncomplete_task(task_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) assert response is True @pytest.mark.asyncio -async def test_quick_add_task( +async def test_delete_task( todoist_api: TodoistAPI, todoist_api_async: TodoistAPIAsync, requests_mock: responses.RequestsMock, - default_quick_add_response: dict[str, Any], - default_quick_add_result: QuickAddResult, ) -> None: - text = "some task" - expected_payload = {"text": text, "meta": True, "auto_reminder": True} + task_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/tasks/{task_id}" requests_mock.add( - responses.POST, - f"{SYNC_API_BASE_URL}/quick/add", - json=default_quick_add_response, - status=200, + method=responses.DELETE, + url=endpoint, + status=204, + match=[auth_matcher()], ) - response = todoist_api.quick_add_task(text=text) + response = todoist_api.delete_task(task_id) assert len(requests_mock.calls) == 1 - assert_auth_header(requests_mock.calls[0].request) - assert requests_mock.calls[0].request.body == json.dumps(expected_payload) - assert response == default_quick_add_result + assert response is True - response = await todoist_api_async.quick_add_task(text=text) + response = await todoist_api_async.delete_task(task_id) assert len(requests_mock.calls) == 2 - assert_auth_header(requests_mock.calls[1].request) - assert requests_mock.calls[1].request.body == json.dumps(expected_payload) - assert response == default_quick_add_result + assert response is True diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 1b42110..cadefbd 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,13 +1,14 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any from urllib.parse import quote import pytest import responses -from tests.data.test_defaults import AUTH_BASE_URL +from tests.data.test_defaults import DEFAULT_OAUTH_URL +from tests.utils.test_utils import data_matcher, param_matcher +from todoist_api_python._core.endpoints import API_URL # Use new base URL from todoist_api_python.authentication import ( get_auth_token, get_auth_token_async, @@ -15,7 +16,6 @@ revoke_auth_token, revoke_auth_token_async, ) -from todoist_api_python.endpoints import SYNC_API if TYPE_CHECKING: from todoist_api_python.models import AuthResult @@ -29,7 +29,7 @@ def test_get_authentication_url() -> None: f"client_id={client_id}&scope={scopes[0]},{scopes[1]},{scopes[2]}&state={state}" ) query = quote(params, safe="=&") - expected_url = f"{AUTH_BASE_URL}/oauth/authorize?{query}" + expected_url = f"{DEFAULT_OAUTH_URL}/authorize?{query}" url = get_authentication_url(client_id, scopes, state) @@ -46,27 +46,26 @@ async def test_get_auth_token( client_secret = "456" code = "789" - expected_payload = json.dumps( - {"client_id": client_id, "client_secret": client_secret, "code": code} - ) - requests_mock.add( responses.POST, - f"{AUTH_BASE_URL}/oauth/access_token", + f"{DEFAULT_OAUTH_URL}/access_token", json=default_auth_response, status=200, + match=[ + data_matcher( + {"client_id": client_id, "client_secret": client_secret, "code": code} + ) + ], ) auth_result = get_auth_token(client_id, client_secret, code) assert len(requests_mock.calls) == 1 - assert requests_mock.calls[0].request.body == expected_payload assert auth_result == default_auth_result auth_result = await get_auth_token_async(client_id, client_secret, code) assert len(requests_mock.calls) == 2 - assert requests_mock.calls[1].request.body == expected_payload assert auth_result == default_auth_result @@ -78,24 +77,27 @@ async def test_revoke_auth_token( client_secret = "456" token = "AToken" - expected_payload = json.dumps( - {"client_id": client_id, "client_secret": client_secret, "access_token": token} - ) - requests_mock.add( - responses.POST, - f"{SYNC_API}access_tokens/revoke", - status=204, + responses.DELETE, + f"{API_URL}/access_tokens", + match=[ + param_matcher( + { + "client_id": client_id, + "client_secret": client_secret, + "access_token": token, + } + ) + ], + status=200, ) result = revoke_auth_token(client_id, client_secret, token) assert len(requests_mock.calls) == 1 - assert requests_mock.calls[0].request.body == expected_payload assert result is True result = await revoke_auth_token_async(client_id, client_secret, token) assert len(requests_mock.calls) == 2 - assert requests_mock.calls[1].request.body == expected_payload assert result is True diff --git a/tests/test_headers.py b/tests/test_http_headers.py similarity index 67% rename from tests/test_headers.py rename to tests/test_http_headers.py index 2564e81..98a2d33 100644 --- a/tests/test_headers.py +++ b/tests/test_http_headers.py @@ -1,6 +1,6 @@ from __future__ import annotations -from todoist_api_python.headers import create_headers +from todoist_api_python._core.http_headers import create_headers def test_create_headers_none() -> None: @@ -14,12 +14,6 @@ def test_create_headers_authorization() -> None: assert headers["Authorization"] == f"Bearer {token}" -def test_create_headers_request_id() -> None: - request_id = "12345" - headers = create_headers(request_id=request_id) - assert headers["X-Request-Id"] == request_id - - def test_create_headers_content_type() -> None: headers = create_headers(with_content=True) assert headers["Content-Type"] == "application/json; charset=utf-8" diff --git a/tests/test_http_requests.py b/tests/test_http_requests.py index 2f477ba..3920289 100644 --- a/tests/test_http_requests.py +++ b/tests/test_http_requests.py @@ -6,134 +6,128 @@ import pytest import responses from requests import HTTPError, Session +from responses.matchers import query_param_matcher from tests.data.test_defaults import DEFAULT_TOKEN -from todoist_api_python.endpoints import BASE_URL, TASKS_ENDPOINT -from todoist_api_python.http_requests import delete, get, post +from tests.utils.test_utils import auth_matcher, param_matcher +from todoist_api_python._core.http_requests import delete, get, post -DEFAULT_URL = f"{BASE_URL}/{TASKS_ENDPOINT}" +EXAMPLE_URL = "https://example.com/" +EXAMPLE_PARAMS = {"param1": "value1", "param2": "value2"} +EXAMPLE_DATA = {"param3": "value31", "param4": "value4"} +EXAMPLE_RESPONSE = {"result": "ok"} @responses.activate def test_get_with_params(default_task_response: dict[str, Any]) -> None: - params = {"param1": "value1", "param2": "value2"} - responses.add( - responses.GET, - DEFAULT_URL, - json=default_task_response, + method=responses.GET, + url=EXAMPLE_URL, + json=EXAMPLE_RESPONSE, status=200, + match=[auth_matcher(), param_matcher(EXAMPLE_PARAMS)], ) - response = get(Session(), DEFAULT_URL, DEFAULT_TOKEN, params) + response: dict[str, Any] = get( + session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN, params=EXAMPLE_PARAMS + ) assert len(responses.calls) == 1 - assert ( - responses.calls[0].request.url == f"{DEFAULT_URL}?param1=value1¶m2=value2" - ) - assert ( - responses.calls[0].request.headers["Authorization"] == f"Bearer {DEFAULT_TOKEN}" - ) - assert response == default_task_response + assert response == EXAMPLE_RESPONSE @responses.activate def test_get_raise_for_status() -> None: responses.add( - responses.GET, - DEFAULT_URL, + method=responses.GET, + url=EXAMPLE_URL, json="", status=500, ) with pytest.raises(HTTPError) as error_info: - get(Session(), DEFAULT_URL, DEFAULT_TOKEN) + get(Session(), EXAMPLE_URL, DEFAULT_TOKEN) assert error_info.value.response.content == b'""' @responses.activate def test_post_with_data(default_task_response: dict[str, Any]) -> None: - request_id = "12345" - - data = {"param1": "value1", "param2": "value2", "request_id": request_id} - responses.add( - responses.POST, - DEFAULT_URL, - json=default_task_response, + method=responses.POST, + url=EXAMPLE_URL, + json=EXAMPLE_RESPONSE, status=200, ) - response = post(Session(), DEFAULT_URL, DEFAULT_TOKEN, data) + response: dict[str, Any] = post( + session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN, data=EXAMPLE_DATA + ) assert len(responses.calls) == 1 - assert responses.calls[0].request.url == DEFAULT_URL + assert responses.calls[0].request.url == EXAMPLE_URL assert ( responses.calls[0].request.headers["Authorization"] == f"Bearer {DEFAULT_TOKEN}" ) - assert responses.calls[0].request.headers["X-Request-Id"] == request_id assert ( responses.calls[0].request.headers["Content-Type"] == "application/json; charset=utf-8" ) - assert responses.calls[0].request.body == json.dumps(data) - assert response == default_task_response + assert responses.calls[0].request.body == json.dumps(EXAMPLE_DATA) + assert response == EXAMPLE_RESPONSE @responses.activate def test_post_return_ok_when_no_response_body() -> None: responses.add( - responses.POST, - DEFAULT_URL, + method=responses.POST, + url=EXAMPLE_URL, status=204, ) - result = post(Session(), DEFAULT_URL, DEFAULT_TOKEN) - + result: bool = post(session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN) assert result is True @responses.activate def test_post_raise_for_status() -> None: responses.add( - responses.POST, - DEFAULT_URL, + method=responses.POST, + url=EXAMPLE_URL, status=500, ) with pytest.raises(HTTPError): - post(Session(), DEFAULT_URL, DEFAULT_TOKEN) + post(session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN) @responses.activate -def test_delete_with_request_id() -> None: - request_id = "12345" - +def test_delete_with_params() -> None: responses.add( - responses.DELETE, - DEFAULT_URL, + method=responses.DELETE, + url=EXAMPLE_URL, status=204, + match=[auth_matcher(), query_param_matcher(EXAMPLE_PARAMS)], ) - result = delete(Session(), DEFAULT_URL, DEFAULT_TOKEN, {"request_id": request_id}) + result = delete( + session=Session(), + url=EXAMPLE_URL, + token=DEFAULT_TOKEN, + params=EXAMPLE_PARAMS, + ) assert len(responses.calls) == 1 - assert responses.calls[0].request.url == DEFAULT_URL - assert ( - responses.calls[0].request.headers["Authorization"] == f"Bearer {DEFAULT_TOKEN}" - ) - assert responses.calls[0].request.headers["X-Request-Id"] == request_id assert result is True @responses.activate def test_delete_raise_for_status() -> None: responses.add( - responses.DELETE, - DEFAULT_URL, + method=responses.DELETE, + url=EXAMPLE_URL, status=500, ) with pytest.raises(HTTPError): - delete(Session(), DEFAULT_URL, DEFAULT_TOKEN) + delete(session=Session(), url=EXAMPLE_URL, token=DEFAULT_TOKEN) diff --git a/tests/test_models.py b/tests/test_models.py index 2f89c7b..c023f0f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,20 +1,14 @@ from __future__ import annotations -from tests.data.quick_add_responses import ( - QUICK_ADD_RESPONSE_FULL, - QUICK_ADD_RESPONSE_MINIMAL, -) from tests.data.test_defaults import ( DEFAULT_ATTACHMENT_RESPONSE, DEFAULT_COLLABORATOR_RESPONSE, DEFAULT_COMMENT_RESPONSE, - DEFAULT_COMPLETED_ITEMS_RESPONSE, DEFAULT_DUE_RESPONSE, DEFAULT_DURATION_RESPONSE, - DEFAULT_ITEM_COMPLETED_INFO_RESPONSE, - DEFAULT_ITEM_RESPONSE, DEFAULT_LABEL_RESPONSE, DEFAULT_PROJECT_RESPONSE, + DEFAULT_PROJECT_RESPONSE_2, DEFAULT_SECTION_RESPONSE, DEFAULT_TASK_RESPONSE, ) @@ -23,14 +17,10 @@ AuthResult, Collaborator, Comment, - CompletedItems, Due, Duration, - Item, - ItemCompletedInfo, Label, Project, - QuickAddResult, Section, Task, ) @@ -38,59 +28,6 @@ unexpected_data = {"unexpected_key": "some value"} -def test_project_from_dict() -> None: - sample_data = dict(DEFAULT_PROJECT_RESPONSE) - sample_data.update(unexpected_data) - - project = Project.from_dict(sample_data) - - assert project.color == sample_data["color"] - assert project.comment_count == sample_data["comment_count"] - assert project.id == sample_data["id"] - assert project.is_favorite == sample_data["is_favorite"] - assert project.is_inbox_project == sample_data["is_inbox_project"] - assert project.is_shared == sample_data["is_shared"] - assert project.is_team_inbox == sample_data["is_team_inbox"] - assert project.can_assign_tasks == sample_data["can_assign_tasks"] - assert project.name == sample_data["name"] - assert project.order == sample_data["order"] - assert project.parent_id == sample_data["parent_id"] - assert project.url == sample_data["url"] - assert project.view_style == sample_data["view_style"] - -def test_project_to_dict(): - sample_data = dict(DEFAULT_PROJECT_RESPONSE) - sample_data.update(unexpected_data) - - project = Project.from_dict(sample_data).to_dict() - - assert project["color"] == sample_data["color"] - assert project["comment_count"] == sample_data["comment_count"] - assert project["id"] == sample_data["id"] - assert project["is_favorite"] == sample_data["is_favorite"] - assert project["is_inbox_project"] == sample_data["is_inbox_project"] - assert project["is_shared"] == sample_data["is_shared"] - assert project["is_team_inbox"] == sample_data["is_team_inbox"] - assert project["can_assign_tasks"] == sample_data["can_assign_tasks"] - assert project["name"] == sample_data["name"] - assert project["order"] == sample_data["order"] - assert project["parent_id"] == sample_data["parent_id"] - assert project["url"] == sample_data["url"] - assert project["view_style"] == sample_data["view_style"] - - -def test_section_from_dict() -> None: - sample_data = dict(DEFAULT_SECTION_RESPONSE) - sample_data.update(unexpected_data) - - section = Section.from_dict(sample_data) - - assert section.id == sample_data["id"] - assert section.name == sample_data["name"] - assert section.order == sample_data["order"] - assert section.project_id == sample_data["project_id"] - - def test_due_from_dict() -> None: sample_data = dict(DEFAULT_DUE_RESPONSE) sample_data.update(unexpected_data) @@ -98,10 +35,10 @@ def test_due_from_dict() -> None: due = Due.from_dict(sample_data) assert due.date == sample_data["date"] - assert due.is_recurring == sample_data["is_recurring"] - assert due.string == sample_data["string"] - assert due.datetime == sample_data["datetime"] assert due.timezone == sample_data["timezone"] + assert due.string == sample_data["string"] + assert due.lang == sample_data["lang"] + assert due.is_recurring == sample_data["is_recurring"] def test_duration_from_dict() -> None: @@ -114,56 +51,81 @@ def test_duration_from_dict() -> None: assert duration.unit == sample_data["unit"] +def test_project_from_dict() -> None: + sample_data = dict(DEFAULT_PROJECT_RESPONSE) + sample_data.update(unexpected_data) + + project = Project.from_dict(sample_data) + + assert project.id == sample_data["id"] + assert project.name == sample_data["name"] + assert project.description == sample_data["description"] + assert project.parent_id == sample_data["parent_id"] + assert project.folder_id == sample_data["folder_id"] + 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_favorite == sample_data["is_favorite"] + assert project.is_inbox_project == sample_data["is_inbox_project"] + assert project.can_assign_tasks == sample_data["can_assign_tasks"] + assert project.view_style == sample_data["view_style"] + assert project.created_at == sample_data["created_at"] + assert project.updated_at == sample_data["updated_at"] + + +def test_project_url() -> None: + inbox = Project.from_dict(dict(DEFAULT_PROJECT_RESPONSE)) + assert inbox.url == "https://app.todoist.com/app/inbox" + project = Project.from_dict(dict(DEFAULT_PROJECT_RESPONSE_2)) + assert project.url == "https://app.todoist.com/app/project/inbox-6X7rfFVPjhvv84XG" + + def test_task_from_dict() -> None: sample_data = dict(DEFAULT_TASK_RESPONSE) sample_data.update(unexpected_data) task = Task.from_dict(sample_data) - assert task.comment_count == sample_data["comment_count"] - assert task.is_completed == sample_data["is_completed"] - assert task.content == sample_data["content"] - assert task.created_at == sample_data["created_at"] - assert task.creator_id == sample_data["creator_id"] assert task.id == sample_data["id"] + assert task.content == sample_data["content"] + assert task.description == sample_data["description"] assert task.project_id == sample_data["project_id"] assert task.section_id == sample_data["section_id"] + assert task.parent_id == sample_data["parent_id"] + assert task.labels == sample_data["labels"] assert task.priority == sample_data["priority"] - assert task.url == sample_data["url"] - assert task.assignee_id == sample_data["assignee_id"] - assert task.assigner_id == sample_data["assigner_id"] assert task.due == Due.from_dict(sample_data["due"]) - assert task.labels == sample_data["labels"] - assert task.order == sample_data["order"] - assert task.parent_id == sample_data["parent_id"] assert task.duration == Duration.from_dict(sample_data["duration"]) + assert task.is_collapsed == sample_data["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"] + assert task.completed_at == sample_data["completed_at"] + assert task.creator_id == sample_data["added_by_uid"] + assert task.created_at == sample_data["added_at"] + assert task.updated_at == sample_data["updated_at"] + + +def test_task_url() -> None: + task = Task.from_dict(dict(DEFAULT_TASK_RESPONSE)) + assert ( + task.url + == "https://app.todoist.com/app/task/some-task-content-6X7rM8997g3RQmvh" + ) -def test_task_to_dict() -> None: - sample_data = dict(DEFAULT_TASK_RESPONSE) +def test_section_from_dict() -> None: + sample_data = dict(DEFAULT_SECTION_RESPONSE) sample_data.update(unexpected_data) - task = Task.from_dict(sample_data).to_dict() - - assert task["comment_count"] == sample_data["comment_count"] - assert task["is_completed"] == sample_data["is_completed"] - assert task["content"] == sample_data["content"] - assert task["created_at"] == sample_data["created_at"] - assert task["creator_id"] == sample_data["creator_id"] - assert task["id"] == sample_data["id"] - assert task["project_id"] == sample_data["project_id"] - assert task["section_id"] == sample_data["section_id"] - assert task["priority"] == sample_data["priority"] - assert task["url"] == sample_data["url"] - assert task["assignee_id"] == sample_data["assignee_id"] - assert task["assigner_id"] == sample_data["assigner_id"] - for key in task["due"]: - assert task["due"][key] == sample_data["due"][key] - assert task["labels"] == sample_data["labels"] - assert task["order"] == sample_data["order"] - assert task["parent_id"] == sample_data["parent_id"] - for key in task["duration"]: - assert task["duration"][key] == sample_data["duration"][key] + section = Section.from_dict(sample_data) + + 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"] def test_collaborator_from_dict() -> None: @@ -204,6 +166,7 @@ def test_comment_from_dict() -> None: assert comment.id == sample_data["id"] assert comment.content == sample_data["content"] + assert comment.poster_id == sample_data["posted_uid"] assert comment.posted_at == sample_data["posted_at"] assert comment.task_id == sample_data["task_id"] assert comment.project_id == sample_data["project_id"] @@ -223,122 +186,6 @@ def test_label_from_dict() -> None: assert label.is_favorite == sample_data["is_favorite"] -def test_quick_add_result_minimal() -> None: - sample_data = dict(QUICK_ADD_RESPONSE_MINIMAL) - sample_data.update(unexpected_data) - - quick_add_result = QuickAddResult.from_quick_add_response(sample_data) - - assert quick_add_result.task.comment_count == 0 - assert quick_add_result.task.is_completed is False - assert quick_add_result.task.content == "some task" - assert quick_add_result.task.created_at == "2021-02-05T11:02:56.00000Z" - assert quick_add_result.task.creator_id == "21180723" - assert quick_add_result.task.id == "4554989047" - assert quick_add_result.task.project_id == "2203108698" - assert quick_add_result.task.section_id is None - assert quick_add_result.task.priority == 1 - assert quick_add_result.task.url == "https://todoist.com/showTask?id=4554989047" - assert quick_add_result.task.assignee_id is None - assert quick_add_result.task.assigner_id is None - assert quick_add_result.task.due is None - assert quick_add_result.task.labels == [] - assert quick_add_result.task.order == 6 - assert quick_add_result.task.parent_id is None - assert quick_add_result.task.sync_id is None - - assert quick_add_result.resolved_assignee_name is None - assert quick_add_result.resolved_label_names == [] - assert quick_add_result.resolved_project_name is None - assert quick_add_result.resolved_section_name is None - - -def test_quick_add_result_full() -> None: - sample_data = dict(QUICK_ADD_RESPONSE_FULL) - sample_data.update(unexpected_data) - - quick_add_result = QuickAddResult.from_quick_add_response(sample_data) - - assert quick_add_result.task.comment_count == 0 - assert quick_add_result.task.is_completed is False - assert quick_add_result.task.content == "some task" - assert quick_add_result.task.created_at == "2021-02-05T11:04:54.00000Z" - assert quick_add_result.task.creator_id == "21180723" - assert quick_add_result.task.id == "4554993687" - assert quick_add_result.task.project_id == "2257514220" - assert quick_add_result.task.section_id == "2232454220" - assert quick_add_result.task.priority == 1 - assert ( - quick_add_result.task.url - == "https://todoist.com/showTask?id=4554993687&sync_id=4554993687" - ) - assert quick_add_result.task.assignee_id == "29172386" - assert quick_add_result.task.assigner_id == "21180723" - assert quick_add_result.task.due.date == "2021-02-06T11:00:00.00000Z" - assert quick_add_result.task.due.is_recurring is False - assert quick_add_result.task.due.string == "Feb 6 11:00 AM" - assert quick_add_result.task.due.datetime == "2021-02-06T11:00:00.00000Z" - assert quick_add_result.task.due.timezone == "Europe/London" - assert quick_add_result.task.labels == ["Label1", "Label2"] - assert quick_add_result.task.order == 1 - assert quick_add_result.task.parent_id is None - assert quick_add_result.task.sync_id == "4554993687" - - assert quick_add_result.resolved_assignee_name == "Some Guy" - assert quick_add_result.resolved_label_names == ["Label1", "Label2"] - assert quick_add_result.resolved_project_name == "test" - assert quick_add_result.resolved_section_name == "A section" - - -def test_quick_add_broken_data() -> None: - none_attribute = QUICK_ADD_RESPONSE_FULL.copy() - missing_attribute = QUICK_ADD_RESPONSE_FULL.copy() - - none_attribute["meta"]["project"] = None - none_attribute["meta"]["assignee"] = None - none_attribute["meta"]["section"] = None - - del missing_attribute["meta"]["project"] - del missing_attribute["meta"]["assignee"] - del missing_attribute["meta"]["section"] - - for quick_add_responses in [none_attribute, missing_attribute]: - sample_data = dict(quick_add_responses) - sample_data.update(unexpected_data) - - quick_add_result = QuickAddResult.from_quick_add_response(sample_data) - - assert quick_add_result.task.comment_count == 0 - assert quick_add_result.task.is_completed is False - assert quick_add_result.task.content == "some task" - assert quick_add_result.task.created_at == "2021-02-05T11:04:54.00000Z" - assert quick_add_result.task.creator_id == "21180723" - assert quick_add_result.task.id == "4554993687" - assert quick_add_result.task.project_id == "2257514220" - assert quick_add_result.task.section_id == "2232454220" - assert quick_add_result.task.priority == 1 - assert ( - quick_add_result.task.url - == "https://todoist.com/showTask?id=4554993687&sync_id=4554993687" - ) - assert quick_add_result.task.assignee_id == "29172386" - assert quick_add_result.task.assigner_id == "21180723" - assert quick_add_result.task.due.date == "2021-02-06T11:00:00.00000Z" - assert quick_add_result.task.due.is_recurring is False - assert quick_add_result.task.due.string == "Feb 6 11:00 AM" - assert quick_add_result.task.due.datetime == "2021-02-06T11:00:00.00000Z" - assert quick_add_result.task.due.timezone == "Europe/London" - assert quick_add_result.task.labels == ["Label1", "Label2"] - assert quick_add_result.task.order == 1 - assert quick_add_result.task.parent_id is None - assert quick_add_result.task.sync_id == "4554993687" - - assert quick_add_result.resolved_assignee_name is None - assert quick_add_result.resolved_label_names == ["Label1", "Label2"] - assert quick_add_result.resolved_project_name is None - assert quick_add_result.resolved_section_name is None - - def test_auth_result_from_dict() -> None: token = "123" state = "456" @@ -349,87 +196,3 @@ def test_auth_result_from_dict() -> None: assert auth_result.access_token == token assert auth_result.state == state - - -def test_item_from_dict() -> None: - sample_data = dict(DEFAULT_ITEM_RESPONSE) - sample_data.update(unexpected_data) - - item = Item.from_dict(sample_data) - - assert item.id == "2995104339" - assert item.user_id == "2671355" - assert item.project_id == "2203306141" - assert item.content == "Buy Milk" - assert item.description == "" - assert item.priority == 1 - assert item.due.date == DEFAULT_DUE_RESPONSE["date"] - assert item.due.is_recurring == DEFAULT_DUE_RESPONSE["is_recurring"] - assert item.due.string == DEFAULT_DUE_RESPONSE["string"] - assert item.due.datetime == DEFAULT_DUE_RESPONSE["datetime"] - assert item.due.timezone == DEFAULT_DUE_RESPONSE["timezone"] - assert item.parent_id is None - assert item.child_order == 1 - assert item.section_id is None - assert item.day_order == -1 - assert item.collapsed is False - assert item.labels == ["Food", "Shopping"] - assert item.added_by_uid == "2671355" - assert item.assigned_by_uid == "2671355" - assert item.responsible_uid is None - assert item.checked is False - assert item.is_deleted is False - assert item.sync_id is None - assert item.added_at == "2014-09-26T08:25:05.000000Z" - - -def test_item_completed_info_from_dict() -> None: - sample_data = dict(DEFAULT_ITEM_COMPLETED_INFO_RESPONSE) - sample_data.update(unexpected_data) - - info = ItemCompletedInfo.from_dict(sample_data) - - assert info.item_id == "2995104339" - assert info.completed_items == 12 - - -def test_completed_items_from_dict() -> None: - sample_data = dict(DEFAULT_COMPLETED_ITEMS_RESPONSE) - sample_data.update(unexpected_data) - - completed_items = CompletedItems.from_dict(sample_data) - - assert completed_items.total == 22 - assert completed_items.next_cursor == "k85gVI5ZAs8AAAABFoOzAQ" - assert completed_items.has_more is True - assert len(completed_items.items) == 1 - assert completed_items.items[0].id == "2995104339" - assert completed_items.items[0].user_id == "2671355" - assert completed_items.items[0].project_id == "2203306141" - assert completed_items.items[0].content == "Buy Milk" - assert completed_items.items[0].description == "" - assert completed_items.items[0].priority == 1 - assert completed_items.items[0].due.date == DEFAULT_DUE_RESPONSE["date"] - assert ( - completed_items.items[0].due.is_recurring - == DEFAULT_DUE_RESPONSE["is_recurring"] - ) - assert completed_items.items[0].due.string == DEFAULT_DUE_RESPONSE["string"] - assert completed_items.items[0].due.datetime == DEFAULT_DUE_RESPONSE["datetime"] - assert completed_items.items[0].due.timezone == DEFAULT_DUE_RESPONSE["timezone"] - assert completed_items.items[0].parent_id is None - assert completed_items.items[0].child_order == 1 - assert completed_items.items[0].section_id is None - assert completed_items.items[0].day_order == -1 - assert completed_items.items[0].collapsed is False - assert completed_items.items[0].labels == ["Food", "Shopping"] - assert completed_items.items[0].added_by_uid == "2671355" - assert completed_items.items[0].assigned_by_uid == "2671355" - assert completed_items.items[0].responsible_uid is None - assert completed_items.items[0].checked is False - assert completed_items.items[0].is_deleted is False - assert completed_items.items[0].sync_id is None - assert completed_items.items[0].added_at == "2014-09-26T08:25:05.000000Z" - assert len(completed_items.completed_info) == 1 - assert completed_items.completed_info[0].item_id == "2995104339" - assert completed_items.completed_info[0].completed_items == 12 diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index cbd1648..9060c78 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -1,29 +1,50 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, TypeVar -from tests.data.test_defaults import DEFAULT_REQUEST_ID, DEFAULT_TOKEN +from responses import matchers + +from tests.data.test_defaults import ( + DEFAULT_TOKEN, +) from todoist_api_python.api import TodoistAPI if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import AsyncIterable, AsyncIterator, Callable - from requests import Request MATCH_ANY_REGEX = re.compile(".*") -def assert_auth_header(request: Request) -> None: - assert request.headers["Authorization"] == f"Bearer {DEFAULT_TOKEN}" +def auth_matcher() -> Callable[..., Any]: + return matchers.header_matcher({"Authorization": f"Bearer {DEFAULT_TOKEN}"}) + +def param_matcher( + params: dict[str, str], cursor: str | None = None +) -> Callable[..., Any]: + return matchers.query_param_matcher(params | ({"cursor": cursor} if cursor else {})) -def assert_request_id_header(request: Request) -> None: - assert request.headers["X-Request-Id"] == DEFAULT_REQUEST_ID +def data_matcher(data: dict[str, Any]) -> Callable[..., Any]: + return matchers.json_params_matcher(data) -def get_todoist_api_patch(method: Callable | None) -> str: + +def get_todoist_api_patch(method: Callable[..., Any] | None) -> str: module = TodoistAPI.__module__ name = TodoistAPI.__qualname__ return f"{module}.{name}.{method.__name__}" if method else f"{module}.{name}" + + +T = TypeVar("T") + + +async def enumerate_async( + iterable: AsyncIterable[T], start: int = 0 +) -> AsyncIterator[tuple[int, T]]: + index = start + async for value in iterable: + yield index, value + index += 1 diff --git a/todoist_api_python/_core/__init__.py b/todoist_api_python/_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/todoist_api_python/_core/endpoints.py b/todoist_api_python/_core/endpoints.py new file mode 100644 index 0000000..c9a40df --- /dev/null +++ b/todoist_api_python/_core/endpoints.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import re +import unicodedata + +API_VERSION = "v1" + +API_URL = f"https://api.todoist.com/api/{API_VERSION}" +OAUTH_URL = "https://todoist.com/oauth" +PROJECT_URL = "https://app.todoist.com/app/project" +INBOX_URL = "https://app.todoist.com/app/inbox" +TASK_URL = "https://app.todoist.com/app/task" + +TASKS_PATH = "tasks" +TASKS_FILTER_PATH = "tasks/filter" +TASKS_QUICK_ADD_PATH = "tasks/quick" +PROJECTS_PATH = "projects" +COLLABORATORS_PATH = "collaborators" +SECTIONS_PATH = "sections" +COMMENTS_PATH = "comments" +LABELS_PATH = "labels" +SHARED_LABELS_PATH = "labels/shared" +SHARED_LABELS_RENAME_PATH = f"{SHARED_LABELS_PATH}/rename" +SHARED_LABELS_REMOVE_PATH = f"{SHARED_LABELS_PATH}/remove" + +AUTHORIZE_PATH = "authorize" +ACCESS_TOKEN_PATH = "access_token" # noqa: S105 +ACCESS_TOKENS_PATH = "access_tokens" + + +def get_oauth_url(relative_path: str) -> str: + """ + Generate the URL for a given OAuth endpoint. + + :param relative_path: The relative path of the endpoint. + :return: The URL string for the OAuth endpoint. + """ + return f"{OAUTH_URL}/{relative_path}" + + +def get_api_url(relative_path: str) -> str: + """ + Generate the URL for a given API endpoint. + + :param relative_path: The relative path of the endpoint. + :return: The URL string for the API endpoint. + """ + return f"{API_URL}/{relative_path}" + + +def get_task_url(task_id: str, content: str | None = None) -> str: + """ + Generate the URL for a given task. + + :param task_id: The ID of the task. + :param content: The content of the task. + :return: The URL string for the task view. + """ + slug = _slugify(content) if content is not None else None + path = f"{slug}-{task_id}" if content else task_id + return f"{TASK_URL}/{path}" + + +def get_project_url(project_id: str, name: str | None = None) -> str: + """ + Generate the URL for a given project. + + :param project_id: The ID of the project. + :param name: The name of the project. + :return: The URL string for the project view. + """ + slug = _slugify(name) if name is not None else None + path = f"{slug}-{project_id}" if name else project_id + return f"{PROJECT_URL}/{path}" + + +def _slugify(value: str) -> str: + """ + Slugify function borrowed from Django. + + Convert to ASCII. Convert spaces or repeated dashes to single dashes. + Remove characters that aren't alphanumerics, underscores, or hyphens. + Convert to lowercase. Strip spaces, dashes, and underscores. + """ + value = ( + unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + ) + value = re.sub(r"[^\w\s-]", "", value.lower()) + return re.sub(r"[-\s]+", "-", value).strip("-_") diff --git a/todoist_api_python/headers.py b/todoist_api_python/_core/http_headers.py similarity index 73% rename from todoist_api_python/headers.py rename to todoist_api_python/_core/http_headers.py index b9751a7..77972ec 100644 --- a/todoist_api_python/headers.py +++ b/todoist_api_python/_core/http_headers.py @@ -2,13 +2,11 @@ CONTENT_TYPE = ("Content-Type", "application/json; charset=utf-8") AUTHORIZATION = ("Authorization", "Bearer %s") -X_REQUEST_ID = ("X-Request-Id", "%s") def create_headers( token: str | None = None, with_content: bool = False, - request_id: str | None = None, ) -> dict[str, str]: headers: dict[str, str] = {} @@ -16,7 +14,5 @@ def create_headers( headers.update([(AUTHORIZATION[0], AUTHORIZATION[1] % token)]) if with_content: headers.update([CONTENT_TYPE]) - if request_id: - headers.update([(X_REQUEST_ID[0], X_REQUEST_ID[1] % request_id)]) return headers diff --git a/todoist_api_python/http_requests.py b/todoist_api_python/_core/http_requests.py similarity index 59% rename from todoist_api_python/http_requests.py rename to todoist_api_python/_core/http_requests.py index 94ee3ca..fd098c5 100644 --- a/todoist_api_python/http_requests.py +++ b/todoist_api_python/_core/http_requests.py @@ -1,16 +1,15 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeVar, cast from requests.status_codes import codes -from todoist_api_python.headers import create_headers +from todoist_api_python._core.http_headers import create_headers if TYPE_CHECKING: from requests import Session - Json = dict[str, "Json"] | list["Json"] | str | int | float | bool | None # Timeouts for requests. # @@ -21,58 +20,60 @@ # forcefully terminated after this time, so there is no point waiting any longer. TIMEOUT = (10, 60) +T = TypeVar("T") + def get( session: Session, url: str, token: str | None = None, params: dict[str, Any] | None = None, -) -> Json | bool: +) -> T: # type: ignore[type-var] response = session.get( url, params=params, headers=create_headers(token=token), timeout=TIMEOUT ) if response.status_code == codes.OK: - return response.json() + return cast("T", response.json()) response.raise_for_status() - return response.ok + return cast("T", response.ok) def post( session: Session, url: str, token: str | None = None, + *, + params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, -) -> Json | bool: - request_id = data.pop("request_id", None) if data else None - - headers = create_headers( - token=token, with_content=bool(data), request_id=request_id - ) +) -> T: # type: ignore[type-var] + headers = create_headers(token=token, with_content=bool(data)) response = session.post( - url, headers=headers, data=json.dumps(data) if data else None, timeout=TIMEOUT + url, + headers=headers, + data=json.dumps(data) if data else None, + params=params, + timeout=TIMEOUT, ) if response.status_code == codes.OK: - return response.json() + return cast("T", response.json()) response.raise_for_status() - return response.ok + return cast("T", response.ok) def delete( session: Session, url: str, token: str | None = None, - args: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, ) -> bool: - request_id = args.pop("request_id", None) if args else None - - headers = create_headers(token=token, request_id=request_id) + headers = create_headers(token=token) - response = session.delete(url, headers=headers, timeout=TIMEOUT) + response = session.delete(url, params=params, headers=headers, timeout=TIMEOUT) response.raise_for_status() return response.ok diff --git a/todoist_api_python/_core/utils.py b/todoist_api_python/_core/utils.py new file mode 100644 index 0000000..26042ca --- /dev/null +++ b/todoist_api_python/_core/utils.py @@ -0,0 +1,25 @@ +import asyncio +from collections.abc import AsyncGenerator, Callable, Iterator +from typing import TypeVar, cast + +T = TypeVar("T") + + +async def run_async(func: Callable[[], T]) -> T: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, func) + + +async def generate_async(iterator: Iterator[T]) -> AsyncGenerator[T]: + def get_next_item() -> tuple[bool, T | None]: + try: + return True, next(iterator) + except StopIteration: + return False, None + + while True: + has_more, item = await run_async(get_next_item) + if has_more is True: + yield cast("T", item) + else: + break diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index 490007a..a9d510b 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -1,33 +1,33 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Self +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING, Annotated, Any, Literal, Self, TypeVar from weakref import finalize import requests +from annotated_types import Ge, Le, MaxLen, MinLen, Predicate -from todoist_api_python.endpoints import ( - COLLABORATORS_ENDPOINT, - COMMENTS_ENDPOINT, - COMPLETED_ITEMS_ENDPOINT, - LABELS_ENDPOINT, - PROJECTS_ENDPOINT, - QUICK_ADD_ENDPOINT, - SECTIONS_ENDPOINT, - SHARED_LABELS_ENDPOINT, - SHARED_LABELS_REMOVE_ENDPOINT, - SHARED_LABELS_RENAME_ENDPOINT, - TASKS_ENDPOINT, - get_rest_url, - get_sync_url, +from todoist_api_python._core.endpoints import ( + COLLABORATORS_PATH, + COMMENTS_PATH, + LABELS_PATH, + PROJECTS_PATH, + SECTIONS_PATH, + SHARED_LABELS_PATH, + SHARED_LABELS_REMOVE_PATH, + SHARED_LABELS_RENAME_PATH, + TASKS_FILTER_PATH, + TASKS_PATH, + TASKS_QUICK_ADD_PATH, + get_api_url, ) -from todoist_api_python.http_requests import delete, get, post +from todoist_api_python._core.http_requests import delete, get, post from todoist_api_python.models import ( + Attachment, Collaborator, Comment, - CompletedItems, Label, Project, - QuickAddResult, Section, Task, ) @@ -35,14 +35,74 @@ if TYPE_CHECKING: from types import TracebackType +DateFormat = Annotated[str, Predicate(lambda x: len(x) == 10 and x.count("-") == 2)] # noqa: PLR2004 +DateTimeFormat = Annotated[ + str, Predicate(lambda x: len(x) >= 20 and "T" in x and ":" in x) # noqa: PLR2004 +] +LanguageCode = Annotated[str, Predicate(lambda x: len(x) == 2)] # noqa: PLR2004 +ColorString = Annotated[ + str, + Predicate( + lambda x: x + in ( + "berry_red", + "red", + "orange", + "yellow", + "olive_green", + "lime_green", + "green", + "mint_green", + "teal", + "sky_blue", + "light_blue", + "blue", + "grape", + "violet", + "lavender", + "magenta", + "salmon", + "charcoal", + "grey", + "taupe", + ) + ), +] +ViewStyle = Annotated[str, Predicate(lambda x: x in ("list", "board", "calendar"))] + class TodoistAPI: + """ + Client for the Todoist API. + + Provides methods for interacting with Todoist resources like tasks, projects, + labels, comments, etc. + + Manages an HTTP session and handles authentication. Can be used as a context manager + to ensure the session is closed properly. + """ + def __init__(self, token: str, session: requests.Session | None = None) -> None: + """ + Initialize the TodoistAPI client. + + :param token: Authentication token for the Todoist API. + :param session: An optional pre-configured requests `Session` object. + """ self._token: str = token self._session = session or requests.Session() self._finalizer = finalize(self, self._session.close) def __enter__(self) -> Self: + """ + Enters the runtime context related to this object. + + The with statement will bind this method's return value to the target(s) + specified in the as clause of the statement, if any. + + :return: The TodoistAPI instance. + :rtype: Self + """ return self def __exit__( @@ -51,202 +111,1044 @@ def __exit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: + """Exit the runtime context and closes the underlying requests session.""" self._finalizer() def get_task(self, task_id: str) -> Task: - endpoint = get_rest_url(f"{TASKS_ENDPOINT}/{task_id}") - task = get(self._session, endpoint, self._token) - return Task.from_dict(task) + """ + Get a specific task by its ID. + + :param task_id: The ID of the task to retrieve. + :return: The requested task. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Task dictionary. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") + task_data: dict[str, Any] = get(self._session, endpoint, self._token) + return Task.from_dict(task_data) + + def get_tasks( + self, + *, + project_id: str | None = None, + section_id: str | None = None, + parent_id: str | None = None, + label: str | None = None, + ids: list[str] | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Task]]: + """ + Get an iterable of lists of active tasks. + + The response is an iterable of lists of active tasks matching the criteria. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param project_id: Filter tasks by project ID. + :param section_id: Filter tasks by section ID. + :param parent_id: Filter tasks by parent task ID. + :param label: Filter tasks by label name. + :param ids: A list of the IDs of the tasks to retrieve. + :param limit: Maximum number of tasks per page. + :return: An iterable of lists of tasks. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(TASKS_PATH) + + params: dict[str, Any] = {} + if project_id is not None: + params["project_id"] = project_id + if section_id is not None: + params["section_id"] = section_id + if parent_id is not None: + params["parent_id"] = parent_id + if label is not None: + params["label"] = label + if ids is not None: + params["ids"] = ",".join(str(i) for i in ids) + if limit is not None: + params["limit"] = limit + + return ResultsPaginator( + self._session, + endpoint, + "results", + Task.from_dict, + self._token, + params, + ) - def get_tasks(self, **kwargs) -> list[Task]: - ids = kwargs.pop("ids", None) + def filter_tasks( + self, + *, + query: Annotated[str, MaxLen(1024)] | None = None, + lang: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Task]]: + """ + Get an iterable of lists of active tasks matching the filter. + + The response is an iterable of lists of active tasks matching the criteria. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. - if ids: - kwargs.update({"ids": ",".join(str(i) for i in ids)}) + :param query: Query tasks using Todoist's filter language. + :param lang: Language for task content (e.g., 'en'). + :param limit: Maximum number of tasks per page. + :return: An iterable of lists of tasks. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(TASKS_FILTER_PATH) - endpoint = get_rest_url(TASKS_ENDPOINT) - tasks = get(self._session, endpoint, self._token, kwargs) - return [Task.from_dict(obj) for obj in tasks] + params: dict[str, Any] = {} + if query is not None: + params["query"] = query + if lang is not None: + params["lang"] = lang + if limit is not None: + params["limit"] = limit + + return ResultsPaginator( + self._session, + endpoint, + "results", + Task.from_dict, + self._token, + params, + ) + + def add_task( # noqa: PLR0912 + self, + content: Annotated[str, MinLen(1), MaxLen(500)], + *, + description: Annotated[str, MaxLen(16383)] | None = None, + project_id: str | None = None, + section_id: str | None = None, + parent_id: str | None = None, + labels: list[Annotated[str, MaxLen(100)]] | None = None, + priority: Annotated[int, Ge(1), Le(4)] | None = None, + due_string: Annotated[str, MaxLen(150)] | None = None, + due_date: DateFormat | None = None, + due_datetime: DateTimeFormat | None = None, + due_lang: LanguageCode | None = None, + assignee_id: str | None = None, + order: int | None = None, + auto_reminder: bool | None = None, + auto_parse_labels: bool | None = None, + duration: Annotated[int, Ge(1)] | None = None, + duration_unit: Literal["minute", "day"] | None = None, + deadline_date: DateFormat | None = None, + deadline_lang: LanguageCode | None = None, + ) -> Task: + """ + Create a new task. + + :param content: The text content of the task. + :param project_id: The ID of the project to add the task to. + :param section_id: The ID of the section to add the task to. + :param parent_id: The ID of the parent task. + :param labels: The task's labels (a list of names). + :param priority: The priority of the task (4 for very urgent). + :param due_string: The due date in natural language format. + :param due_date: The due date in YYYY-MM-DD format. + :param due_datetime: The due date and time in RFC 3339 format. + :param due_lang: Language for parsing the due date (e.g., 'en'). + :param assignee_id: User ID to whom the task is assigned. + :param description: Description for the task. + :param order: The order of task in the project or section. + :param auto_reminder: Whether to add default reminder if date with time is set. + :param auto_parse_labels: Whether to parse labels from task content. + :param duration: The amount of time the task will take. + :param duration_unit: The unit of time for duration. + :param deadline_date: The deadline date in YYYY-MM-DD format. + :param deadline_lang: Language for parsing the deadline date. + :return: The newly created task. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Task dictionary. + """ + endpoint = get_api_url(TASKS_PATH) - def add_task(self, content: str, **kwargs) -> Task: - endpoint = get_rest_url(TASKS_ENDPOINT) data: dict[str, Any] = {"content": content} - data.update(kwargs) - task = post(self._session, endpoint, self._token, data=data) - return Task.from_dict(task) + if description is not None: + data["description"] = description + if project_id is not None: + data["project_id"] = project_id + if section_id is not None: + data["section_id"] = section_id + if parent_id is not None: + data["parent_id"] = parent_id + if labels is not None: + data["labels"] = labels + if priority is not None: + data["priority"] = priority + if due_string is not None: + data["due_string"] = due_string + if due_date is not None: + data["due_date"] = due_date + if due_datetime is not None: + data["due_datetime"] = due_datetime + if due_lang is not None: + data["due_lang"] = due_lang + if assignee_id is not None: + data["assignee_id"] = assignee_id + if order is not None: + data["order"] = order + if auto_reminder is not None: + data["auto_reminder"] = auto_reminder + if auto_parse_labels is not None: + data["auto_parse_labels"] = auto_parse_labels + if duration is not None: + data["duration"] = duration + if duration_unit is not None: + data["duration_unit"] = duration_unit + if deadline_date is not None: + data["deadline_date"] = deadline_date + if deadline_lang is not None: + data["deadline_lang"] = deadline_lang - def update_task(self, task_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{TASKS_ENDPOINT}/{task_id}") - return post(self._session, endpoint, self._token, data=kwargs) + task_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Task.from_dict(task_data) - def close_task(self, task_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{TASKS_ENDPOINT}/{task_id}/close") - return post(self._session, endpoint, self._token, data=kwargs) + def add_task_quick( + self, + text: str, + *, + note: str | None = None, + reminder: str | None = None, + auto_reminder: bool = True, + ) -> Task: + """ + Create a new task using Todoist's Quick Add syntax. - def reopen_task(self, task_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{TASKS_ENDPOINT}/{task_id}/reopen") - return post(self._session, endpoint, self._token, data=kwargs) + This automatically parses dates, deadlines, projects, labels, priorities, etc, + from the provided text (e.g., "Buy milk #Shopping @groceries tomorrow p1"). - def delete_task(self, task_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{TASKS_ENDPOINT}/{task_id}") - return delete(self._session, endpoint, self._token, args=kwargs) + :param text: The task text using Quick Add syntax. + :param note: Optional note to be added to the task. + :param reminder: Optional reminder date in free form text. + :param auto_reminder: Whether to add default reminder if date with time is set. + :return: A result object containing the parsed task data and metadata. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response cannot be parsed into a QuickAddResult. + """ + endpoint = get_api_url(TASKS_QUICK_ADD_PATH) - def quick_add_task(self, text: str) -> QuickAddResult: - endpoint = get_sync_url(QUICK_ADD_ENDPOINT) data = { - "text": text, "meta": True, - "auto_reminder": True, + "text": text, + "auto_reminder": auto_reminder, } - task_data = post(self._session, endpoint, self._token, data=data) - return QuickAddResult.from_quick_add_response(task_data) + + if note is not None: + data["note"] = note + if reminder is not None: + data["reminder"] = reminder + + task_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Task.from_dict(task_data) + + def update_task( # noqa: PLR0912 + self, + task_id: str, + *, + content: Annotated[str, MinLen(1), MaxLen(500)] | None = None, + description: Annotated[str, MaxLen(16383)] | None = None, + labels: list[Annotated[str, MaxLen(60)]] | None = None, + priority: Annotated[int, Ge(1), Le(4)] | None = None, + due_string: Annotated[str, MaxLen(150)] | None = None, + due_date: DateFormat | None = None, + due_datetime: DateTimeFormat | None = None, + due_lang: LanguageCode | None = None, + assignee_id: str | None = None, + day_order: int | None = None, + collapsed: bool | None = None, + duration: Annotated[int, Ge(1)] | None = None, + duration_unit: Literal["minute", "day"] | None = None, + deadline_date: DateFormat | None = None, + deadline_lang: LanguageCode | None = None, + ) -> Task: + """ + Update an existing task. + + Only the fields to be updated need to be provided. + + :param task_id: The ID of the task to update. + :param content: The text content of the task. + :param description: Description for the task. + :param labels: The task's labels (a list of names). + :param priority: The priority of the task (4 for very urgent). + :param due_string: The due date in natural language format. + :param due_date: The due date in YYYY-MM-DD format. + :param due_datetime: The due date and time in RFC 3339 format. + :param due_lang: Language for parsing the due date (e.g., 'en'). + :param assignee_id: User ID to whom the task is assigned. + :param day_order: The order of the task inside Today or Next 7 days view. + :param collapsed: Whether the task's sub-tasks are collapsed. + :param duration: The amount of time the task will take. + :param duration_unit: The unit of time for duration. + :param deadline_date: The deadline date in YYYY-MM-DD format. + :param deadline_lang: Language for parsing the deadline date. + :return: the updated Task. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") + + data: dict[str, Any] = {} + if content is not None: + data["content"] = content + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + if priority is not None: + data["priority"] = priority + if due_string is not None: + data["due_string"] = due_string + if due_date is not None: + data["due_date"] = due_date + if due_datetime is not None: + data["due_datetime"] = due_datetime + if due_lang is not None: + data["due_lang"] = due_lang + if assignee_id is not None: + data["assignee_id"] = assignee_id + if day_order is not None: + data["day_order"] = day_order + if collapsed is not None: + data["collapsed"] = collapsed + if duration is not None: + data["duration"] = duration + if duration_unit is not None: + data["duration_unit"] = duration_unit + if deadline_date is not None: + data["deadline_date"] = deadline_date + if deadline_lang is not None: + data["deadline_lang"] = deadline_lang + + task_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Task.from_dict(task_data) + + def complete_task(self, task_id: str) -> bool: + """ + Complete a task. + + For recurring tasks, this schedules the next occurrence. + For non-recurring tasks, it marks them as completed. + + :param task_id: The ID of the task to close. + :return: True if the task was closed successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/close") + return post(self._session, endpoint, self._token) + + def uncomplete_task(self, task_id: str) -> bool: + """ + Uncomplete a (completed) task. + + Any parent tasks or sections will also be uncompleted. + + :param task_id: The ID of the task to reopen. + :return: True if the task was uncompleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :rtype: bool + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}/reopen") + return post(self._session, endpoint, self._token) + + def delete_task(self, task_id: str) -> bool: + """ + Delete a task. + + :param task_id: The ID of the task to delete. + :return: True if the task was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{TASKS_PATH}/{task_id}") + return delete(self._session, endpoint, self._token) def get_project(self, project_id: str) -> Project: - endpoint = get_rest_url(f"{PROJECTS_ENDPOINT}/{project_id}") - project = get(self._session, endpoint, self._token) - return Project.from_dict(project) + """ + Get a project by its ID. + + :param project_id: The ID of the project to retrieve. + :return: The requested project. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Project dictionary. + """ + endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") + project_data: dict[str, Any] = get(self._session, endpoint, self._token) + return Project.from_dict(project_data) + + def get_projects( + self, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Project]]: + """ + Get an iterable of lists of active projects. + + The response is an iterable of lists of active projects. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. - def get_projects(self) -> list[Project]: - endpoint = get_rest_url(PROJECTS_ENDPOINT) - projects = get(self._session, endpoint, self._token) - return [Project.from_dict(obj) for obj in projects] + :param limit: Maximum number of projects per page. + :return: An iterable of lists of projects. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(PROJECTS_PATH) + params: dict[str, Any] = {} + if limit is not None: + params["limit"] = limit + return ResultsPaginator( + self._session, endpoint, "results", Project.from_dict, self._token, params + ) + + def add_project( + self, + name: Annotated[str, MinLen(1), MaxLen(120)], + *, + description: Annotated[str, MaxLen(16383)] | None = None, + parent_id: str | None = None, + color: ColorString | None = None, + is_favorite: bool | None = None, + view_style: ViewStyle | None = None, + ) -> Project: + """ + Create a new project. + + :param name: The name of the project. + :param description: Description for the project (up to 1024 characters). + :param parent_id: The ID of the parent project. Set to null for root projects. + :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', default is 'list'). + :return: The newly created project. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Project dictionary. + """ + endpoint = get_api_url(PROJECTS_PATH) - def add_project(self, name: str, **kwargs) -> Project: - endpoint = get_rest_url(PROJECTS_ENDPOINT) data: dict[str, Any] = {"name": name} - data.update(kwargs) - project = post(self._session, endpoint, self._token, data=data) - return Project.from_dict(project) + if parent_id is not None: + data["parent_id"] = parent_id + if description is not None: + data["description"] = description + if color is not None: + data["color"] = color + if is_favorite is not None: + data["is_favorite"] = is_favorite + if view_style is not None: + data["view_style"] = view_style + + project_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Project.from_dict(project_data) - def update_project(self, project_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{PROJECTS_ENDPOINT}/{project_id}") - return post(self._session, endpoint, self._token, data=kwargs) + def update_project( + self, + project_id: str, + *, + name: Annotated[str, MinLen(1), MaxLen(120)] | None = None, + description: Annotated[str, MaxLen(16383)] | None = None, + color: ColorString | None = None, + is_favorite: bool | None = None, + view_style: ViewStyle | None = None, + ) -> Project: + """ + Update an existing project. + + Only the fields to be updated need to be provided as keyword arguments. + + :param project_id: The ID of the project to update. + :param name: The name of the project. + :param description: Description for the project (up to 1024 characters). + :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'). + :return: the updated Project. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") - def delete_project(self, project_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{PROJECTS_ENDPOINT}/{project_id}") - return delete(self._session, endpoint, self._token, args=kwargs) + data: dict[str, Any] = {} - def get_collaborators(self, project_id: str) -> list[Collaborator]: - endpoint = get_rest_url( - f"{PROJECTS_ENDPOINT}/{project_id}/{COLLABORATORS_ENDPOINT}" + if name is not None: + data["name"] = name + if description is not None: + data["description"] = description + if color is not None: + data["color"] = color + if is_favorite is not None: + data["is_favorite"] = is_favorite + if view_style is not None: + data["view_style"] = view_style + + project_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Project.from_dict(project_data) + + def delete_project(self, project_id: str) -> bool: + """ + Delete a project. + + All nested sections and tasks will also be deleted. + + :param project_id: The ID of the project to delete. + :return: True if the project was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}") + return delete(self._session, endpoint, self._token) + + def get_collaborators( + self, + project_id: str, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Collaborator]]: + """ + Get an iterable of lists of collaborators in shared projects. + + The response is an iterable of lists of collaborators in shared projects, + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param project_id: The ID of the project. + :param limit: Maximum number of collaborators per page. + :return: An iterable of lists of collaborators. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(f"{PROJECTS_PATH}/{project_id}/{COLLABORATORS_PATH}") + params: dict[str, Any] = {} + if limit is not None: + params["limit"] = limit + return ResultsPaginator( + self._session, + endpoint, + "results", + Collaborator.from_dict, + self._token, + params, ) - collaborators = get(self._session, endpoint, self._token) - return [Collaborator.from_dict(obj) for obj in collaborators] def get_section(self, section_id: str) -> Section: - endpoint = get_rest_url(f"{SECTIONS_ENDPOINT}/{section_id}") - section = get(self._session, endpoint, self._token) - return Section.from_dict(section) - - def get_sections(self, **kwargs) -> list[Section]: - endpoint = get_rest_url(SECTIONS_ENDPOINT) - sections = get(self._session, endpoint, self._token, kwargs) - return [Section.from_dict(obj) for obj in sections] - - def add_section(self, name: str, project_id: str, **kwargs) -> Section: - endpoint = get_rest_url(SECTIONS_ENDPOINT) - data = {"name": name, "project_id": project_id} - data.update(kwargs) - section = post(self._session, endpoint, self._token, data=data) - return Section.from_dict(section) - - def update_section(self, section_id: str, name: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{SECTIONS_ENDPOINT}/{section_id}") - data: dict[str, Any] = {"name": name} - data.update(kwargs) - return post(self._session, endpoint, self._token, data=data) + """ + Get a specific section by its ID. + + :param section_id: The ID of the section to retrieve. + :return: The requested section. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Section dictionary. + """ + endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") + section_data: dict[str, Any] = get(self._session, endpoint, self._token) + return Section.from_dict(section_data) + + def get_sections( + self, + project_id: str | None = None, + *, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Section]]: + """ + Get an iterable of lists of active sections. + + Supports filtering by `project_id` and pagination arguments. + + The response is an iterable of lists of active sections. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param project_id: Filter sections by project ID. + :param limit: Maximum number of sections per page. + :return: An iterable of lists of sections. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(SECTIONS_PATH) + + params: dict[str, Any] = {} + if project_id is not None: + params["project_id"] = project_id + if limit is not None: + params["limit"] = limit + + return ResultsPaginator( + self._session, endpoint, "results", Section.from_dict, self._token, params + ) + + def add_section( + self, + name: Annotated[str, MinLen(1), MaxLen(2048)], + project_id: str, + *, + order: int | None = None, + ) -> Section: + """ + Create a new section within a project. - def delete_section(self, section_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{SECTIONS_ENDPOINT}/{section_id}") - return delete(self._session, endpoint, self._token, args=kwargs) + :param name: The name of the section. + :param project_id: The ID of the project to add the section to. + :param order: The order of the section among all sections in the project. + :return: The newly created section. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Section dictionary. + """ + endpoint = get_api_url(SECTIONS_PATH) + + data: dict[str, Any] = {"name": name, "project_id": project_id} + if order is not None: + data["order"] = order + + section_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Section.from_dict(section_data) + + def update_section( + self, + section_id: str, + name: Annotated[str, MinLen(1), MaxLen(2048)], + ) -> 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. + :return: the updated Section. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") + section_data: dict[str, Any] = post( + self._session, endpoint, self._token, data={"name": name} + ) + return Section.from_dict(section_data) + + def delete_section(self, section_id: str) -> bool: + """ + Delete a section. + + All tasks within the section will also be deleted. + + :param section_id: The ID of the section to delete. + :return: True if the section was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{SECTIONS_PATH}/{section_id}") + return delete(self._session, endpoint, self._token) def get_comment(self, comment_id: str) -> Comment: - endpoint = get_rest_url(f"{COMMENTS_ENDPOINT}/{comment_id}") - comment = get(self._session, endpoint, self._token) - return Comment.from_dict(comment) - - def get_comments(self, **kwargs) -> list[Comment]: - endpoint = get_rest_url(COMMENTS_ENDPOINT) - comments = get(self._session, endpoint, self._token, kwargs) - return [Comment.from_dict(obj) for obj in comments] - - def add_comment(self, content: str, **kwargs) -> Comment: - endpoint = get_rest_url(COMMENTS_ENDPOINT) - data = {"content": content} - data.update(kwargs) - comment = post(self._session, endpoint, self._token, data=data) - return Comment.from_dict(comment) - - def update_comment(self, comment_id: str, content: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{COMMENTS_ENDPOINT}/{comment_id}") + """ + Get a specific comment by its ID. + + :param comment_id: The ID of the comment to retrieve. + :return: The requested comment. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Comment dictionary. + """ + endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") + comment_data: dict[str, Any] = get(self._session, endpoint, self._token) + return Comment.from_dict(comment_data) + + def get_comments( + self, + *, + project_id: str | None = None, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Comment]]: + """ + Get an iterable of lists of comments for a task or project. + + Requires either `project_id` or `task_id` to be set. + + The response is an iterable of lists of comments. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param project_id: The ID of the project to retrieve comments for. + :param task_id: The ID of the task to retrieve comments for. + :param limit: Maximum number of comments per page. + :return: An iterable of lists of comments. + :raises ValueError: If neither `project_id` nor `task_id` is provided. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + if project_id is None and task_id is None: + raise ValueError("Either `project_id` or `task_id` must be provided.") + + endpoint = get_api_url(COMMENTS_PATH) + + params: dict[str, Any] = {} + if project_id is not None: + params["project_id"] = project_id + if task_id is not None: + params["task_id"] = task_id + if limit is not None: + params["limit"] = limit + + return ResultsPaginator( + self._session, endpoint, "results", Comment.from_dict, self._token, params + ) + + def add_comment( + self, + content: Annotated[str, MaxLen(15000)], + *, + project_id: str | None = None, + task_id: str | None = None, + attachment: Attachment | None = None, + uids_to_notify: list[str] | None = None, + ) -> Comment: + """ + Create a new comment on a task or project. + + Requires either `project_id` or `task_id` to be set, + and can optionally include an `attachment` object. + + :param content: The text content of the comment (supports Markdown). + :param project_id: The ID of the project to add the comment to. + :param task_id: The ID of the task to add the comment to. + :param attachment: The attachment object to include with the comment. + :param uids_to_notify: A list of user IDs to notify. + :return: The newly created comment. + :raises ValueError: If neither `project_id` nor `task_id` is provided. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Comment dictionary. + """ + if project_id is None and task_id is None: + raise ValueError("Either `project_id` or `task_id` must be provided.") + + endpoint = get_api_url(COMMENTS_PATH) + data: dict[str, Any] = {"content": content} - data.update(kwargs) - return post(self._session, endpoint, self._token, data=data) + if project_id is not None: + data["project_id"] = project_id + if task_id is not None: + data["task_id"] = task_id + if attachment is not None: + data["attachment"] = attachment.to_dict() + if uids_to_notify is not None: + data["uids_to_notify"] = uids_to_notify + + comment_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Comment.from_dict(comment_data) + + def update_comment( + self, comment_id: str, content: Annotated[str, MaxLen(15000)] + ) -> Comment: + """ + Update an existing comment. + + Currently, only `content` can be updated. + + :param comment_id: The ID of the comment to update. + :param content: The new text content for the comment. + :return: the updated Comment. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") + comment_data: dict[str, Any] = post( + self._session, endpoint, self._token, data={"content": content} + ) + return Comment.from_dict(comment_data) - def delete_comment(self, comment_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{COMMENTS_ENDPOINT}/{comment_id}") - return delete(self._session, endpoint, self._token, args=kwargs) + def delete_comment(self, comment_id: str) -> bool: + """ + Delete a comment. + + :param comment_id: The ID of the comment to delete. + :return: True if the comment was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{COMMENTS_PATH}/{comment_id}") + return delete(self._session, endpoint, self._token) def get_label(self, label_id: str) -> Label: - endpoint = get_rest_url(f"{LABELS_ENDPOINT}/{label_id}") - label = get(self._session, endpoint, self._token) - return Label.from_dict(label) + """ + Get a specific personal label by its ID. - def get_labels(self) -> list[Label]: - endpoint = get_rest_url(LABELS_ENDPOINT) - labels = get(self._session, endpoint, self._token) - return [Label.from_dict(obj) for obj in labels] + :param label_id: The ID of the label to retrieve. + :return: The requested label. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Label dictionary. + """ + endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") + label_data: dict[str, Any] = get(self._session, endpoint, self._token) + return Label.from_dict(label_data) - def add_label(self, name: str, **kwargs) -> Label: - endpoint = get_rest_url(LABELS_ENDPOINT) - data = {"name": name} - data.update(kwargs) - label = post(self._session, endpoint, self._token, data=data) - return Label.from_dict(label) + def get_labels( + self, + *, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Label]]: + """ + Get an iterable of lists of personal labels. - def update_label(self, label_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{LABELS_ENDPOINT}/{label_id}") - return post(self._session, endpoint, self._token, data=kwargs) + Supports pagination arguments. - def delete_label(self, label_id: str, **kwargs) -> bool: - endpoint = get_rest_url(f"{LABELS_ENDPOINT}/{label_id}") - return delete(self._session, endpoint, self._token, args=kwargs) + The response is an iterable of lists of personal labels. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. - def get_shared_labels(self) -> list[str]: - endpoint = get_rest_url(SHARED_LABELS_ENDPOINT) - return get(self._session, endpoint, self._token) + :param limit: ` number of labels per page. + :return: An iterable of lists of personal labels. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(LABELS_PATH) - def rename_shared_label(self, name: str, new_name: str) -> bool: - endpoint = get_rest_url(SHARED_LABELS_RENAME_ENDPOINT) - data = {"name": name, "new_name": new_name} - return post(self._session, endpoint, self._token, data=data) + params: dict[str, Any] = {} + if limit is not None: + params["limit"] = limit - def remove_shared_label(self, name: str) -> bool: - endpoint = get_rest_url(SHARED_LABELS_REMOVE_ENDPOINT) - data = {"name": name} - return post(self._session, endpoint, self._token, data=data) + return ResultsPaginator( + self._session, endpoint, "results", Label.from_dict, self._token, params + ) - def get_completed_items( + def add_label( self, - project_id: str | None = None, - section_id: str | None = None, - item_id: str | None = None, - last_seen_id: str | None = None, - limit: int | None = None, - cursor: str | None = None, - ) -> CompletedItems: - endpoint = get_sync_url(COMPLETED_ITEMS_ENDPOINT) - completed_items = get( + name: Annotated[str, MinLen(1), MaxLen(60)], + *, + color: ColorString | None = None, + item_order: int | None = None, + is_favorite: bool | None = None, + ) -> Label: + """ + Create a new personal label. + + :param name: The name of the label. + :param color: The color of the label icon. + :param item_order: Label's order in the label list. + :param is_favorite: Whether the label is a favorite. + :return: The newly created label. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Label dictionary. + """ + endpoint = get_api_url(LABELS_PATH) + + data: dict[str, Any] = {"name": name} + + if color is not None: + data["color"] = color + if item_order is not None: + data["item_order"] = item_order + if is_favorite is not None: + data["is_favorite"] = is_favorite + + label_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Label.from_dict(label_data) + + def update_label( + self, + label_id: str, + *, + name: Annotated[str, MinLen(1), MaxLen(60)] | None = None, + color: ColorString | None = None, + item_order: int | None = None, + is_favorite: bool | None = None, + ) -> Label: + """ + Update a personal label. + + Only the fields to be updated need to be provided as keyword arguments. + + :param label_id: The ID of the label. + :param name: The name of the label. + :param color: The color of the label icon. + :param item_order: Label's order in the label list. + :param is_favorite: Whether the label is a favorite. + :return: the updated Label. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") + + data: dict[str, Any] = {} + if name is not None: + data["name"] = name + if color is not None: + data["color"] = color + if item_order is not None: + data["item_order"] = item_order + if is_favorite is not None: + data["is_favorite"] = is_favorite + + label_data: dict[str, Any] = post( + self._session, endpoint, self._token, data=data + ) + return Label.from_dict(label_data) + + def delete_label(self, label_id: str) -> bool: + """ + Delete a personal label. + + Instances of the label will be removed from tasks. + + :param label_id: The ID of the label to delete. + :return: True if the label was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{LABELS_PATH}/{label_id}") + return delete(self._session, endpoint, self._token) + + def get_shared_labels( + self, + *, + omit_personal: bool = False, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[str]]: + """ + Get an iterable of lists of shared label names. + + Includes labels from collaborators on shared projects that are not in the + user's personal labels. Can optionally exclude personal label names using + `omit_personal=True`. Supports pagination arguments. + + The response is an iterable of lists of shared label names. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param omit_personal: Optional boolean flag to omit personal label names. + :param limit: Maximum number of labels per page. + :return: An iterable of lists of shared label names (strings). + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(SHARED_LABELS_PATH) + + params: dict[str, Any] = {"omit_personal": omit_personal} + if limit is not None: + params["limit"] = limit + + return ResultsPaginator( + self._session, endpoint, "results", str, self._token, params + ) + + def rename_shared_label( + self, + name: Annotated[str, MaxLen(60)], + new_name: Annotated[str, MinLen(1), MaxLen(60)], + ) -> bool: + """ + Rename all occurrences of a shared label across all projects. + + :param name: The current name of the shared label to rename. + :param new_name: The new name for the shared label. + :return: True if the rename was successful, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(SHARED_LABELS_RENAME_PATH) + return post( self._session, endpoint, self._token, - { - "project_id": project_id, - "section_id": section_id, - "item_id": item_id, - "last_seen_id": last_seen_id, - "limit": limit, - "cursor": cursor, - }, - ) - return CompletedItems.from_dict(completed_items) + params={"name": name}, + data={"new_name": new_name}, + ) + + def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: + """ + Remove all occurrences of a shared label across all projects. + + This action removes the label string from all tasks where it appears. + + :param name: The name of the shared label to remove. + :return: True if the removal was successful, + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(SHARED_LABELS_REMOVE_PATH) + data = {"name": name} + return post(self._session, endpoint, self._token, data=data) + + +T = TypeVar("T") + + +class ResultsPaginator(Iterator[list[T]]): + """ + Iterator for paginated results from the Todoist API. + + It encapsulates the logic for fetching and iterating through paginated results + from Todoist API endpoints. It handles cursor-based pagination automatically, + requesting new pages as needed when iterating. + """ + + _session: requests.Session + _url: str + _results_field: str + _results_inst: Callable[[Any], T] + _token: str + _cursor: str | None + + def __init__( + self, + session: requests.Session, + url: str, + results_field: str, + results_inst: Callable[[Any], T], + token: str, + params: dict[str, Any], + ) -> None: + """ + Initialize the ResultsPaginator. + + :param session: The requests Session to use for API calls. + :param url: The API endpoint URL to fetch results from. + :param results_field: The key in the API response that contains the results. + :param results_inst: A callable that converts result items to objects of type T. + :param token: The authentication token for the Todoist API. + :param params: Query parameters to include in API requests. + """ + self._session = session + self._url = url + self._results_field = results_field + self._results_inst = results_inst + self._token = token + self._params = params + self._cursor = "" # empty string for first page + + def __next__(self) -> list[T]: + """ + Fetch and return the next page of results from the Todoist API. + + :return: A list of results. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + if self._cursor is None: + raise StopIteration + + params = self._params.copy() + if self._cursor != "": + params["cursor"] = self._cursor + + data: dict[str, Any] = get(self._session, self._url, self._token, params) + self._cursor = data.get("next_cursor") + + results: list[Any] = data.get(self._results_field, []) + return [self._results_inst(result) for result in results] diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index 3db285a..519d26a 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -1,142 +1,804 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Annotated, Literal, Self +from annotated_types import Ge, Le, MaxLen, MinLen + +from todoist_api_python._core.utils import generate_async, run_async from todoist_api_python.api import TodoistAPI -from todoist_api_python.utils import run_async if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from types import TracebackType + import requests from todoist_api_python.models import ( + Attachment, Collaborator, Comment, - CompletedItems, Label, Project, - QuickAddResult, Section, Task, ) +from todoist_api_python.api import ( + ColorString, + DateFormat, + DateTimeFormat, + LanguageCode, + ViewStyle, +) + class TodoistAPIAsync: + """ + Async client for the Todoist API. + + Provides asynchronous methods for interacting with Todoist resources like tasks, + projects,labels, comments, etc. + + Manages an HTTP session and handles authentication. Can be used as an async context + manager to ensure the session is closed properly. + """ + def __init__(self, token: str, session: requests.Session | None = None) -> None: + """ + Initialize the TodoistAPIAsync client. + + :param token: Authentication token for the Todoist API. + :param session: An optional pre-configured requests `Session` object. + """ self._api = TodoistAPI(token, session) - async def get_task(self, task_id: str) -> Task: - return await run_async(lambda: self._api.get_task(task_id)) + async def __aenter__(self) -> Self: + """ + Enters the async runtime context related to this object. - async def get_tasks(self, **kwargs) -> list[Task]: - return await run_async(lambda: self._api.get_tasks(**kwargs)) + The with statement will bind this method's return value to the target(s) + specified in the as clause of the statement, if any. - async def add_task(self, content: str, **kwargs) -> Task: - return await run_async(lambda: self._api.add_task(content, **kwargs)) + :return: The TodoistAPIAsync instance. + """ + return self - async def update_task(self, task_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.update_task(task_id, **kwargs)) + def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Exit the async runtime context and closes the underlying requests session.""" - async def close_task(self, task_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.close_task(task_id, **kwargs)) + async def get_task(self, task_id: str) -> Task: + """ + Get a specific task by its ID. + + :param task_id: The ID of the task to retrieve. + :return: The requested task. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Task dictionary. + """ + return await run_async(lambda: self._api.get_task(task_id)) - async def reopen_task(self, task_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.reopen_task(task_id, **kwargs)) + async def get_tasks( + self, + *, + project_id: str | None = None, + section_id: str | None = None, + parent_id: str | None = None, + label: str | None = None, + ids: list[str] | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Task]]: + """ + Get a list of active tasks. + + :param project_id: Filter tasks by project ID. + :param section_id: Filter tasks by section ID. + :param parent_id: Filter tasks by parent task ID. + :param label: Filter tasks by label name. + :param ids: A list of the IDs of the tasks to retrieve. + :param limit: Maximum number of tasks per page (between 1 and 200). + :return: A list of tasks. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.get_tasks( + project_id=project_id, + section_id=section_id, + parent_id=parent_id, + label=label, + ids=ids, + limit=limit, + ) + return generate_async(paginator) - async def delete_task(self, task_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.delete_task(task_id, **kwargs)) + async def filter_tasks( + self, + *, + query: Annotated[str, MaxLen(1024)] | None = None, + lang: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Task]]: + """ + Get a lists of active tasks matching the filter. + + The response is an iterable of lists of active tasks matching the criteria. + Be aware that each iteration fires off a network request to the Todoist API, + and may result in rate limiting or other API restrictions. + + :param query: Query tasks using Todoist's filter language. + :param lang: Language for task content (e.g., 'en'). + :param limit: Maximum number of tasks per page (between 1 and 200). + :return: An iterable of lists of tasks. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.filter_tasks( + query=query, + lang=lang, + limit=limit, + ) + return generate_async(paginator) - async def quick_add_task(self, text: str) -> QuickAddResult: - return await run_async(lambda: self._api.quick_add_task(text)) + async def add_task( + self, + content: Annotated[str, MinLen(1), MaxLen(500)], + *, + description: Annotated[str, MaxLen(16383)] | None = None, + project_id: str | None = None, + section_id: str | None = None, + parent_id: str | None = None, + labels: list[Annotated[str, MaxLen(100)]] | None = None, + priority: Annotated[int, Ge(1), Le(4)] | None = None, + due_string: Annotated[str, MaxLen(150)] | None = None, + due_date: DateFormat | None = None, + due_datetime: DateTimeFormat | None = None, + due_lang: LanguageCode | None = None, + assignee_id: str | None = None, + order: int | None = None, + auto_reminder: bool | None = None, + auto_parse_labels: bool | None = None, + duration: Annotated[int, Ge(1)] | None = None, + duration_unit: Literal["minute", "day"] | None = None, + deadline_date: DateFormat | None = None, + deadline_lang: LanguageCode | None = None, + ) -> Task: + """ + Create a new task. + + :param content: The text content of the task. + :param project_id: The ID of the project to add the task to. + :param section_id: The ID of the section to add the task to. + :param parent_id: The ID of the parent task. + :param labels: The task's labels (a list of names). + :param priority: The priority of the task (4 for very urgent). + :param due_string: The due date in natural language format. + :param due_date: The due date in YYYY-MM-DD format. + :param due_datetime: The due date and time in RFC 3339 format. + :param due_lang: Language for parsing the due date (e.g., 'en'). + :param assignee_id: User ID to whom the task is assigned. + :param description: Description for the task. + :param order: The order of task in the project or section. + :param auto_reminder: Whether to add default reminder if date with time is set. + :param auto_parse_labels: Whether to parse labels from task content. + :param duration: The amount of time the task will take. + :param duration_unit: The unit of time for duration. + :param deadline_date: The deadline date in YYYY-MM-DD format. + :param deadline_lang: Language for parsing the deadline date. + :return: The newly created task. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Task dictionary. + """ + return await run_async( + lambda: self._api.add_task( + content, + description=description, + project_id=project_id, + section_id=section_id, + parent_id=parent_id, + labels=labels, + priority=priority, + due_string=due_string, + due_date=due_date, + due_datetime=due_datetime, + due_lang=due_lang, + assignee_id=assignee_id, + order=order, + auto_reminder=auto_reminder, + auto_parse_labels=auto_parse_labels, + duration=duration, + duration_unit=duration_unit, + deadline_date=deadline_date, + deadline_lang=deadline_lang, + ) + ) + + async def update_task( + self, + task_id: str, + *, + content: Annotated[str, MinLen(1), MaxLen(500)] | None = None, + description: Annotated[str, MaxLen(16383)] | None = None, + labels: list[Annotated[str, MaxLen(60)]] | None = None, + priority: Annotated[int, Ge(1), Le(4)] | None = None, + due_string: Annotated[str, MaxLen(150)] | None = None, + due_date: DateFormat | None = None, + due_datetime: DateTimeFormat | None = None, + due_lang: LanguageCode | None = None, + assignee_id: str | None = None, + day_order: int | None = None, + collapsed: bool | None = None, + duration: Annotated[int, Ge(1)] | None = None, + duration_unit: Literal["minute", "day"] | None = None, + deadline_date: DateFormat | None = None, + deadline_lang: LanguageCode | None = None, + ) -> Task: + """ + Update an existing task. + + Only the fields to be updated need to be provided. + + :param task_id: The ID of the task to update. + :param content: The text content of the task. + :param description: Description for the task. + :param labels: The task's labels (a list of names). + :param priority: The priority of the task (4 for very urgent). + :param due_string: The due date in natural language format. + :param due_date: The due date in YYYY-MM-DD format. + :param due_datetime: The due date and time in RFC 3339 format. + :param due_lang: Language for parsing the due date (e.g., 'en'). + :param assignee_id: User ID to whom the task is assigned. + :param day_order: The order of the task inside Today or Next 7 days view. + :param collapsed: Whether the task's sub-tasks are collapsed. + :param duration: The amount of time the task will take. + :param duration_unit: The unit of time for duration. + :param deadline_date: The deadline date in YYYY-MM-DD format. + :param deadline_lang: Language for parsing the deadline date. + :return: the updated Task. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async( + lambda: self._api.update_task( + task_id, + content=content, + description=description, + labels=labels, + priority=priority, + due_string=due_string, + due_date=due_date, + due_datetime=due_datetime, + due_lang=due_lang, + assignee_id=assignee_id, + day_order=day_order, + collapsed=collapsed, + duration=duration, + duration_unit=duration_unit, + deadline_date=deadline_date, + deadline_lang=deadline_lang, + ) + ) + + async def complete_task(self, task_id: str) -> bool: + """ + Complete a task. + + For recurring tasks, this schedules the next occurrence. + For non-recurring tasks, it marks them as completed. + + :param task_id: The ID of the task to close. + :return: True if the task was closed successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.complete_task(task_id)) + + async def uncomplete_task(self, task_id: str) -> bool: + """ + Uncomplete a (completed) task. + + Any parent tasks or sections will also be uncompleted. + + :param task_id: The ID of the task to reopen. + :return: True if the task was uncompleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :rtype: bool + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.uncomplete_task(task_id)) + + async def delete_task(self, task_id: str) -> bool: + """ + Delete a task. + + :param task_id: The ID of the task to delete. + :return: True if the task was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.delete_task(task_id)) + + async def add_task_quick( + self, + text: str, + *, + note: str | None = None, + reminder: str | None = None, + auto_reminder: bool = True, + ) -> Task: + """ + Create a new task using Todoist's Quick Add syntax. + + This automatically parses dates, deadlines, projects, labels, priorities, etc, + from the provided text (e.g., "Buy milk #Shopping @groceries tomorrow p1"). + + :param text: The task text using Quick Add syntax. + :param note: Optional note to be added to the task. + :param reminder: Optional reminder date in free form text. + :param auto_reminder: Whether to add default reminder if date with time is set. + :return: A result object containing the parsed task data and metadata. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response cannot be parsed into a QuickAddResult. + """ + return await run_async( + lambda: self._api.add_task_quick( + text, note=note, reminder=reminder, auto_reminder=auto_reminder + ) + ) async def get_project(self, project_id: str) -> Project: + """ + Get a project by its ID. + + :param project_id: The ID of the project to retrieve. + :return: The requested project. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Project dictionary. + """ return await run_async(lambda: self._api.get_project(project_id)) - async def get_projects(self) -> list[Project]: - return await run_async(lambda: self._api.get_projects()) + async def get_projects( + self, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Project]]: + """ + Get a list of active projects. + + :param limit: Maximum number of projects per page. + :return: A list of projects. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.get_projects(limit=limit) + return generate_async(paginator) + + async def add_project( + self, + name: Annotated[str, MinLen(1), MaxLen(120)], + *, + description: Annotated[str, MaxLen(16383)] | None = None, + parent_id: str | None = None, + color: ColorString | None = None, + is_favorite: bool | None = None, + view_style: ViewStyle | None = None, + ) -> Project: + """ + Create a new project. + + :param name: The name of the project. + :param description: Description for the project (up to 1024 characters). + :param parent_id: The ID of the parent project. Set to null for root projects. + :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', default is 'list'). + :return: The newly created project. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Project dictionary. + """ + return await run_async( + lambda: self._api.add_project( + name, + description=description, + parent_id=parent_id, + color=color, + is_favorite=is_favorite, + view_style=view_style, + ) + ) + + async def update_project( + self, + project_id: str, + *, + name: Annotated[str, MinLen(1), MaxLen(120)] | None = None, + description: Annotated[str, MaxLen(16383)] | None = None, + color: ColorString | None = None, + is_favorite: bool | None = None, + view_style: ViewStyle | None = None, + ) -> Project: + """ + Update an existing project. + + Only the fields to be updated need to be provided as keyword arguments. + + :param project_id: The ID of the project to update. + :param name: The name of the project. + :param description: Description for the project (up to 1024 characters). + :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'). + :return: the updated Project. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async( + lambda: self._api.update_project( + project_id, + name=name, + description=description, + color=color, + is_favorite=is_favorite, + view_style=view_style, + ) + ) - async def add_project(self, name: str, **kwargs) -> Project: - return await run_async(lambda: self._api.add_project(name, **kwargs)) + async def delete_project(self, project_id: str) -> bool: + """ + Delete a project. - async def update_project(self, project_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.update_project(project_id, **kwargs)) + All nested sections and tasks will also be deleted. - async def delete_project(self, project_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.delete_project(project_id, **kwargs)) + :param project_id: The ID of the project to delete. + :return: True if the project was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.delete_project(project_id)) - async def get_collaborators(self, project_id: str) -> list[Collaborator]: - return await run_async(lambda: self._api.get_collaborators(project_id)) + async def get_collaborators( + self, + project_id: str, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Collaborator]]: + """ + Get a list of collaborators in shared projects. + + :param project_id: The ID of the project. + :param limit: Maximum number of collaborators per page. + :return: A list of collaborators. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.get_collaborators(project_id, limit=limit) + return generate_async(paginator) async def get_section(self, section_id: str) -> Section: + """ + Get a specific section by its ID. + + :param section_id: The ID of the section to retrieve. + :return: The requested section. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Section dictionary. + """ return await run_async(lambda: self._api.get_section(section_id)) - async def get_sections(self, **kwargs) -> list[Section]: - return await run_async(lambda: self._api.get_sections(**kwargs)) - - async def add_section(self, name: str, project_id: str, **kwargs) -> Section: - return await run_async( - lambda: self._api.add_section(name, project_id, **kwargs) - ) - - async def update_section(self, section_id: str, name: str, **kwargs) -> bool: + async def get_sections( + self, + project_id: str | None = None, + *, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Section]]: + """ + Get a list of active sections. + + Supports filtering by `project_id` and pagination arguments. + + :param project_id: Filter sections by project ID. + :param limit: Maximum number of sections per page (between 1 and 200). + :return: A list of sections. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.get_sections(project_id=project_id, limit=limit) + return generate_async(paginator) + + async def add_section( + self, + name: Annotated[str, MinLen(1), MaxLen(2048)], + project_id: str, + *, + order: int | None = None, + ) -> Section: + """ + Create a new section within a project. + + :param name: The name of the section. + :param project_id: The ID of the project to add the section to. + :param order: The order of the section among all sections in the project. + :return: The newly created section. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Section dictionary. + """ return await run_async( - lambda: self._api.update_section(section_id, name, **kwargs) + lambda: self._api.add_section(name, project_id, order=order) ) - async def delete_section(self, section_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.delete_section(section_id, **kwargs)) + async def update_section( + self, + section_id: str, + name: Annotated[str, MinLen(1), MaxLen(2048)], + ) -> 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. + :return: the updated Section. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.update_section(section_id, name)) + + async def delete_section(self, section_id: str) -> bool: + """ + Delete a section. + + All tasks within the section will also be deleted. + + :param section_id: The ID of the section to delete. + :return: True if the section was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.delete_section(section_id)) async def get_comment(self, comment_id: str) -> Comment: + """ + Get a specific comment by its ID. + + :param comment_id: The ID of the comment to retrieve. + :return: The requested comment. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Comment dictionary. + """ return await run_async(lambda: self._api.get_comment(comment_id)) - async def get_comments(self, **kwargs) -> list[Comment]: - return await run_async(lambda: self._api.get_comments(**kwargs)) - - async def add_comment(self, content: str, **kwargs) -> Comment: - return await run_async(lambda: self._api.add_comment(content, **kwargs)) + async def get_comments( + self, + *, + project_id: str | None = None, + task_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Comment]]: + """ + Get a list of comments for a task or project. + + Requires either `project_id` or `task_id` to be set. + + :param project_id: The ID of the project to retrieve comments for. + :param task_id: The ID of the task to retrieve comments for. + :param limit: Maximum number of comments per page (between 1 and 200). + :return: A list of comments. + :raises ValueError: If neither `project_id` nor `task_id` is provided. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.get_comments( + project_id=project_id, task_id=task_id, limit=limit + ) + return generate_async(paginator) - async def update_comment(self, comment_id: str, content: str, **kwargs) -> bool: + async def add_comment( + self, + content: Annotated[str, MaxLen(15000)], + *, + project_id: str | None = None, + task_id: str | None = None, + attachment: Attachment | None = None, + uids_to_notify: list[str] | None = None, + ) -> Comment: + """ + Create a new comment on a task or project. + + Requires either `project_id` or `task_id` to be set, + and can optionally include an `attachment` object. + + :param content: The text content of the comment (supports Markdown). + :param project_id: The ID of the project to add the comment to. + :param task_id: The ID of the task to add the comment to. + :param attachment: The attachment object to include with the comment. + :param uids_to_notify: A list of user IDs to notify. + :return: The newly created comment. + :raises ValueError: If neither `project_id` nor `task_id` is provided. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Comment dictionary. + """ return await run_async( - lambda: self._api.update_comment(comment_id, content, **kwargs) + lambda: self._api.add_comment( + content, + project_id=project_id, + task_id=task_id, + attachment=attachment, + uids_to_notify=uids_to_notify, + ) ) - async def delete_comment(self, comment_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.delete_comment(comment_id, **kwargs)) + async def update_comment( + self, comment_id: str, content: Annotated[str, MaxLen(15000)] + ) -> Comment: + """ + Update an existing comment. + + Currently, only `content` can be updated. + + :param comment_id: The ID of the comment to update. + :param content: The new text content for the comment. + :return: the updated Comment. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.update_comment(comment_id, content)) + + async def delete_comment(self, comment_id: str) -> bool: + """ + Delete a comment. + + :param comment_id: The ID of the comment to delete. + :return: True if the comment was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.delete_comment(comment_id)) async def get_label(self, label_id: str) -> Label: + """ + Get a specific personal label by its ID. + + :param label_id: The ID of the label to retrieve. + :return: The requested label. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Label dictionary. + """ return await run_async(lambda: self._api.get_label(label_id)) - async def get_labels(self) -> list[Label]: - return await run_async(lambda: self._api.get_labels()) + async def get_labels( + self, + *, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Label]]: + """ + Get a list of personal labels. + + Supports pagination arguments. + + :param limit: Maximum number of labels per page (between 1 and 200). + :return: A list of personal labels. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.get_labels(limit=limit) + return generate_async(paginator) + + async def add_label( + self, + name: Annotated[str, MinLen(1), MaxLen(60)], + *, + color: ColorString | None = None, + item_order: int | None = None, + is_favorite: bool | None = None, + ) -> Label: + """ + Create a new personal label. + + :param name: The name of the label. + :param color: The color of the label icon. + :param item_order: Label's order in the label list. + :param is_favorite: Whether the label is a favorite. + :return: The newly created label. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Label dictionary. + """ + return await run_async( + lambda: self._api.add_label( + name, color=color, item_order=item_order, is_favorite=is_favorite + ) + ) - async def add_label(self, name: str, **kwargs) -> Label: - return await run_async(lambda: self._api.add_label(name, **kwargs)) + async def update_label( + self, + label_id: str, + *, + name: Annotated[str, MinLen(1), MaxLen(60)] | None = None, + color: ColorString | None = None, + item_order: int | None = None, + is_favorite: bool | None = None, + ) -> Label: + """ + Update a personal label. + + Only the fields to be updated need to be provided as keyword arguments. + + :param label_id: The ID of the label. + :param name: The name of the label. + :param color: The color of the label icon. + :param item_order: Label's order in the label list. + :param is_favorite: Whether the label is a favorite. + :return: the updated Label. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async( + lambda: self._api.update_label( + label_id, + name=name, + color=color, + item_order=item_order, + is_favorite=is_favorite, + ) + ) + + async def delete_label(self, label_id: str) -> bool: + """ + Delete a personal label. - async def update_label(self, label_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.update_label(label_id, **kwargs)) + Instances of the label will be removed from tasks. - async def delete_label(self, label_id: str, **kwargs) -> bool: - return await run_async(lambda: self._api.delete_label(label_id, **kwargs)) + :param label_id: The ID of the label to delete. + :return: True if the label was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.delete_label(label_id)) - async def get_shared_labels(self) -> list[str]: - return await run_async(lambda: self._api.get_shared_labels()) + async def get_shared_labels( + self, + *, + omit_personal: bool = False, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[str]]: + """ + Get a list of shared label names. + + Includes labels from collaborators on shared projects that are not in the + user's personal labels. Can optionally exclude personal label names using + `omit_personal=True`. Supports pagination arguments. + + :param omit_personal: Optional boolean flag to omit personal label names. + :param limit: Maximum number of labels per page (between 1 and 200). + :return: A list of shared label names (strings). + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.get_shared_labels( + omit_personal=omit_personal, limit=limit + ) + return generate_async(paginator) - async def rename_shared_label(self, name: str, new_name: str) -> bool: + async def rename_shared_label( + self, + name: Annotated[str, MaxLen(60)], + new_name: Annotated[str, MinLen(1), MaxLen(60)], + ) -> bool: + """ + Rename all occurrences of a shared label across all projects. + + :param name: The current name of the shared label to rename. + :param new_name: The new name for the shared label. + :return: True if the rename was successful, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ return await run_async(lambda: self._api.rename_shared_label(name, new_name)) - async def remove_shared_label(self, name: str) -> bool: - return await run_async(lambda: self._api.remove_shared_label(name)) + async def remove_shared_label(self, name: Annotated[str, MaxLen(60)]) -> bool: + """ + Remove all occurrences of a shared label across all projects. - async def get_completed_items( - self, - project_id: str | None = None, - section_id: str | None = None, - item_id: str | None = None, - last_seen_id: str | None = None, - limit: int | None = None, - cursor: str | None = None, - ) -> CompletedItems: - return await run_async( - lambda: self._api.get_completed_items( - project_id, section_id, item_id, last_seen_id, limit, cursor - ) - ) + This action removes the label string from all tasks where it appears. + + :param name: The name of the shared label to remove. + :return: True if the removal was successful, + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async(lambda: self._api.remove_shared_label(name)) diff --git a/todoist_api_python/authentication.py b/todoist_api_python/authentication.py index 7609139..3ec60d5 100644 --- a/todoist_api_python/authentication.py +++ b/todoist_api_python/authentication.py @@ -1,30 +1,49 @@ from __future__ import annotations +from typing import Any from urllib.parse import urlencode import requests from requests import Session -from todoist_api_python.endpoints import ( - AUTHORIZE_ENDPOINT, - REVOKE_TOKEN_ENDPOINT, - TOKEN_ENDPOINT, - get_auth_url, - get_sync_url, +from todoist_api_python._core.endpoints import ( + ACCESS_TOKEN_PATH, + ACCESS_TOKENS_PATH, + AUTHORIZE_PATH, + get_api_url, + get_oauth_url, ) -from todoist_api_python.http_requests import post +from todoist_api_python._core.http_requests import delete, post +from todoist_api_python._core.utils import run_async from todoist_api_python.models import AuthResult -from todoist_api_python.utils import run_async + + +def get_authentication_url(client_id: str, scopes: list[str], state: str) -> str: + """Get authorization URL to initiate OAuth flow.""" + if len(scopes) == 0: + raise ValueError("At least one authorization scope should be requested.") + + endpoint = get_oauth_url(AUTHORIZE_PATH) + query = { + "client_id": client_id, + "scope": ",".join(scopes), + "state": state, + } + return f"{endpoint}?{urlencode(query)}" def get_auth_token( client_id: str, client_secret: str, code: str, session: Session | None = None ) -> AuthResult: - endpoint = get_auth_url(TOKEN_ENDPOINT) + """Get access token using provided client ID, client secret, and auth code.""" + endpoint = get_oauth_url(ACCESS_TOKEN_PATH) session = session or requests.Session() - payload = {"client_id": client_id, "client_secret": client_secret, "code": code} - response = post(session=session, url=endpoint, data=payload) - + data = { + "client_id": client_id, + "client_secret": client_secret, + "code": code, + } + response: dict[str, Any] = post(session=session, url=endpoint, data=data) return AuthResult.from_dict(response) @@ -37,37 +56,19 @@ async def get_auth_token_async( def revoke_auth_token( client_id: str, client_secret: str, token: str, session: Session | None = None ) -> bool: - endpoint = get_sync_url(REVOKE_TOKEN_ENDPOINT) + """Revoke an access token.""" + # `get_api_url` is not a typo. Deleting access tokens is done using the regular API. + endpoint = get_api_url(ACCESS_TOKENS_PATH) session = session or requests.Session() - payload = { + params = { "client_id": client_id, "client_secret": client_secret, "access_token": token, } - return post(session=session, url=endpoint, data=payload) + return delete(session=session, url=endpoint, params=params) async def revoke_auth_token_async( client_id: str, client_secret: str, token: str ) -> bool: return await run_async(lambda: revoke_auth_token(client_id, client_secret, token)) - - -class ArgumentError(Exception): - pass - - -class NoAuthScopesError(ArgumentError): - def __init__(self) -> None: - super().__init__("At least one authorization scope should be requested.") - - -def get_authentication_url(client_id: str, scopes: list[str], state: str) -> str: - if len(scopes) == 0: - raise NoAuthScopesError - - query = {"client_id": client_id, "scope": ",".join(scopes), "state": state} - - auth_url = get_auth_url(AUTHORIZE_ENDPOINT) - - return f"{auth_url}?{urlencode(query)}" diff --git a/todoist_api_python/endpoints.py b/todoist_api_python/endpoints.py deleted file mode 100644 index b1c2def..0000000 --- a/todoist_api_python/endpoints.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from urllib.parse import urljoin - -BASE_URL = "https://api.todoist.com" -AUTH_BASE_URL = "https://todoist.com" -SYNC_VERSION = "v9" -REST_VERSION = "v2" - -SYNC_API = urljoin(BASE_URL, f"/sync/{SYNC_VERSION}/") -REST_API = urljoin(BASE_URL, f"/rest/{REST_VERSION}/") - - -TASKS_ENDPOINT = "tasks" -PROJECTS_ENDPOINT = "projects" -COLLABORATORS_ENDPOINT = "collaborators" -SECTIONS_ENDPOINT = "sections" -COMMENTS_ENDPOINT = "comments" -LABELS_ENDPOINT = "labels" -SHARED_LABELS_ENDPOINT = "labels/shared" -SHARED_LABELS_RENAME_ENDPOINT = f"{SHARED_LABELS_ENDPOINT}/rename" -SHARED_LABELS_REMOVE_ENDPOINT = f"{SHARED_LABELS_ENDPOINT}/remove" -QUICK_ADD_ENDPOINT = "quick/add" - -AUTHORIZE_ENDPOINT = "oauth/authorize" -TOKEN_ENDPOINT = "oauth/access_token" # noqa:S105 -REVOKE_TOKEN_ENDPOINT = "access_tokens/revoke" # noqa:S105 - -COMPLETED_ITEMS_ENDPOINT = "archive/items" - - -def get_rest_url(relative_path: str) -> str: - return urljoin(REST_API, relative_path) - - -def get_sync_url(relative_path: str) -> str: - return urljoin(SYNC_API, relative_path) - - -def get_auth_url(relative_path: str) -> str: - return urljoin(AUTH_BASE_URL, relative_path) diff --git a/todoist_api_python/models.py b/todoist_api_python/models.py index 1976640..0425226 100644 --- a/todoist_api_python/models.py +++ b/todoist_api_python/models.py @@ -1,13 +1,15 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Literal +from dataclasses import dataclass +from typing import Annotated, Literal from dataclass_wizard import JSONPyWizard +from dataclass_wizard.v1.models import Alias -from todoist_api_python.utils import get_url_for_task +from todoist_api_python._core.endpoints import INBOX_URL, get_project_url, get_task_url -VIEW_STYLE = Literal["list", "board"] +VIEW_STYLE = Literal["list", "board", "calendar"] +DURATION_UNIT = Literal["minute", "day"] @dataclass @@ -15,19 +17,32 @@ class Project(JSONPyWizard): class _(JSONPyWizard.Meta): # noqa:N801 v1 = True - color: str - comment_count: int id: str + name: str + description: str + order: Annotated[int, Alias(load=("child_order", "order"))] + color: str + is_collapsed: Annotated[bool, Alias(load=("collapsed", "is_collapsed"))] + is_shared: Annotated[bool, Alias(load=("shared", "is_shared"))] is_favorite: bool - is_inbox_project: bool | None - is_shared: bool - is_team_inbox: bool | None can_assign_tasks: bool | None - name: str - order: int - parent_id: str | None - url: str view_style: VIEW_STYLE + created_at: str | None = None + updated_at: str | None = None + + parent_id: str | None = None + is_inbox_project: Annotated[ + bool | None, Alias(load=("inbox_project", "is_inbox_project")) + ] = None + + workspace_id: str | None = None + folder_id: str | None = None + + @property + def url(self) -> str: + if self.is_inbox_project: + return INBOX_URL + return get_project_url(self.id, self.name) @dataclass @@ -37,8 +52,9 @@ class _(JSONPyWizard.Meta): # noqa:N801 id: str name: str - order: int project_id: str + is_collapsed: Annotated[bool, Alias(load=("collapsed", "is_collapsed"))] + order: Annotated[int, Alias(load=("section_order", "order"))] @dataclass @@ -47,102 +63,53 @@ class _(JSONPyWizard.Meta): # noqa:N801 v1 = True date: str - is_recurring: bool string: str - - datetime: str | None = None + lang: str = "en" + is_recurring: bool = False timezone: str | None = None - def __post_init__(self) -> None: - if not self.datetime and (self.date and self.timezone): - self.datetime = self.date - @dataclass -class Task(JSONPyWizard): +class Meta(JSONPyWizard): class _(JSONPyWizard.Meta): # noqa:N801 v1 = True - assignee_id: str | None - assigner_id: str | None - comment_count: int - is_completed: bool - content: str - created_at: str - creator_id: str - description: str + project: tuple[str, str] + section: tuple[str, str] + assignee: tuple[str, str] + labels: dict[int, str] due: Due | None - id: str - labels: list[str] | None - order: int - parent_id: str | None - priority: int - project_id: str - section_id: str | None - url: str = field(init=False) - duration: Duration | None = None - - sync_id: str | None = None - - def __post_init__(self) -> None: - self.url = get_url_for_task( - int(self.id), int(self.sync_id) if self.sync_id else None - ) - - @classmethod - def from_quick_add_response(cls, obj: dict[str, Any]) -> Task: - obj_copy = obj.copy() - 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(JSONPyWizard): +class Task(JSONPyWizard): class _(JSONPyWizard.Meta): # noqa:N801 v1 = True - task: Task - - resolved_project_name: str | None = None - resolved_assignee_name: str | None = None - resolved_label_names: list[str] | None = None - resolved_section_name: str | None = None - - @classmethod - def from_quick_add_response(cls, obj: dict[str, Any]) -> QuickAddResult: - project_data = obj["meta"].get("project", {}) - assignee_data = obj["meta"].get("assignee", {}) - section_data = obj["meta"].get("section", {}) - - resolved_project_name = None - resolved_assignee_name = None - resolved_section_name = None - - if project_data and len(project_data) == 2: # noqa: PLR2004 - resolved_project_name = obj["meta"]["project"][1] - - if assignee_data and len(assignee_data) == 2: # noqa: PLR2004 - resolved_assignee_name = obj["meta"]["assignee"][1] - - if section_data and len(section_data) == 2: # noqa: PLR2004 - resolved_section_name = obj["meta"]["section"][1] + id: str + content: str + description: str + project_id: str + section_id: str | None + parent_id: str | None + labels: list[str] | None + priority: int + due: Due | None + duration: Duration | None + is_collapsed: Annotated[bool, Alias(load=("collapsed", "is_collapsed"))] + order: Annotated[int, Alias(load=("child_order", "order"))] + assignee_id: Annotated[str | None, Alias(load=("responsible_uid", "assignee_id"))] + assigner_id: Annotated[str | None, Alias(load=("assigned_by_uid", "assigner_id"))] + completed_at: str | None + creator_id: Annotated[str, Alias(load=("added_by_uid", "creator_id"))] + created_at: Annotated[str, Alias(load=("added_at", "created_at"))] + updated_at: str | None - resolved_label_names = list(obj["meta"]["labels"].values()) + meta: Meta | None = None - return cls( - task=Task.from_quick_add_response(obj), - resolved_project_name=resolved_project_name, - resolved_assignee_name=resolved_assignee_name, - resolved_label_names=resolved_label_names, - resolved_section_name=resolved_section_name, - ) + @property + def url(self) -> str: + return get_task_url(self.id, self.content) @dataclass @@ -182,12 +149,24 @@ class Comment(JSONPyWizard): class _(JSONPyWizard.Meta): # noqa:N801 v1 = True - content: str id: str + content: str + poster_id: Annotated[str, Alias(load=("posted_uid", "poster_id"))] posted_at: str - project_id: str | None - task_id: str | None - attachment: Attachment | None = None + task_id: Annotated[str | None, Alias(load=("item_id", "task_id"))] = None + project_id: str | None = None + attachment: Annotated[ + Attachment | None, Alias(load=("file_attachment", "attachment")) + ] = None + + def __post_init__(self) -> None: + """ + Finish initialization of the Comment object. + + :raises ValueError: If neither `task_id` nor `project_id` is specified. + """ + if self.task_id is None and self.project_id is None: + raise ValueError("Must specify `task_id` or `project_id`") @dataclass @@ -211,59 +190,10 @@ class _(JSONPyWizard.Meta): # noqa:N801 state: str | None -@dataclass -class Item(JSONPyWizard): - class _(JSONPyWizard.Meta): # noqa:N801 - v1 = True - - id: str - user_id: str - project_id: str - content: str - description: str - priority: int - child_order: int - collapsed: bool - labels: list[str] - checked: bool - is_deleted: bool - added_at: str - due: Due | None = None - parent_id: int | None = None - section_id: str | None = None - day_order: int | None = None - added_by_uid: str | None = None - assigned_by_uid: str | None = None - responsible_uid: str | None = None - sync_id: str | None = None - completed_at: str | None = None - - -@dataclass -class ItemCompletedInfo(JSONPyWizard): - class _(JSONPyWizard.Meta): # noqa:N801 - v1 = True - - item_id: str - completed_items: int - - -@dataclass -class CompletedItems(JSONPyWizard): - class _(JSONPyWizard.Meta): # noqa:N801 - v1 = True - - items: list[Item] - total: int - completed_info: list[ItemCompletedInfo] - has_more: bool - next_cursor: str | None = None - - @dataclass class Duration(JSONPyWizard): class _(JSONPyWizard.Meta): # noqa:N801 v1 = True amount: int - unit: str + unit: DURATION_UNIT diff --git a/todoist_api_python/utils.py b/todoist_api_python/utils.py deleted file mode 100644 index b698866..0000000 --- a/todoist_api_python/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING, TypeVar - -if TYPE_CHECKING: - from collections.abc import Callable - -SHOW_TASK_ENDPOINT = "https://todoist.com/showTask" - - -def get_url_for_task(task_id: int, sync_id: int | None) -> str: - return ( - f"{SHOW_TASK_ENDPOINT}?id={task_id}&sync_id={sync_id}" - if sync_id - else f"{SHOW_TASK_ENDPOINT}?id={task_id}" - ) - - -T = TypeVar("T") - - -async def run_async(func: Callable[[], T]) -> T: - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, func) diff --git a/uv.lock b/uv.lock index e5a1867..19a45e6 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 1 requires-python = ">=3.13, <4" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "certifi" version = "2025.1.31" @@ -288,6 +297,7 @@ name = "todoist-api-python" version = "2.1.7" source = { editable = "." } dependencies = [ + { name = "annotated-types" }, { name = "dataclass-wizard" }, { name = "requests" }, ] @@ -305,6 +315,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "annotated-types" }, { name = "dataclass-wizard", specifier = ">=0.35.0,<1.0" }, { name = "requests", specifier = ">=2.32.3,<3" }, ]