Skip to content

Commit 4b26487

Browse files
authored
⬆️ Support free-threaded Python 3.14t (#15149)
1 parent f796c34 commit 4b26487

11 files changed

Lines changed: 190 additions & 342 deletions

File tree

.github/workflows/test.yml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ jobs:
4949
strategy:
5050
matrix:
5151
os: [ windows-latest, macos-latest ]
52-
python-version: [ "3.14" ]
52+
python-version: [ "3.14", "3.14t" ]
53+
deprecated-tests: [ "no-deprecation" ]
5354
uv-resolution:
5455
- highest
5556
starlette-src:
@@ -60,23 +61,33 @@ jobs:
6061
python-version: "3.10"
6162
coverage: coverage
6263
uv-resolution: lowest-direct
64+
deprecated-tests: "no-deprecation"
6365
- os: windows-latest
6466
python-version: "3.12"
6567
coverage: coverage
6668
uv-resolution: lowest-direct
69+
deprecated-tests: "no-deprecation"
6770
- os: ubuntu-latest
6871
python-version: "3.13"
6972
coverage: coverage
7073
uv-resolution: highest
74+
deprecated-tests: "no-deprecation"
7175
- os: ubuntu-latest
7276
python-version: "3.13"
7377
uv-resolution: highest
7478
codspeed: codspeed
79+
deprecated-tests: "no-deprecation"
7580
- os: ubuntu-latest
7681
python-version: "3.14"
7782
coverage: coverage
7883
uv-resolution: highest
7984
starlette-src: starlette-git
85+
deprecated-tests: "test-deprecation"
86+
- os: ubuntu-latest
87+
python-version: "3.14t"
88+
coverage: coverage
89+
uv-resolution: highest
90+
deprecated-tests: "no-deprecation"
8091
fail-fast: false
8192
runs-on: ${{ matrix.os }}
8293
env:
@@ -108,18 +119,24 @@ jobs:
108119
- name: Install Starlette from source
109120
if: matrix.starlette-src == 'starlette-git'
110121
run: uv pip install "git+https://github.com/Kludex/starlette@main"
122+
- name: Install deprecated libraries just for testing
123+
if: matrix.deprecated-tests == 'test-deprecation'
124+
run: uv pip install orjson ujson
125+
- name: Reinstall SQLAlchemy without Cython extensions
126+
if: matrix.python-version == '3.14t' && matrix.os == 'ubuntu-latest'
127+
run: "DISABLE_SQLALCHEMY_CEXT=1 uv pip install --force-reinstall --no-binary :all: sqlalchemy"
111128
- run: mkdir coverage
112129
- name: Test
113130
run: uv run --no-sync bash scripts/test-cov.sh
114131
env:
115-
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}
116-
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}
132+
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-${{ matrix.deprecated-tests}}
133+
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}-${{ matrix.deprecated-tests}}
117134
# Do not store coverage for all possible combinations to avoid file size max errors in Smokeshow
118135
- name: Store coverage files
119136
if: matrix.coverage == 'coverage'
120137
uses: actions/upload-artifact@v7
121138
with:
122-
name: coverage-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/coverage/.coverage.*') }}
139+
name: coverage-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.deprecated-tests}}-${{ hashFiles('**/coverage/.coverage.*') }}
123140
path: coverage
124141
include-hidden-files: true
125142

fastapi/responses.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import Any
1+
import importlib
2+
from typing import Any, Protocol, cast
23

34
from fastapi.exceptions import FastAPIDeprecationWarning
45
from fastapi.sse import EventSourceResponse as EventSourceResponse # noqa
@@ -11,16 +12,28 @@
1112
from starlette.responses import StreamingResponse as StreamingResponse # noqa
1213
from typing_extensions import deprecated
1314

15+
16+
class _UjsonModule(Protocol):
17+
def dumps(self, __obj: Any, *, ensure_ascii: bool = ...) -> str: ...
18+
19+
20+
class _OrjsonModule(Protocol):
21+
OPT_NON_STR_KEYS: int
22+
OPT_SERIALIZE_NUMPY: int
23+
24+
def dumps(self, __obj: Any, *, option: int = ...) -> bytes: ...
25+
26+
1427
try:
15-
import ujson
16-
except ImportError: # pragma: nocover
17-
ujson = None # type: ignore
28+
ujson = cast(_UjsonModule, importlib.import_module("ujson"))
29+
except ModuleNotFoundError: # pragma: nocover
30+
ujson = None # type: ignore # ty: ignore[unused-ignore-comment]
1831

1932

2033
try:
21-
import orjson
22-
except ImportError: # pragma: nocover
23-
orjson = None # type: ignore
34+
orjson = cast(_OrjsonModule, importlib.import_module("orjson"))
35+
except ModuleNotFoundError: # pragma: nocover
36+
orjson = None # type: ignore # ty: ignore[unused-ignore-comment]
2437

2538

2639
@deprecated(

pyproject.toml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Changelog = "https://fastapi.tiangolo.com/release-notes/"
5959
[project.optional-dependencies]
6060
standard = [
6161
"fastapi-cli[standard] >=0.0.8",
62+
"fastar >= 0.9.0",
6263
# For the test client
6364
"httpx >=0.23.0,<1.0.0",
6465
# For templates
@@ -149,10 +150,6 @@ docs = [
149150
docs-tests = [
150151
"httpx >=0.23.0,<1.0.0",
151152
"ruff >=0.14.14",
152-
# For UJSONResponse
153-
"ujson >=5.8.0",
154-
# For ORJSONResponse
155-
"orjson >=3.9.3",
156153
]
157154
github-actions = [
158155
"httpx >=0.27.0,<1.0.0",
@@ -178,8 +175,6 @@ tests = [
178175
"sqlmodel >=0.0.31",
179176
"strawberry-graphql >=0.200.0,<1.0.0",
180177
"ty>=0.0.9",
181-
"types-orjson >=3.6.2",
182-
"types-ujson >=5.10.0.20240515",
183178
"a2wsgi >=1.9.0,<=2.0.0",
184179
"pytest-xdist[psutil]>=2.5.0",
185180
"pytest-cov>=4.0.0",

tests/test_default_response_class.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
from typing import Any
22

3-
import orjson
43
from fastapi import APIRouter, FastAPI
54
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
65
from fastapi.testclient import TestClient
76

7+
from tests.utils import needs_orjson
8+
89

910
class ORJSONResponse(JSONResponse):
1011
media_type = "application/x-orjson"
1112

1213
def render(self, content: Any) -> bytes:
14+
import orjson
15+
1316
return orjson.dumps(content)
1417

1518

@@ -118,6 +121,7 @@ def get_b_a_c_path_override():
118121
override_type = "application/x-override"
119122

120123

124+
@needs_orjson
121125
def test_app():
122126
with client:
123127
response = client.get("/")
@@ -132,6 +136,7 @@ def test_app_override():
132136
assert response.headers["content-type"] == text_type
133137

134138

139+
@needs_orjson
135140
def test_router_a():
136141
with client:
137142
response = client.get("/a")
@@ -146,6 +151,7 @@ def test_router_a_override():
146151
assert response.headers["content-type"] == text_type
147152

148153

154+
@needs_orjson
149155
def test_router_a_a():
150156
with client:
151157
response = client.get("/a/a")

tests/test_deprecated_responses.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from fastapi.testclient import TestClient
88
from pydantic import BaseModel
99

10+
from tests.utils import needs_orjson, needs_ujson
11+
1012

1113
class Item(BaseModel):
1214
name: str
@@ -28,6 +30,7 @@ def get_items() -> Item:
2830
return app
2931

3032

33+
@needs_orjson
3134
def test_orjson_response_returns_correct_data():
3235
app = _make_orjson_app()
3336
client = TestClient(app)
@@ -38,6 +41,7 @@ def test_orjson_response_returns_correct_data():
3841
assert response.json() == {"name": "widget", "price": 9.99}
3942

4043

44+
@needs_orjson
4145
def test_orjson_response_emits_deprecation_warning():
4246
with pytest.warns(FastAPIDeprecationWarning, match="ORJSONResponse is deprecated"):
4347
ORJSONResponse(content={"hello": "world"})
@@ -58,6 +62,7 @@ def get_items() -> Item:
5862
return app
5963

6064

65+
@needs_ujson
6166
def test_ujson_response_returns_correct_data():
6267
app = _make_ujson_app()
6368
client = TestClient(app)
@@ -68,6 +73,7 @@ def test_ujson_response_returns_correct_data():
6873
assert response.json() == {"name": "widget", "price": 9.99}
6974

7075

76+
@needs_ujson
7177
def test_ujson_response_emits_deprecation_warning():
7278
with pytest.warns(FastAPIDeprecationWarning, match="UJSONResponse is deprecated"):
7379
UJSONResponse(content={"hello": "world"})

tests/test_orjson_response_class.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import warnings
22

3+
import pytest
4+
5+
pytest.importorskip("orjson")
6+
37
from fastapi import FastAPI
48
from fastapi.exceptions import FastAPIDeprecationWarning
59
from fastapi.responses import ORJSONResponse

tests/test_tutorial/test_custom_response/test_tutorial001.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from fastapi.testclient import TestClient
55
from inline_snapshot import snapshot
66

7+
pytest.importorskip("orjson")
8+
79

810
@pytest.fixture(
911
name="client",

tests/test_tutorial/test_custom_response/test_tutorial001b.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
client = TestClient(app)
1313

14+
pytest.importorskip("orjson")
15+
1416

1517
@pytest.mark.filterwarnings("ignore::fastapi.exceptions.FastAPIDeprecationWarning")
1618
def test_get_custom_response():

tests/test_tutorial/test_custom_response/test_tutorial009c.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import pytest
12
from fastapi.testclient import TestClient
23

4+
pytest.importorskip("orjson")
5+
36
from docs_src.custom_response.tutorial009c_py310 import app
47

58
client = TestClient(app)

tests/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import importlib
12
import sys
23

34
import pytest
@@ -9,6 +10,16 @@
910
sys.version_info < (3, 14), reason="requires python3.14+"
1011
)
1112

13+
needs_orjson = pytest.mark.skipif(
14+
importlib.util.find_spec("orjson") is None,
15+
reason="requires orjson",
16+
)
17+
18+
needs_ujson = pytest.mark.skipif(
19+
importlib.util.find_spec("ujson") is None,
20+
reason="requires ujson",
21+
)
22+
1223
workdir_lock = pytest.mark.xdist_group("workdir_lock")
1324

1425

0 commit comments

Comments
 (0)