diff --git a/changes/1102-samuelcolvin.md b/changes/1102-samuelcolvin.md new file mode 100644 index 0000000..9cfe6ec --- /dev/null +++ b/changes/1102-samuelcolvin.md @@ -0,0 +1 @@ +Fix for `__modify_schema__` when it conflicted with `field_class_to_schema*` diff --git a/docs/build/schema_mapping.py b/docs/build/schema_mapping.py index 16e97ac..0972c4b 100755 --- a/docs/build/schema_mapping.py +++ b/docs/build/schema_mapping.py @@ -6,6 +6,7 @@ Done like this rather than as a raw rst table to make future edits easier. Please edit this file directly not .tmp_schema_mappings.html """ +import json import re from pathlib import Path @@ -48,35 +49,35 @@ table = [ [ 'list', 'array', - '{"items": {}}', + {'items': {}}, 'JSON Schema Core', '' ], [ 'tuple', 'array', - '{"items": {}}', + {'items': {}}, 'JSON Schema Core', '' ], [ 'set', 'array', - '{"items": {}, {"uniqueItems": true}', + {'items': {}, 'uniqueItems': True}, 'JSON Schema Validation', '' ], [ 'List[str]', 'array', - '{"items": {"type": "string"}}', + {'items': {'type': 'string'}}, 'JSON Schema Validation', 'And equivalently for any other sub type, e.g. `List[int]`.' ], [ 'Tuple[str, int]', 'array', - '{"items": [{"type": "string"}, {"type": "integer"}]}', + {'items': [{'type': 'string'}, {'type': 'integer'}]}, 'JSON Schema Validation', ( 'And equivalently for any other set of subtypes. Note: If using schemas for OpenAPI, ' @@ -87,7 +88,7 @@ table = [ [ 'Dict[str, int]', 'object', - '{"additionalProperties": {"type": "integer"}}', + {'additionalProperties': {'type': 'integer'}}, 'JSON Schema Validation', ( 'And equivalently for any other subfields for dicts. Have in mind that although you can use other types as ' @@ -98,7 +99,7 @@ table = [ [ 'Union[str, int]', 'anyOf', - '{"anyOf": [{"type": "string"}, {"type": "integer"}]}', + {'anyOf': [{'type': 'string'}, {'type': 'integer'}]}, 'JSON Schema Validation', 'And equivalently for any other subfields for unions.' ], @@ -112,49 +113,42 @@ table = [ [ 'SecretStr', 'string', - '{"writeOnly": true}', + {'writeOnly': True}, 'JSON Schema Validation', '' ], [ 'SecretBytes', 'string', - '{"writeOnly": true}', + {'writeOnly': True}, 'JSON Schema Validation', '' ], [ 'EmailStr', 'string', - '{"format": "email"}', + {'format': 'email'}, 'JSON Schema Validation', '' ], [ 'NameEmail', 'string', - '{"format": "name-email"}', + {'format': 'name-email'}, 'Pydantic standard "format" extension', '' ], [ 'AnyUrl', 'string', - '{"format": "uri"}', + {'format': 'uri'}, 'JSON Schema Validation', '' ], - [ - 'DSN', - 'string', - '{"format": "dsn"}', - 'Pydantic standard "format" extension', - '' - ], [ 'bytes', 'string', - '{"format": "binary"}', + {'format': 'binary'}, 'OpenAPI', '' ], @@ -168,154 +162,154 @@ table = [ [ 'UUID1', 'string', - '{"format": "uuid1"}', + {'format': 'uuid1'}, 'Pydantic standard "format" extension', '' ], [ 'UUID3', 'string', - '{"format": "uuid3"}', + {'format': 'uuid3'}, 'Pydantic standard "format" extension', '' ], [ 'UUID4', 'string', - '{"format": "uuid4"}', + {'format': 'uuid4'}, 'Pydantic standard "format" extension', '' ], [ 'UUID5', 'string', - '{"format": "uuid5"}', + {'format': 'uuid5'}, 'Pydantic standard "format" extension', '' ], [ 'UUID', 'string', - '{"format": "uuid"}', + {'format': 'uuid'}, 'Pydantic standard "format" extension', 'Suggested in OpenAPI.' ], [ 'FilePath', 'string', - '{"format": "file-path"}', + {'format': 'file-path'}, 'Pydantic standard "format" extension', '' ], [ 'DirectoryPath', 'string', - '{"format": "directory-path"}', + {'format': 'directory-path'}, 'Pydantic standard "format" extension', '' ], [ 'Path', 'string', - '{"format": "path"}', + {'format': 'path'}, 'Pydantic standard "format" extension', '' ], [ 'datetime', 'string', - '{"format": "date-time"}', + {'format': 'date-time'}, 'JSON Schema Validation', '' ], [ 'date', 'string', - '{"format": "date"}', + {'format': 'date'}, 'JSON Schema Validation', '' ], [ 'time', 'string', - '{"format": "time"}', + {'format': 'time'}, 'JSON Schema Validation', '' ], [ 'timedelta', 'number', - '{"format": "time-delta"}', + {'format': 'time-delta'}, 'Difference in seconds (a `float`), with Pydantic standard "format" extension', 'Suggested in JSON Schema repository\'s issues by maintainer.' ], [ 'Json', 'string', - '{"format": "json-string"}', + {'format': 'json-string'}, 'Pydantic standard "format" extension', '' ], [ 'IPv4Address', 'string', - '{"format": "ipv4"}', + {'format': 'ipv4'}, 'JSON Schema Validation', '' ], [ 'IPv6Address', 'string', - '{"format": "ipv6"}', + {'format': 'ipv6'}, 'JSON Schema Validation', '' ], [ 'IPvAnyAddress', 'string', - '{"format": "ipvanyaddress"}', + {'format': 'ipvanyaddress'}, 'Pydantic standard "format" extension', 'IPv4 or IPv6 address as used in `ipaddress` module', ], [ 'IPv4Interface', 'string', - '{"format": "ipv4interface"}', + {'format': 'ipv4interface'}, 'Pydantic standard "format" extension', 'IPv4 interface as used in `ipaddress` module', ], [ 'IPv6Interface', 'string', - '{"format": "ipv6interface"}', + {'format': 'ipv6interface'}, 'Pydantic standard "format" extension', 'IPv6 interface as used in `ipaddress` module', ], [ 'IPvAnyInterface', 'string', - '{"format": "ipvanyinterface"}', + {'format': 'ipvanyinterface'}, 'Pydantic standard "format" extension', 'IPv4 or IPv6 interface as used in `ipaddress` module', ], [ 'IPv4Network', 'string', - '{"format": "ipv4network"}', + {'format': 'ipv4network'}, 'Pydantic standard "format" extension', 'IPv4 network as used in `ipaddress` module', ], [ 'IPv6Network', 'string', - '{"format": "ipv6network"}', + {'format': 'ipv6network'}, 'Pydantic standard "format" extension', 'IPv6 network as used in `ipaddress` module', ], [ 'IPvAnyNetwork', 'string', - '{"format": "ipvanynetwork"}', + {'format': 'ipvanynetwork'}, 'Pydantic standard "format" extension', 'IPv4 or IPv6 network as used in `ipaddress` module', ], @@ -346,7 +340,7 @@ table = [ [ 'constr(regex=\'^text$\', min_length=2, max_length=10)', 'string', - '{"pattern": "^text$", "minLength": 2, "maxLength": 10}', + {'pattern': '^text$', 'minLength': 2, 'maxLength': 10}, 'JSON Schema Validation', 'Any argument not passed to the function (not defined) will not be included in the schema.' ], @@ -363,21 +357,21 @@ table = [ [ 'conint(gt=1, ge=2, lt=6, le=5, multiple_of=2)', 'integer', - '{"maximum": 5, "exclusiveMaximum": 6, "minimum": 2, "exclusiveMinimum": 1, "multipleOf": 2}', + {'maximum': 5, 'exclusiveMaximum': 6, 'minimum': 2, 'exclusiveMinimum': 1, 'multipleOf': 2}, '', 'Any argument not passed to the function (not defined) will not be included in the schema.' ], [ 'PositiveInt', 'integer', - '{"exclusiveMinimum": 0}', + {'exclusiveMinimum': 0}, 'JSON Schema Validation', '' ], [ 'NegativeInt', 'integer', - '{"exclusiveMaximum": 0}', + {'exclusiveMaximum': 0}, 'JSON Schema Validation', '' ], @@ -394,21 +388,21 @@ table = [ [ 'confloat(gt=1, ge=2, lt=6, le=5, multiple_of=2)', 'number', - '{"maximum": 5, "exclusiveMaximum": 6, "minimum": 2, "exclusiveMinimum": 1, "multipleOf": 2}', + {'maximum': 5, 'exclusiveMaximum': 6, 'minimum': 2, 'exclusiveMinimum': 1, 'multipleOf': 2}, 'JSON Schema Validation', 'Any argument not passed to the function (not defined) will not be included in the schema.' ], [ 'PositiveFloat', 'number', - '{"exclusiveMinimum": 0}', + {'exclusiveMinimum': 0}, 'JSON Schema Validation', '' ], [ 'NegativeFloat', 'number', - '{"exclusiveMaximum": 0}', + {'exclusiveMaximum': 0}, 'JSON Schema Validation', '' ], @@ -425,7 +419,7 @@ table = [ [ 'condecimal(gt=1, ge=2, lt=6, le=5, multiple_of=2)', 'number', - '{"maximum": 5, "exclusiveMaximum": 6, "minimum": 2, "exclusiveMinimum": 1, "multipleOf": 2}', + {'maximum': 5, 'exclusiveMaximum': 6, 'minimum': 2, 'exclusiveMinimum': 1, 'multipleOf': 2}, 'JSON Schema Validation', 'Any argument not passed to the function (not defined) will not be included in the schema.' ], @@ -439,7 +433,7 @@ table = [ [ 'Color', 'string', - '{"format": "color"}', + {'format': 'color'}, 'Pydantic standard "format" extension', '', ], @@ -461,7 +455,8 @@ def build_schema_mappings(): rows = [] for py_type, json_type, additional, defined_in, notes in table: - + if additional and not isinstance(additional, str): + additional = json.dumps(additional) cols = [ f'{py_type}', f'{json_type}', @@ -479,7 +474,7 @@ def build_schema_mappings(): heading = '\n'.join(f' {h}' for h in headings) body = '\n\n\n'.join(rows) text = f"""\ - diff --git a/pydantic/color.py b/pydantic/color.py index 75348c3..7b3970b 100644 --- a/pydantic/color.py +++ b/pydantic/color.py @@ -10,7 +10,7 @@ eg. Color((0, 255, 255)).as_named() == 'cyan' because "cyan" comes after "aqua". import math import re from colorsys import hls_to_rgb, rgb_to_hls -from typing import TYPE_CHECKING, Any, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union, cast from .errors import ColorError from .utils import Representation, almost_equal_floats @@ -75,6 +75,10 @@ class Color(Representation): # if we've got here value must be a valid color self._original = value + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', format='color') + def original(self) -> ColorType: """ Original value passed to Color diff --git a/pydantic/fields.py b/pydantic/fields.py index 0af6ff7..b5a56f8 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -186,6 +186,7 @@ SHAPE_NAME_LOOKUP = { class ModelField(Representation): __slots__ = ( 'type_', + 'outer_type_', 'sub_fields', 'key_field', 'validators', @@ -222,6 +223,7 @@ class ModelField(Representation): self.has_alias: bool = bool(alias) self.alias: str = alias or name self.type_: Any = type_ + self.outer_type_: Any = type_ self.class_validators = class_validators or {} self.default: Any = default self.required: 'BoolUndefined' = required @@ -298,6 +300,7 @@ class ModelField(Representation): """ if self.default is not None and self.type_ is None: self.type_ = type(self.default) + self.outer_type_ = self.type_ if self.type_ is None: raise errors_.ConfigError(f'unable to infer type for attribute "{self.name}"') @@ -366,7 +369,10 @@ class ModelField(Representation): types_.append(type_) if len(types_) == 1: + # Optional[] self.type_ = types_[0] + # this is the one case where the "outer type" isn't just the original type + self.outer_type_ = self.type_ # re-run to correctly interpret the new self.type_ self._type_analysis() else: @@ -647,6 +653,7 @@ class ModelField(Representation): def _type_display(self) -> PyObjectStr: t = display_as_type(self.type_) + # have to do this since display_as_type(self.outer_type_) is different (and wrong) on python 3.6 if self.shape == SHAPE_MAPPING: t = f'Mapping[{display_as_type(self.key_field.type_)}, {t}]' # type: ignore elif self.shape == SHAPE_TUPLE: diff --git a/pydantic/networks.py b/pydantic/networks.py index c34d903..593e3cd 100644 --- a/pydantic/networks.py +++ b/pydantic/networks.py @@ -141,7 +141,7 @@ class AnyUrl(str): @classmethod def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: - update_not_none(field_schema, minLength=cls.min_length, maxLength=cls.max_length) + update_not_none(field_schema, minLength=cls.min_length, maxLength=cls.max_length, format='uri') @classmethod def __get_validators__(cls) -> 'CallableGenerator': @@ -264,6 +264,10 @@ def stricturl( class EmailStr(str): + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', format='email') + @classmethod def __get_validators__(cls) -> 'CallableGenerator': # included here and below so the error happens straight away @@ -285,6 +289,10 @@ class NameEmail(Representation): self.name = name self.email = email + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', format='name-email') + @classmethod def __get_validators__(cls) -> 'CallableGenerator': if email_validator is None: @@ -302,6 +310,10 @@ class NameEmail(Representation): class IPvAnyAddress(_BaseAddress): + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', format='ipvanyaddress') + @classmethod def __get_validators__(cls) -> 'CallableGenerator': yield cls.validate @@ -320,6 +332,10 @@ class IPvAnyAddress(_BaseAddress): class IPvAnyInterface(_BaseAddress): + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', format='ipvanyinterface') + @classmethod def __get_validators__(cls) -> 'CallableGenerator': yield cls.validate @@ -338,6 +354,10 @@ class IPvAnyInterface(_BaseAddress): class IPvAnyNetwork(_BaseNetwork): # type: ignore + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', format='ipvanynetwork') + @classmethod def __get_validators__(cls) -> 'CallableGenerator': yield cls.validate diff --git a/pydantic/schema.py b/pydantic/schema.py index 2c3b1e7..528525a 100644 --- a/pydantic/schema.py +++ b/pydantic/schema.py @@ -25,7 +25,6 @@ from typing import ( from uuid import UUID from .class_validators import ROOT_KEY -from .color import Color from .fields import ( SHAPE_FROZENSET, SHAPE_LIST, @@ -39,23 +38,13 @@ from .fields import ( ModelField, ) from .json import pydantic_encoder -from .networks import AnyUrl, EmailStr, IPvAnyAddress, IPvAnyInterface, IPvAnyNetwork, NameEmail +from .networks import AnyUrl, EmailStr from .types import ( - UUID1, - UUID3, - UUID4, - UUID5, ConstrainedDecimal, ConstrainedFloat, ConstrainedInt, ConstrainedList, ConstrainedStr, - DirectoryPath, - FilePath, - Json, - SecretBytes, - SecretStr, - StrictBool, conbytes, condecimal, confloat, @@ -385,38 +374,34 @@ def field_type_schema( """ definitions = {} nested_models: Set[str] = set() + f_schema: Dict[str, Any] ref_prefix = ref_prefix or default_prefix if field.shape in {SHAPE_LIST, SHAPE_TUPLE_ELLIPSIS, SHAPE_SEQUENCE, SHAPE_SET, SHAPE_FROZENSET}: - f_schema, f_definitions, f_nested_models = field_singleton_schema( + items_schema, f_definitions, f_nested_models = field_singleton_schema( field, by_alias=by_alias, model_name_map=model_name_map, ref_prefix=ref_prefix, known_models=known_models ) definitions.update(f_definitions) nested_models.update(f_nested_models) - s: Dict[str, Any] = {'type': 'array', 'items': f_schema} + f_schema = {'type': 'array', 'items': items_schema} if field.shape in {SHAPE_SET, SHAPE_FROZENSET}: - s['uniqueItems'] = True - if field.field_info.min_items is not None: - s['minItems'] = field.field_info.min_items - if field.field_info.max_items is not None: - s['maxItems'] = field.field_info.max_items - return s, definitions, nested_models + f_schema['uniqueItems'] = True + elif field.shape == SHAPE_MAPPING: - dict_schema: Dict[str, Any] = {'type': 'object'} + f_schema = {'type': 'object'} key_field = cast(ModelField, field.key_field) regex = getattr(key_field.type_, 'regex', None) - f_schema, f_definitions, f_nested_models = field_singleton_schema( + items_schema, f_definitions, f_nested_models = field_singleton_schema( field, by_alias=by_alias, model_name_map=model_name_map, ref_prefix=ref_prefix, known_models=known_models ) definitions.update(f_definitions) nested_models.update(f_nested_models) if regex: # Dict keys have a regex pattern - # f_schema might be a schema or empty dict, add it either way - dict_schema['patternProperties'] = {regex.pattern: f_schema} - elif f_schema: + # items_schema might be a schema or empty dict, add it either way + f_schema['patternProperties'] = {regex.pattern: items_schema} + elif items_schema: # The dict values are not simply Any, so they need a schema - dict_schema['additionalProperties'] = f_schema - return dict_schema, definitions, nested_models + f_schema['additionalProperties'] = items_schema elif field.shape == SHAPE_TUPLE: sub_schema = [] sub_fields = cast(List[ModelField], field.sub_fields) @@ -429,7 +414,7 @@ def field_type_schema( sub_schema.append(sf_schema) if len(sub_schema) == 1: sub_schema = sub_schema[0] # type: ignore - return {'type': 'array', 'items': sub_schema}, definitions, nested_models + f_schema = {'type': 'array', 'items': sub_schema} else: assert field.shape == SHAPE_SINGLETON, field.shape f_schema, f_definitions, f_nested_models = field_singleton_schema( @@ -442,7 +427,13 @@ def field_type_schema( ) definitions.update(f_definitions) nested_models.update(f_nested_models) - return f_schema, definitions, nested_models + + # check field type to avoid repeated calls to the same __modify_schema__ method + if field.type_ != field.outer_type_: + modify_schema = getattr(field.outer_type_, '__modify_schema__', None) + if modify_schema: + modify_schema(f_schema) + return f_schema, definitions, nested_models def model_process_schema( @@ -573,55 +564,35 @@ def field_singleton_sub_fields_schema( return {'anyOf': sub_field_schemas}, definitions, nested_models -# Order is important, subclasses of str must go before str, etc -field_class_to_schema_enum_enabled: Tuple[Tuple[Any, Dict[str, Any]], ...] = ( - (EmailStr, {'type': 'string', 'format': 'email'}), - (AnyUrl, {'type': 'string', 'format': 'uri'}), - (SecretStr, {'type': 'string', 'writeOnly': True}), - (str, {'type': 'string'}), - (SecretBytes, {'type': 'string', 'writeOnly': True}), - (bytes, {'type': 'string', 'format': 'binary'}), - (StrictBool, {'type': 'boolean'}), - (bool, {'type': 'boolean'}), - (int, {'type': 'integer'}), - (float, {'type': 'number'}), - (Decimal, {'type': 'number'}), - (UUID1, {'type': 'string', 'format': 'uuid1'}), - (UUID3, {'type': 'string', 'format': 'uuid3'}), - (UUID4, {'type': 'string', 'format': 'uuid4'}), - (UUID5, {'type': 'string', 'format': 'uuid5'}), - (UUID, {'type': 'string', 'format': 'uuid'}), - (NameEmail, {'type': 'string', 'format': 'name-email'}), - (dict, {'type': 'object'}), - (list, {'type': 'array', 'items': {}}), - (tuple, {'type': 'array', 'items': {}}), - (set, {'type': 'array', 'items': {}, 'uniqueItems': True}), - (Color, {'type': 'string', 'format': 'color'}), -) - -json_scheme = {'type': 'string', 'format': 'json-string'} - -# Order is important, subclasses of Path must go before Path, etc -field_class_to_schema_enum_disabled = ( - (FilePath, {'type': 'string', 'format': 'file-path'}), - (DirectoryPath, {'type': 'string', 'format': 'directory-path'}), +# Order is important, e.g. subclasses of str must go before str +# this is used only for standard library types, custom types should use __modify_schema__ instead +field_class_to_schema: Tuple[Tuple[Any, Dict[str, Any]], ...] = ( (Path, {'type': 'string', 'format': 'path'}), (datetime, {'type': 'string', 'format': 'date-time'}), (date, {'type': 'string', 'format': 'date'}), (time, {'type': 'string', 'format': 'time'}), (timedelta, {'type': 'number', 'format': 'time-delta'}), - (Json, json_scheme), (IPv4Network, {'type': 'string', 'format': 'ipv4network'}), (IPv6Network, {'type': 'string', 'format': 'ipv6network'}), - (IPvAnyNetwork, {'type': 'string', 'format': 'ipvanynetwork'}), (IPv4Interface, {'type': 'string', 'format': 'ipv4interface'}), (IPv6Interface, {'type': 'string', 'format': 'ipv6interface'}), - (IPvAnyInterface, {'type': 'string', 'format': 'ipvanyinterface'}), (IPv4Address, {'type': 'string', 'format': 'ipv4'}), (IPv6Address, {'type': 'string', 'format': 'ipv6'}), - (IPvAnyAddress, {'type': 'string', 'format': 'ipvanyaddress'}), + (str, {'type': 'string'}), + (bytes, {'type': 'string', 'format': 'binary'}), + (bool, {'type': 'boolean'}), + (int, {'type': 'integer'}), + (float, {'type': 'number'}), + (Decimal, {'type': 'number'}), + (UUID, {'type': 'string', 'format': 'uuid'}), + (dict, {'type': 'object'}), + (list, {'type': 'array', 'items': {}}), + (tuple, {'type': 'array', 'items': {}}), + (set, {'type': 'array', 'items': {}, 'uniqueItems': True}), ) +json_scheme = {'type': 'string', 'format': 'json-string'} + def field_singleton_schema( # noqa: C901 (ignore complexity) field: ModelField, @@ -652,10 +623,7 @@ def field_singleton_schema( # noqa: C901 (ignore complexity) known_models=known_models, ) if field.type_ is Any or type(field.type_) == TypeVar: - if field.parse_json: - return json_scheme, definitions, nested_models - else: - return {}, definitions, nested_models # no restrictions + return {}, definitions, nested_models # no restrictions if is_callable_type(field.type_): raise SkipField(f'Callable {field.name} was excluded from schema since JSON schema has no equivalent type.') f_schema: Dict[str, Any] = {} @@ -677,11 +645,12 @@ def field_singleton_schema( # noqa: C901 (ignore complexity) literal_value = values[0] field_type = type(literal_value) f_schema['const'] = literal_value + if issubclass(field_type, Enum): f_schema.update({'enum': [item.value for item in field_type]}) # Don't return immediately, to allow adding specific types - for type_, t_schema in field_class_to_schema_enum_enabled: + for type_, t_schema in field_class_to_schema: if issubclass(field_type, type_): f_schema.update(t_schema) break @@ -690,16 +659,13 @@ def field_singleton_schema( # noqa: C901 (ignore complexity) if modify_schema: modify_schema(f_schema) - # Return schema, with or without enum definitions if f_schema: return f_schema, definitions, nested_models - for type_, t_schema in field_class_to_schema_enum_disabled: - if issubclass(field_type, type_): - return t_schema, definitions, nested_models # Handle dataclass-based models if lenient_issubclass(getattr(field_type, '__pydantic_model__', None), BaseModel): field_type = field_type.__pydantic_model__ + if issubclass(field_type, BaseModel): model_name = model_name_map[field_type] if field_type not in known_models: @@ -720,6 +686,7 @@ def field_singleton_schema( # noqa: C901 (ignore complexity) return schema_ref, definitions, nested_models else: return {'allOf': [schema_ref]}, definitions, nested_models + raise ValueError(f'Value not declarable with JSON Schema, field: {field}') diff --git a/pydantic/types.py b/pydantic/types.py index 90e00fc..a19319f 100644 --- a/pydantic/types.py +++ b/pydantic/types.py @@ -92,9 +92,7 @@ class ConstrainedBytes(bytes): @classmethod def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: - update_not_none( - field_schema, minLength=cls.min_length, maxLength=cls.max_length, - ) + update_not_none(field_schema, minLength=cls.min_length, maxLength=cls.max_length) @classmethod def __get_validators__(cls) -> 'CallableGenerator': @@ -129,9 +127,7 @@ class ConstrainedList(list): # type: ignore @classmethod def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: - update_not_none( - field_schema, minLength=cls.min_items, maxLength=cls.max_items, - ) + update_not_none(field_schema, minItems=cls.min_items, maxItems=cls.max_items) @classmethod def list_length_validator(cls, v: 'List[T]') -> 'List[T]': @@ -220,6 +216,10 @@ else: StrictBool to allow for bools which are not type-coerced. """ + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='boolean') + @classmethod def __get_validators__(cls) -> 'CallableGenerator': yield cls.validate @@ -453,20 +453,28 @@ def condecimal( class UUID1(UUID): _required_version = 1 + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', format=f'uuid{cls._required_version}') -class UUID3(UUID): + +class UUID3(UUID1): _required_version = 3 -class UUID4(UUID): +class UUID4(UUID1): _required_version = 4 -class UUID5(UUID): +class UUID5(UUID1): _required_version = 5 class FilePath(Path): + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(format='file-path') + @classmethod def __get_validators__(cls) -> 'CallableGenerator': yield path_validator @@ -482,6 +490,10 @@ class FilePath(Path): class DirectoryPath(Path): + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(format='directory-path') + @classmethod def __get_validators__(cls) -> 'CallableGenerator': yield path_validator @@ -506,10 +518,16 @@ class JsonMeta(type): class Json(metaclass=JsonMeta): - pass + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', format='json-string') class SecretStr: + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', writeOnly=True) + @classmethod def __get_validators__(cls) -> 'CallableGenerator': yield str_validator @@ -540,6 +558,10 @@ class SecretStr: class SecretBytes: + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update(type='string', writeOnly=True) + @classmethod def __get_validators__(cls) -> 'CallableGenerator': yield bytes_validator diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 7ed3082..74cd295 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -2,7 +2,7 @@ import re import sys from decimal import Decimal from enum import Enum -from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union +from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple, Type, TypeVar, Union import pytest @@ -1222,8 +1222,10 @@ def test_field_str_shape(): (Union[List[int], Set[bytes]], 'Union[List[int], Set[bytes]]'), (List[Tuple[int, int]], 'List[Tuple[int, int]]'), (Dict[int, str], 'Mapping[int, str]'), + (FrozenSet[int], 'FrozenSet[int]'), (Tuple[int, ...], 'Tuple[int, ...]'), (Optional[List[int]], 'Optional[List[int]]'), + (dict, 'dict'), ], ) def test_field_type_display(type_, expected): diff --git a/tests/test_schema.py b/tests/test_schema.py index c1fd59f..3a96360 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -516,8 +516,7 @@ def test_str_constrained_types(field_type, expected_schema): model_schema = Model.schema() assert model_schema['properties']['a'] == expected_schema - base_schema = {'title': 'Model', 'type': 'object', 'properties': {'a': {}}, 'required': ['a']} - base_schema['properties']['a'] = expected_schema + base_schema = {'title': 'Model', 'type': 'object', 'properties': {'a': expected_schema}, 'required': ['a']} assert model_schema == base_schema @@ -670,12 +669,16 @@ def test_path_types(field_type, expected_schema): def test_json_type(): class Model(BaseModel): a: Json + b: Json[int] - model_schema = Model.schema() - assert model_schema == { + assert Model.schema() == { 'title': 'Model', 'type': 'object', - 'properties': {'a': {'title': 'A', 'type': 'string', 'format': 'json-string'}}, + 'properties': { + 'a': {'title': 'A', 'type': 'string', 'format': 'json-string'}, + 'b': {'title': 'B', 'type': 'integer'}, + }, + 'required': ['b'], } @@ -1496,10 +1499,7 @@ def test_model_with_schema_extra_callable(): schema.pop('properties') schema['type'] = 'override' - assert Model.schema() == { - 'title': 'Model', - 'type': 'override', - } + assert Model.schema() == {'title': 'Model', 'type': 'override'} def test_model_with_extra_forbidden(): @@ -1613,8 +1613,9 @@ def test_real_vs_phony_constraints(): def test_conlist(): class Model(BaseModel): foo: List[int] = Field(..., min_items=2, max_items=4) + bar: conlist(str, min_items=1, max_items=4) = None - assert Model(foo=[1, 2]).dict() == {'foo': [1, 2]} + assert Model(foo=[1, 2], bar=['spoon']).dict() == {'foo': [1, 2], 'bar': ['spoon']} with pytest.raises(ValidationError, match='ensure this value has at least 2 items'): Model(foo=[1]) @@ -1626,7 +1627,8 @@ def test_conlist(): 'title': 'Model', 'type': 'object', 'properties': { - 'foo': {'title': 'Foo', 'type': 'array', 'items': {'type': 'integer'}, 'minItems': 2, 'maxItems': 4} + 'foo': {'title': 'Foo', 'type': 'array', 'items': {'type': 'integer'}, 'minItems': 2, 'maxItems': 4}, + 'bar': {'title': 'Bar', 'type': 'array', 'items': {'type': 'string'}, 'minItems': 1, 'maxItems': 4}, }, 'required': ['foo'], } @@ -1706,6 +1708,27 @@ def test_schema_attributes(): } +def test_path_modify_schema(): + class MyPath(Path): + @classmethod + def __modify_schema__(cls, schema): + schema.update(foobar=123) + + class Model(BaseModel): + path1: Path + path2: MyPath + + assert Model.schema() == { + 'title': 'Model', + 'type': 'object', + 'properties': { + 'path1': {'title': 'Path1', 'type': 'string', 'format': 'path'}, + 'path2': {'title': 'Path2', 'type': 'string', 'format': 'path', 'foobar': 123}, + }, + 'required': ['path1', 'path2'], + } + + def test_frozen_set(): class Model(BaseModel): a: FrozenSet[int] = frozenset({1, 2, 3})