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"),