Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions tests/test_api_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions tests/test_api_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions tests/test_api_sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions todoist_api_python/_core/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
114 changes: 113 additions & 1 deletion todoist_api_python/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -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.
Expand All @@ -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)],
Expand Down
Loading