mirror of https://github.com/tiangolo/fastapi.git
Compare commits
9 Commits
c5bcdebbb3
...
b08add0945
| Author | SHA1 | Date |
|---|---|---|
|
|
b08add0945 | |
|
|
c48539f4c6 | |
|
|
2e7d3754cd | |
|
|
8eac94bd91 | |
|
|
58cdfc7f4b | |
|
|
d59fbc3494 | |
|
|
5a254e566f | |
|
|
a5d28b0bc4 | |
|
|
bec5e359e8 |
|
|
@ -9,6 +9,13 @@ hide:
|
|||
|
||||
### Refactors
|
||||
|
||||
* ♻️ Refactor and simplify Pydantic v2 (and v1) compatibility internal utils. PR [#14862](https://github.com/fastapi/fastapi/pull/14862) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.128.4
|
||||
|
||||
### Refactors
|
||||
|
||||
* ♻️ Refactor internals, simplify Pydantic v2/v1 utils, `create_model_field`, better types for `lenient_issubclass`. PR [#14860](https://github.com/fastapi/fastapi/pull/14860) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Simplify internals, remove Pydantic v1 only logic, no longer needed. PR [#14857](https://github.com/fastapi/fastapi/pull/14857) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Refactor internals, cleanup unneeded Pydantic v1 specific logic. PR [#14856](https://github.com/fastapi/fastapi/pull/14856) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.128.3"
|
||||
__version__ = "0.128.4"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
from .shared import PYDANTIC_VERSION_MINOR_TUPLE as PYDANTIC_VERSION_MINOR_TUPLE
|
||||
from .shared import annotation_is_pydantic_v1 as annotation_is_pydantic_v1
|
||||
from .shared import field_annotation_is_scalar as field_annotation_is_scalar
|
||||
from .shared import is_pydantic_v1_model_class as is_pydantic_v1_model_class
|
||||
from .shared import (
|
||||
field_annotation_is_scalar_sequence as field_annotation_is_scalar_sequence,
|
||||
)
|
||||
from .shared import field_annotation_is_sequence as field_annotation_is_sequence
|
||||
from .shared import (
|
||||
is_bytes_or_nonable_bytes_annotation as is_bytes_or_nonable_bytes_annotation,
|
||||
)
|
||||
from .shared import is_bytes_sequence_annotation as is_bytes_sequence_annotation
|
||||
from .shared import is_pydantic_v1_model_instance as is_pydantic_v1_model_instance
|
||||
from .shared import (
|
||||
is_uploadfile_or_nonable_uploadfile_annotation as is_uploadfile_or_nonable_uploadfile_annotation,
|
||||
|
|
@ -12,27 +19,21 @@ from .shared import (
|
|||
from .shared import lenient_issubclass as lenient_issubclass
|
||||
from .shared import sequence_types as sequence_types
|
||||
from .shared import value_is_sequence as value_is_sequence
|
||||
from .v2 import BaseConfig as BaseConfig
|
||||
from .v2 import ModelField as ModelField
|
||||
from .v2 import PydanticSchemaGenerationError as PydanticSchemaGenerationError
|
||||
from .v2 import RequiredParam as RequiredParam
|
||||
from .v2 import Undefined as Undefined
|
||||
from .v2 import UndefinedType as UndefinedType
|
||||
from .v2 import Url as Url
|
||||
from .v2 import Validator as Validator
|
||||
from .v2 import _regenerate_error_with_loc as _regenerate_error_with_loc
|
||||
from .v2 import copy_field_info as copy_field_info
|
||||
from .v2 import create_body_model as create_body_model
|
||||
from .v2 import evaluate_forwardref as evaluate_forwardref
|
||||
from .v2 import get_cached_model_fields as get_cached_model_fields
|
||||
from .v2 import get_definitions as get_definitions
|
||||
from .v2 import get_flat_models_from_fields as get_flat_models_from_fields
|
||||
from .v2 import get_missing_field_error as get_missing_field_error
|
||||
from .v2 import get_model_name_map as get_model_name_map
|
||||
from .v2 import get_schema_from_model_field as get_schema_from_model_field
|
||||
from .v2 import is_bytes_field as is_bytes_field
|
||||
from .v2 import is_bytes_sequence_field as is_bytes_sequence_field
|
||||
from .v2 import is_scalar_field as is_scalar_field
|
||||
from .v2 import is_scalar_sequence_field as is_scalar_sequence_field
|
||||
from .v2 import is_sequence_field as is_sequence_field
|
||||
from .v2 import serialize_sequence_value as serialize_sequence_value
|
||||
from .v2 import (
|
||||
with_info_plain_validator_function as with_info_plain_validator_function,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from dataclasses import is_dataclass
|
|||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
|
|
@ -15,7 +16,9 @@ from fastapi.types import UnionType
|
|||
from pydantic import BaseModel
|
||||
from pydantic.version import VERSION as PYDANTIC_VERSION
|
||||
from starlette.datastructures import UploadFile
|
||||
from typing_extensions import get_args, get_origin
|
||||
from typing_extensions import TypeGuard, get_args, get_origin
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
# Copy from Pydantic: pydantic/_internal/_typing_extra.py
|
||||
if sys.version_info < (3, 10):
|
||||
|
|
@ -39,15 +42,13 @@ sequence_annotation_to_type = {
|
|||
deque: deque,
|
||||
}
|
||||
|
||||
sequence_types = tuple(sequence_annotation_to_type.keys())
|
||||
|
||||
Url: type[Any]
|
||||
sequence_types: tuple[type[Any], ...] = tuple(sequence_annotation_to_type.keys())
|
||||
|
||||
|
||||
# Copy of Pydantic: pydantic/_internal/_utils.py
|
||||
# Copy of Pydantic: pydantic/_internal/_utils.py with added TypeGuard
|
||||
def lenient_issubclass(
|
||||
cls: Any, class_or_tuple: Union[type[Any], tuple[type[Any], ...], None]
|
||||
) -> bool:
|
||||
cls: Any, class_or_tuple: Union[type[_T], tuple[type[_T], ...], None]
|
||||
) -> TypeGuard[type[_T]]:
|
||||
try:
|
||||
return isinstance(cls, type) and issubclass(cls, class_or_tuple) # type: ignore[arg-type]
|
||||
except TypeError: # pragma: no cover
|
||||
|
|
@ -177,16 +178,26 @@ def is_uploadfile_sequence_annotation(annotation: Any) -> bool:
|
|||
|
||||
|
||||
def is_pydantic_v1_model_instance(obj: Any) -> bool:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
from pydantic import v1
|
||||
# TODO: remove this function once the required version of Pydantic fully
|
||||
# removes pydantic.v1
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
from pydantic import v1
|
||||
except ImportError: # pragma: no cover
|
||||
return False
|
||||
return isinstance(obj, v1.BaseModel)
|
||||
|
||||
|
||||
def is_pydantic_v1_model_class(cls: Any) -> bool:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
from pydantic import v1
|
||||
# TODO: remove this function once the required version of Pydantic fully
|
||||
# removes pydantic.v1
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
from pydantic import v1
|
||||
except ImportError: # pragma: no cover
|
||||
return False
|
||||
return lenient_issubclass(cls, v1.BaseModel)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from typing import (
|
|||
cast,
|
||||
)
|
||||
|
||||
from fastapi._compat import shared
|
||||
from fastapi._compat import lenient_issubclass, shared
|
||||
from fastapi.openapi.constants import REF_TEMPLATE
|
||||
from fastapi.types import IncEx, ModelNameMap, UnionType
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model
|
||||
|
|
@ -23,29 +23,20 @@ from pydantic._internal._schema_generation_shared import ( # type: ignore[attr-
|
|||
GetJsonSchemaHandler as GetJsonSchemaHandler,
|
||||
)
|
||||
from pydantic._internal._typing_extra import eval_type_lenient
|
||||
from pydantic._internal._utils import lenient_issubclass as lenient_issubclass
|
||||
from pydantic.fields import FieldInfo as FieldInfo
|
||||
from pydantic.json_schema import GenerateJsonSchema as GenerateJsonSchema
|
||||
from pydantic.json_schema import JsonSchemaValue as JsonSchemaValue
|
||||
from pydantic_core import CoreSchema as CoreSchema
|
||||
from pydantic_core import PydanticUndefined, PydanticUndefinedType
|
||||
from pydantic_core import PydanticUndefined
|
||||
from pydantic_core import Url as Url
|
||||
from pydantic_core.core_schema import (
|
||||
with_info_plain_validator_function as with_info_plain_validator_function,
|
||||
)
|
||||
from typing_extensions import Literal, get_args, get_origin
|
||||
|
||||
try:
|
||||
from pydantic_core.core_schema import (
|
||||
with_info_plain_validator_function as with_info_plain_validator_function,
|
||||
)
|
||||
except ImportError: # pragma: no cover
|
||||
from pydantic_core.core_schema import (
|
||||
general_plain_validator_function as with_info_plain_validator_function, # noqa: F401
|
||||
)
|
||||
|
||||
RequiredParam = PydanticUndefined
|
||||
Undefined = PydanticUndefined
|
||||
UndefinedType = PydanticUndefinedType
|
||||
evaluate_forwardref = eval_type_lenient
|
||||
Validator = Any
|
||||
|
||||
# TODO: remove when dropping support for Pydantic < v2.12.3
|
||||
_Attrs = {
|
||||
|
|
@ -87,14 +78,6 @@ def asdict(field_info: FieldInfo) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
class BaseConfig:
|
||||
pass
|
||||
|
||||
|
||||
class ErrorWrapper(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelField:
|
||||
field_info: FieldInfo
|
||||
|
|
@ -119,18 +102,10 @@ class ModelField:
|
|||
sa = self.field_info.serialization_alias
|
||||
return sa or None
|
||||
|
||||
@property
|
||||
def required(self) -> bool:
|
||||
return self.field_info.is_required()
|
||||
|
||||
@property
|
||||
def default(self) -> Any:
|
||||
return self.get_default()
|
||||
|
||||
@property
|
||||
def type_(self) -> Any:
|
||||
return self.field_info.annotation
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
with warnings.catch_warnings():
|
||||
# Pydantic >= 2.12.0 warns about field specific metadata that is unused
|
||||
|
|
@ -143,8 +118,8 @@ class ModelField:
|
|||
warnings.simplefilter(
|
||||
"ignore", category=UnsupportedFieldAttributeWarning
|
||||
)
|
||||
# TODO: remove after dropping support for Python 3.8 and
|
||||
# setting the min Pydantic to v2.12.3 that adds asdict()
|
||||
# TODO: remove after setting the min Pydantic to v2.12.3
|
||||
# that adds asdict(), and use self.field_info.asdict() instead
|
||||
field_dict = asdict(self.field_info)
|
||||
annotated_args = (
|
||||
field_dict["annotation"],
|
||||
|
|
@ -284,9 +259,9 @@ def get_definitions(
|
|||
for model in flat_serialization_models
|
||||
]
|
||||
flat_model_fields = flat_validation_model_fields + flat_serialization_model_fields
|
||||
input_types = {f.type_ for f in fields}
|
||||
input_types = {f.field_info.annotation for f in fields}
|
||||
unique_flat_model_fields = {
|
||||
f for f in flat_model_fields if f.type_ not in input_types
|
||||
f for f in flat_model_fields if f.field_info.annotation not in input_types
|
||||
}
|
||||
inputs = [
|
||||
(
|
||||
|
|
@ -321,22 +296,6 @@ def is_scalar_field(field: ModelField) -> bool:
|
|||
) and not isinstance(field.field_info, params.Body)
|
||||
|
||||
|
||||
def is_sequence_field(field: ModelField) -> bool:
|
||||
return shared.field_annotation_is_sequence(field.field_info.annotation)
|
||||
|
||||
|
||||
def is_scalar_sequence_field(field: ModelField) -> bool:
|
||||
return shared.field_annotation_is_scalar_sequence(field.field_info.annotation)
|
||||
|
||||
|
||||
def is_bytes_field(field: ModelField) -> bool:
|
||||
return shared.is_bytes_or_nonable_bytes_annotation(field.type_)
|
||||
|
||||
|
||||
def is_bytes_sequence_field(field: ModelField) -> bool:
|
||||
return shared.is_bytes_sequence_annotation(field.type_)
|
||||
|
||||
|
||||
def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo:
|
||||
cls = type(field_info)
|
||||
merged_field_info = cls.from_annotation(annotation)
|
||||
|
|
@ -432,10 +391,11 @@ def get_flat_models_from_annotation(
|
|||
origin = get_origin(annotation)
|
||||
if origin is not None:
|
||||
for arg in get_args(annotation):
|
||||
if lenient_issubclass(arg, (BaseModel, Enum)) and arg not in known_models:
|
||||
known_models.add(arg)
|
||||
if lenient_issubclass(arg, BaseModel):
|
||||
get_flat_models_from_model(arg, known_models=known_models)
|
||||
if lenient_issubclass(arg, (BaseModel, Enum)):
|
||||
if arg not in known_models:
|
||||
known_models.add(arg) # type: ignore[arg-type]
|
||||
if lenient_issubclass(arg, BaseModel):
|
||||
get_flat_models_from_model(arg, known_models=known_models)
|
||||
else:
|
||||
get_flat_models_from_annotation(arg, known_models=known_models)
|
||||
return known_models
|
||||
|
|
@ -444,7 +404,7 @@ def get_flat_models_from_annotation(
|
|||
def get_flat_models_from_field(
|
||||
field: ModelField, known_models: TypeModelSet
|
||||
) -> TypeModelSet:
|
||||
field_type = field.type_
|
||||
field_type = field.field_info.annotation
|
||||
if lenient_issubclass(field_type, BaseModel):
|
||||
if field_type in known_models:
|
||||
return known_models
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ from fastapi._compat import (
|
|||
create_body_model,
|
||||
evaluate_forwardref,
|
||||
field_annotation_is_scalar,
|
||||
field_annotation_is_scalar_sequence,
|
||||
field_annotation_is_sequence,
|
||||
get_cached_model_fields,
|
||||
get_missing_field_error,
|
||||
is_bytes_field,
|
||||
is_bytes_sequence_field,
|
||||
is_bytes_or_nonable_bytes_annotation,
|
||||
is_bytes_sequence_annotation,
|
||||
is_scalar_field,
|
||||
is_scalar_sequence_field,
|
||||
is_sequence_field,
|
||||
is_uploadfile_or_nonable_uploadfile_annotation,
|
||||
is_uploadfile_sequence_annotation,
|
||||
lenient_issubclass,
|
||||
|
|
@ -67,6 +67,15 @@ from starlette.websockets import WebSocket
|
|||
from typing_extensions import Literal, get_args, get_origin
|
||||
from typing_inspection.typing_objects import is_typealiastype
|
||||
|
||||
try:
|
||||
# This was added in 4.14.0 (June 2, 2025) We want to be able to support older versions
|
||||
from typing_extensions import Sentinel
|
||||
except ImportError:
|
||||
|
||||
class Sentinel:
|
||||
pass
|
||||
|
||||
|
||||
multipart_not_installed_error = (
|
||||
'Form data requires "python-multipart" to be installed. \n'
|
||||
'You can install "python-multipart" with: \n\n'
|
||||
|
|
@ -182,8 +191,10 @@ def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]:
|
|||
if not fields:
|
||||
return fields
|
||||
first_field = fields[0]
|
||||
if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel):
|
||||
fields_to_extract = get_cached_model_fields(first_field.type_)
|
||||
if len(fields) == 1 and lenient_issubclass(
|
||||
first_field.field_info.annotation, BaseModel
|
||||
):
|
||||
fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
|
||||
return fields_to_extract
|
||||
return fields
|
||||
|
||||
|
|
@ -512,7 +523,6 @@ def analyze_param(
|
|||
type_=use_annotation_from_field_info,
|
||||
default=field_info.default,
|
||||
alias=alias,
|
||||
required=field_info.default in (RequiredParam, Undefined),
|
||||
field_info=field_info,
|
||||
)
|
||||
if is_path_param:
|
||||
|
|
@ -522,12 +532,8 @@ def analyze_param(
|
|||
elif isinstance(field_info, params.Query):
|
||||
assert (
|
||||
is_scalar_field(field)
|
||||
or is_scalar_sequence_field(field)
|
||||
or (
|
||||
lenient_issubclass(field.type_, BaseModel)
|
||||
# For Pydantic v1
|
||||
and getattr(field, "shape", 1) == 1
|
||||
)
|
||||
or field_annotation_is_scalar_sequence(field.field_info.annotation)
|
||||
or lenient_issubclass(field.field_info.annotation, BaseModel)
|
||||
), f"Query parameter {param_name!r} must be one of the supported types"
|
||||
|
||||
return ParamDetails(type_annotation=type_annotation, depends=depends, field=field)
|
||||
|
|
@ -713,7 +719,7 @@ def _validate_value_with_model_field(
|
|||
*, field: ModelField, value: Any, values: dict[str, Any], loc: tuple[str, ...]
|
||||
) -> tuple[Any, list[Any]]:
|
||||
if value is None:
|
||||
if field.required:
|
||||
if field.field_info.is_required():
|
||||
return None, [get_missing_field_error(loc=loc)]
|
||||
else:
|
||||
return deepcopy(field.default), []
|
||||
|
|
@ -730,7 +736,7 @@ def _get_multidict_value(
|
|||
alias = alias or get_validation_alias(field)
|
||||
if (
|
||||
(not _is_json_field(field))
|
||||
and is_sequence_field(field)
|
||||
and field_annotation_is_sequence(field.field_info.annotation)
|
||||
and isinstance(values, (ImmutableMultiDict, Headers))
|
||||
):
|
||||
value = values.getlist(alias)
|
||||
|
|
@ -743,12 +749,19 @@ def _get_multidict_value(
|
|||
and isinstance(value, str) # For type checks
|
||||
and value == ""
|
||||
)
|
||||
or (is_sequence_field(field) and len(value) == 0)
|
||||
or (
|
||||
field_annotation_is_sequence(field.field_info.annotation)
|
||||
and len(value) == 0
|
||||
)
|
||||
):
|
||||
if field.required:
|
||||
if field.field_info.is_required():
|
||||
return
|
||||
else:
|
||||
return deepcopy(field.default)
|
||||
return (
|
||||
field.default
|
||||
if isinstance(field.default, Sentinel)
|
||||
else deepcopy(field.default)
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
|
|
@ -766,8 +779,10 @@ def request_params_to_args(
|
|||
fields_to_extract = fields
|
||||
single_not_embedded_field = False
|
||||
default_convert_underscores = True
|
||||
if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel):
|
||||
fields_to_extract = get_cached_model_fields(first_field.type_)
|
||||
if len(fields) == 1 and lenient_issubclass(
|
||||
first_field.field_info.annotation, BaseModel
|
||||
):
|
||||
fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
|
||||
single_not_embedded_field = True
|
||||
# If headers are in a Pydantic model, the way to disable convert_underscores
|
||||
# would be with Header(convert_underscores=False) at the Pydantic model level
|
||||
|
|
@ -871,8 +886,8 @@ def _should_embed_body_fields(fields: list[ModelField]) -> bool:
|
|||
# otherwise it has to be embedded, so that the key value pair can be extracted
|
||||
if (
|
||||
isinstance(first_field.field_info, params.Form)
|
||||
and not lenient_issubclass(first_field.type_, BaseModel)
|
||||
and not is_union_of_base_models(first_field.type_)
|
||||
and not lenient_issubclass(first_field.field_info.annotation, BaseModel)
|
||||
and not is_union_of_base_models(first_field.field_info.annotation)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
|
@ -889,12 +904,12 @@ async def _extract_form_body(
|
|||
field_info = field.field_info
|
||||
if (
|
||||
isinstance(field_info, params.File)
|
||||
and is_bytes_field(field)
|
||||
and is_bytes_or_nonable_bytes_annotation(field.field_info.annotation)
|
||||
and isinstance(value, UploadFile)
|
||||
):
|
||||
value = await value.read()
|
||||
elif (
|
||||
is_bytes_sequence_field(field)
|
||||
is_bytes_sequence_annotation(field.field_info.annotation)
|
||||
and isinstance(field_info, params.File)
|
||||
and value_is_sequence(value)
|
||||
):
|
||||
|
|
@ -941,10 +956,10 @@ async def request_body_to_args(
|
|||
|
||||
if (
|
||||
single_not_embedded_field
|
||||
and lenient_issubclass(first_field.type_, BaseModel)
|
||||
and lenient_issubclass(first_field.field_info.annotation, BaseModel)
|
||||
and isinstance(received_body, FormData)
|
||||
):
|
||||
fields_to_extract = get_cached_model_fields(first_field.type_)
|
||||
fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)
|
||||
|
||||
if isinstance(received_body, FormData):
|
||||
body_to_process = await _extract_form_body(fields_to_extract, received_body)
|
||||
|
|
@ -997,7 +1012,9 @@ def get_body_field(
|
|||
BodyModel = create_body_model(
|
||||
fields=flat_dependant.body_params, model_name=model_name
|
||||
)
|
||||
required = any(True for f in flat_dependant.body_params if f.required)
|
||||
required = any(
|
||||
True for f in flat_dependant.body_params if f.field_info.is_required()
|
||||
)
|
||||
BodyFieldInfo_kwargs: dict[str, Any] = {
|
||||
"annotation": BodyModel,
|
||||
"alias": "body",
|
||||
|
|
@ -1021,7 +1038,6 @@ def get_body_field(
|
|||
final_field = create_model_field(
|
||||
name="body",
|
||||
type_=BodyModel,
|
||||
required=required,
|
||||
alias="body",
|
||||
field_info=BodyFieldInfo(**BodyFieldInfo_kwargs),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -143,10 +143,7 @@ class Schema(BaseModelWithConfig):
|
|||
else_: Optional["SchemaOrBool"] = Field(default=None, alias="else")
|
||||
dependentSchemas: Optional[dict[str, "SchemaOrBool"]] = None
|
||||
prefixItems: Optional[list["SchemaOrBool"]] = None
|
||||
# TODO: uncomment and remove below when deprecating Pydantic v1
|
||||
# It generates a list of schemas for tuples, before prefixItems was available
|
||||
# items: Optional["SchemaOrBool"] = None
|
||||
items: Optional[Union["SchemaOrBool", list["SchemaOrBool"]]] = None
|
||||
items: Optional["SchemaOrBool"] = None
|
||||
contains: Optional["SchemaOrBool"] = None
|
||||
properties: Optional[dict[str, "SchemaOrBool"]] = None
|
||||
patternProperties: Optional[dict[str, "SchemaOrBool"]] = None
|
||||
|
|
|
|||
|
|
@ -10,12 +10,10 @@ from fastapi._compat import (
|
|||
ModelField,
|
||||
Undefined,
|
||||
get_definitions,
|
||||
get_schema_from_model_field,
|
||||
lenient_issubclass,
|
||||
)
|
||||
from fastapi._compat.v2 import (
|
||||
get_flat_models_from_fields,
|
||||
get_model_name_map,
|
||||
get_schema_from_model_field,
|
||||
lenient_issubclass,
|
||||
)
|
||||
from fastapi.datastructures import DefaultPlaceholder
|
||||
from fastapi.dependencies.models import Dependant
|
||||
|
|
@ -131,7 +129,7 @@ def _get_openapi_operation_parameters(
|
|||
default_convert_underscores = True
|
||||
if len(flat_dependant.header_params) == 1:
|
||||
first_field = flat_dependant.header_params[0]
|
||||
if lenient_issubclass(first_field.type_, BaseModel):
|
||||
if lenient_issubclass(first_field.field_info.annotation, BaseModel):
|
||||
default_convert_underscores = getattr(
|
||||
first_field.field_info, "convert_underscores", True
|
||||
)
|
||||
|
|
@ -163,7 +161,7 @@ def _get_openapi_operation_parameters(
|
|||
parameter = {
|
||||
"name": name,
|
||||
"in": param_type.value,
|
||||
"required": param.required,
|
||||
"required": param.field_info.is_required(),
|
||||
"schema": param_schema,
|
||||
}
|
||||
if field_info.description:
|
||||
|
|
@ -200,7 +198,7 @@ def get_openapi_operation_request_body(
|
|||
)
|
||||
field_info = cast(Body, body_field.field_info)
|
||||
request_media_type = field_info.media_type
|
||||
required = body_field.required
|
||||
required = body_field.field_info.is_required()
|
||||
request_body_oai: dict[str, Any] = {}
|
||||
if required:
|
||||
request_body_oai["required"] = required
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ from fastapi import params
|
|||
from fastapi._compat import (
|
||||
ModelField,
|
||||
Undefined,
|
||||
annotation_is_pydantic_v1,
|
||||
lenient_issubclass,
|
||||
)
|
||||
from fastapi.datastructures import Default, DefaultPlaceholder
|
||||
|
|
@ -52,7 +51,6 @@ from fastapi.encoders import jsonable_encoder
|
|||
from fastapi.exceptions import (
|
||||
EndpointContext,
|
||||
FastAPIError,
|
||||
PydanticV1NotSupportedError,
|
||||
RequestValidationError,
|
||||
ResponseValidationError,
|
||||
WebSocketRequestValidationError,
|
||||
|
|
@ -638,11 +636,6 @@ class APIRoute(routing.Route):
|
|||
f"Status code {status_code} must not have a response body"
|
||||
)
|
||||
response_name = "Response_" + self.unique_id
|
||||
if annotation_is_pydantic_v1(self.response_model):
|
||||
raise PydanticV1NotSupportedError(
|
||||
"pydantic.v1 models are no longer supported by FastAPI."
|
||||
f" Please update the response model {self.response_model!r}."
|
||||
)
|
||||
self.response_field = create_model_field(
|
||||
name=response_name,
|
||||
type_=self.response_model,
|
||||
|
|
@ -664,11 +657,6 @@ class APIRoute(routing.Route):
|
|||
f"Status code {additional_status_code} must not have a response body"
|
||||
)
|
||||
response_name = f"Response_{additional_status_code}_{self.unique_id}"
|
||||
if annotation_is_pydantic_v1(model):
|
||||
raise PydanticV1NotSupportedError(
|
||||
"pydantic.v1 models are no longer supported by FastAPI."
|
||||
f" In responses={{}}, please update {model}."
|
||||
)
|
||||
response_field = create_model_field(
|
||||
name=response_name, type_=model, mode="serialization"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,9 @@ from typing import (
|
|||
|
||||
import fastapi
|
||||
from fastapi._compat import (
|
||||
BaseConfig,
|
||||
ModelField,
|
||||
PydanticSchemaGenerationError,
|
||||
Undefined,
|
||||
UndefinedType,
|
||||
Validator,
|
||||
annotation_is_pydantic_v1,
|
||||
)
|
||||
from fastapi.datastructures import DefaultPlaceholder, DefaultType
|
||||
|
|
@ -63,26 +60,19 @@ _invalid_args_message = (
|
|||
def create_model_field(
|
||||
name: str,
|
||||
type_: Any,
|
||||
class_validators: Optional[dict[str, Validator]] = None,
|
||||
default: Optional[Any] = Undefined,
|
||||
required: Union[bool, UndefinedType] = Undefined,
|
||||
model_config: Union[type[BaseConfig], None] = None,
|
||||
field_info: Optional[FieldInfo] = None,
|
||||
alias: Optional[str] = None,
|
||||
mode: Literal["validation", "serialization"] = "validation",
|
||||
version: Literal["1", "auto"] = "auto",
|
||||
) -> ModelField:
|
||||
if annotation_is_pydantic_v1(type_):
|
||||
raise PydanticV1NotSupportedError(
|
||||
"pydantic.v1 models are no longer supported by FastAPI."
|
||||
f" Please update the response model {type_!r}."
|
||||
)
|
||||
class_validators = class_validators or {}
|
||||
|
||||
field_info = field_info or FieldInfo(annotation=type_, default=default, alias=alias)
|
||||
kwargs = {"mode": mode, "name": name, "field_info": field_info}
|
||||
try:
|
||||
return v2.ModelField(**kwargs) # type: ignore[arg-type]
|
||||
return v2.ModelField(mode=mode, name=name, field_info=field_info)
|
||||
except PydanticSchemaGenerationError:
|
||||
raise fastapi.exceptions.FastAPIError(
|
||||
_invalid_args_message.format(type_=type_)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
from typing import Union
|
||||
|
||||
import pydantic_core
|
||||
import pytest
|
||||
from fastapi import Body, Cookie, FastAPI, Header, Query
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
try:
|
||||
# We support older pydantic versions, so do a safe import
|
||||
from pydantic_core import MISSING
|
||||
except ImportError:
|
||||
|
||||
class MISSING:
|
||||
pass
|
||||
|
||||
|
||||
def create_app():
|
||||
app = FastAPI()
|
||||
|
||||
class Item(BaseModel):
|
||||
data: Union[str, MISSING, None] = MISSING # pyright: ignore[reportInvalidTypeForm] - requires pyright option: enableExperimentalFeatures = true
|
||||
# see https://docs.pydantic.dev/latest/concepts/experimental/#missing-sentinel
|
||||
|
||||
@app.post("/sentinel/")
|
||||
def sentinel(
|
||||
item: Item = Body(),
|
||||
):
|
||||
return item
|
||||
|
||||
@app.get("/query_sentinel/")
|
||||
def query_sentinel(
|
||||
data: Union[str, MISSING, None] = Query(default=MISSING), # pyright: ignore[reportInvalidTypeForm]
|
||||
):
|
||||
return data
|
||||
|
||||
@app.get("/header_sentinel/")
|
||||
def header_sentinel(
|
||||
data: Union[str, MISSING, None] = Header(default=MISSING), # pyright: ignore[reportInvalidTypeForm]
|
||||
):
|
||||
return data
|
||||
|
||||
@app.get("/cookie_sentinel/")
|
||||
def cookie_sentinel(
|
||||
data: Union[str, MISSING, None] = Cookie(default=MISSING), # pyright: ignore[reportInvalidTypeForm]
|
||||
):
|
||||
return data
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
pydantic_core.__version__ < "2.37.0",
|
||||
reason="This pydantic_core version doesn't support MISSING",
|
||||
)
|
||||
def test_call_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
response = client.post("/sentinel/", json={})
|
||||
assert response.status_code == 200, response.text
|
||||
response = client.post("/sentinel/", json={"data": "Foo"})
|
||||
assert response.status_code == 200, response.text
|
||||
response = client.get("/query_sentinel/")
|
||||
assert response.status_code == 200, response.text
|
||||
response = client.get("/query_sentinel/", params={"data": "Foo"})
|
||||
assert response.status_code == 200, response.text
|
||||
response = client.get("/header_sentinel/")
|
||||
assert response.status_code == 200, response.text
|
||||
response = client.get("/header_sentinel/", headers={"data": "Foo"})
|
||||
assert response.status_code == 200, response.text
|
||||
response = client.get("/cookie_sentinel/")
|
||||
assert response.status_code == 200, response.text
|
||||
client.cookies = {"data": "Foo"}
|
||||
response = client.get("/cookie_sentinel/")
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
pydantic_core.__version__ < "2.37.0",
|
||||
reason="This pydantic_core version doesn't support MISSING",
|
||||
)
|
||||
def test_openapi_schema():
|
||||
"""
|
||||
Test that example overrides work:
|
||||
|
||||
* pydantic model schema_extra is included
|
||||
* Body(example={}) overrides schema_extra in pydantic model
|
||||
* Body(examples{}) overrides Body(example={}) and schema_extra in pydantic model
|
||||
"""
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "FastAPI", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/sentinel/": {
|
||||
"post": {
|
||||
"summary": "Sentinel",
|
||||
"operationId": "sentinel_sentinel__post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Item",
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": True,
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/query_sentinel/": {
|
||||
"get": {
|
||||
"summary": "Query Sentinel",
|
||||
"operationId": "query_sentinel_query_sentinel__get",
|
||||
"parameters": [
|
||||
{
|
||||
"required": False,
|
||||
"schema": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"title": "Data",
|
||||
},
|
||||
"name": "data",
|
||||
"in": "query",
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/header_sentinel/": {
|
||||
"get": {
|
||||
"summary": "Header Sentinel",
|
||||
"operationId": "header_sentinel_header_sentinel__get",
|
||||
"parameters": [
|
||||
{
|
||||
"required": False,
|
||||
"schema": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"title": "Data",
|
||||
},
|
||||
"name": "data",
|
||||
"in": "header",
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"/cookie_sentinel/": {
|
||||
"get": {
|
||||
"summary": "Cookie Sentinel",
|
||||
"operationId": "cookie_sentinel_cookie_sentinel__get",
|
||||
"parameters": [
|
||||
{
|
||||
"required": False,
|
||||
"schema": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"title": "Data",
|
||||
},
|
||||
"name": "data",
|
||||
"in": "cookie",
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {"application/json": {"schema": {}}},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"HTTPValidationError": {
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail": {
|
||||
"title": "Detail",
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/ValidationError"},
|
||||
}
|
||||
},
|
||||
},
|
||||
"Item": {
|
||||
"title": "Item",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"title": "Data",
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
}
|
||||
},
|
||||
},
|
||||
"ValidationError": {
|
||||
"title": "ValidationError",
|
||||
"required": ["loc", "msg", "type"],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"loc": {
|
||||
"title": "Location",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer"}]
|
||||
},
|
||||
},
|
||||
"msg": {"title": "Message", "type": "string"},
|
||||
"type": {"title": "Error Type", "type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
Loading…
Reference in New Issue