Skip to content

Commit

Permalink
feat: implement const keyword in Schema
Browse files Browse the repository at this point in the history
  • Loading branch information
Sean1708 authored and Sean Marshallsay committed Apr 9, 2019
1 parent a704662 commit 594908d
Show file tree
Hide file tree
Showing 9 changed files with 96 additions and 6 deletions.
5 changes: 5 additions & 0 deletions HISTORY.rst
Expand Up @@ -3,6 +3,11 @@
History
-------

v0.24
.....

* support ``const`` keyword in ``Schema``, #434 by @Sean1708

v0.23 (2019-04-04)
..................
* improve documentation for contributing section, #441 by @pilosus
Expand Down
3 changes: 2 additions & 1 deletion docs/index.rst
Expand Up @@ -281,10 +281,11 @@ the ``Schema`` class.
Optionally the ``Schema`` class can be used to provide extra information about the field and validations, arguments:

* ``default`` (positional argument), since the ``Schema`` is replacing the field's default, its first
argument is used to set the default, use ellipsis (``...``) to indicate the field is required
argument is used to set the default, use ellipsis (``...``) or ``const`` to indicate the field is required
* ``alias`` - the public name of the field
* ``title`` if omitted ``field_name.title()`` is used
* ``description`` if omitted and the annotation is a sub-model, the docstring of the sub-model will be used
* ``const`` the field is required and *must* take it's default value
* ``gt`` for numeric values (``int``, ``float``, ``Decimal``), adds a validation of "greater than" and an annotation
of ``exclusiveMinimum`` to the JSON Schema
* ``ge`` for numeric values, adds a validation of "greater than or equal" and an annotation of ``minimum`` to the
Expand Down
4 changes: 4 additions & 0 deletions pydantic/errors.py
Expand Up @@ -47,6 +47,10 @@ class NoneIsAllowedError(PydanticTypeError):
msg_template = 'value is not none'


class WrongConstantError(PydanticTypeError):
msg_template = 'constant value {given} is not expected value {expected}'


class BytesError(PydanticTypeError):
msg_template = 'byte type expected'

Expand Down
10 changes: 8 additions & 2 deletions pydantic/fields.py
Expand Up @@ -24,7 +24,7 @@
from .error_wrappers import ErrorWrapper
from .types import Json, JsonWrapper
from .utils import AnyCallable, AnyType, Callable, ForwardRef, display_as_type, lenient_issubclass, sequence_like
from .validators import NoneType, dict_validator, find_validators
from .validators import NoneType, constant_validator, dict_validator, find_validators

Required: Any = Ellipsis

Expand Down Expand Up @@ -58,6 +58,7 @@ class Field:
'whole_post_validators',
'default',
'required',
'const',
'model_config',
'name',
'alias',
Expand All @@ -79,6 +80,7 @@ def __init__(
model_config: Type['BaseConfig'],
default: Any = None,
required: bool = True,
const: bool = False,
alias: str = None,
schema: Optional['Schema'] = None,
) -> None:
Expand All @@ -89,7 +91,8 @@ def __init__(
self.type_: type = type_
self.class_validators = class_validators or {}
self.default: Any = default
self.required: bool = required
self.required: bool = required or const
self.const: bool = const
self.model_config = model_config
self.schema: Optional['Schema'] = schema

Expand Down Expand Up @@ -132,6 +135,8 @@ def infer(
class_validators=class_validators,
default=None if required else value,
required=required,
# cast since ``const`` could be None
const=bool(schema.const),
model_config=config,
schema=schema,
)
Expand Down Expand Up @@ -248,6 +253,7 @@ def _populate_validators(self) -> None:
if get_validators
else find_validators(self.type_, self.model_config.arbitrary_types_allowed)
),
constant_validator,
*[v.func for v in class_validators_ if not v.whole and not v.pre],
)
self.validators = self._prep_vals(v_funcs)
Expand Down
10 changes: 9 additions & 1 deletion pydantic/schema.py
Expand Up @@ -63,10 +63,11 @@ class Schema:
apply only to number fields (``int``, ``float``, ``Decimal``) and some apply only to ``str``
:param default: since the Schema is replacing the field’s default, its first argument is used
to set the default, use ellipsis (``...``) to indicate the field is required
to set the default, use ellipsis (``...``) or ``const`` to indicate the field is required
:param alias: the public name of the field
:param title: can be any string, used in the schema
:param description: can be any string, used in the schema
:param const: this field is required and *must* take it's default value
:param gt: only applies to numbers, requires the field to be "greater than". The schema
will have an ``exclusiveMinimum`` validation keyword
:param ge: only applies to numbers, requires the field to be "greater than or equal to". The
Expand All @@ -91,6 +92,7 @@ class Schema:
'alias',
'title',
'description',
'const',
'gt',
'ge',
'lt',
Expand All @@ -109,6 +111,7 @@ def __init__(
alias: str = None,
title: str = None,
description: str = None,
const: bool = None,
gt: float = None,
ge: float = None,
lt: float = None,
Expand All @@ -123,6 +126,7 @@ def __init__(
self.alias = alias
self.title = title
self.description = description
self.const = const
self.extra = extra
self.gt = gt
self.ge = ge
Expand Down Expand Up @@ -297,6 +301,8 @@ def get_field_schema_validations(field: Field) -> Dict[str, Any]:
attr = getattr(field.schema, attr_name, None)
if isinstance(attr, t):
f_schema[keyword] = attr
if field.const:
f_schema['const'] = field.default
schema = cast('Schema', field.schema)
if schema.extra:
f_schema.update(schema.extra)
Expand Down Expand Up @@ -652,6 +658,8 @@ def field_singleton_schema( # noqa: C901 (ignore complexity)
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] = {}
if field.const:
f_schema['const'] = field.default
if issubclass(field.type_, Enum):
f_schema.update({'enum': [item.value for item in field.type_]}) # type: ignore
# Don't return immediately, to allow adding specific types
Expand Down
7 changes: 7 additions & 0 deletions pydantic/validators.py
Expand Up @@ -113,6 +113,13 @@ def number_size_validator(v: 'Number', field: 'Field') -> 'Number':
return v


def constant_validator(v: 'Any', field: 'Field') -> 'Any':
if field.const and v != field.default:
raise errors.WrongConstantError(given=v, expected=field.default)

return v


def anystr_length_validator(v: 'StrBytes', field: 'Field', config: 'BaseConfig') -> 'StrBytes':
v_len = len(v)

Expand Down
21 changes: 20 additions & 1 deletion tests/test_main.py
Expand Up @@ -3,7 +3,7 @@

import pytest

from pydantic import BaseModel, Extra, NoneBytes, NoneStr, Required, ValidationError, constr
from pydantic import BaseModel, Extra, NoneBytes, NoneStr, Required, Schema, ValidationError, constr


def test_success():
Expand Down Expand Up @@ -365,6 +365,25 @@ class Config:
assert '"TestModel" object has no field "b"' in str(exc_info)


class ConstModel(BaseModel):
a: int = Schema(3, const=True)


def test_const_validates():
m = ConstModel(a=3)
assert m.a == 3


def test_const_is_required():
with pytest.raises(ValidationError):
ConstModel()


def test_const_with_wrong_value():
with pytest.raises(ValidationError):
ConstModel(a=4)


class ValidateAssignmentModel(BaseModel):
a: int = 2
b: constr(min_length=1)
Expand Down
19 changes: 18 additions & 1 deletion tests/test_parse.py
@@ -1,8 +1,9 @@
import pickle
from typing import Union

import pytest

from pydantic import BaseModel, Protocol, ValidationError
from pydantic import BaseModel, Protocol, Schema, ValidationError


class Model(BaseModel):
Expand Down Expand Up @@ -83,3 +84,19 @@ def test_file_pickle_no_ext(tmpdir):
p = tmpdir.join('test')
p.write_binary(pickle.dumps(dict(a=12, b=8)))
assert Model.parse_file(str(p), content_type='application/pickle', allow_pickle=True) == Model(a=12, b=8)


def test_const_differentiates_union():
class SubModelA(BaseModel):
key: str = Schema('A', const=True)
foo: int

class SubModelB(BaseModel):
key: str = Schema('B', const=True)
foo: int

class Model(BaseModel):
a: Union[SubModelA, SubModelB]

m = Model.parse_obj({'a': {'key': 'B', 'foo': 3}})
assert isinstance(m.a, SubModelB)
23 changes: 23 additions & 0 deletions tests/test_schema.py
Expand Up @@ -274,6 +274,29 @@ class Model(BaseModel):
}


def test_const_str():
class Model(BaseModel):
a: str = Schema('some string', const=True)

assert Model.schema() == {
'title': 'Model',
'type': 'object',
'properties': {'a': {'title': 'A', 'type': 'string', 'const': 'some string'}},
'required': ['a'],
}


def test_const_false():
class Model(BaseModel):
a: str = Schema('some string', const=False)

assert Model.schema() == {
'title': 'Model',
'type': 'object',
'properties': {'a': {'title': 'A', 'type': 'string', 'default': 'some string'}},
}


@pytest.mark.parametrize(
'field_type,expected_schema',
[
Expand Down

0 comments on commit 594908d

Please sign in to comment.