Skip to content

Commit 7098d3a

Browse files
Merge pull request #221 from Doist/goncalossilva/httpx
2 parents 875729f + 6812770 commit 7098d3a

26 files changed

Lines changed: 2285 additions & 1446 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `TodoistAPIAsync` now performs true async HTTP I/O with `httpx.AsyncClient`.
13+
14+
### Changed
15+
16+
- **Breaking**: `TodoistAPI` now accepts an optional `client: httpx.Client` instead of `session: requests.Session`.
17+
- **Breaking**: `TodoistAPIAsync` now accepts an optional `client: httpx.AsyncClient` instead of `session: requests.Session`.
18+
- **Breaking**: Async paginated return types now use `AsyncIterator[...]` instead of `AsyncGenerator[...]`.
19+
- **Breaking**: API errors now raise `httpx.HTTPStatusError` instead of `requests.exceptions.HTTPError`.
20+
- **Breaking**: Authentication helpers now accept optional `httpx.Client` / `httpx.AsyncClient` instances instead of `session: requests.Session`.
21+
1022
## [3.2.1] - 2026-01-22
1123

1224
### Fixed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,30 @@ for comments in comments_iter:
3838
print(f"Comment: {comment.content}")
3939
```
4040

41+
### Async usage
42+
43+
Always close `TodoistAPIAsync` explicitly, either via `async with` (recommended) or by calling `await api.close()`.
44+
45+
```python
46+
from todoist_api_python.api_async import TodoistAPIAsync
47+
48+
async with TodoistAPIAsync("YOUR_API_TOKEN") as api:
49+
task = await api.get_task("6X4Vw2Hfmg73Q2XR")
50+
print(task.content)
51+
```
52+
4153
## Documentation
4254

4355
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).
4456

57+
## Migrating from 3.x
58+
59+
Version `4.x` introduces a breaking HTTP stack migration from `requests` to `httpx`.
60+
61+
- `TodoistAPI(..., session=...)` is now `TodoistAPI(..., client=...)` with `httpx.Client`.
62+
- `TodoistAPIAsync(..., session=...)` is now `TodoistAPIAsync(..., client=...)` with `httpx.AsyncClient`.
63+
- Error handling should catch `httpx.HTTPStatusError` instead of `requests.exceptions.HTTPError`.
64+
4565
## Development
4666

4767
To install Python dependencies:

docs/authentication.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,37 @@
11
# Authentication
22

3-
This module provides functions to help authenticate with Todoist using the OAuth protocol.
3+
This module provides helpers to authenticate with Todoist using OAuth.
44

55
## Quick start
66

77
```python
88
import uuid
9-
from todoist_api_python.authentication import get_access_token, get_authentication_url
9+
10+
from todoist_api_python.authentication import get_auth_token, get_authentication_url
1011

1112
# 1. Generate a random state
12-
state = uuid.uuid4()
13+
state = str(uuid.uuid4())
1314

14-
# 2. Get authorization url
15+
# 2. Build the authorization URL
1516
url = get_authentication_url(
1617
client_id="YOUR_CLIENT_ID",
1718
scopes=["data:read", "task:add"],
18-
state=uuid.uuid4()
19+
state=state,
1920
)
2021

21-
# 3.Redirect user to url
22-
# 4. Handle OAuth callback and get code
22+
# 3. Redirect the user to `url`
23+
# 4. Handle the OAuth callback and obtain the auth code
2324
code = "CODE_YOU_OBTAINED"
2425

25-
# 5. Exchange code for access token
26-
auth_result = get_access_token(
26+
# 5. Exchange code for an access token
27+
auth_result = get_auth_token(
2728
client_id="YOUR_CLIENT_ID",
2829
client_secret="YOUR_CLIENT_SECRET",
2930
code=code,
3031
)
3132

3233
# 6. Ensure state is consistent, and done!
33-
assert(auth_result.state == state)
34+
assert auth_result.state == state
3435
access_token = auth_result.access_token
3536
```
3637

docs/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ for comments in comments_iter:
3434
print(f"Comment: {comment.content}")
3535
```
3636

37+
### Async usage
38+
39+
Use `TodoistAPIAsync` with `async with` (or call `await api.close()` manually)
40+
to ensure the underlying `httpx.AsyncClient` is closed.
41+
3742
## Quick start
3843

3944
- [Authentication](authentication.md)

pyproject.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "todoist_api_python"
33
version = "3.2.1"
44
description = "Official Python SDK for the Todoist API."
55
authors = [{ name = "Doist Developers", email = "dev@doist.com" }]
6-
requires-python = "~=3.9"
6+
requires-python = ">=3.9,<4"
77
readme = "README.md"
88
license = "MIT"
99
keywords = ["todoist", "rest", "sync", "api", "python"]
@@ -13,7 +13,7 @@ classifiers = [
1313
]
1414

1515
dependencies = [
16-
"requests>=2.32.3,<3",
16+
"httpx>=0.28.1,<1",
1717
"dataclass-wizard>=0.35.0,<1.0",
1818
"annotated-types",
1919
]
@@ -32,8 +32,7 @@ dev = [
3232
"tox-uv>=1.25.0,<2",
3333
"mypy~=1.11",
3434
"ruff>=0.11.0,<0.12",
35-
"responses>=0.25.3,<0.26",
36-
"types-requests~=2.32",
35+
"respx>=0.22.0,<0.23",
3736
]
3837

3938
docs = [

tests/conftest.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import TYPE_CHECKING, Any
44

55
import pytest
6-
import responses
6+
import pytest_asyncio
77

88
from tests.data.test_defaults import (
99
DEFAULT_AUTH_RESPONSE,
@@ -15,6 +15,7 @@
1515
DEFAULT_LABELS_RESPONSE,
1616
DEFAULT_PROJECT_RESPONSE,
1717
DEFAULT_PROJECTS_RESPONSE,
18+
DEFAULT_REQUEST_ID,
1819
DEFAULT_SECTION_RESPONSE,
1920
DEFAULT_SECTIONS_RESPONSE,
2021
DEFAULT_TASK_META_RESPONSE,
@@ -37,23 +38,25 @@
3738
)
3839

3940
if TYPE_CHECKING:
40-
from collections.abc import Iterator
41+
from collections.abc import AsyncIterator, Iterator
4142

4243

4344
@pytest.fixture
44-
def requests_mock() -> Iterator[responses.RequestsMock]:
45-
with responses.RequestsMock() as requests_mock:
46-
yield requests_mock
45+
def todoist_api() -> Iterator[TodoistAPI]:
46+
with TodoistAPI(
47+
DEFAULT_TOKEN,
48+
request_id_fn=lambda: DEFAULT_REQUEST_ID,
49+
) as api:
50+
yield api
4751

4852

49-
@pytest.fixture
50-
def todoist_api() -> TodoistAPI:
51-
return TodoistAPI(DEFAULT_TOKEN)
52-
53-
54-
@pytest.fixture
55-
def todoist_api_async() -> TodoistAPIAsync:
56-
return TodoistAPIAsync(DEFAULT_TOKEN)
53+
@pytest_asyncio.fixture
54+
async def todoist_api_async() -> AsyncIterator[TodoistAPIAsync]:
55+
async with TodoistAPIAsync(
56+
DEFAULT_TOKEN,
57+
request_id_fn=lambda: DEFAULT_REQUEST_ID,
58+
) as api:
59+
yield api
5760

5861

5962
@pytest.fixture

tests/test_api_async_client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import warnings
5+
6+
from tests.data.test_defaults import DEFAULT_TOKEN
7+
from todoist_api_python.api_async import TodoistAPIAsync
8+
9+
10+
def test_warns_if_async_client_is_not_closed() -> None:
11+
api = TodoistAPIAsync(DEFAULT_TOKEN)
12+
13+
with warnings.catch_warnings(record=True) as caught:
14+
warnings.simplefilter("always", ResourceWarning)
15+
api.__del__()
16+
17+
assert any(item.category is ResourceWarning for item in caught)
18+
19+
asyncio.run(api.close())

0 commit comments

Comments
 (0)