From eea8462618fc02640ed10b8272aafdebdd677015 Mon Sep 17 00:00:00 2001 From: Neev Cohen Date: Mon, 8 Apr 2024 00:24:29 +0300 Subject: [PATCH 01/10] Implement programmatic title generation --- pydantic/_internal/_config.py | 4 + pydantic/_internal/_generate_schema.py | 81 ++++- pydantic/config.py | 7 + pydantic/fields.py | 31 +- tests/test_json_schema.py | 6 +- tests/test_titles.py | 450 +++++++++++++++++++++++++ 6 files changed, 568 insertions(+), 11 deletions(-) create mode 100644 tests/test_titles.py diff --git a/pydantic/_internal/_config.py b/pydantic/_internal/_config.py index 027aee8f20..0c70009755 100644 --- a/pydantic/_internal/_config.py +++ b/pydantic/_internal/_config.py @@ -57,6 +57,8 @@ class ConfigWrapper: # to construct error `loc`s, default `True` loc_by_alias: bool alias_generator: Callable[[str], str] | AliasGenerator | None + class_title_generator: Callable[[str], str] | None + field_title_generator: Callable[[str], str] | None ignored_types: tuple[type, ...] allow_inf_nan: bool json_schema_extra: JsonDict | JsonSchemaExtraCallable | None @@ -240,6 +242,8 @@ def push(self, config_wrapper: ConfigWrapper | ConfigDict | None): from_attributes=False, loc_by_alias=True, alias_generator=None, + class_title_generator=None, + field_title_generator=None, ignored_types=(), allow_inf_nan=True, json_schema_extra=None, diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index e07e68f6ac..4da20ff277 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -102,7 +102,6 @@ ModifyCoreSchemaWrapHandler = GetCoreSchemaHandler GetCoreSchemaFunction = Callable[[Any, ModifyCoreSchemaWrapHandler], core_schema.CoreSchema] - TUPLE_TYPES: list[type] = [tuple, typing.Tuple] LIST_TYPES: list[type] = [list, typing.List, collections.abc.MutableSequence] SET_TYPES: list[type] = [set, typing.Set, collections.abc.MutableSet] @@ -202,7 +201,11 @@ def apply_each_item_validators( def modify_model_json_schema( - schema_or_field: CoreSchemaOrField, handler: GetJsonSchemaHandler, *, cls: Any + schema_or_field: CoreSchemaOrField, + handler: GetJsonSchemaHandler, + *, + cls: Any, + title: str | None = None, ) -> JsonSchemaValue: """Add title and description for model-like classes' JSON schema. @@ -210,11 +213,14 @@ def modify_model_json_schema( schema_or_field: The schema data to generate a JSON schema from. handler: The `GetCoreSchemaHandler` instance. cls: The model-like class. + title: The title to set for the model's schema, defaults to the models name Returns: JsonSchemaValue: The updated JSON schema. """ + from ..dataclasses import is_pydantic_dataclass from ..main import BaseModel + from ._dataclasses import is_builtin_dataclass json_schema = handler(schema_or_field) original_schema = handler.resolve_ref_schema(json_schema) @@ -223,10 +229,12 @@ def modify_model_json_schema( ref = original_schema['$ref'] original_schema.clear() original_schema['allOf'] = [{'$ref': ref}] - if 'title' not in original_schema: + if title is not None: + original_schema['title'] = title + elif 'title' not in original_schema: original_schema['title'] = cls.__name__ - # BaseModel; don't use cls.__doc__ as it will contain the verbose class signature by default - docstring = None if cls is BaseModel else cls.__doc__ + # BaseModel + Dataclass; don't use cls.__doc__ as it will contain the verbose class signature by default + docstring = None if cls is BaseModel or is_builtin_dataclass(cls) or is_pydantic_dataclass(cls) else cls.__doc__ if docstring and 'description' not in original_schema: original_schema['description'] = inspect.cleandoc(docstring) return json_schema @@ -527,7 +535,8 @@ def _model_schema(self, cls: type[BaseModel]) -> core_schema.CoreSchema: ) config_wrapper = ConfigWrapper(cls.model_config, check=False) core_config = config_wrapper.core_config(cls) - metadata = build_metadata_dict(js_functions=[partial(modify_model_json_schema, cls=cls)]) + title = self._get_class_title_from_config(cls, config_wrapper) + metadata = build_metadata_dict(js_functions=[partial(modify_model_json_schema, cls=cls, title=title)]) model_validators = decorators.model_validators.values() @@ -604,6 +613,26 @@ def _model_schema(self, cls: type[BaseModel]) -> core_schema.CoreSchema: self.defs.definitions[model_ref] = schema return core_schema.definition_reference_schema(model_ref) + @staticmethod + def _get_class_title_from_config( + cls: type[BaseModel | StandardDataclass], config_wrapper: ConfigWrapper | None = None + ) -> str | None: + """Get the title of a class if `class_title_generator` or `title` are set in the config, else return None""" + if config_wrapper is None: + return None + + if config_wrapper.title: + return config_wrapper.title + + class_title_generator = config_wrapper.class_title_generator + if class_title_generator: + title = class_title_generator(cls.__name__) + if not isinstance(title, str): + raise TypeError(f'class_title_generator {class_title_generator} must return str, not {title.__class__}') + return title + + return None + def _unpack_refs_defs(self, schema: CoreSchema) -> CoreSchema: """Unpack all 'definitions' schemas into `GenerateSchema.defs.definitions` and return the inner schema. @@ -1034,6 +1063,23 @@ def _apply_alias_generator_to_computed_field_info( if computed_field_info.alias_priority == 1: computed_field_info.alias = _get_first_non_null(serialization_alias, alias) + @staticmethod + def _apply_field_title_generator_to_field_info( + field_title_generator: Callable[[str], str], field_info: FieldInfo | ComputedFieldInfo, field_name: str + ) -> None: + """Apply a field_title_generator on a FieldInfo or ComputedFieldInfo instance if appropriate + Args: + field_title_generator: A callable that takes a string and returns a string. + field_info: The FieldInfo or ComputedField instance to which the title_generator is (maybe) applied. + field_name: The name of the field from which to generate the title. + """ + if field_info.title_priority is None or field_info.title_priority <= 1 or field_info.title is None: + title = field_title_generator(field_name) + if not isinstance(title, str): + raise TypeError(f'field_title_generator {field_title_generator} must return str, not {title.__class__}') + + field_info.title = title + def _common_field_schema( # C901 self, name: str, field_info: FieldInfo, decorators: DecoratorInfos ) -> _CommonField: @@ -1105,6 +1151,10 @@ def set_discriminator(schema: CoreSchema) -> CoreSchema: schema = self._apply_field_serializers( schema, filter_field_decorator_info_by_field(decorators.field_serializers.values(), name) ) + field_title_generator = field_info.field_title_generator or self._config_wrapper.field_title_generator + if field_title_generator is not None: + self._apply_field_title_generator_to_field_info(field_title_generator, field_info, name) + json_schema_updates = { 'title': field_info.title, 'description': field_info.description, @@ -1274,14 +1324,20 @@ def _typed_dict_schema(self, typed_dict_cls: Any, origin: Any) -> core_schema.Co and field_name in field_docstrings ): field_info.description = field_docstrings[field_name] + field_title_generator = ( + field_info.field_title_generator or self._config_wrapper.field_title_generator + ) + if field_title_generator is not None: + self._apply_field_title_generator_to_field_info(field_title_generator, field_info, field_name) fields[field_name] = self._generate_td_field_schema( field_name, field_info, decorators, required=required ) + title = self._get_class_title_from_config(typed_dict_cls, self._config_wrapper) metadata = build_metadata_dict( - js_functions=[partial(modify_model_json_schema, cls=typed_dict_cls)], typed_dict_cls=typed_dict_cls + js_functions=[partial(modify_model_json_schema, cls=typed_dict_cls, title=title)], + typed_dict_cls=typed_dict_cls, ) - td_schema = core_schema.typed_dict_schema( fields, computed_fields=[ @@ -1577,6 +1633,11 @@ def _dataclass_schema( model_validators = decorators.model_validators.values() inner_schema = apply_model_validators(inner_schema, model_validators, 'inner') + title = self._get_class_title_from_config(dataclass, self._config_wrapper) + metadata = build_metadata_dict( + js_functions=[partial(modify_model_json_schema, cls=dataclass, title=title)] + ) + dc_schema = core_schema.dataclass_schema( dataclass, inner_schema, @@ -1585,6 +1646,7 @@ def _dataclass_schema( fields=[field.name for field in dataclasses.fields(dataclass)], slots=has_slots, config=core_config, + metadata=metadata, ) schema = self._apply_model_serializers(dc_schema, decorators.model_serializers.values()) schema = apply_model_validators(schema, model_validators, 'outer') @@ -1706,6 +1768,9 @@ def _computed_field_schema( self._apply_alias_generator_to_computed_field_info( alias_generator=alias_generator, computed_field_info=d.info, computed_field_name=d.cls_var_name ) + field_title_generator = d.info.field_title_generator or self._config_wrapper.field_title_generator + if field_title_generator is not None: + self._apply_field_title_generator_to_field_info(field_title_generator, d.info, d.cls_var_name) def set_computed_field_metadata(schema: CoreSchemaOrField, handler: GetJsonSchemaHandler) -> JsonSchemaValue: json_schema = handler(schema) diff --git a/pydantic/config.py b/pydantic/config.py index 7edf7c60a0..5804f7532c 100644 --- a/pydantic/config.py +++ b/pydantic/config.py @@ -33,11 +33,18 @@ class ConfigDict(TypedDict, total=False): title: str | None """The title for the generated JSON schema, defaults to the model's name""" + class_title_generator: Callable[[str], str] | None + """A callable that takes a class name and returns the title for it. Defaults to `None`.""" + + field_title_generator: Callable[[str], str] | None + """A callable that takes a field name and returns title for it. Defaults to `None`.""" + str_to_lower: bool """Whether to convert all characters to lowercase for str types. Defaults to `False`.""" str_to_upper: bool """Whether to convert all characters to uppercase for str types. Defaults to `False`.""" + str_strip_whitespace: bool """Whether to strip leading and trailing whitespace for str types.""" diff --git a/pydantic/fields.py b/pydantic/fields.py index 5555cfbfe2..5d0387e03c 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -53,6 +53,8 @@ class _FromFieldInfoInputs(typing_extensions.TypedDict, total=False): validation_alias: str | AliasPath | AliasChoices | None serialization_alias: str | None title: str | None + title_priority: int | None + field_title_generator: typing_extensions.Callable[[str], str] | None description: str | None examples: list[Any] | None exclude: bool | None @@ -105,6 +107,8 @@ class FieldInfo(_repr.Representation): validation_alias: The validation alias of the field. serialization_alias: The serialization alias of the field. title: The title of the field. + title_priority: Priority of the field's title. This affects whether a title generator is used. + field_title_generator: A callable that takes a field name and returns title for it. description: The description of the field. examples: List of examples of the field. exclude: Whether to exclude the field from the model serialization. @@ -129,6 +133,8 @@ class FieldInfo(_repr.Representation): validation_alias: str | AliasPath | AliasChoices | None serialization_alias: str | None title: str | None + title_priority: int | None + field_title_generator: typing.Callable[[str], str] | None description: str | None examples: list[Any] | None exclude: bool | None @@ -152,6 +158,8 @@ class FieldInfo(_repr.Representation): 'validation_alias', 'serialization_alias', 'title', + 'title_priority', + 'field_title_generator', 'description', 'examples', 'exclude', @@ -213,6 +221,8 @@ def __init__(self, **kwargs: Unpack[_FieldInfoInputs]) -> None: self.serialization_alias = kwargs.pop('serialization_alias', None) alias_is_set = any(alias is not None for alias in (self.alias, self.validation_alias, self.serialization_alias)) self.alias_priority = kwargs.pop('alias_priority', None) or 2 if alias_is_set else None + self.field_title_generator = kwargs.pop('field_title_generator', None) + self.title_priority = kwargs.pop('title_priority', None) or 2 if self.title is not None else None self.description = kwargs.pop('description', None) self.examples = kwargs.pop('examples', None) self.exclude = kwargs.pop('exclude', None) @@ -633,6 +643,7 @@ class _EmptyKwargs(typing_extensions.TypedDict): validation_alias=None, serialization_alias=None, title=None, + title_priority=None, description=None, examples=None, exclude=None, @@ -668,6 +679,8 @@ def Field( # noqa: C901 validation_alias: str | AliasPath | AliasChoices | None = _Unset, serialization_alias: str | None = _Unset, title: str | None = _Unset, + title_priority: int | None = _Unset, + field_title_generator: typing_extensions.Callable[[str], str] | None = _Unset, description: str | None = _Unset, examples: list[Any] | None = _Unset, exclude: bool | None = _Unset, @@ -714,6 +727,8 @@ def Field( # noqa: C901 validation_alias: Like `alias`, but only affects validation, not serialization. serialization_alias: Like `alias`, but only affects serialization, not validation. title: Human-readable title. + title_priority: Priority of the field's title. This affects whether a title generator is used. + field_title_generator: A callable that takes a field name and returns title for it. description: Human-readable description. examples: Example values for this field. exclude: Whether to exclude the field from the model serialization. @@ -830,6 +845,8 @@ def Field( # noqa: C901 validation_alias=validation_alias, serialization_alias=serialization_alias, title=title, + title_priority=title_priority, + field_title_generator=field_title_generator, description=description, examples=examples, exclude=exclude, @@ -969,6 +986,7 @@ class ComputedFieldInfo: alias: The alias of the property to be used during serialization. alias_priority: The priority of the alias. This affects whether an alias generator is used. title: Title of the computed field to include in the serialization JSON schema. + title_priority: Priority of the title. This affects whether a title generator is used. description: Description of the computed field to include in the serialization JSON schema. deprecated: A deprecation message, an instance of `warnings.deprecated` or the `typing_extensions.deprecated` backport, or a boolean. If `True`, a default deprecation message will be emitted when accessing the field. @@ -983,6 +1001,8 @@ class ComputedFieldInfo: alias: str | None alias_priority: int | None title: str | None + title_priority: int | None + field_title_generator: typing.Callable[[str], str] | None description: str | None deprecated: Deprecated | str | bool | None examples: list[Any] | None @@ -1022,6 +1042,8 @@ def computed_field( alias: str | None = None, alias_priority: int | None = None, title: str | None = None, + title_priority: int | None = None, + field_title_generator: typing.Callable[[str], str] | None = None, description: str | None = None, deprecated: Deprecated | str | bool | None = None, examples: list[Any] | None = None, @@ -1044,6 +1066,8 @@ def computed_field( alias: str | None = None, alias_priority: int | None = None, title: str | None = None, + title_priority: int | None = None, + field_title_generator: typing.Callable[[str], str] | None = None, description: str | None = None, deprecated: Deprecated | str | bool | None = None, examples: list[Any] | None = None, @@ -1174,6 +1198,8 @@ def _private_property(self) -> int: alias: alias to use when serializing this computed field, only used when `by_alias=True` alias_priority: priority of the alias. This affects whether an alias generator is used title: Title to use when including this computed field in JSON Schema + title_priority: Priority of the title. This affects whether a title generator is used. + field_title_generator: A callable that takes a field name and returns title for it. description: Description to use when including this computed field in JSON Schema, defaults to the function's docstring deprecated: A deprecation message (or an instance of `warnings.deprecated` or the `typing_extensions.deprecated` backport). @@ -1193,7 +1219,7 @@ def _private_property(self) -> int: """ def dec(f: Any) -> Any: - nonlocal description, deprecated, return_type, alias_priority + nonlocal description, deprecated, return_type, alias_priority, title_priority unwrapped = _decorators.unwrap_wrapped_function(f) if description is None and unwrapped.__doc__: @@ -1205,6 +1231,7 @@ def dec(f: Any) -> Any: # if the function isn't already decorated with `@property` (or another descriptor), then we wrap it now f = _decorators.ensure_property(f) alias_priority = (alias_priority or 2) if alias is not None else None + title_priority = (title_priority or 2) if title is not None else None if repr is None: repr_: bool = not _wrapped_property_is_private(property_=f) @@ -1217,6 +1244,8 @@ def dec(f: Any) -> Any: alias, alias_priority, title, + title_priority, + field_title_generator, description, deprecated, examples, diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 46637d9da7..9a875359c5 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -232,8 +232,10 @@ class Model(BaseModel): def test_schema_repr(): s = Field(4, title='Foo is Great') - assert str(s) == "annotation=NoneType required=False default=4 title='Foo is Great'" - assert repr(s) == "FieldInfo(annotation=NoneType, required=False, default=4, title='Foo is Great')" + assert str(s) == "annotation=NoneType required=False default=4 title='Foo is Great' title_priority=2" + assert ( + repr(s) == "FieldInfo(annotation=NoneType, required=False, default=4, title='Foo is Great', title_priority=2)" + ) def test_schema_class_by_alias(): diff --git a/tests/test_titles.py b/tests/test_titles.py new file mode 100644 index 0000000000..4a009b58af --- /dev/null +++ b/tests/test_titles.py @@ -0,0 +1,450 @@ +import re +from typing import Annotated, Any, Callable, List, TypedDict + +import pytest + +import pydantic +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, computed_field +from pydantic.alias_generators import to_camel, to_pascal, to_snake +from pydantic.json_schema import model_json_schema + + +def make_title(name: str): + def _capitalize(v: str): + return v[0].upper() + v[1:] + + return re.sub(r'(?<=[a-z])([A-Z])', r' \1', _capitalize(name)) + + +TITLE_GENERATORS: List[Callable[[str], str]] = [ + lambda t: t.lower(), + lambda t: t * 2, + lambda t: 'My Title', + make_title, + to_camel, + to_snake, + to_pascal, +] + + +@pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) +def test_model_class_title_generator(class_title_generator): + class Model(BaseModel): + model_config = ConfigDict(class_title_generator=class_title_generator) + + assert Model.model_json_schema() == { + 'properties': {}, + 'title': class_title_generator(Model.__name__), + 'type': 'object', + } + + +@pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) +def test_class_title_generator_in_submodel(class_title_generator): + class SubModel(BaseModel): + model_config = ConfigDict(class_title_generator=class_title_generator) + + class Model(BaseModel): + sub: SubModel + + assert Model.model_json_schema() == { + '$defs': {'SubModel': {'properties': {}, 'title': class_title_generator(SubModel.__name__), 'type': 'object'}}, + 'properties': {'sub': {'$ref': '#/$defs/SubModel'}}, + 'required': ['sub'], + 'title': 'Model', + 'type': 'object', + } + + +@pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) +def test_field_title_generator_in_model_fields(field_title_generator): + class Model(BaseModel): + field_a: str = Field(field_title_generator=field_title_generator) + field_b: int = Field(field_title_generator=field_title_generator) + + @computed_field(field_title_generator=field_title_generator) + def field_c(self) -> str: + return self.field_a + + assert Model.model_json_schema(mode='serialization') == { + 'properties': { + 'field_a': {'title': field_title_generator('field_a'), 'type': 'string'}, + 'field_b': {'title': field_title_generator('field_b'), 'type': 'integer'}, + 'field_c': {'readOnly': True, 'title': field_title_generator('field_c'), 'type': 'string'}, + }, + 'required': ['field_a', 'field_b', 'field_c'], + 'title': 'Model', + 'type': 'object', + } + + +@pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) +def test_model_config_field_title_generator(field_title_generator): + class Model(BaseModel): + model_config = ConfigDict(field_title_generator=field_title_generator) + + field_a: str + field_b: int + field___c: bool + + @computed_field + def field_d(self) -> str: + return self.field_a + + assert Model.model_json_schema(mode='serialization') == { + 'properties': { + 'field_a': {'title': field_title_generator('field_a'), 'type': 'string'}, + 'field_b': {'title': field_title_generator('field_b'), 'type': 'integer'}, + 'field___c': {'title': field_title_generator('field___c'), 'type': 'boolean'}, + 'field_d': {'readOnly': True, 'title': field_title_generator('field_d'), 'type': 'string'}, + }, + 'required': ['field_a', 'field_b', 'field___c', 'field_d'], + 'title': 'Model', + 'type': 'object', + } + + +def test_field_title_priority_over_config_field_title_generator(): + class Model(BaseModel): + model_config = ConfigDict(field_title_generator=lambda f: f.replace('_', '')) + + field_a: str = Field(title='Field A', title_priority=2) + field_b: int = Field(title='Field B', title_priority=1) + field___c: bool = Field(title='Field C', title_priority=10) + + @computed_field(title='Field D', title_priority=2) + def field_d(self) -> str: + return self.field_a + + assert Model.model_json_schema(mode='serialization') == { + 'properties': { + 'field_a': {'title': 'Field A', 'type': 'string'}, + 'field_b': {'title': 'fieldb', 'type': 'integer'}, + 'field___c': {'title': 'Field C', 'type': 'boolean'}, + 'field_d': {'readOnly': True, 'title': 'Field D', 'type': 'string'}, + }, + 'required': ['field_a', 'field_b', 'field___c', 'field_d'], + 'title': 'Model', + 'type': 'object', + } + + +def test_field_title_priority_over_field_title_generator(): + class Model(BaseModel): + model_config = ConfigDict() + + field_a: str = Field(title='Field A', title_priority=2, field_title_generator=lambda f: f.replace('_', '')) + field_b: int = Field(title='Field B', title_priority=1, field_title_generator=lambda f: f.replace('_', '')) + field___c: bool = Field(title='Field C', title_priority=10, field_title_generator=lambda f: f.replace('_', '')) + + @computed_field(title='Field D', title_priority=1, field_title_generator=lambda f: f.replace('_', '')) + def field_d(self) -> str: + return self.field_a + + assert Model.model_json_schema(mode='serialization') == { + 'properties': { + 'field_a': {'title': 'Field A', 'type': 'string'}, + 'field_b': {'title': 'fieldb', 'type': 'integer'}, + 'field___c': {'title': 'Field C', 'type': 'boolean'}, + 'field_d': {'readOnly': True, 'title': 'fieldd', 'type': 'string'}, + }, + 'required': ['field_a', 'field_b', 'field___c', 'field_d'], + 'title': 'Model', + 'type': 'object', + } + + +@pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) +def test_dataclass_class_title_generator(class_title_generator): + @pydantic.dataclasses.dataclass(config=ConfigDict(class_title_generator=class_title_generator)) + class MyDataclass: + field_a: int + + assert model_json_schema(MyDataclass) == { + 'properties': {'field_a': {'title': 'Field A', 'type': 'integer'}}, + 'required': ['field_a'], + 'title': class_title_generator(MyDataclass.__name__), + 'type': 'object', + } + + +@pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) +def test_field_title_generator_in_dataclass_fields(field_title_generator): + @pydantic.dataclasses.dataclass + class MyDataclass: + field_a: str = Field(field_title_generator=field_title_generator) + field_b: int = Field(field_title_generator=field_title_generator) + + assert model_json_schema(MyDataclass) == { + 'properties': { + 'field_a': {'title': field_title_generator('field_a'), 'type': 'string'}, + 'field_b': {'title': field_title_generator('field_b'), 'type': 'integer'}, + }, + 'required': ['field_a', 'field_b'], + 'title': 'MyDataclass', + 'type': 'object', + } + + +@pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) +def test_dataclass_config_field_title_generator(field_title_generator): + @pydantic.dataclasses.dataclass(config=ConfigDict(field_title_generator=field_title_generator)) + class MyDataclass: + field_a: str + field_b: int + field___c: bool + + assert model_json_schema(MyDataclass) == { + 'properties': { + 'field_a': {'title': field_title_generator('field_a'), 'type': 'string'}, + 'field_b': {'title': field_title_generator('field_b'), 'type': 'integer'}, + 'field___c': {'title': field_title_generator('field___c'), 'type': 'boolean'}, + }, + 'required': ['field_a', 'field_b', 'field___c'], + 'title': 'MyDataclass', + 'type': 'object', + } + + +@pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) +def test_typeddict_class_title_generator(class_title_generator): + class MyTypedDict(TypedDict): + __pydantic_config__ = ConfigDict(class_title_generator=class_title_generator) + pass + + assert TypeAdapter(MyTypedDict).json_schema() == { + 'properties': {}, + 'title': class_title_generator(MyTypedDict.__name__), + 'type': 'object', + } + + +@pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) +def test_field_title_generator_in_typeddict_fields(field_title_generator): + class MyTypedDict(TypedDict): + field_a: Annotated[str, Field(field_title_generator=field_title_generator)] + field_b: Annotated[int, Field(field_title_generator=field_title_generator)] + + assert TypeAdapter(MyTypedDict).json_schema() == { + 'properties': { + 'field_a': {'title': field_title_generator('field_a'), 'type': 'string'}, + 'field_b': {'title': field_title_generator('field_b'), 'type': 'integer'}, + }, + 'required': ['field_a', 'field_b'], + 'title': 'MyTypedDict', + 'type': 'object', + } + + +@pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) +def test_typeddict_config_field_title_generator(field_title_generator): + class MyTypedDict(TypedDict): + __pydantic_config__ = ConfigDict(field_title_generator=field_title_generator) + field_a: str + field_b: int + field___c: bool + + assert TypeAdapter(MyTypedDict).json_schema() == { + 'properties': { + 'field_a': {'title': field_title_generator('field_a'), 'type': 'string'}, + 'field_b': {'title': field_title_generator('field_b'), 'type': 'integer'}, + 'field___c': {'title': field_title_generator('field___c'), 'type': 'boolean'}, + }, + 'required': ['field_a', 'field_b', 'field___c'], + 'title': 'MyTypedDict', + 'type': 'object', + } + + +@pytest.mark.parametrize( + 'field_level_title_generator,config_level_title_generator', + ((lambda f: f.lower(), lambda f: f.upper()), (lambda f: f, make_title)), +) +def test_field_level_field_title_generator_precedence_over_config_level( + field_level_title_generator, config_level_title_generator +): + class MyModel(BaseModel): + model_config = ConfigDict(field_title_generator=field_level_title_generator) + field_a: str = Field(field_title_generator=field_level_title_generator) + + assert MyModel.model_json_schema() == { + 'properties': {'field_a': {'title': field_level_title_generator('field_a'), 'type': 'string'}}, + 'required': ['field_a'], + 'title': 'MyModel', + 'type': 'object', + } + + @pydantic.dataclasses.dataclass(config=ConfigDict(field_title_generator=field_level_title_generator)) + class MyDataclass: + field_a: str = Field(field_title_generator=field_level_title_generator) + + assert model_json_schema(MyDataclass) == { + 'properties': {'field_a': {'title': field_level_title_generator('field_a'), 'type': 'string'}}, + 'required': ['field_a'], + 'title': 'MyDataclass', + 'type': 'object', + } + + class MyTypedDict(TypedDict): + __pydantic_config__ = ConfigDict(field_title_generator=field_level_title_generator) + field_a: Annotated[str, Field(field_title_generator=field_level_title_generator)] + + assert TypeAdapter(MyTypedDict).json_schema() == { + 'properties': {'field_a': {'title': field_level_title_generator('field_a'), 'type': 'string'}}, + 'required': ['field_a'], + 'title': 'MyTypedDict', + 'type': 'object', + } + + +def test_field_title_precedence_over_generators(): + class Model(BaseModel): + model_config = ConfigDict(field_title_generator=lambda f: f.upper()) + + field_a: str = Field(title='MyFieldA', field_title_generator=lambda f: f.upper()) + + @computed_field(title='MyFieldB', field_title_generator=lambda f: f.upper()) + def field_b(self) -> str: + return self.field_a + + assert Model.model_json_schema(mode='serialization') == { + 'properties': { + 'field_a': {'title': 'MyFieldA', 'type': 'string'}, + 'field_b': {'readOnly': True, 'title': 'MyFieldB', 'type': 'string'}, + }, + 'required': ['field_a', 'field_b'], + 'title': 'Model', + 'type': 'object', + } + + @pydantic.dataclasses.dataclass(config=ConfigDict(field_title_generator=lambda f: f.upper())) + class MyDataclass: + field_a: str = Field(title='MyTitle', field_title_generator=lambda f: f.upper()) + + assert model_json_schema(MyDataclass) == { + 'properties': {'field_a': {'title': 'MyTitle', 'type': 'string'}}, + 'required': ['field_a'], + 'title': 'MyDataclass', + 'type': 'object', + } + + class MyTypedDict(TypedDict): + __pydantic_config__ = ConfigDict(field_title_generator=lambda f: f.upper()) + field_a: Annotated[str, Field(title='MyTitle', field_title_generator=lambda f: f.upper())] + + assert TypeAdapter(MyTypedDict).json_schema() == { + 'properties': {'field_a': {'title': 'MyTitle', 'type': 'string'}}, + 'required': ['field_a'], + 'title': 'MyTypedDict', + 'type': 'object', + } + + +def test_class_title_precedence_over_generator(): + class Model(BaseModel): + model_config = ConfigDict(title='MyTitle', class_title_generator=lambda c: c.upper()) + + assert Model.model_json_schema() == { + 'properties': {}, + 'title': 'MyTitle', + 'type': 'object', + } + + @pydantic.dataclasses.dataclass(config=ConfigDict(title='MyTitle', class_title_generator=lambda c: c.upper())) + class MyDataclass: + pass + + assert model_json_schema(MyDataclass) == { + 'properties': {}, + 'title': 'MyTitle', + 'type': 'object', + } + + +@pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) +def test_class_title_generator_returns_invalid_type(invalid_return_value): + with pytest.raises( + TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + class Model(BaseModel): + model_config = ConfigDict(class_title_generator=lambda m: invalid_return_value) + + with pytest.raises( + TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + @pydantic.dataclasses.dataclass(config=ConfigDict(class_title_generator=lambda m: invalid_return_value)) + class MyDataclass: + pass + + with pytest.raises( + TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + class MyTypedDict(TypedDict): + __pydantic_config__ = ConfigDict(class_title_generator=lambda m: invalid_return_value) + pass + + TypeAdapter(MyTypedDict) + + +@pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) +def test_config_field_title_generator_returns_invalid_type(invalid_return_value): + with pytest.raises( + TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + class Model(BaseModel): + model_config = ConfigDict(field_title_generator=lambda f: invalid_return_value) + + field_a: str + + with pytest.raises( + TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + @pydantic.dataclasses.dataclass(config=ConfigDict(field_title_generator=lambda f: invalid_return_value)) + class MyDataclass: + field_a: str + + with pytest.raises( + TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + class MyTypedDict(TypedDict): + __pydantic_config__ = ConfigDict(field_title_generator=lambda f: invalid_return_value) + field_a: str + + TypeAdapter(MyTypedDict) + + +@pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) +def test_field_title_generator_returns_invalid_type(invalid_return_value): + with pytest.raises( + TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + class Model(BaseModel): + field_a: Any = Field(field_title_generator=lambda f: invalid_return_value) + + Model(field_a=invalid_return_value).model_json_schema() + + with pytest.raises( + TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + @pydantic.dataclasses.dataclass + class MyDataclass: + field_a: Any = Field(field_title_generator=lambda f: invalid_return_value) + + model_json_schema(MyDataclass) + + with pytest.raises( + TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' + ): + + class MyTypedDict(TypedDict): + field_a: Annotated[str, Field(field_title_generator=lambda f: invalid_return_value)] + + TypeAdapter(MyTypedDict) From 7edfd87ffc1325621054f53c968836bf0f2fa276 Mon Sep 17 00:00:00 2001 From: Neev Cohen Date: Mon, 8 Apr 2024 00:39:54 +0300 Subject: [PATCH 02/10] Use typed dict fixture --- tests/test_titles.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/tests/test_titles.py b/tests/test_titles.py index 4a009b58af..800a477122 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -1,5 +1,5 @@ import re -from typing import Annotated, Any, Callable, List, TypedDict +from typing import Annotated, Any, Callable, List import pytest @@ -8,6 +8,8 @@ from pydantic.alias_generators import to_camel, to_pascal, to_snake from pydantic.json_schema import model_json_schema +from .test_types_typeddict import fixture_typed_dict_all # noqa + def make_title(name: str): def _capitalize(v: str): @@ -207,8 +209,8 @@ class MyDataclass: @pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) -def test_typeddict_class_title_generator(class_title_generator): - class MyTypedDict(TypedDict): +def test_typeddict_class_title_generator(class_title_generator, TypedDictAll): + class MyTypedDict(TypedDictAll): __pydantic_config__ = ConfigDict(class_title_generator=class_title_generator) pass @@ -220,8 +222,8 @@ class MyTypedDict(TypedDict): @pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) -def test_field_title_generator_in_typeddict_fields(field_title_generator): - class MyTypedDict(TypedDict): +def test_field_title_generator_in_typeddict_fields(field_title_generator, TypedDictAll): + class MyTypedDict(TypedDictAll): field_a: Annotated[str, Field(field_title_generator=field_title_generator)] field_b: Annotated[int, Field(field_title_generator=field_title_generator)] @@ -237,8 +239,8 @@ class MyTypedDict(TypedDict): @pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) -def test_typeddict_config_field_title_generator(field_title_generator): - class MyTypedDict(TypedDict): +def test_typeddict_config_field_title_generator(field_title_generator, TypedDictAll): + class MyTypedDict(TypedDictAll): __pydantic_config__ = ConfigDict(field_title_generator=field_title_generator) field_a: str field_b: int @@ -261,7 +263,7 @@ class MyTypedDict(TypedDict): ((lambda f: f.lower(), lambda f: f.upper()), (lambda f: f, make_title)), ) def test_field_level_field_title_generator_precedence_over_config_level( - field_level_title_generator, config_level_title_generator + field_level_title_generator, config_level_title_generator, TypedDictAll ): class MyModel(BaseModel): model_config = ConfigDict(field_title_generator=field_level_title_generator) @@ -285,7 +287,7 @@ class MyDataclass: 'type': 'object', } - class MyTypedDict(TypedDict): + class MyTypedDict(TypedDictAll): __pydantic_config__ = ConfigDict(field_title_generator=field_level_title_generator) field_a: Annotated[str, Field(field_title_generator=field_level_title_generator)] @@ -297,7 +299,7 @@ class MyTypedDict(TypedDict): } -def test_field_title_precedence_over_generators(): +def test_field_title_precedence_over_generators(TypedDictAll): class Model(BaseModel): model_config = ConfigDict(field_title_generator=lambda f: f.upper()) @@ -328,7 +330,7 @@ class MyDataclass: 'type': 'object', } - class MyTypedDict(TypedDict): + class MyTypedDict(TypedDictAll): __pydantic_config__ = ConfigDict(field_title_generator=lambda f: f.upper()) field_a: Annotated[str, Field(title='MyTitle', field_title_generator=lambda f: f.upper())] @@ -362,7 +364,7 @@ class MyDataclass: @pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) -def test_class_title_generator_returns_invalid_type(invalid_return_value): +def test_class_title_generator_returns_invalid_type(invalid_return_value, TypedDictAll): with pytest.raises( TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' ): @@ -382,7 +384,7 @@ class MyDataclass: TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' ): - class MyTypedDict(TypedDict): + class MyTypedDict(TypedDictAll): __pydantic_config__ = ConfigDict(class_title_generator=lambda m: invalid_return_value) pass @@ -390,7 +392,7 @@ class MyTypedDict(TypedDict): @pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) -def test_config_field_title_generator_returns_invalid_type(invalid_return_value): +def test_config_field_title_generator_returns_invalid_type(invalid_return_value, TypedDictAll): with pytest.raises( TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' ): @@ -412,7 +414,7 @@ class MyDataclass: TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' ): - class MyTypedDict(TypedDict): + class MyTypedDict(TypedDictAll): __pydantic_config__ = ConfigDict(field_title_generator=lambda f: invalid_return_value) field_a: str @@ -420,7 +422,7 @@ class MyTypedDict(TypedDict): @pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) -def test_field_title_generator_returns_invalid_type(invalid_return_value): +def test_field_title_generator_returns_invalid_type(invalid_return_value, TypedDictAll): with pytest.raises( TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' ): @@ -444,7 +446,7 @@ class MyDataclass: TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' ): - class MyTypedDict(TypedDict): + class MyTypedDict(TypedDictAll): field_a: Annotated[str, Field(field_title_generator=lambda f: invalid_return_value)] TypeAdapter(MyTypedDict) From 2f5d5b0cff90addd2391be6aaa8233328ca56a6d Mon Sep 17 00:00:00 2001 From: Neev Cohen Date: Mon, 8 Apr 2024 00:54:34 +0300 Subject: [PATCH 03/10] Add fixture for typing.Annotated --- tests/test_titles.py | 52 +++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/tests/test_titles.py b/tests/test_titles.py index 800a477122..c2ad7e08df 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -1,14 +1,30 @@ import re -from typing import Annotated, Any, Callable, List +import typing +from typing import Any, Callable, List import pytest +import typing_extensions import pydantic from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, computed_field from pydantic.alias_generators import to_camel, to_pascal, to_snake from pydantic.json_schema import model_json_schema -from .test_types_typeddict import fixture_typed_dict_all # noqa +from .test_types_typeddict import fixture_typed_dict, fixture_typed_dict_all # noqa + + +@pytest.fixture( + name='Annotated', + params=[ + pytest.param(typing, id='typing.Annotated'), + pytest.param(typing_extensions, id='t_e.Annotated'), + ], +) +def fixture_annotated(request): + try: + return request.param.Annotated + except AttributeError: + pytest.skip(f'Annotated is not available from {request.param}') def make_title(name: str): @@ -209,8 +225,8 @@ class MyDataclass: @pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) -def test_typeddict_class_title_generator(class_title_generator, TypedDictAll): - class MyTypedDict(TypedDictAll): +def test_typeddict_class_title_generator(class_title_generator, TypedDict): + class MyTypedDict(TypedDict): __pydantic_config__ = ConfigDict(class_title_generator=class_title_generator) pass @@ -222,8 +238,8 @@ class MyTypedDict(TypedDictAll): @pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) -def test_field_title_generator_in_typeddict_fields(field_title_generator, TypedDictAll): - class MyTypedDict(TypedDictAll): +def test_field_title_generator_in_typeddict_fields(field_title_generator, TypedDict, Annotated): + class MyTypedDict(TypedDict): field_a: Annotated[str, Field(field_title_generator=field_title_generator)] field_b: Annotated[int, Field(field_title_generator=field_title_generator)] @@ -239,8 +255,8 @@ class MyTypedDict(TypedDictAll): @pytest.mark.parametrize('field_title_generator', TITLE_GENERATORS) -def test_typeddict_config_field_title_generator(field_title_generator, TypedDictAll): - class MyTypedDict(TypedDictAll): +def test_typeddict_config_field_title_generator(field_title_generator, TypedDict): + class MyTypedDict(TypedDict): __pydantic_config__ = ConfigDict(field_title_generator=field_title_generator) field_a: str field_b: int @@ -263,7 +279,7 @@ class MyTypedDict(TypedDictAll): ((lambda f: f.lower(), lambda f: f.upper()), (lambda f: f, make_title)), ) def test_field_level_field_title_generator_precedence_over_config_level( - field_level_title_generator, config_level_title_generator, TypedDictAll + field_level_title_generator, config_level_title_generator, TypedDict, Annotated ): class MyModel(BaseModel): model_config = ConfigDict(field_title_generator=field_level_title_generator) @@ -287,7 +303,7 @@ class MyDataclass: 'type': 'object', } - class MyTypedDict(TypedDictAll): + class MyTypedDict(TypedDict): __pydantic_config__ = ConfigDict(field_title_generator=field_level_title_generator) field_a: Annotated[str, Field(field_title_generator=field_level_title_generator)] @@ -299,7 +315,7 @@ class MyTypedDict(TypedDictAll): } -def test_field_title_precedence_over_generators(TypedDictAll): +def test_field_title_precedence_over_generators(TypedDict, Annotated): class Model(BaseModel): model_config = ConfigDict(field_title_generator=lambda f: f.upper()) @@ -330,7 +346,7 @@ class MyDataclass: 'type': 'object', } - class MyTypedDict(TypedDictAll): + class MyTypedDict(TypedDict): __pydantic_config__ = ConfigDict(field_title_generator=lambda f: f.upper()) field_a: Annotated[str, Field(title='MyTitle', field_title_generator=lambda f: f.upper())] @@ -364,7 +380,7 @@ class MyDataclass: @pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) -def test_class_title_generator_returns_invalid_type(invalid_return_value, TypedDictAll): +def test_class_title_generator_returns_invalid_type(invalid_return_value, TypedDict): with pytest.raises( TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' ): @@ -384,7 +400,7 @@ class MyDataclass: TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' ): - class MyTypedDict(TypedDictAll): + class MyTypedDict(TypedDict): __pydantic_config__ = ConfigDict(class_title_generator=lambda m: invalid_return_value) pass @@ -392,7 +408,7 @@ class MyTypedDict(TypedDictAll): @pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) -def test_config_field_title_generator_returns_invalid_type(invalid_return_value, TypedDictAll): +def test_config_field_title_generator_returns_invalid_type(invalid_return_value, TypedDict): with pytest.raises( TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' ): @@ -414,7 +430,7 @@ class MyDataclass: TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' ): - class MyTypedDict(TypedDictAll): + class MyTypedDict(TypedDict): __pydantic_config__ = ConfigDict(field_title_generator=lambda f: invalid_return_value) field_a: str @@ -422,7 +438,7 @@ class MyTypedDict(TypedDictAll): @pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) -def test_field_title_generator_returns_invalid_type(invalid_return_value, TypedDictAll): +def test_field_title_generator_returns_invalid_type(invalid_return_value, TypedDict, Annotated): with pytest.raises( TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' ): @@ -446,7 +462,7 @@ class MyDataclass: TypeError, match=f'field_title_generator .* must return str, not {invalid_return_value.__class__}' ): - class MyTypedDict(TypedDictAll): + class MyTypedDict(TypedDict): field_a: Annotated[str, Field(field_title_generator=lambda f: invalid_return_value)] TypeAdapter(MyTypedDict) From 823d3d5c4ec124b9766b5bcf0e0720fa86b72f2c Mon Sep 17 00:00:00 2001 From: Neev Cohen <70970900+NeevCohen@users.noreply.github.com> Date: Thu, 9 May 2024 22:25:53 +0300 Subject: [PATCH 04/10] Update pydantic/_internal/_generate_schema.py Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com> --- pydantic/_internal/_generate_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index 1a2d57c40a..4b5e4c1a59 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -214,7 +214,7 @@ def modify_model_json_schema( schema_or_field: The schema data to generate a JSON schema from. handler: The `GetCoreSchemaHandler` instance. cls: The model-like class. - title: The title to set for the model's schema, defaults to the models name + title: The title to set for the model's schema, defaults to the model's name Returns: JsonSchemaValue: The updated JSON schema. From 939c7a18eabd5ec048edcbefb165c27518e31dcd Mon Sep 17 00:00:00 2001 From: Neev Cohen Date: Thu, 9 May 2024 22:28:02 +0300 Subject: [PATCH 05/10] Reorder members --- pydantic/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic/fields.py b/pydantic/fields.py index 58ebc000c0..39fe4e2cfb 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -217,14 +217,14 @@ def __init__(self, **kwargs: Unpack[_FieldInfoInputs]) -> None: if self.default is not PydanticUndefined and self.default_factory is not None: raise TypeError('cannot specify both default and default_factory') - self.title = kwargs.pop('title', None) self.alias = kwargs.pop('alias', None) self.validation_alias = kwargs.pop('validation_alias', None) self.serialization_alias = kwargs.pop('serialization_alias', None) alias_is_set = any(alias is not None for alias in (self.alias, self.validation_alias, self.serialization_alias)) self.alias_priority = kwargs.pop('alias_priority', None) or 2 if alias_is_set else None - self.field_title_generator = kwargs.pop('field_title_generator', None) + self.title = kwargs.pop('title', None) self.title_priority = kwargs.pop('title_priority', None) or 2 if self.title is not None else None + self.field_title_generator = kwargs.pop('field_title_generator', None) self.description = kwargs.pop('description', None) self.examples = kwargs.pop('examples', None) self.exclude = kwargs.pop('exclude', None) From 306ae099965c62b328abffa4c1adb8a343d99cdb Mon Sep 17 00:00:00 2001 From: Neev Cohen Date: Thu, 9 May 2024 22:35:46 +0300 Subject: [PATCH 06/10] Refactor common logic into method --- pydantic/_internal/_generate_schema.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index 4b5e4c1a59..a6951effd0 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -1071,14 +1071,18 @@ def _apply_alias_generator_to_computed_field_info( @staticmethod def _apply_field_title_generator_to_field_info( - field_title_generator: Callable[[str], str], field_info: FieldInfo | ComputedFieldInfo, field_name: str + config_wrapper: ConfigWrapper, field_info: FieldInfo | ComputedFieldInfo, field_name: str ) -> None: """Apply a field_title_generator on a FieldInfo or ComputedFieldInfo instance if appropriate Args: - field_title_generator: A callable that takes a string and returns a string. + config_wrapper: The config of the model field_info: The FieldInfo or ComputedField instance to which the title_generator is (maybe) applied. field_name: The name of the field from which to generate the title. """ + field_title_generator = field_info.field_title_generator or config_wrapper.field_title_generator + if field_title_generator is None: + return + if field_info.title_priority is None or field_info.title_priority <= 1 or field_info.title is None: title = field_title_generator(field_name) if not isinstance(title, str): @@ -1157,9 +1161,7 @@ def set_discriminator(schema: CoreSchema) -> CoreSchema: schema = self._apply_field_serializers( schema, filter_field_decorator_info_by_field(decorators.field_serializers.values(), name) ) - field_title_generator = field_info.field_title_generator or self._config_wrapper.field_title_generator - if field_title_generator is not None: - self._apply_field_title_generator_to_field_info(field_title_generator, field_info, name) + self._apply_field_title_generator_to_field_info(self._config_wrapper, field_info, name) json_schema_updates = { 'title': field_info.title, @@ -1330,11 +1332,7 @@ def _typed_dict_schema(self, typed_dict_cls: Any, origin: Any) -> core_schema.Co and field_name in field_docstrings ): field_info.description = field_docstrings[field_name] - field_title_generator = ( - field_info.field_title_generator or self._config_wrapper.field_title_generator - ) - if field_title_generator is not None: - self._apply_field_title_generator_to_field_info(field_title_generator, field_info, field_name) + self._apply_field_title_generator_to_field_info(self._config_wrapper, field_info, field_name) fields[field_name] = self._generate_td_field_schema( field_name, field_info, decorators, required=required ) @@ -1776,9 +1774,7 @@ def _computed_field_schema( self._apply_alias_generator_to_computed_field_info( alias_generator=alias_generator, computed_field_info=d.info, computed_field_name=d.cls_var_name ) - field_title_generator = d.info.field_title_generator or self._config_wrapper.field_title_generator - if field_title_generator is not None: - self._apply_field_title_generator_to_field_info(field_title_generator, d.info, d.cls_var_name) + self._apply_field_title_generator_to_field_info(self._config_wrapper, d.info, d.cls_var_name) def set_computed_field_metadata(schema: CoreSchemaOrField, handler: GetJsonSchemaHandler) -> JsonSchemaValue: json_schema = handler(schema) From 428463edf4b91657ad8e505e2bd7ec15e2b2fc48 Mon Sep 17 00:00:00 2001 From: Neev Cohen Date: Fri, 10 May 2024 12:48:16 +0300 Subject: [PATCH 07/10] Add documentation for title generation --- docs/concepts/json_schema.md | 135 +++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/docs/concepts/json_schema.md b/docs/concepts/json_schema.md index 6314a0523d..8c6a8ae244 100644 --- a/docs/concepts/json_schema.md +++ b/docs/concepts/json_schema.md @@ -330,6 +330,7 @@ Some field parameters are used exclusively to customize the generated JSON Schem * `description`: The description of the field. * `examples`: The examples of the field. * `json_schema_extra`: Extra JSON Schema properties to be added to the field. +* `field_title_generator`: A function that programmatically sets the field's title, based on its name. Here's an example: @@ -481,6 +482,50 @@ print(json.dumps(Foo.model_json_schema(), indent=2)) """ ``` +### Programmatic field title generation + +The `field_title_generator` parameter can be used to programmatically generate the title for a field based on its name. + +See the following example: + +```py +import json + +from pydantic import BaseModel, Field + + +def make_title(field_name: str) -> str: + return field_name.upper() + + +class Person(BaseModel): + name: str = Field(field_title_generator=make_title) + age: int = Field(field_title_generator=make_title) + + +print(json.dumps(Person.model_json_schema(), indent=2)) +""" +{ + "properties": { + "name": { + "title": "NAME", + "type": "string" + }, + "age": { + "title": "AGE", + "type": "integer" + } + }, + "required": [ + "name", + "age" + ], + "title": "Person", + "type": "object" +} +""" +``` + ### Model-Level Customization You can also use [model config][pydantic.config.ConfigDict] to customize JSON schema generation on a model. @@ -490,6 +535,8 @@ Specifically, the following config options are relevant: * [`json_schema_extra`][pydantic.config.ConfigDict.json_schema_extra] * [`schema_generator`][pydantic.config.ConfigDict.schema_generator] * [`json_schema_mode_override`][pydantic.config.ConfigDict.json_schema_mode_override] +* [`field_title_generator`][pydantic.config.ConfigDict.field_title_generator] +* [`class_title_generator`][pydantic.config.ConfigDict.class_title_generator] ### Using `json_schema_extra` @@ -1029,6 +1076,94 @@ print(json.dumps(TypeAdapter(Person).json_schema(), indent=2)) ``` +### Using `field_title_generator` + +The `field_title_generator` parameter can be used to dynamically generate the title for a field based on its name. +This is similar to the field level `field_title_generator`, but the `ConfigDict` option will be applied to all fields of the class. + +See the following example: + +```py +import json + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_pascal + + +class Person(BaseModel): + model_config = ConfigDict(field_title_generator=to_pascal) + name: str + age: int + + +print(json.dumps(Person.model_json_schema(), indent=2)) +""" +{ + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "age": { + "title": "Age", + "type": "integer" + } + }, + "required": [ + "name", + "age" + ], + "title": "Person", + "type": "object" +} +""" +``` + +### Using `class_title_generator` + +The `class_title_generator` config option is similar to the `field_title_generator` option, but it applies to the title of the class itself. + +See the following example: + +```py +import json + +from pydantic import BaseModel, ConfigDict + + +def make_title(field_name: str) -> str: + return f'Title-{field_name}' + + +class Person(BaseModel): + model_config = ConfigDict(class_title_generator=make_title) + name: str + age: int + + +print(json.dumps(Person.model_json_schema(), indent=2)) +""" +{ + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "age": { + "title": "Age", + "type": "integer" + } + }, + "required": [ + "name", + "age" + ], + "title": "Title-Person", + "type": "object" +} +""" +``` + ## JSON schema types Types, custom field types, and constraints (like `max_length`) are mapped to the corresponding spec formats in the From 838b0c958a14cabd46b093e81e6467e9e7bd00eb Mon Sep 17 00:00:00 2001 From: Neev Cohen <70970900+NeevCohen@users.noreply.github.com> Date: Tue, 21 May 2024 23:44:54 +0300 Subject: [PATCH 08/10] Update pydantic/fields.py Apply suggestion Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com> --- pydantic/fields.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pydantic/fields.py b/pydantic/fields.py index 39fe4e2cfb..34dbae0f52 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -995,6 +995,7 @@ class ComputedFieldInfo: alias_priority: The priority of the alias. This affects whether an alias generator is used. title: Title of the computed field to include in the serialization JSON schema. title_priority: Priority of the title. This affects whether a title generator is used. + field_title_generator: A callable that takes a field name and returns title for it. description: Description of the computed field to include in the serialization JSON schema. deprecated: A deprecation message, an instance of `warnings.deprecated` or the `typing_extensions.deprecated` backport, or a boolean. If `True`, a default deprecation message will be emitted when accessing the field. From e12c5a2be81aea723cae006ea794d9e8d0a6d3aa Mon Sep 17 00:00:00 2001 From: Neev Cohen Date: Tue, 21 May 2024 23:51:10 +0300 Subject: [PATCH 09/10] Change field_title_generator in example --- docs/concepts/json_schema.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/concepts/json_schema.md b/docs/concepts/json_schema.md index 8c6a8ae244..663ed8acb0 100644 --- a/docs/concepts/json_schema.md +++ b/docs/concepts/json_schema.md @@ -1087,11 +1087,10 @@ See the following example: import json from pydantic import BaseModel, ConfigDict -from pydantic.alias_generators import to_pascal class Person(BaseModel): - model_config = ConfigDict(field_title_generator=to_pascal) + model_config = ConfigDict(field_title_generator=lambda m: m.upper()) name: str age: int @@ -1101,11 +1100,11 @@ print(json.dumps(Person.model_json_schema(), indent=2)) { "properties": { "name": { - "title": "Name", + "title": "NAME", "type": "string" }, "age": { - "title": "Age", + "title": "AGE", "type": "integer" } }, From eff9e0260d1ebb5b585e881afca95292111152e8 Mon Sep 17 00:00:00 2001 From: Neev Cohen Date: Fri, 31 May 2024 17:59:12 +0300 Subject: [PATCH 10/10] Rename class_title_generator to model_title_generator --- docs/concepts/json_schema.md | 10 +++--- pydantic/_internal/_config.py | 4 +-- pydantic/_internal/_generate_schema.py | 18 +++++----- pydantic/config.py | 4 +-- tests/test_titles.py | 50 +++++++++++++------------- 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/docs/concepts/json_schema.md b/docs/concepts/json_schema.md index 663ed8acb0..84cc7a06bf 100644 --- a/docs/concepts/json_schema.md +++ b/docs/concepts/json_schema.md @@ -536,7 +536,7 @@ Specifically, the following config options are relevant: * [`schema_generator`][pydantic.config.ConfigDict.schema_generator] * [`json_schema_mode_override`][pydantic.config.ConfigDict.json_schema_mode_override] * [`field_title_generator`][pydantic.config.ConfigDict.field_title_generator] -* [`class_title_generator`][pydantic.config.ConfigDict.class_title_generator] +* [`model_title_generator`][pydantic.config.ConfigDict.model_title_generator] ### Using `json_schema_extra` @@ -1090,7 +1090,7 @@ from pydantic import BaseModel, ConfigDict class Person(BaseModel): - model_config = ConfigDict(field_title_generator=lambda m: m.upper()) + model_config = ConfigDict(field_title_generator=str.upper) name: str age: int @@ -1118,9 +1118,9 @@ print(json.dumps(Person.model_json_schema(), indent=2)) """ ``` -### Using `class_title_generator` +### Using `model_title_generator` -The `class_title_generator` config option is similar to the `field_title_generator` option, but it applies to the title of the class itself. +The `model_title_generator` config option is similar to the `field_title_generator` option, but it applies to the title of the model itself. See the following example: @@ -1135,7 +1135,7 @@ def make_title(field_name: str) -> str: class Person(BaseModel): - model_config = ConfigDict(class_title_generator=make_title) + model_config = ConfigDict(model_title_generator=make_title) name: str age: int diff --git a/pydantic/_internal/_config.py b/pydantic/_internal/_config.py index 0694bc1241..48723b7000 100644 --- a/pydantic/_internal/_config.py +++ b/pydantic/_internal/_config.py @@ -57,7 +57,7 @@ class ConfigWrapper: # to construct error `loc`s, default `True` loc_by_alias: bool alias_generator: Callable[[str], str] | AliasGenerator | None - class_title_generator: Callable[[str], str] | None + model_title_generator: Callable[[str], str] | None field_title_generator: Callable[[str], str] | None ignored_types: tuple[type, ...] allow_inf_nan: bool @@ -245,7 +245,7 @@ def push(self, config_wrapper: ConfigWrapper | ConfigDict | None): from_attributes=False, loc_by_alias=True, alias_generator=None, - class_title_generator=None, + model_title_generator=None, field_title_generator=None, ignored_types=(), allow_inf_nan=True, diff --git a/pydantic/_internal/_generate_schema.py b/pydantic/_internal/_generate_schema.py index a6951effd0..337ea38526 100644 --- a/pydantic/_internal/_generate_schema.py +++ b/pydantic/_internal/_generate_schema.py @@ -539,7 +539,7 @@ def _model_schema(self, cls: type[BaseModel]) -> core_schema.CoreSchema: ) config_wrapper = ConfigWrapper(cls.model_config, check=False) core_config = config_wrapper.core_config(cls) - title = self._get_class_title_from_config(cls, config_wrapper) + title = self._get_model_title_from_config(cls, config_wrapper) metadata = build_metadata_dict(js_functions=[partial(modify_model_json_schema, cls=cls, title=title)]) model_validators = decorators.model_validators.values() @@ -618,21 +618,21 @@ def _model_schema(self, cls: type[BaseModel]) -> core_schema.CoreSchema: return core_schema.definition_reference_schema(model_ref) @staticmethod - def _get_class_title_from_config( + def _get_model_title_from_config( cls: type[BaseModel | StandardDataclass], config_wrapper: ConfigWrapper | None = None ) -> str | None: - """Get the title of a class if `class_title_generator` or `title` are set in the config, else return None""" + """Get the title of a model if `model_title_generator` or `title` are set in the config, else return None""" if config_wrapper is None: return None if config_wrapper.title: return config_wrapper.title - class_title_generator = config_wrapper.class_title_generator - if class_title_generator: - title = class_title_generator(cls.__name__) + model_title_generator = config_wrapper.model_title_generator + if model_title_generator: + title = model_title_generator(cls.__name__) if not isinstance(title, str): - raise TypeError(f'class_title_generator {class_title_generator} must return str, not {title.__class__}') + raise TypeError(f'model_title_generator {model_title_generator} must return str, not {title.__class__}') return title return None @@ -1337,7 +1337,7 @@ def _typed_dict_schema(self, typed_dict_cls: Any, origin: Any) -> core_schema.Co field_name, field_info, decorators, required=required ) - title = self._get_class_title_from_config(typed_dict_cls, ConfigWrapper(config)) + title = self._get_model_title_from_config(typed_dict_cls, ConfigWrapper(config)) metadata = build_metadata_dict( js_functions=[partial(modify_model_json_schema, cls=typed_dict_cls, title=title)], typed_dict_cls=typed_dict_cls, @@ -1639,7 +1639,7 @@ def _dataclass_schema( model_validators = decorators.model_validators.values() inner_schema = apply_model_validators(inner_schema, model_validators, 'inner') - title = self._get_class_title_from_config(dataclass, ConfigWrapper(config)) + title = self._get_model_title_from_config(dataclass, ConfigWrapper(config)) metadata = build_metadata_dict( js_functions=[partial(modify_model_json_schema, cls=dataclass, title=title)] ) diff --git a/pydantic/config.py b/pydantic/config.py index 3ac6ae722f..3c3905f813 100644 --- a/pydantic/config.py +++ b/pydantic/config.py @@ -33,8 +33,8 @@ class ConfigDict(TypedDict, total=False): title: str | None """The title for the generated JSON schema, defaults to the model's name""" - class_title_generator: Callable[[str], str] | None - """A callable that takes a class name and returns the title for it. Defaults to `None`.""" + model_title_generator: Callable[[str], str] | None + """A callable that takes a model name and returns the title for it. Defaults to `None`.""" field_title_generator: Callable[[str], str] | None """A callable that takes a field name and returns title for it. Defaults to `None`.""" diff --git a/tests/test_titles.py b/tests/test_titles.py index c2ad7e08df..4f2dceeb6d 100644 --- a/tests/test_titles.py +++ b/tests/test_titles.py @@ -45,28 +45,28 @@ def _capitalize(v: str): ] -@pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) -def test_model_class_title_generator(class_title_generator): +@pytest.mark.parametrize('model_title_generator', TITLE_GENERATORS) +def test_model_model_title_generator(model_title_generator): class Model(BaseModel): - model_config = ConfigDict(class_title_generator=class_title_generator) + model_config = ConfigDict(model_title_generator=model_title_generator) assert Model.model_json_schema() == { 'properties': {}, - 'title': class_title_generator(Model.__name__), + 'title': model_title_generator(Model.__name__), 'type': 'object', } -@pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) -def test_class_title_generator_in_submodel(class_title_generator): +@pytest.mark.parametrize('model_title_generator', TITLE_GENERATORS) +def test_model_title_generator_in_submodel(model_title_generator): class SubModel(BaseModel): - model_config = ConfigDict(class_title_generator=class_title_generator) + model_config = ConfigDict(model_title_generator=model_title_generator) class Model(BaseModel): sub: SubModel assert Model.model_json_schema() == { - '$defs': {'SubModel': {'properties': {}, 'title': class_title_generator(SubModel.__name__), 'type': 'object'}}, + '$defs': {'SubModel': {'properties': {}, 'title': model_title_generator(SubModel.__name__), 'type': 'object'}}, 'properties': {'sub': {'$ref': '#/$defs/SubModel'}}, 'required': ['sub'], 'title': 'Model', @@ -172,16 +172,16 @@ def field_d(self) -> str: } -@pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) -def test_dataclass_class_title_generator(class_title_generator): - @pydantic.dataclasses.dataclass(config=ConfigDict(class_title_generator=class_title_generator)) +@pytest.mark.parametrize('model_title_generator', TITLE_GENERATORS) +def test_dataclass_model_title_generator(model_title_generator): + @pydantic.dataclasses.dataclass(config=ConfigDict(model_title_generator=model_title_generator)) class MyDataclass: field_a: int assert model_json_schema(MyDataclass) == { 'properties': {'field_a': {'title': 'Field A', 'type': 'integer'}}, 'required': ['field_a'], - 'title': class_title_generator(MyDataclass.__name__), + 'title': model_title_generator(MyDataclass.__name__), 'type': 'object', } @@ -224,15 +224,15 @@ class MyDataclass: } -@pytest.mark.parametrize('class_title_generator', TITLE_GENERATORS) -def test_typeddict_class_title_generator(class_title_generator, TypedDict): +@pytest.mark.parametrize('model_title_generator', TITLE_GENERATORS) +def test_typeddict_model_title_generator(model_title_generator, TypedDict): class MyTypedDict(TypedDict): - __pydantic_config__ = ConfigDict(class_title_generator=class_title_generator) + __pydantic_config__ = ConfigDict(model_title_generator=model_title_generator) pass assert TypeAdapter(MyTypedDict).json_schema() == { 'properties': {}, - 'title': class_title_generator(MyTypedDict.__name__), + 'title': model_title_generator(MyTypedDict.__name__), 'type': 'object', } @@ -360,7 +360,7 @@ class MyTypedDict(TypedDict): def test_class_title_precedence_over_generator(): class Model(BaseModel): - model_config = ConfigDict(title='MyTitle', class_title_generator=lambda c: c.upper()) + model_config = ConfigDict(title='MyTitle', model_title_generator=lambda c: c.upper()) assert Model.model_json_schema() == { 'properties': {}, @@ -368,7 +368,7 @@ class Model(BaseModel): 'type': 'object', } - @pydantic.dataclasses.dataclass(config=ConfigDict(title='MyTitle', class_title_generator=lambda c: c.upper())) + @pydantic.dataclasses.dataclass(config=ConfigDict(title='MyTitle', model_title_generator=lambda c: c.upper())) class MyDataclass: pass @@ -380,28 +380,28 @@ class MyDataclass: @pytest.mark.parametrize('invalid_return_value', (1, 2, 3, tuple(), list(), object())) -def test_class_title_generator_returns_invalid_type(invalid_return_value, TypedDict): +def test_model_title_generator_returns_invalid_type(invalid_return_value, TypedDict): with pytest.raises( - TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' + TypeError, match=f'model_title_generator .* must return str, not {invalid_return_value.__class__}' ): class Model(BaseModel): - model_config = ConfigDict(class_title_generator=lambda m: invalid_return_value) + model_config = ConfigDict(model_title_generator=lambda m: invalid_return_value) with pytest.raises( - TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' + TypeError, match=f'model_title_generator .* must return str, not {invalid_return_value.__class__}' ): - @pydantic.dataclasses.dataclass(config=ConfigDict(class_title_generator=lambda m: invalid_return_value)) + @pydantic.dataclasses.dataclass(config=ConfigDict(model_title_generator=lambda m: invalid_return_value)) class MyDataclass: pass with pytest.raises( - TypeError, match=f'class_title_generator .* must return str, not {invalid_return_value.__class__}' + TypeError, match=f'model_title_generator .* must return str, not {invalid_return_value.__class__}' ): class MyTypedDict(TypedDict): - __pydantic_config__ = ConfigDict(class_title_generator=lambda m: invalid_return_value) + __pydantic_config__ = ConfigDict(model_title_generator=lambda m: invalid_return_value) pass TypeAdapter(MyTypedDict)