diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index 701c74697f..51c069d9eb 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -28,6 +28,8 @@ jobs:
- docs/**
- docs_src/**
- requirements-docs.txt
+ - .github/workflows/build-docs.yml
+ - .github/workflows/deploy-docs.yml
langs:
needs:
- changes
@@ -55,6 +57,8 @@ jobs:
pip install git+https://${{ secrets.FASTAPI_MKDOCS_MATERIAL_INSIDERS }}@github.com/squidfunk/mkdocs-material-insiders.git
pip install git+https://${{ secrets.FASTAPI_MKDOCS_MATERIAL_INSIDERS }}@github.com/pawamoy-insiders/griffe-typing-deprecated.git
pip install git+https://${{ secrets.FASTAPI_MKDOCS_MATERIAL_INSIDERS }}@github.com/pawamoy-insiders/mkdocstrings-python.git
+ - name: Verify README
+ run: python ./scripts/docs.py verify-readme
- name: Export Language Codes
id: show-langs
run: |
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 59754525d7..7ebb80efdf 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -29,7 +29,7 @@ jobs:
id: cache
with:
path: ${{ env.pythonLocation }}
- key: ${{ runner.os }}-python-${{ env.pythonLocation }}-pydantic-v2-${{ hashFiles('pyproject.toml', 'requirements-tests.txt', 'requirements-docs-tests.txt') }}-test-v06
+ key: ${{ runner.os }}-python-${{ env.pythonLocation }}-pydantic-v2-${{ hashFiles('pyproject.toml', 'requirements-tests.txt', 'requirements-docs-tests.txt') }}-test-v07
- name: Install Dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -r requirements-tests.txt
@@ -62,7 +62,7 @@ jobs:
id: cache
with:
path: ${{ env.pythonLocation }}
- key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ matrix.pydantic-version }}-${{ hashFiles('pyproject.toml', 'requirements-tests.txt', 'requirements-docs-tests.txt') }}-test-v06
+ key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ matrix.pydantic-version }}-${{ hashFiles('pyproject.toml', 'requirements-tests.txt', 'requirements-docs-tests.txt') }}-test-v07
- name: Install Dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -r requirements-tests.txt
diff --git a/README.md b/README.md
index c8d17889d5..2df5cba0bd 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,7 @@ The key features are:
+
diff --git a/docs/en/data/sponsors.yml b/docs/en/data/sponsors.yml
index 113772964a..121a3b7616 100644
--- a/docs/en/data/sponsors.yml
+++ b/docs/en/data/sponsors.yml
@@ -17,6 +17,9 @@ gold:
- url: https://github.com/scalar/scalar/?utm_source=fastapi&utm_medium=website&utm_campaign=main-badge
title: "Scalar: Beautiful Open-Source API References from Swagger/OpenAPI files"
img: https://fastapi.tiangolo.com/img/sponsors/scalar.svg
+ - url: https://www.propelauth.com/?utm_source=fastapi&utm_campaign=1223&utm_medium=mainbadge
+ title: Auth, user management and more for your B2B product
+ img: https://fastapi.tiangolo.com/img/sponsors/propelauth.png
silver:
- url: https://www.deta.sh/?ref=fastapi
title: The launchpad for all your (team's) ideas
diff --git a/docs/en/docs/img/sponsors/propelauth-banner.png b/docs/en/docs/img/sponsors/propelauth-banner.png
new file mode 100644
index 0000000000..7a1bb25803
Binary files /dev/null and b/docs/en/docs/img/sponsors/propelauth-banner.png differ
diff --git a/docs/en/docs/img/sponsors/propelauth.png b/docs/en/docs/img/sponsors/propelauth.png
new file mode 100644
index 0000000000..8234d631f6
Binary files /dev/null and b/docs/en/docs/img/sponsors/propelauth.png differ
diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md
index f315344573..835df984ce 100644
--- a/docs/en/docs/release-notes.md
+++ b/docs/en/docs/release-notes.md
@@ -7,7 +7,15 @@ hide:
## Latest Changes
-* 🔧 Update sponsors, add Scalar. PR [#10728](https://github.com/tiangolo/fastapi/pull/10728) by [@tiangolo](https://github.com/tiangolo).
+## 0.105.0
+
+### Features
+
+* ✨ Add support for multiple Annotated annotations, e.g. `Annotated[str, Field(), Query()]`. PR [#10773](https://github.com/tiangolo/fastapi/pull/10773) by [@tiangolo](https://github.com/tiangolo).
+
+### Refactors
+
+* 🔥 Remove unused NoneType. PR [#10774](https://github.com/tiangolo/fastapi/pull/10774) by [@tiangolo](https://github.com/tiangolo).
### Docs
@@ -15,6 +23,9 @@ hide:
### Internal
+* 🔧 Update sponsors, add Scalar. PR [#10728](https://github.com/tiangolo/fastapi/pull/10728) by [@tiangolo](https://github.com/tiangolo).
+* 🔧 Update sponsors, add PropelAuth. PR [#10760](https://github.com/tiangolo/fastapi/pull/10760) by [@tiangolo](https://github.com/tiangolo).
+* 👷 Update build docs, verify README on CI. PR [#10750](https://github.com/tiangolo/fastapi/pull/10750) by [@tiangolo](https://github.com/tiangolo).
* 🔧 Update sponsors, remove Fern. PR [#10729](https://github.com/tiangolo/fastapi/pull/10729) by [@tiangolo](https://github.com/tiangolo).
* 🔧 Update sponsors, add Codacy. PR [#10677](https://github.com/tiangolo/fastapi/pull/10677) by [@tiangolo](https://github.com/tiangolo).
* 🔧 Update sponsors, add Reflex. PR [#10676](https://github.com/tiangolo/fastapi/pull/10676) by [@tiangolo](https://github.com/tiangolo).
diff --git a/docs/en/overrides/main.html b/docs/en/overrides/main.html
index c4aea9a8e6..476b436767 100644
--- a/docs/en/overrides/main.html
+++ b/docs/en/overrides/main.html
@@ -58,6 +58,12 @@
+
{% endblock %}
diff --git a/fastapi/__init__.py b/fastapi/__init__.py
index c81f09b27e..dd16ea34db 100644
--- a/fastapi/__init__.py
+++ b/fastapi/__init__.py
@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
-__version__ = "0.104.1"
+__version__ = "0.105.0"
from starlette import status as status
diff --git a/fastapi/_compat.py b/fastapi/_compat.py
index fc605d0ec6..35d4a87231 100644
--- a/fastapi/_compat.py
+++ b/fastapi/_compat.py
@@ -249,7 +249,12 @@ if PYDANTIC_V2:
return is_bytes_sequence_annotation(field.type_)
def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo:
- return type(field_info).from_annotation(annotation)
+ cls = type(field_info)
+ merged_field_info = cls.from_annotation(annotation)
+ new_field_info = copy(field_info)
+ new_field_info.metadata = merged_field_info.metadata
+ new_field_info.annotation = merged_field_info.annotation
+ return new_field_info
def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]:
origin_type = (
diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index 6287249fae..9d620ac864 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -326,10 +326,11 @@ def analyze_param(
field_info = None
depends = None
type_annotation: Any = Any
- if (
- annotation is not inspect.Signature.empty
- and get_origin(annotation) is Annotated
- ):
+ use_annotation: Any = Any
+ if annotation is not inspect.Signature.empty:
+ use_annotation = annotation
+ type_annotation = annotation
+ if get_origin(use_annotation) is Annotated:
annotated_args = get_args(annotation)
type_annotation = annotated_args[0]
fastapi_annotations = [
@@ -337,14 +338,21 @@ def analyze_param(
for arg in annotated_args[1:]
if isinstance(arg, (FieldInfo, params.Depends))
]
- assert (
- len(fastapi_annotations) <= 1
- ), f"Cannot specify multiple `Annotated` FastAPI arguments for {param_name!r}"
- fastapi_annotation = next(iter(fastapi_annotations), None)
+ fastapi_specific_annotations = [
+ arg
+ for arg in fastapi_annotations
+ if isinstance(arg, (params.Param, params.Body, params.Depends))
+ ]
+ if fastapi_specific_annotations:
+ fastapi_annotation: Union[
+ FieldInfo, params.Depends, None
+ ] = fastapi_specific_annotations[-1]
+ else:
+ fastapi_annotation = None
if isinstance(fastapi_annotation, FieldInfo):
# Copy `field_info` because we mutate `field_info.default` below.
field_info = copy_field_info(
- field_info=fastapi_annotation, annotation=annotation
+ field_info=fastapi_annotation, annotation=use_annotation
)
assert field_info.default is Undefined or field_info.default is Required, (
f"`{field_info.__class__.__name__}` default value cannot be set in"
@@ -357,8 +365,6 @@ def analyze_param(
field_info.default = Required
elif isinstance(fastapi_annotation, params.Depends):
depends = fastapi_annotation
- elif annotation is not inspect.Signature.empty:
- type_annotation = annotation
if isinstance(value, params.Depends):
assert depends is None, (
@@ -403,15 +409,15 @@ def analyze_param(
# We might check here that `default_value is Required`, but the fact is that the same
# parameter might sometimes be a path parameter and sometimes not. See
# `tests/test_infer_param_optionality.py` for an example.
- field_info = params.Path(annotation=type_annotation)
+ field_info = params.Path(annotation=use_annotation)
elif is_uploadfile_or_nonable_uploadfile_annotation(
type_annotation
) or is_uploadfile_sequence_annotation(type_annotation):
- field_info = params.File(annotation=type_annotation, default=default_value)
+ field_info = params.File(annotation=use_annotation, default=default_value)
elif not field_annotation_is_scalar(annotation=type_annotation):
- field_info = params.Body(annotation=type_annotation, default=default_value)
+ field_info = params.Body(annotation=use_annotation, default=default_value)
else:
- field_info = params.Query(annotation=type_annotation, default=default_value)
+ field_info = params.Query(annotation=use_annotation, default=default_value)
field = None
if field_info is not None:
@@ -425,8 +431,8 @@ def analyze_param(
and getattr(field_info, "in_", None) is None
):
field_info.in_ = params.ParamTypes.query
- use_annotation = get_annotation_from_field_info(
- type_annotation,
+ use_annotation_from_field_info = get_annotation_from_field_info(
+ use_annotation,
field_info,
param_name,
)
@@ -437,7 +443,7 @@ def analyze_param(
field_info.alias = alias
field = create_response_field(
name=param_name,
- type_=use_annotation,
+ type_=use_annotation_from_field_info,
default=field_info.default,
alias=alias,
required=field_info.default in (Required, Undefined),
@@ -467,16 +473,17 @@ def is_body_param(*, param_field: ModelField, is_path_param: bool) -> bool:
def add_param_to_fields(*, field: ModelField, dependant: Dependant) -> None:
- field_info = cast(params.Param, field.field_info)
- if field_info.in_ == params.ParamTypes.path:
+ field_info = field.field_info
+ field_info_in = getattr(field_info, "in_", None)
+ if field_info_in == params.ParamTypes.path:
dependant.path_params.append(field)
- elif field_info.in_ == params.ParamTypes.query:
+ elif field_info_in == params.ParamTypes.query:
dependant.query_params.append(field)
- elif field_info.in_ == params.ParamTypes.header:
+ elif field_info_in == params.ParamTypes.header:
dependant.header_params.append(field)
else:
assert (
- field_info.in_ == params.ParamTypes.cookie
+ field_info_in == params.ParamTypes.cookie
), f"non-body parameters must be in path, query, header or cookie: {field.name}"
dependant.cookie_params.append(field)
diff --git a/fastapi/types.py b/fastapi/types.py
index 7adf565a7b..3205654c73 100644
--- a/fastapi/types.py
+++ b/fastapi/types.py
@@ -6,6 +6,5 @@ from pydantic import BaseModel
DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any])
UnionType = getattr(types, "UnionType", Union)
-NoneType = getattr(types, "UnionType", None)
ModelNameMap = Dict[Union[Type[BaseModel], Type[Enum]], str]
IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]]
diff --git a/tests/test_ambiguous_params.py b/tests/test_ambiguous_params.py
index 42bcc27a1d..8a31442eb0 100644
--- a/tests/test_ambiguous_params.py
+++ b/tests/test_ambiguous_params.py
@@ -1,6 +1,8 @@
import pytest
from fastapi import Depends, FastAPI, Path
from fastapi.param_functions import Query
+from fastapi.testclient import TestClient
+from fastapi.utils import PYDANTIC_V2
from typing_extensions import Annotated
app = FastAPI()
@@ -28,18 +30,13 @@ def test_no_annotated_defaults():
pass # pragma: nocover
-def test_no_multiple_annotations():
+def test_multiple_annotations():
async def dep():
pass # pragma: nocover
- with pytest.raises(
- AssertionError,
- match="Cannot specify multiple `Annotated` FastAPI arguments for 'foo'",
- ):
-
- @app.get("/")
- async def get(foo: Annotated[int, Query(min_length=1), Query()]):
- pass # pragma: nocover
+ @app.get("/multi-query")
+ async def get(foo: Annotated[int, Query(gt=2), Query(lt=10)]):
+ return foo
with pytest.raises(
AssertionError,
@@ -64,3 +61,15 @@ def test_no_multiple_annotations():
@app.get("/")
async def get3(foo: Annotated[int, Query(min_length=1)] = Depends(dep)):
pass # pragma: nocover
+
+ client = TestClient(app)
+ response = client.get("/multi-query", params={"foo": "5"})
+ assert response.status_code == 200
+ assert response.json() == 5
+
+ response = client.get("/multi-query", params={"foo": "123"})
+ assert response.status_code == 422
+
+ if PYDANTIC_V2:
+ response = client.get("/multi-query", params={"foo": "1"})
+ assert response.status_code == 422
diff --git a/tests/test_annotated.py b/tests/test_annotated.py
index 541f84bca1..2222be9783 100644
--- a/tests/test_annotated.py
+++ b/tests/test_annotated.py
@@ -57,7 +57,7 @@ foo_is_short = {
{
"ctx": {"min_length": 1},
"loc": ["query", "foo"],
- "msg": "String should have at least 1 characters",
+ "msg": "String should have at least 1 character",
"type": "string_too_short",
"input": "",
"url": match_pydantic_error_url("string_too_short"),