From 23146e74a102074b7a3ffde2a8726b5e31b5fb67 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Wed, 18 Feb 2026 13:23:47 +0000 Subject: [PATCH] Add workspace folders CRUD support Add Folder model and full API support (get, list, create, update, delete) for the /api/v1/folders endpoints, with sync and async clients and tests. Co-Authored-By: Claude Opus 4.6 --- tests/conftest.py | 26 +++ tests/data/test_defaults.py | 26 +++ tests/test_api_folders.py | 283 ++++++++++++++++++++++++++ tests/test_models.py | 16 ++ todoist_api_python/_core/endpoints.py | 1 + todoist_api_python/api.py | 145 +++++++++++++ todoist_api_python/api_async.py | 98 +++++++++ todoist_api_python/models.py | 13 ++ uv.lock | 2 +- 9 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 tests/test_api_folders.py diff --git a/tests/conftest.py b/tests/conftest.py index 1e3864a..883cd06 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,8 @@ DEFAULT_COMMENT_RESPONSE, DEFAULT_COMMENTS_RESPONSE, DEFAULT_COMPLETED_TASKS_RESPONSE, + DEFAULT_FOLDER_RESPONSE, + DEFAULT_FOLDERS_RESPONSE, DEFAULT_LABEL_RESPONSE, DEFAULT_LABELS_RESPONSE, DEFAULT_PROJECT_RESPONSE, @@ -30,6 +32,7 @@ AuthResult, Collaborator, Comment, + Folder, Label, Project, Section, @@ -207,6 +210,29 @@ def default_labels_list() -> list[list[Label]]: ] +@pytest.fixture +def default_folder_response() -> dict[str, Any]: + return DEFAULT_FOLDER_RESPONSE + + +@pytest.fixture +def default_folder() -> Folder: + return Folder.from_dict(DEFAULT_FOLDER_RESPONSE) + + +@pytest.fixture +def default_folders_response() -> list[PaginatedResults]: + return DEFAULT_FOLDERS_RESPONSE + + +@pytest.fixture +def default_folders_list() -> list[list[Folder]]: + return [ + [Folder.from_dict(result) for result in response["results"]] + for response in DEFAULT_FOLDERS_RESPONSE + ] + + @pytest.fixture def default_quick_add_response() -> dict[str, Any]: return DEFAULT_TASK_RESPONSE diff --git a/tests/data/test_defaults.py b/tests/data/test_defaults.py index 99adc08..cc7a4b6 100644 --- a/tests/data/test_defaults.py +++ b/tests/data/test_defaults.py @@ -268,6 +268,32 @@ class PaginatedItems(TypedDict): }, ] +DEFAULT_FOLDER_RESPONSE = { + "id": "6X7rM8997g3RQmvh", + "name": "Test Folder", + "workspace_id": "ws_001", + "default_order": 1, + "child_order": 1, + "is_deleted": False, +} + +DEFAULT_FOLDER_RESPONSE_2 = dict(DEFAULT_FOLDER_RESPONSE) +DEFAULT_FOLDER_RESPONSE_2["id"] = "6X7rfFVPjhvv84XG" + +DEFAULT_FOLDER_RESPONSE_3 = dict(DEFAULT_FOLDER_RESPONSE) +DEFAULT_FOLDER_RESPONSE_3["id"] = "6X7rfEVP8hvv25ZQ" + +DEFAULT_FOLDERS_RESPONSE: list[PaginatedResults] = [ + { + "results": [DEFAULT_FOLDER_RESPONSE, DEFAULT_FOLDER_RESPONSE_2], + "next_cursor": "next", + }, + { + "results": [DEFAULT_FOLDER_RESPONSE_3], + "next_cursor": None, + }, +] + DEFAULT_AUTH_RESPONSE = { "access_token": "123456789", "state": "somestate", diff --git a/tests/test_api_folders.py b/tests/test_api_folders.py new file mode 100644 index 0000000..7f9ed4f --- /dev/null +++ b/tests/test_api_folders.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +import responses + +from tests.data.test_defaults import DEFAULT_API_URL, PaginatedResults +from tests.utils.test_utils import ( + auth_matcher, + data_matcher, + enumerate_async, + param_matcher, + request_id_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 Folder + + +@pytest.mark.asyncio +async def test_get_folder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_folder_response: dict[str, Any], + default_folder: Folder, +) -> None: + folder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/folders/{folder_id}" + + requests_mock.add( + method=responses.GET, + url=endpoint, + json=default_folder_response, + status=200, + match=[auth_matcher()], + ) + + folder = todoist_api.get_folder(folder_id) + + assert len(requests_mock.calls) == 1 + assert folder == default_folder + + folder = await todoist_api_async.get_folder(folder_id) + + assert len(requests_mock.calls) == 2 + assert folder == default_folder + + +@pytest.mark.asyncio +async def test_get_folders( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_folders_response: list[PaginatedResults], + default_folders_list: list[list[Folder]], +) -> None: + endpoint = f"{DEFAULT_API_URL}/folders" + + cursor: str | None = None + for page in default_folders_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[auth_matcher(), request_id_matcher(), param_matcher({}, cursor)], + ) + cursor = page["next_cursor"] + + count = 0 + + folders_iter = todoist_api.get_folders() + + for i, folders in enumerate(folders_iter): + assert len(requests_mock.calls) == count + 1 + assert folders == default_folders_list[i] + count += 1 + + folders_async_iter = await todoist_api_async.get_folders() + + async for i, folders in enumerate_async(folders_async_iter): + assert len(requests_mock.calls) == count + 1 + assert folders == default_folders_list[i] + count += 1 + + +@pytest.mark.asyncio +async def test_get_folders_with_workspace_id( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_folders_response: list[PaginatedResults], + default_folders_list: list[list[Folder]], +) -> None: + endpoint = f"{DEFAULT_API_URL}/folders" + workspace_id = "ws_001" + + cursor: str | None = None + for page in default_folders_response: + requests_mock.add( + method=responses.GET, + url=endpoint, + json=page, + status=200, + match=[ + auth_matcher(), + request_id_matcher(), + param_matcher({"workspace_id": workspace_id}, cursor), + ], + ) + cursor = page["next_cursor"] + + count = 0 + + folders_iter = todoist_api.get_folders(workspace_id=workspace_id) + + for i, folders in enumerate(folders_iter): + assert len(requests_mock.calls) == count + 1 + assert folders == default_folders_list[i] + count += 1 + + folders_async_iter = await todoist_api_async.get_folders( + workspace_id=workspace_id, + ) + + async for i, folders in enumerate_async(folders_async_iter): + assert len(requests_mock.calls) == count + 1 + assert folders == default_folders_list[i] + count += 1 + + +@pytest.mark.asyncio +async def test_add_folder_minimal( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_folder_response: dict[str, Any], + default_folder: Folder, +) -> None: + folder_name = "Test Folder" + workspace_id = "ws_001" + + requests_mock.add( + method=responses.POST, + url=f"{DEFAULT_API_URL}/folders", + json=default_folder_response, + status=200, + match=[ + auth_matcher(), + request_id_matcher(), + data_matcher({"name": folder_name, "workspace_id": workspace_id}), + ], + ) + + new_folder = todoist_api.add_folder(name=folder_name, workspace_id=workspace_id) + + assert len(requests_mock.calls) == 1 + assert new_folder == default_folder + + new_folder = await todoist_api_async.add_folder( + name=folder_name, workspace_id=workspace_id + ) + + assert len(requests_mock.calls) == 2 + assert new_folder == default_folder + + +@pytest.mark.asyncio +async def test_add_folder_full( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_folder_response: dict[str, Any], + default_folder: Folder, +) -> None: + folder_name = "Test Folder" + workspace_id = "ws_001" + default_order = 1 + child_order = 2 + + requests_mock.add( + method=responses.POST, + url=f"{DEFAULT_API_URL}/folders", + json=default_folder_response, + status=200, + match=[ + auth_matcher(), + request_id_matcher(), + data_matcher( + { + "name": folder_name, + "workspace_id": workspace_id, + "default_order": default_order, + "child_order": child_order, + } + ), + ], + ) + + new_folder = todoist_api.add_folder( + name=folder_name, + workspace_id=workspace_id, + default_order=default_order, + child_order=child_order, + ) + + assert len(requests_mock.calls) == 1 + assert new_folder == default_folder + + new_folder = await todoist_api_async.add_folder( + name=folder_name, + workspace_id=workspace_id, + default_order=default_order, + child_order=child_order, + ) + + assert len(requests_mock.calls) == 2 + assert new_folder == default_folder + + +@pytest.mark.asyncio +async def test_update_folder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_folder: Folder, +) -> None: + args: dict[str, Any] = { + "name": "Updated Folder", + "default_order": 5, + } + updated_folder_dict = default_folder.to_dict() | args + + requests_mock.add( + method=responses.POST, + url=f"{DEFAULT_API_URL}/folders/{default_folder.id}", + json=updated_folder_dict, + status=200, + match=[auth_matcher(), request_id_matcher(), data_matcher(args)], + ) + + response = todoist_api.update_folder(folder_id=default_folder.id, **args) + + assert len(requests_mock.calls) == 1 + assert response == Folder.from_dict(updated_folder_dict) + + response = await todoist_api_async.update_folder( + folder_id=default_folder.id, **args + ) + + assert len(requests_mock.calls) == 2 + assert response == Folder.from_dict(updated_folder_dict) + + +@pytest.mark.asyncio +async def test_delete_folder( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, +) -> None: + folder_id = "6X7rM8997g3RQmvh" + endpoint = f"{DEFAULT_API_URL}/folders/{folder_id}" + + requests_mock.add( + method=responses.DELETE, + url=endpoint, + status=204, + match=[auth_matcher(), request_id_matcher()], + ) + + response = todoist_api.delete_folder(folder_id) + + assert len(requests_mock.calls) == 1 + assert response is True + + response = await todoist_api_async.delete_folder(folder_id) + + assert len(requests_mock.calls) == 2 + assert response is True diff --git a/tests/test_models.py b/tests/test_models.py index 06840d6..a8c4ce3 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,6 +6,7 @@ DEFAULT_COMMENT_RESPONSE, DEFAULT_DUE_RESPONSE, DEFAULT_DURATION_RESPONSE, + DEFAULT_FOLDER_RESPONSE, DEFAULT_LABEL_RESPONSE, DEFAULT_PROJECT_RESPONSE, DEFAULT_PROJECT_RESPONSE_2, @@ -20,6 +21,7 @@ Comment, Due, Duration, + Folder, Label, Project, Section, @@ -187,6 +189,20 @@ def test_label_from_dict() -> None: assert label.is_favorite == sample_data["is_favorite"] +def test_folder_from_dict() -> None: + sample_data = dict(DEFAULT_FOLDER_RESPONSE) + sample_data.update(unexpected_data) + + folder = Folder.from_dict(sample_data) + + assert folder.id == sample_data["id"] + assert folder.name == sample_data["name"] + assert folder.workspace_id == sample_data["workspace_id"] + assert folder.default_order == sample_data["default_order"] + assert folder.child_order == sample_data["child_order"] + assert folder.is_deleted == sample_data["is_deleted"] + + def test_auth_result_from_dict() -> None: token = "123" state = "456" diff --git a/todoist_api_python/_core/endpoints.py b/todoist_api_python/_core/endpoints.py index e10d0ae..e482eb8 100644 --- a/todoist_api_python/_core/endpoints.py +++ b/todoist_api_python/_core/endpoints.py @@ -26,6 +26,7 @@ SECTIONS_SEARCH_PATH_SUFFIX = "search" COMMENTS_PATH = "comments" LABELS_PATH = "labels" +FOLDERS_PATH = "folders" LABELS_SEARCH_PATH_SUFFIX = "search" SHARED_LABELS_PATH = "labels/shared" SHARED_LABELS_RENAME_PATH = f"{SHARED_LABELS_PATH}/rename" diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index 85343cf..cf18c27 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -11,6 +11,7 @@ from todoist_api_python._core.endpoints import ( COLLABORATORS_PATH, COMMENTS_PATH, + FOLDERS_PATH, LABELS_PATH, LABELS_SEARCH_PATH_SUFFIX, PROJECT_ARCHIVE_PATH_SUFFIX, @@ -39,6 +40,7 @@ Attachment, Collaborator, Comment, + Folder, Label, Project, Section, @@ -941,6 +943,149 @@ def delete_project(self, project_id: str) -> bool: self._request_id_fn() if self._request_id_fn else None, ) + def get_folder(self, folder_id: str) -> Folder: + """ + Get a specific folder by its ID. + + :param folder_id: The ID of the folder to retrieve. + :return: The requested folder. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Folder dictionary. + """ + endpoint = get_api_url(f"{FOLDERS_PATH}/{folder_id}") + folder_data: dict[str, Any] = get( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + return Folder.from_dict(folder_data) + + def get_folders( + self, + *, + workspace_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Folder]]: + """ + Get an iterable of lists of folders. + + The response is an iterable of lists of folders. + 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 workspace_id: Filter folders by workspace ID. + :param limit: Maximum number of folders per page. + :return: An iterable of lists of folders. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(FOLDERS_PATH) + + params: dict[str, Any] = {} + if workspace_id is not None: + params["workspace_id"] = workspace_id + if limit is not None: + params["limit"] = limit + + return ResultsPaginator( + self._session, + endpoint, + "results", + Folder.from_dict, + self._token, + self._request_id_fn, + params, + ) + + def add_folder( + self, + name: str, + workspace_id: str, + *, + default_order: int | None = None, + child_order: int | None = None, + ) -> Folder: + """ + Create a new folder. + + :param name: The name of the folder. + :param workspace_id: The ID of the workspace to add the folder to. + :param default_order: The default order of the folder. + :param child_order: The child order of the folder. + :return: The newly created folder. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Folder dictionary. + """ + endpoint = get_api_url(FOLDERS_PATH) + + data: dict[str, Any] = {"name": name, "workspace_id": workspace_id} + if default_order is not None: + data["default_order"] = default_order + if child_order is not None: + data["child_order"] = child_order + + folder_data: dict[str, Any] = post( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + return Folder.from_dict(folder_data) + + def update_folder( + self, + folder_id: str, + *, + name: str | None = None, + default_order: int | None = None, + ) -> Folder: + """ + Update an existing folder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param folder_id: The ID of the folder to update. + :param name: The name of the folder. + :param default_order: The default order of the folder. + :return: The updated folder. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{FOLDERS_PATH}/{folder_id}") + + data: dict[str, Any] = {} + if name is not None: + data["name"] = name + if default_order is not None: + data["default_order"] = default_order + + folder_data: dict[str, Any] = post( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + data=data, + ) + return Folder.from_dict(folder_data) + + def delete_folder(self, folder_id: str) -> bool: + """ + Delete a folder. + + :param folder_id: The ID of the folder to delete. + :return: True if the folder was deleted successfully, + False otherwise (possibly raise `HTTPError` instead). + :raises requests.exceptions.HTTPError: If the API request fails. + """ + endpoint = get_api_url(f"{FOLDERS_PATH}/{folder_id}") + return delete( + self._session, + endpoint, + self._token, + self._request_id_fn() if self._request_id_fn else None, + ) + def get_collaborators( self, project_id: str, diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index cb6945b..12150ba 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -23,6 +23,7 @@ Attachment, Collaborator, Comment, + Folder, Label, Project, Section, @@ -641,6 +642,103 @@ async def delete_project(self, project_id: str) -> bool: """ return await run_async(lambda: self._api.delete_project(project_id)) + async def get_folder(self, folder_id: str) -> Folder: + """ + Get a specific folder by its ID. + + :param folder_id: The ID of the folder to retrieve. + :return: The requested folder. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Folder dictionary. + """ + return await run_async(lambda: self._api.get_folder(folder_id)) + + async def get_folders( + self, + *, + workspace_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Folder]]: + """ + Get a list of folders. + + :param workspace_id: Filter folders by workspace ID. + :param limit: Maximum number of folders per page. + :return: A list of folders. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.get_folders( + workspace_id=workspace_id, + limit=limit, + ) + return generate_async(paginator) + + async def add_folder( + self, + name: str, + workspace_id: str, + *, + default_order: int | None = None, + child_order: int | None = None, + ) -> Folder: + """ + Create a new folder. + + :param name: The name of the folder. + :param workspace_id: The ID of the workspace to add the folder to. + :param default_order: The default order of the folder. + :param child_order: The child order of the folder. + :return: The newly created folder. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response is not a valid Folder dictionary. + """ + return await run_async( + lambda: self._api.add_folder( + name, + workspace_id, + default_order=default_order, + child_order=child_order, + ) + ) + + async def update_folder( + self, + folder_id: str, + *, + name: str | None = None, + default_order: int | None = None, + ) -> Folder: + """ + Update an existing folder. + + Only the fields to be updated need to be provided as keyword arguments. + + :param folder_id: The ID of the folder to update. + :param name: The name of the folder. + :param default_order: The default order of the folder. + :return: The updated folder. + :raises requests.exceptions.HTTPError: If the API request fails. + """ + return await run_async( + lambda: self._api.update_folder( + folder_id, + name=name, + default_order=default_order, + ) + ) + + async def delete_folder(self, folder_id: str) -> bool: + """ + Delete a folder. + + :param folder_id: The ID of the folder to delete. + :return: True if the folder 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_folder(folder_id)) + async def get_collaborators( self, project_id: str, diff --git a/todoist_api_python/models.py b/todoist_api_python/models.py index 24bd33c..74bcb36 100644 --- a/todoist_api_python/models.py +++ b/todoist_api_python/models.py @@ -202,6 +202,19 @@ class _(JSONPyWizard.Meta): # noqa:N801 is_favorite: bool +@dataclass +class Folder(JSONPyWizard): + class _(JSONPyWizard.Meta): # noqa:N801 + v1 = True + + id: str + name: str + workspace_id: str + default_order: int + child_order: int + is_deleted: bool + + @dataclass class AuthResult(JSONPyWizard): class _(JSONPyWizard.Meta): # noqa:N801 diff --git a/uv.lock b/uv.lock index 30c2445..54c08fe 100644 --- a/uv.lock +++ b/uv.lock @@ -839,7 +839,7 @@ wheels = [ [[package]] name = "todoist-api-python" -version = "3.2.0" +version = "3.2.1" source = { editable = "." } dependencies = [ { name = "annotated-types" },