diff --git a/CHANGELOG.md b/CHANGELOG.md index cbb36dc..6e5ee7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support for searching projects +- Support for searching sections +- Support for searching labels + ## [3.1.0] - 2025-05-07 ### Added diff --git a/tests/test_api_labels.py b/tests/test_api_labels.py index 94e3282..fbf090b 100644 --- a/tests/test_api_labels.py +++ b/tests/test_api_labels.py @@ -88,6 +88,49 @@ async def test_get_labels( count += 1 +@pytest.mark.asyncio +async def test_search_labels( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_labels_response: list[PaginatedResults], + default_labels_list: list[list[Label]], +) -> None: + endpoint = f"{DEFAULT_API_URL}/labels/search" + query = "A label" + + 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(), + request_id_matcher(), + param_matcher({"query": query}, cursor), + ], + ) + cursor = page["next_cursor"] + + count = 0 + + labels_iter = todoist_api.search_labels(query) + + 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.search_labels(query) + + 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 async def test_add_label_minimal( todoist_api: TodoistAPI, diff --git a/tests/test_api_projects.py b/tests/test_api_projects.py index e89f8dc..01002cf 100644 --- a/tests/test_api_projects.py +++ b/tests/test_api_projects.py @@ -88,6 +88,49 @@ async def test_get_projects( count += 1 +@pytest.mark.asyncio +async def test_search_projects( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_projects_response: list[PaginatedResults], + default_projects_list: list[list[Project]], +) -> None: + endpoint = f"{DEFAULT_API_URL}/projects/search" + query = "Inbox" + + 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(), + request_id_matcher(), + param_matcher({"query": query}, cursor), + ], + ) + cursor = page["next_cursor"] + + count = 0 + + projects_iter = todoist_api.search_projects(query) + + 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.search_projects(query) + + 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 async def test_add_project_minimal( todoist_api: TodoistAPI, diff --git a/tests/test_api_sections.py b/tests/test_api_sections.py index b37ef02..8fbbcda 100644 --- a/tests/test_api_sections.py +++ b/tests/test_api_sections.py @@ -131,6 +131,95 @@ async def test_get_sections_by_project( count += 1 +@pytest.mark.asyncio +async def test_search_sections( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_sections_response: list[PaginatedResults], + default_sections_list: list[list[Section]], +) -> None: + endpoint = f"{DEFAULT_API_URL}/sections/search" + query = "A Section" + + 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(), + request_id_matcher(), + param_matcher({"query": query}, cursor), + ], + ) + cursor = page["next_cursor"] + + count = 0 + + sections_iter = todoist_api.search_sections(query) + + 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.search_sections(query) + + 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_search_sections_by_project( + todoist_api: TodoistAPI, + todoist_api_async: TodoistAPIAsync, + requests_mock: responses.RequestsMock, + default_sections_response: list[PaginatedResults], + default_sections_list: list[list[Section]], +) -> None: + endpoint = f"{DEFAULT_API_URL}/sections/search" + project_id = "123" + query = "A Section" + + 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(), + request_id_matcher(), + param_matcher({"query": query, "project_id": project_id}, cursor), + ], + ) + cursor = page["next_cursor"] + + count = 0 + + sections_iter = todoist_api.search_sections(query, 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.search_sections( + query, 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 async def test_add_section( todoist_api: TodoistAPI, diff --git a/todoist_api_python/_core/endpoints.py b/todoist_api_python/_core/endpoints.py index f081ffc..e10d0ae 100644 --- a/todoist_api_python/_core/endpoints.py +++ b/todoist_api_python/_core/endpoints.py @@ -18,12 +18,15 @@ TASKS_COMPLETED_BY_DUE_DATE_PATH = f"{TASKS_COMPLETED_PATH}/by_due_date" TASKS_COMPLETED_BY_COMPLETION_DATE_PATH = f"{TASKS_COMPLETED_PATH}/by_completion_date" PROJECTS_PATH = "projects" +PROJECTS_SEARCH_PATH_SUFFIX = "search" PROJECT_ARCHIVE_PATH_SUFFIX = "archive" PROJECT_UNARCHIVE_PATH_SUFFIX = "unarchive" COLLABORATORS_PATH = "collaborators" SECTIONS_PATH = "sections" +SECTIONS_SEARCH_PATH_SUFFIX = "search" COMMENTS_PATH = "comments" LABELS_PATH = "labels" +LABELS_SEARCH_PATH_SUFFIX = "search" SHARED_LABELS_PATH = "labels/shared" SHARED_LABELS_RENAME_PATH = f"{SHARED_LABELS_PATH}/rename" SHARED_LABELS_REMOVE_PATH = f"{SHARED_LABELS_PATH}/remove" diff --git a/todoist_api_python/api.py b/todoist_api_python/api.py index a297bf5..85343cf 100644 --- a/todoist_api_python/api.py +++ b/todoist_api_python/api.py @@ -12,10 +12,13 @@ COLLABORATORS_PATH, COMMENTS_PATH, LABELS_PATH, + LABELS_SEARCH_PATH_SUFFIX, PROJECT_ARCHIVE_PATH_SUFFIX, PROJECT_UNARCHIVE_PATH_SUFFIX, PROJECTS_PATH, + PROJECTS_SEARCH_PATH_SUFFIX, SECTIONS_PATH, + SECTIONS_SEARCH_PATH_SUFFIX, SHARED_LABELS_PATH, SHARED_LABELS_REMOVE_PATH, SHARED_LABELS_RENAME_PATH, @@ -745,6 +748,41 @@ def get_projects( params, ) + def search_projects( + self, + query: Annotated[str, MinLen(1), MaxLen(1024)], + *, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Project]]: + """ + Search active projects by name. + + The response is an iterable of lists of projects matching the query. + 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 string for project names. + :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(f"{PROJECTS_PATH}/{PROJECTS_SEARCH_PATH_SUFFIX}") + + params: dict[str, Any] = {"query": query} + if limit is not None: + params["limit"] = limit + + return ResultsPaginator( + self._session, + endpoint, + "results", + Project.from_dict, + self._token, + self._request_id_fn, + params, + ) + def add_project( self, name: Annotated[str, MinLen(1), MaxLen(120)], @@ -992,6 +1030,45 @@ def get_sections( params, ) + def search_sections( + self, + query: Annotated[str, MinLen(1), MaxLen(1024)], + *, + project_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Section]]: + """ + Search active sections by name. + + The response is an iterable of lists of sections matching the query. + 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 string for section names. + :param project_id: If set, search sections within the given project only. + :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(f"{SECTIONS_PATH}/{SECTIONS_SEARCH_PATH_SUFFIX}") + + params: dict[str, Any] = {"query": query} + 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, + self._request_id_fn, + params, + ) + def add_section( self, name: Annotated[str, MinLen(1), MaxLen(2048)], @@ -1254,7 +1331,7 @@ def get_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. - :param limit: ` number of labels per page. + :param limit: Maximum 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. @@ -1275,6 +1352,41 @@ def get_labels( params, ) + def search_labels( + self, + query: Annotated[str, MinLen(1), MaxLen(1024)], + *, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> Iterator[list[Label]]: + """ + Search personal labels by name. + + The response is an iterable of lists of labels matching the query. + 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 string for label names. + :param limit: Maximum number of labels per page. + :return: An iterable of lists of labels. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + endpoint = get_api_url(f"{LABELS_PATH}/{LABELS_SEARCH_PATH_SUFFIX}") + + params: dict[str, Any] = {"query": query} + if limit is not None: + params["limit"] = limit + + return ResultsPaginator( + self._session, + endpoint, + "results", + Label.from_dict, + self._token, + self._request_id_fn, + params, + ) + def add_label( self, name: Annotated[str, MinLen(1), MaxLen(60)], diff --git a/todoist_api_python/api_async.py b/todoist_api_python/api_async.py index 02aa84d..cb6945b 100644 --- a/todoist_api_python/api_async.py +++ b/todoist_api_python/api_async.py @@ -510,6 +510,28 @@ async def get_projects( paginator = self._api.get_projects(limit=limit) return generate_async(paginator) + async def search_projects( + self, + query: Annotated[str, MinLen(1), MaxLen(1024)], + *, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Project]]: + """ + Search active projects by name. + + The response is an iterable of lists of projects matching the query. + 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 string for project names. + :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. + """ + paginator = self._api.search_projects(query, limit=limit) + return generate_async(paginator) + async def add_project( self, name: Annotated[str, MinLen(1), MaxLen(120)], @@ -667,6 +689,34 @@ async def get_sections( paginator = self._api.get_sections(project_id=project_id, limit=limit) return generate_async(paginator) + async def search_sections( + self, + query: Annotated[str, MinLen(1), MaxLen(1024)], + *, + project_id: str | None = None, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Section]]: + """ + Search active sections by name. + + The response is an iterable of lists of sections matching the query. + 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 string for section names. + :param project_id: If set, search sections within the given project only. + :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. + """ + paginator = self._api.search_sections( + query, + project_id=project_id, + limit=limit, + ) + return generate_async(paginator) + async def add_section( self, name: Annotated[str, MinLen(1), MaxLen(2048)], @@ -844,6 +894,28 @@ async def get_labels( paginator = self._api.get_labels(limit=limit) return generate_async(paginator) + async def search_labels( + self, + query: Annotated[str, MinLen(1), MaxLen(1024)], + *, + limit: Annotated[int, Ge(1), Le(200)] | None = None, + ) -> AsyncGenerator[list[Label]]: + """ + Search personal labels by name. + + The response is an iterable of lists of labels matching the query. + 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 string for label names. + :param limit: Maximum number of labels per page. + :return: An iterable of lists of labels. + :raises requests.exceptions.HTTPError: If the API request fails. + :raises TypeError: If the API response structure is unexpected. + """ + paginator = self._api.search_labels(query, limit=limit) + return generate_async(paginator) + async def add_label( self, name: Annotated[str, MinLen(1), MaxLen(60)],