mirror of
https://github.com/fastapi/fastapi.git
synced 2026-09-12 05:18:57 -04:00
Compare commits
No files matched your search
@@ -201,6 +201,78 @@ jobs:
|
||||
mode: memory
|
||||
run: uv run --no-sync pytest tests/memory_benchmarks --codspeed
|
||||
|
||||
regression-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out the pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: pr
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- name: Find changed tests
|
||||
if: github.event_name == 'pull_request'
|
||||
id: changed-tests
|
||||
working-directory: pr
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
git diff --name-only --diff-filter=AM -z "$BASE_SHA" "$HEAD_SHA" -- tests \
|
||||
| while IFS= read -r -d '' file; do
|
||||
case "$(basename "$file")" in
|
||||
test_*.py) printf '%s\0' "$file" ;;
|
||||
esac
|
||||
done > "$RUNNER_TEMP/changed-tests"
|
||||
if [ -s "$RUNNER_TEMP/changed-tests" ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
git diff --binary "$BASE_SHA" "$HEAD_SHA" -- tests \
|
||||
> "$RUNNER_TEMP/tests.patch"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No added or modified test files; regression proof is not applicable."
|
||||
fi
|
||||
- name: Check out the base revision
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version-file: "base/.python-version"
|
||||
- name: Setup uv
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
||||
with:
|
||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||
version: "0.11.18"
|
||||
enable-cache: true
|
||||
- name: Run the changed tests against the base code
|
||||
if: steps.changed-tests.outputs.found == 'true'
|
||||
working-directory: base
|
||||
run: |
|
||||
git apply "$RUNNER_TEMP/tests.patch"
|
||||
uv sync --locked --no-dev --group tests --extra all
|
||||
set +e
|
||||
xargs -0 uv run --no-sync pytest -- < "$RUNNER_TEMP/changed-tests"
|
||||
status=$?
|
||||
set -e
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "::warning::The changed tests already pass on the base revision. Check whether the fix is still needed."
|
||||
echo "### Regression proof: base already passes :warning:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The changed tests pass without the pull request's code changes." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "The changed tests fail on the base revision as expected (pytest exit code $status)."
|
||||
echo "### Regression proof: base fails as expected :white_check_mark:" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
coverage-combine:
|
||||
needs:
|
||||
- test
|
||||
@@ -253,6 +325,7 @@ jobs:
|
||||
- test
|
||||
- coverage-combine
|
||||
- benchmark
|
||||
- regression-test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -7,6 +7,30 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
## 0.140.10 (2026-07-28)
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐛 Fix handling sequences with nested Annotated types. PR [#14874](https://github.com/fastapi/fastapi/pull/14874) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
### Internal
|
||||
|
||||
* 🐛 Accept any base test failure as regression. PR [#16092](https://github.com/fastapi/fastapi/pull/16092) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 🐛 Preserve pytest exit code in regression check. PR [#16091](https://github.com/fastapi/fastapi/pull/16091) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ✅ Test PR regressions against base code. PR [#16090](https://github.com/fastapi/fastapi/pull/16090) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.140.9 (2026-07-28)
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐛 Fix `exclude_defaults` not propagated to dict keys and values in `jsonable_encoder`. PR [#16043](https://github.com/fastapi/fastapi/pull/16043) by [@MBGrao](https://github.com/MBGrao).
|
||||
|
||||
### Internal
|
||||
|
||||
* ⬆ Bump gitpython from 3.1.50 to 3.1.54. PR [#16047](https://github.com/fastapi/fastapi/pull/16047) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump pymdown-extensions from 10.21.3 to 11.0. PR [#16048](https://github.com/fastapi/fastapi/pull/16048) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
* ⬆ Bump pyasn1 from 0.6.3 to 0.6.4. PR [#16045](https://github.com/fastapi/fastapi/pull/16045) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
|
||||
## 0.140.8 (2026-07-28)
|
||||
|
||||
### Fixes
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.140.8"
|
||||
__version__ = "0.140.10"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ def _annotation_is_sequence(annotation: type[Any] | None) -> bool:
|
||||
|
||||
def field_annotation_is_sequence(annotation: type[Any] | None) -> bool:
|
||||
origin = get_origin(annotation)
|
||||
|
||||
if origin is Annotated:
|
||||
return field_annotation_is_sequence(get_args(annotation)[0])
|
||||
|
||||
if origin is Union or origin is UnionType:
|
||||
for arg in get_args(annotation):
|
||||
if field_annotation_is_sequence(arg):
|
||||
@@ -108,6 +112,10 @@ def field_annotation_is_scalar(annotation: Any) -> bool:
|
||||
|
||||
def field_annotation_is_scalar_sequence(annotation: type[Any] | None) -> bool:
|
||||
origin = get_origin(annotation)
|
||||
|
||||
if origin is Annotated:
|
||||
return field_annotation_is_scalar_sequence(get_args(annotation)[0])
|
||||
|
||||
if origin is Union or origin is UnionType:
|
||||
at_least_one_scalar_sequence = False
|
||||
for arg in get_args(annotation):
|
||||
|
||||
@@ -299,6 +299,7 @@ def jsonable_encoder(
|
||||
key,
|
||||
by_alias=by_alias,
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_defaults=exclude_defaults,
|
||||
exclude_none=exclude_none,
|
||||
custom_encoder=custom_encoder,
|
||||
sqlalchemy_safe=sqlalchemy_safe,
|
||||
@@ -307,6 +308,7 @@ def jsonable_encoder(
|
||||
value,
|
||||
by_alias=by_alias,
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_defaults=exclude_defaults,
|
||||
exclude_none=exclude_none,
|
||||
custom_encoder=custom_encoder,
|
||||
sqlalchemy_safe=sqlalchemy_safe,
|
||||
|
||||
@@ -202,6 +202,20 @@ def test_encode_model_with_default():
|
||||
}
|
||||
|
||||
|
||||
def test_encode_model_with_default_in_dict_and_list():
|
||||
model = ModelWithDefault(foo="foo", bar="bar")
|
||||
assert jsonable_encoder([model], exclude_defaults=True) == [{"foo": "foo"}]
|
||||
assert jsonable_encoder({"key": model}, exclude_defaults=True) == {
|
||||
"key": {"foo": "foo"}
|
||||
}
|
||||
assert jsonable_encoder({"key": [model]}, exclude_defaults=True) == {
|
||||
"key": [{"foo": "foo"}]
|
||||
}
|
||||
assert jsonable_encoder({"key": model}) == {
|
||||
"key": {"foo": "foo", "bar": "bar", "bla": "bla"}
|
||||
}
|
||||
|
||||
|
||||
def test_custom_encoders():
|
||||
class safe_datetime(datetime):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
from typing import Annotated
|
||||
|
||||
from dirty_equals import IsList
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import Field
|
||||
|
||||
MaxSizedSet = Annotated[set[str], Field(max_length=3)]
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root(foo: Annotated[MaxSizedSet | None, Query()] = None):
|
||||
return {"foo": foo}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_endpoint_none():
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": None}
|
||||
|
||||
|
||||
def test_endpoint_valid():
|
||||
response = client.get("/", params={"foo": ["a", "b"]})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"foo": IsList("a", "b", check_order=False)}
|
||||
|
||||
|
||||
def test_endpoint_too_long():
|
||||
response = client.get("/", params={"foo": ["a", "b", "c", "d"]})
|
||||
assert response.status_code == 422
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"detail": [
|
||||
{
|
||||
"type": "too_long",
|
||||
"loc": ["query", "foo"],
|
||||
"msg": "Set should have at most 3 items after validation, not more",
|
||||
"input": IsList("a", "b", "c", "d", check_order=False),
|
||||
"ctx": {
|
||||
"actual_length": None,
|
||||
"field_type": "Set",
|
||||
"max_length": 3,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_openapi():
|
||||
assert app.openapi() == snapshot(
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
},
|
||||
"title": "Detail",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object",
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"ctx": {"title": "Context", "type": "object"},
|
||||
"input": {"title": "Input"},
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer"}],
|
||||
},
|
||||
"title": "Location",
|
||||
"type": "array",
|
||||
},
|
||||
"msg": {"title": "Message", "type": "string"},
|
||||
"type": {"title": "Error Type", "type": "string"},
|
||||
},
|
||||
"required": ["loc", "msg", "type"],
|
||||
"title": "ValidationError",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
"info": {
|
||||
"title": "FastAPI",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/": {
|
||||
"get": {
|
||||
"operationId": "read_root__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "foo",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 3,
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"title": "Foo",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
"description": "Successful Response",
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Validation Error",
|
||||
},
|
||||
},
|
||||
"summary": "Read Root",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1384,14 +1384,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gitpython"
|
||||
version = "3.1.50"
|
||||
version = "3.1.54"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gitdb" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/d5/3da0b92033887033f4c27f2dd109a303c4ca62813c7b3bb2511edb4777de/gitpython-3.1.54.tar.gz", hash = "sha256:53f2085e24a2cda300eed7c3fc5f1559ae289634b725e98acaf4791940247aa0", size = 225076, upload-time = "2026-07-22T04:08:51.403Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b9/876f442a28df5c068ca69b0122d5c35e65fd2d2fa9992ea5cb5944ea00a6/gitpython-3.1.54-py3-none-any.whl", hash = "sha256:b90d7b3d9bc0238681d24369130826f0dcdb0ceaa45db67cf1d4ffa4c302dedf", size = 216575, upload-time = "2026-07-22T04:08:50.05Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2820,11 +2820,11 @@ memory = [
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3166,15 +3166,15 @@ crypto = [
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "10.21.3"
|
||||
version = "11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user