Browse Source

🎨 [pre-commit.ci] Auto format from pre-commit.com hooks

pull/14142/head
pre-commit-ci[bot] 10 months ago
parent
commit
f45531dfd5
  1. 67
      fastapi/_compat.py
  2. 57
      fastapi/schema.py
  3. 78
      tests/test_property_names.py

67
fastapi/_compat.py

@ -205,17 +205,23 @@ if PYDANTIC_V2:
json_schema["title"] = ( json_schema["title"] = (
field.field_info.title or field.alias.title().replace("_", " ") field.field_info.title or field.alias.title().replace("_", " ")
) )
# Check for PropertyNames constraint in field metadata # Check for PropertyNames constraint in field metadata
try: try:
from fastapi.schema import get_property_names_constraint, apply_property_names_to_schema from fastapi.schema import (
apply_property_names_to_schema,
get_property_names_constraint,
)
property_names_constraint = get_property_names_constraint(field.field_info) property_names_constraint = get_property_names_constraint(field.field_info)
if property_names_constraint: if property_names_constraint:
json_schema = apply_property_names_to_schema(json_schema, property_names_constraint) json_schema = apply_property_names_to_schema(
json_schema, property_names_constraint
)
except ImportError: except ImportError:
# Gracefully handle if schema module is not available # Gracefully handle if schema module is not available
pass pass
return json_schema return json_schema
def get_compat_model_name_map(fields: List[ModelField]) -> ModelNameMap: def get_compat_model_name_map(fields: List[ModelField]) -> ModelNameMap:
@ -247,12 +253,12 @@ if PYDANTIC_V2:
if "description" in item_def: if "description" in item_def:
item_description = cast(str, item_def["description"]).split("\f")[0] item_description = cast(str, item_def["description"]).split("\f")[0]
item_def["description"] = item_description item_def["description"] = item_description
# Apply PropertyNames constraints to model definitions # Apply PropertyNames constraints to model definitions
# Convert DefsRef keys to strings for compatibility # Convert DefsRef keys to strings for compatibility
string_definitions = {str(k): v for k, v in definitions.items()} string_definitions = {str(k): v for k, v in definitions.items()}
_apply_property_names_to_definitions(string_definitions, fields) _apply_property_names_to_definitions(string_definitions, fields)
return field_mapping, definitions # type: ignore[return-value] return field_mapping, definitions # type: ignore[return-value]
def is_scalar_field(field: ModelField) -> bool: def is_scalar_field(field: ModelField) -> bool:
@ -486,20 +492,29 @@ else:
separate_input_output_schemas: bool = True, separate_input_output_schemas: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
# This expects that GenerateJsonSchema was already used to generate the definitions # This expects that GenerateJsonSchema was already used to generate the definitions
json_schema = cast(Dict[str, Any], field_schema( json_schema = cast(
field, model_name_map=model_name_map, ref_prefix=REF_PREFIX Dict[str, Any],
)[0]) field_schema(field, model_name_map=model_name_map, ref_prefix=REF_PREFIX)[
0
],
)
# Check for PropertyNames constraint in field metadata # Check for PropertyNames constraint in field metadata
try: try:
from fastapi.schema import get_property_names_constraint, apply_property_names_to_schema from fastapi.schema import (
apply_property_names_to_schema,
get_property_names_constraint,
)
property_names_constraint = get_property_names_constraint(field.field_info) property_names_constraint = get_property_names_constraint(field.field_info)
if property_names_constraint: if property_names_constraint:
json_schema = apply_property_names_to_schema(json_schema, property_names_constraint) json_schema = apply_property_names_to_schema(
json_schema, property_names_constraint
)
except ImportError: except ImportError:
# Gracefully handle if schema module is not available # Gracefully handle if schema module is not available
pass pass
return json_schema return json_schema
def get_compat_model_name_map(fields: List[ModelField]) -> ModelNameMap: def get_compat_model_name_map(fields: List[ModelField]) -> ModelNameMap:
@ -690,18 +705,20 @@ def is_uploadfile_sequence_annotation(annotation: Any) -> bool:
def _apply_property_names_to_definitions( def _apply_property_names_to_definitions(
definitions: Dict[str, Dict[str, Any]], definitions: Dict[str, Dict[str, Any]], fields: List[ModelField]
fields: List[ModelField]
) -> None: ) -> None:
""" """
Apply PropertyNames constraints to model definitions by checking each model's fields. Apply PropertyNames constraints to model definitions by checking each model's fields.
""" """
try: try:
from fastapi.schema import get_property_names_constraint, apply_property_names_to_schema from fastapi.schema import (
apply_property_names_to_schema,
get_property_names_constraint,
)
# Group fields by their model type # Group fields by their model type
model_fields_map: Dict[Type[BaseModel], List[ModelField]] = {} model_fields_map: Dict[Type[BaseModel], List[ModelField]] = {}
for field in fields: for field in fields:
# Get the actual annotation (which might be the BaseModel class) # Get the actual annotation (which might be the BaseModel class)
annotation = field.field_info.annotation annotation = field.field_info.annotation
@ -711,8 +728,10 @@ def _apply_property_names_to_definitions(
if model_annotation not in model_fields_map: if model_annotation not in model_fields_map:
model_fields_map[model_annotation] = [] model_fields_map[model_annotation] = []
# Get fields from the model itself, not the field that references it # Get fields from the model itself, not the field that references it
model_fields_map[model_annotation].extend(get_model_fields(model_annotation)) model_fields_map[model_annotation].extend(
get_model_fields(model_annotation)
)
# Apply PropertyNames constraints to definitions # Apply PropertyNames constraints to definitions
for model_class, model_field_list in model_fields_map.items(): for model_class, model_field_list in model_fields_map.items():
model_name = model_class.__name__ model_name = model_class.__name__
@ -720,12 +739,16 @@ def _apply_property_names_to_definitions(
definition = definitions[model_name] definition = definitions[model_name]
if "properties" in definition: if "properties" in definition:
for model_field in model_field_list: for model_field in model_field_list:
property_names_constraint = get_property_names_constraint(model_field.field_info) property_names_constraint = get_property_names_constraint(
model_field.field_info
)
if property_names_constraint: if property_names_constraint:
field_name = model_field.alias field_name = model_field.alias
if field_name in definition["properties"]: if field_name in definition["properties"]:
field_schema = definition["properties"][field_name] field_schema = definition["properties"][field_name]
field_schema = apply_property_names_to_schema(field_schema, property_names_constraint) field_schema = apply_property_names_to_schema(
field_schema, property_names_constraint
)
definition["properties"][field_name] = field_schema definition["properties"][field_name] = field_schema
except ImportError: except ImportError:
# Schema module not available # Schema module not available

57
fastapi/schema.py

@ -2,40 +2,40 @@
FastAPI schema utilities including PropertyNames constraint support. FastAPI schema utilities including PropertyNames constraint support.
""" """
from typing import Any, Dict, Optional, Union from typing import Any, Dict, Optional
from pydantic import BaseModel
from pydantic.fields import FieldInfo from pydantic.fields import FieldInfo
class PropertyNames: class PropertyNames:
""" """
A constraint class for specifying propertyNames validation in JSON Schema. A constraint class for specifying propertyNames validation in JSON Schema.
This allows developers to specify patterns or schemas that all property names This allows developers to specify patterns or schemas that all property names
in a dictionary/object must match. in a dictionary/object must match.
Examples: Examples:
# Pattern-based constraint # Pattern-based constraint
PropertyNames(pattern="^prefix_") PropertyNames(pattern="^prefix_")
# Schema-based constraint # Schema-based constraint
PropertyNames(min_length=3, max_length=50) PropertyNames(min_length=3, max_length=50)
# Complex schema constraint # Complex schema constraint
PropertyNames(schema={"type": "string", "enum": ["key1", "key2", "key3"]}) PropertyNames(schema={"type": "string", "enum": ["key1", "key2", "key3"]})
""" """
def __init__( def __init__(
self, self,
pattern: Optional[str] = None, pattern: Optional[str] = None,
min_length: Optional[int] = None, min_length: Optional[int] = None,
max_length: Optional[int] = None, max_length: Optional[int] = None,
schema: Optional[Dict[str, Any]] = None, schema: Optional[Dict[str, Any]] = None,
**kwargs: Any **kwargs: Any,
): ):
""" """
Initialize PropertyNames constraint. Initialize PropertyNames constraint.
Args: Args:
pattern: Regular expression pattern that property names must match pattern: Regular expression pattern that property names must match
min_length: Minimum length for property names min_length: Minimum length for property names
@ -44,31 +44,33 @@ class PropertyNames:
**kwargs: Additional JSON schema properties **kwargs: Additional JSON schema properties
""" """
self.constraint_schema: Dict[str, Any] = {} self.constraint_schema: Dict[str, Any] = {}
if pattern is not None: if pattern is not None:
self.constraint_schema["pattern"] = pattern self.constraint_schema["pattern"] = pattern
if min_length is not None: if min_length is not None:
self.constraint_schema["minLength"] = min_length self.constraint_schema["minLength"] = min_length
if max_length is not None: if max_length is not None:
self.constraint_schema["maxLength"] = max_length self.constraint_schema["maxLength"] = max_length
if schema is not None: if schema is not None:
self.constraint_schema.update(schema) self.constraint_schema.update(schema)
# Add any additional properties # Add any additional properties
for key, value in kwargs.items(): for key, value in kwargs.items():
if value is not None: if value is not None:
self.constraint_schema[key] = value self.constraint_schema[key] = value
if not self.constraint_schema: if not self.constraint_schema:
raise ValueError("PropertyNames constraint must specify at least one validation rule") raise ValueError(
"PropertyNames constraint must specify at least one validation rule"
)
def get_constraint_schema(self) -> Dict[str, Any]: def get_constraint_schema(self) -> Dict[str, Any]:
"""Get the JSON schema representation of this constraint.""" """Get the JSON schema representation of this constraint."""
return self.constraint_schema.copy() return self.constraint_schema.copy()
def __repr__(self) -> str: def __repr__(self) -> str:
return f"PropertyNames({self.constraint_schema})" return f"PropertyNames({self.constraint_schema})"
@ -76,34 +78,33 @@ class PropertyNames:
def get_property_names_constraint(field_info: FieldInfo) -> Optional[Dict[str, Any]]: def get_property_names_constraint(field_info: FieldInfo) -> Optional[Dict[str, Any]]:
""" """
Extract PropertyNames constraint from field metadata. Extract PropertyNames constraint from field metadata.
Args: Args:
field_info: Pydantic FieldInfo object field_info: Pydantic FieldInfo object
Returns: Returns:
PropertyNames constraint schema or None if not found PropertyNames constraint schema or None if not found
""" """
if not hasattr(field_info, 'metadata') or not field_info.metadata: if not hasattr(field_info, "metadata") or not field_info.metadata:
return None return None
for metadata_item in field_info.metadata: for metadata_item in field_info.metadata:
if isinstance(metadata_item, PropertyNames): if isinstance(metadata_item, PropertyNames):
return metadata_item.get_constraint_schema() return metadata_item.get_constraint_schema()
return None return None
def apply_property_names_to_schema( def apply_property_names_to_schema(
schema: Dict[str, Any], schema: Dict[str, Any], property_names_constraint: Dict[str, Any]
property_names_constraint: Dict[str, Any]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Apply PropertyNames constraint to a JSON schema. Apply PropertyNames constraint to a JSON schema.
Args: Args:
schema: The JSON schema to modify schema: The JSON schema to modify
property_names_constraint: The propertyNames constraint to apply property_names_constraint: The propertyNames constraint to apply
Returns: Returns:
Modified schema with propertyNames constraint Modified schema with propertyNames constraint
""" """

78
tests/test_property_names.py

@ -3,11 +3,11 @@ Tests for PropertyNames constraint support in FastAPI.
""" """
from typing import Dict from typing import Dict
from typing_extensions import Annotated
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.schema import PropertyNames from fastapi.schema import PropertyNames
from pydantic import BaseModel from pydantic import BaseModel
import json from typing_extensions import Annotated
def test_property_names_creation(): def test_property_names_creation():
@ -15,42 +15,43 @@ def test_property_names_creation():
# Test pattern constraint # Test pattern constraint
pn1 = PropertyNames(pattern="^test_") pn1 = PropertyNames(pattern="^test_")
assert pn1.get_constraint_schema() == {"pattern": "^test_"} assert pn1.get_constraint_schema() == {"pattern": "^test_"}
# Test multiple constraints # Test multiple constraints
pn2 = PropertyNames(min_length=5, max_length=20, pattern="^[a-z]+$") pn2 = PropertyNames(min_length=5, max_length=20, pattern="^[a-z]+$")
expected = {"minLength": 5, "maxLength": 20, "pattern": "^[a-z]+$"} expected = {"minLength": 5, "maxLength": 20, "pattern": "^[a-z]+$"}
assert pn2.get_constraint_schema() == expected assert pn2.get_constraint_schema() == expected
# Test schema constraint # Test schema constraint
pn3 = PropertyNames(schema={"type": "string", "format": "email"}) pn3 = PropertyNames(schema={"type": "string", "format": "email"})
expected = {"type": "string", "format": "email"} expected = {"type": "string", "format": "email"}
assert pn3.get_constraint_schema() == expected assert pn3.get_constraint_schema() == expected
# Test empty constraint should raise error # Test empty constraint should raise error
try: try:
PropertyNames() PropertyNames()
assert False, "Should have raised ValueError" assert False, "Should have raised ValueError"
except ValueError as e: except ValueError as e:
assert "PropertyNames constraint must specify at least one validation rule" in str(e) assert (
"PropertyNames constraint must specify at least one validation rule"
in str(e)
)
def test_property_names_pattern_constraint(): def test_property_names_pattern_constraint():
"""Test PropertyNames with pattern constraint in FastAPI schema generation.""" """Test PropertyNames with pattern constraint in FastAPI schema generation."""
class ModelWithPropertyNames(BaseModel): class ModelWithPropertyNames(BaseModel):
properties: Annotated[ properties: Annotated[Dict[str, str], PropertyNames(pattern="^prefix_")]
Dict[str, str],
PropertyNames(pattern="^prefix_")
]
app = FastAPI() app = FastAPI()
@app.post("/test") @app.post("/test")
def test_endpoint(data: ModelWithPropertyNames): def test_endpoint(data: ModelWithPropertyNames):
return data return data
# Get the OpenAPI schema # Get the OpenAPI schema
openapi_schema = app.openapi() openapi_schema = app.openapi()
# Find the schema for our model # Find the schema for our model
model_schema = None model_schema = None
for schema_name, schema_def in openapi_schema["components"]["schemas"].items(): for schema_name, schema_def in openapi_schema["components"]["schemas"].items():
@ -59,13 +60,13 @@ def test_property_names_pattern_constraint():
if "propertyNames" in properties_field: if "propertyNames" in properties_field:
model_schema = properties_field model_schema = properties_field
break break
assert model_schema is not None, "Could not find model schema with propertyNames" assert model_schema is not None, "Could not find model schema with propertyNames"
# Verify the propertyNames constraint is present # Verify the propertyNames constraint is present
property_names_constraint = model_schema["propertyNames"] property_names_constraint = model_schema["propertyNames"]
assert property_names_constraint["pattern"] == "^prefix_" assert property_names_constraint["pattern"] == "^prefix_"
# Verify the full schema structure # Verify the full schema structure
expected_keys = {"type", "additionalProperties", "propertyNames", "title"} expected_keys = {"type", "additionalProperties", "propertyNames", "title"}
assert set(model_schema.keys()) >= expected_keys assert set(model_schema.keys()) >= expected_keys
@ -73,21 +74,22 @@ def test_property_names_pattern_constraint():
def test_property_names_complex_constraints(): def test_property_names_complex_constraints():
"""Test PropertyNames with multiple constraints.""" """Test PropertyNames with multiple constraints."""
class ComplexPropertyNames(BaseModel): class ComplexPropertyNames(BaseModel):
config_vars: Annotated[ config_vars: Annotated[
Dict[str, str], Dict[str, str],
PropertyNames(min_length=3, max_length=50, pattern="^[A-Z_]+$") PropertyNames(min_length=3, max_length=50, pattern="^[A-Z_]+$"),
] ]
app = FastAPI() app = FastAPI()
@app.post("/test") @app.post("/test")
def test_endpoint(data: ComplexPropertyNames): def test_endpoint(data: ComplexPropertyNames):
return data return data
# Get the OpenAPI schema # Get the OpenAPI schema
openapi_schema = app.openapi() openapi_schema = app.openapi()
# Find the schema for our model # Find the schema for our model
model_schema = None model_schema = None
for schema_name, schema_def in openapi_schema["components"]["schemas"].items(): for schema_name, schema_def in openapi_schema["components"]["schemas"].items():
@ -96,9 +98,9 @@ def test_property_names_complex_constraints():
if "propertyNames" in config_vars_field: if "propertyNames" in config_vars_field:
model_schema = config_vars_field model_schema = config_vars_field
break break
assert model_schema is not None, "Could not find model schema with propertyNames" assert model_schema is not None, "Could not find model schema with propertyNames"
# Verify the propertyNames constraint has all expected properties # Verify the propertyNames constraint has all expected properties
property_names_constraint = model_schema["propertyNames"] property_names_constraint = model_schema["propertyNames"]
assert property_names_constraint["pattern"] == "^[A-Z_]+$" assert property_names_constraint["pattern"] == "^[A-Z_]+$"
@ -108,21 +110,22 @@ def test_property_names_complex_constraints():
def test_property_names_schema_constraint(): def test_property_names_schema_constraint():
"""Test PropertyNames with custom schema constraint.""" """Test PropertyNames with custom schema constraint."""
class SchemaPropertyNames(BaseModel): class SchemaPropertyNames(BaseModel):
allowed_keys: Annotated[ allowed_keys: Annotated[
Dict[str, int], Dict[str, int],
PropertyNames(schema={"type": "string", "enum": ["key1", "key2", "key3"]}) PropertyNames(schema={"type": "string", "enum": ["key1", "key2", "key3"]}),
] ]
app = FastAPI() app = FastAPI()
@app.post("/test") @app.post("/test")
def test_endpoint(data: SchemaPropertyNames): def test_endpoint(data: SchemaPropertyNames):
return data return data
# Get the OpenAPI schema # Get the OpenAPI schema
openapi_schema = app.openapi() openapi_schema = app.openapi()
# Find the schema for our model # Find the schema for our model
model_schema = None model_schema = None
for schema_name, schema_def in openapi_schema["components"]["schemas"].items(): for schema_name, schema_def in openapi_schema["components"]["schemas"].items():
@ -131,9 +134,9 @@ def test_property_names_schema_constraint():
if "propertyNames" in allowed_keys_field: if "propertyNames" in allowed_keys_field:
model_schema = allowed_keys_field model_schema = allowed_keys_field
break break
assert model_schema is not None, "Could not find model schema with propertyNames" assert model_schema is not None, "Could not find model schema with propertyNames"
# Verify the propertyNames constraint with custom schema # Verify the propertyNames constraint with custom schema
property_names_constraint = model_schema["propertyNames"] property_names_constraint = model_schema["propertyNames"]
assert property_names_constraint["type"] == "string" assert property_names_constraint["type"] == "string"
@ -142,18 +145,19 @@ def test_property_names_schema_constraint():
def test_property_names_no_constraint(): def test_property_names_no_constraint():
"""Test that models without PropertyNames constraints work normally.""" """Test that models without PropertyNames constraints work normally."""
class NormalModel(BaseModel): class NormalModel(BaseModel):
properties: Dict[str, str] properties: Dict[str, str]
app = FastAPI() app = FastAPI()
@app.post("/test") @app.post("/test")
def test_endpoint(data: NormalModel): def test_endpoint(data: NormalModel):
return data return data
# Get the OpenAPI schema # Get the OpenAPI schema
openapi_schema = app.openapi() openapi_schema = app.openapi()
# Find the schema for our model # Find the schema for our model
for schema_name, schema_def in openapi_schema["components"]["schemas"].items(): for schema_name, schema_def in openapi_schema["components"]["schemas"].items():
if schema_name == "NormalModel": if schema_name == "NormalModel":
@ -169,7 +173,7 @@ def test_property_names_kwargs():
"""Test PropertyNames with additional kwargs.""" """Test PropertyNames with additional kwargs."""
pn = PropertyNames(format="email", description="Email addresses") pn = PropertyNames(format="email", description="Email addresses")
constraint_schema = pn.get_constraint_schema() constraint_schema = pn.get_constraint_schema()
assert constraint_schema["format"] == "email" assert constraint_schema["format"] == "email"
assert constraint_schema["description"] == "Email addresses" assert constraint_schema["description"] == "Email addresses"
@ -178,7 +182,7 @@ def test_property_names_repr():
"""Test PropertyNames string representation.""" """Test PropertyNames string representation."""
pn = PropertyNames(pattern="^test_") pn = PropertyNames(pattern="^test_")
repr_str = repr(pn) repr_str = repr(pn)
assert "PropertyNames" in repr_str assert "PropertyNames" in repr_str
assert "pattern" in repr_str assert "pattern" in repr_str
assert "^test_" in repr_str assert "^test_" in repr_str

Loading…
Cancel
Save