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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `TodoistAPIAsync` now performs true async HTTP I/O with `httpx.AsyncClient`.

### Changed

- **Breaking**: `TodoistAPI` now accepts an optional `client: httpx.Client` instead of `session: requests.Session`.
- **Breaking**: `TodoistAPIAsync` now accepts an optional `client: httpx.AsyncClient` instead of `session: requests.Session`.
- **Breaking**: API errors now raise `httpx.HTTPStatusError` instead of `requests.exceptions.HTTPError`.
- **Breaking**: Authentication helpers now accept optional `httpx.Client` / `httpx.AsyncClient` instances instead of `session: requests.Session`.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should also mention that all AsyncGenerator became AsyncIterator in the breaking changes, in case people have typed variables with the former?

## [3.2.1] - 2026-01-22

### Fixed
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,30 @@ for comments in comments_iter:
print(f"Comment: {comment.content}")
```

### Async usage

Always close `TodoistAPIAsync` explicitly, either via `async with` (recommended) or by calling `await api.close()`.

```python
from todoist_api_python.api_async import TodoistAPIAsync

async with TodoistAPIAsync("YOUR_API_TOKEN") as api:
task = await api.get_task("6X4Vw2Hfmg73Q2XR")
print(task.content)
```

## Documentation

For more detailed reference documentation, have a look at the [SDK documentation](https://doist.github.io/todoist-api-python/) and the [API documentation](https://developer.todoist.com).

## Migrating from 3.x

Version `4.x` introduces a breaking HTTP stack migration from `requests` to `httpx`.

- `TodoistAPI(..., session=...)` is now `TodoistAPI(..., client=...)` with `httpx.Client`.
- `TodoistAPIAsync(..., session=...)` is now `TodoistAPIAsync(..., client=...)` with `httpx.AsyncClient`.
- Error handling should catch `httpx.HTTPStatusError` instead of `requests.exceptions.HTTPError`.

## Development

To install Python dependencies:
Expand Down
21 changes: 11 additions & 10 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
# Authentication

This module provides functions to help authenticate with Todoist using the OAuth protocol.
This module provides helpers to authenticate with Todoist using OAuth.

## Quick start

```python
import uuid
from todoist_api_python.authentication import get_access_token, get_authentication_url

from todoist_api_python.authentication import get_auth_token, get_authentication_url

# 1. Generate a random state
state = uuid.uuid4()
state = str(uuid.uuid4())

# 2. Get authorization url
# 2. Build the authorization URL
url = get_authentication_url(
client_id="YOUR_CLIENT_ID",
scopes=["data:read", "task:add"],
state=uuid.uuid4()
state=state,
)

# 3.Redirect user to url
# 4. Handle OAuth callback and get code
# 3. Redirect the user to `url`
# 4. Handle the OAuth callback and obtain the auth code
code = "CODE_YOU_OBTAINED"

# 5. Exchange code for access token
auth_result = get_access_token(
# 5. Exchange code for an access token
auth_result = get_auth_token(
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
code=code,
)

# 6. Ensure state is consistent, and done!
assert(auth_result.state == state)
assert auth_result.state == state
access_token = auth_result.access_token
```

Expand Down
5 changes: 5 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ for comments in comments_iter:
print(f"Comment: {comment.content}")
```

### Async usage

Use `TodoistAPIAsync` with `async with` (or call `await api.close()` manually)
to ensure the underlying `httpx.AsyncClient` is closed.

## Quick start

- [Authentication](authentication.md)
Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ classifiers = [
]

dependencies = [
"requests>=2.32.3,<3",
"httpx>=0.28.1,<1",
"dataclass-wizard>=0.35.0,<1.0",
"annotated-types",
]
Expand All @@ -32,8 +32,7 @@ dev = [
"tox-uv>=1.25.0,<2",
"mypy~=1.11",
"ruff>=0.11.0,<0.12",
"responses>=0.25.3,<0.26",
"types-requests~=2.32",
"respx>=0.22.0,<0.23",
]

docs = [
Expand Down
29 changes: 16 additions & 13 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import TYPE_CHECKING, Any

import pytest
import responses
import pytest_asyncio

from tests.data.test_defaults import (
DEFAULT_AUTH_RESPONSE,
Expand All @@ -15,6 +15,7 @@
DEFAULT_LABELS_RESPONSE,
DEFAULT_PROJECT_RESPONSE,
DEFAULT_PROJECTS_RESPONSE,
DEFAULT_REQUEST_ID,
DEFAULT_SECTION_RESPONSE,
DEFAULT_SECTIONS_RESPONSE,
DEFAULT_TASK_META_RESPONSE,
Expand All @@ -37,23 +38,25 @@
)

if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import AsyncIterator, Iterator


@pytest.fixture
def requests_mock() -> Iterator[responses.RequestsMock]:
with responses.RequestsMock() as requests_mock:
yield requests_mock
def todoist_api() -> Iterator[TodoistAPI]:
with TodoistAPI(
DEFAULT_TOKEN,
request_id_fn=lambda: DEFAULT_REQUEST_ID,
) as api:
yield api


@pytest.fixture
def todoist_api() -> TodoistAPI:
return TodoistAPI(DEFAULT_TOKEN)


@pytest.fixture
def todoist_api_async() -> TodoistAPIAsync:
return TodoistAPIAsync(DEFAULT_TOKEN)
@pytest_asyncio.fixture
async def todoist_api_async() -> AsyncIterator[TodoistAPIAsync]:
async with TodoistAPIAsync(
DEFAULT_TOKEN,
request_id_fn=lambda: DEFAULT_REQUEST_ID,
) as api:
yield api


@pytest.fixture
Expand Down
19 changes: 19 additions & 0 deletions tests/test_api_async_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from __future__ import annotations

import asyncio
import warnings

from tests.data.test_defaults import DEFAULT_TOKEN
from todoist_api_python.api_async import TodoistAPIAsync


def test_warns_if_async_client_is_not_closed() -> None:
api = TodoistAPIAsync(DEFAULT_TOKEN)

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", ResourceWarning)
api.__del__()

assert any(item.category is ResourceWarning for item in caught)

asyncio.run(api.close())
Loading
Loading