From ed807575b0b0243b6dd72a7391bda04a0f3d2426 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 26 Feb 2015 18:17:23 -0500 Subject: [PATCH 1/6] Make sure Optionals (and other validators) are favored over types. This will let Optional('foo', default=1) take precedence over simply `str` and thus have the opportunity to insert its default. The only case for which this should change behavior is if somebody was comparing against a type with a `validate` attr. In that case, the logic would have gone down the wrong path anyway, attempting to call validate() on the type itself. --- schema.py | 14 +++++++------- test_schema.py | 2 ++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/schema.py b/schema.py index 534ae9e..b90018b 100644 --- a/schema.py +++ b/schema.py @@ -79,9 +79,9 @@ def priority(s): return 6 if type(s) is dict: return 5 - if hasattr(s, 'validate'): - return 4 if issubclass(type(s), type): + return 4 + if hasattr(s, 'validate'): return 3 if callable(s): return 2 @@ -146,6 +146,11 @@ def validate(self, data): raise SchemaError('wrong keys %s in %r' % (s_wrong_keys, data), e) return new + if issubclass(type(s), type): + if isinstance(data, s): + return data + else: + raise SchemaError('%r should be instance of %r' % (data, s), e) if hasattr(s, 'validate'): try: return s.validate(data) @@ -154,11 +159,6 @@ def validate(self, data): except BaseException as x: raise SchemaError('%r.validate(%r) raised %r' % (s, data, x), self._error) - if issubclass(type(s), type): - if isinstance(data, s): - return data - else: - raise SchemaError('%r should be instance of %r' % (data, s), e) if callable(s): f = s.__name__ try: diff --git a/test_schema.py b/test_schema.py index 079185e..8fa1393 100644 --- a/test_schema.py +++ b/test_schema.py @@ -151,6 +151,8 @@ def test_dict_optional_keys(): assert Schema({'a': 1, Optional('b'): 2}).validate({'a': 1}) == {'a': 1} assert Schema({'a': 1, Optional('b'): 2}).validate( {'a': 1, 'b': 2}) == {'a': 1, 'b': 2} + # Make sure Optionals are favored over types: + assert Schema({basestring: 1, Optional('b'): 2}).validate({'a': 1, 'b': 2}) == {'a': 1, 'b': 2} def test_complex(): From 57409cb18e617fa6e33ceeb23e8b5237b23d0f73 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 26 Feb 2015 18:24:05 -0500 Subject: [PATCH 2/6] Don't add Optionals to the coverage set in the first place. Then we don't have to filter them back out later. --- schema.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schema.py b/schema.py index b90018b..9bd920d 100644 --- a/schema.py +++ b/schema.py @@ -128,6 +128,8 @@ def validate(self, data): raise else: coverage.add(skey) + if type(skey) is not Optional: + coverage.add(skey) valid = True break if valid: @@ -136,7 +138,6 @@ def validate(self, data): if x is not None: raise SchemaError(['invalid value for key %r' % key] + x.autos, [e] + x.errors) - coverage = set(k for k in coverage if type(k) is not Optional) required = set(k for k in s if type(k) is not Optional) if coverage != required: raise SchemaError('missed keys %r' % (required - coverage), e) From 709c1a825cc4057abf1085f551d9d59edafe2cc4 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 26 Feb 2015 20:22:24 -0500 Subject: [PATCH 3/6] Add default support for Optionals. Error trapping on Optional construction is to follow, split out for ease of review, since it will likely require some refactoring. --- README.rst | 13 +++++++++++++ schema.py | 27 ++++++++++++++++++++++++--- test_schema.py | 11 +++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index fbee293..b5a9ffd 100644 --- a/README.rst +++ b/README.rst @@ -225,6 +225,19 @@ You can mark a key as optional as follows: ... Optional('occupation'): str}).validate({'name': 'Sam'}) {'name': 'Sam'} +``Optional`` keys can also carry a ``default``, to be used when no key in the +data matches: + +.. code:: python + + >>> from schema import Optional + >>> Schema({Optional('color', default='blue'): str, + ... str: str}).validate({'texture': 'furry'}) + {'color': 'blue', 'texture': 'furry'} + +Defaults are used verbatim, not passed through any validators specified in the +value. + **schema** has classes ``And`` and ``Or`` that help validating several schemas for the same data: diff --git a/schema.py b/schema.py index 9bd920d..1e3c705 100644 --- a/schema.py +++ b/schema.py @@ -109,6 +109,7 @@ def validate(self, data): new = type(data)() # new - is a dict of the validated values x = None coverage = set() # non-optional schema keys that were matched + covered_optionals = set() # for each key and value find a schema entry matching them, if any sorted_skeys = list(sorted(s, key=priority)) for key, value in data.items(): @@ -127,9 +128,8 @@ def validate(self, data): x = _x raise else: - coverage.add(skey) - if type(skey) is not Optional: - coverage.add(skey) + (covered_optionals if type(skey) is Optional + else coverage).add(skey) valid = True break if valid: @@ -146,6 +146,13 @@ def validate(self, data): s_wrong_keys = ', '.join('%r' % k for k in sorted(wrong_keys)) raise SchemaError('wrong keys %s in %r' % (s_wrong_keys, data), e) + + # Apply default-having optionals that haven't been used: + defaults = set(k for k in s if type(k) is Optional and + hasattr(k, 'default')) - covered_optionals + for default in defaults: + new[default.name] = default.default + return new if issubclass(type(s), type): if isinstance(data, s): @@ -177,6 +184,20 @@ def validate(self, data): raise SchemaError('%r does not match %r' % (s, data), e) +MARKER = object() + + class Optional(Schema): """Marker for an optional part of Schema.""" + + def __init__(self, *args, **kwargs): + default = kwargs.pop('default', MARKER) + super(Optional, self).__init__(*args, **kwargs) + if default is not MARKER: + # TODO: If I can't figure out a key name for myself, freak out. + self.default = default + + @property + def name(self): + return self._schema diff --git a/test_schema.py b/test_schema.py index 8fa1393..7a0178f 100644 --- a/test_schema.py +++ b/test_schema.py @@ -155,6 +155,17 @@ def test_dict_optional_keys(): assert Schema({basestring: 1, Optional('b'): 2}).validate({'a': 1, 'b': 2}) == {'a': 1, 'b': 2} +def test_dict_optional_defaults(): + # Optionals fill out their defaults: + assert Schema({Optional('a', default=1): 11, + Optional('b', default=2): 22}).validate({'a': 11}) == {'a': 11, 'b': 2} + + # Optionals take precedence over types. Here, the "a" is served by the + # Optional: + assert Schema({Optional('a', default=1): 11, + basestring: 22}).validate({'b': 22}) == {'a': 1, 'b': 22} + + def test_complex(): s = Schema({'': And([Use(open)], lambda l: len(l)), '': os.path.exists, From 734405edf0058e951421b17472484fa537685aac Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 26 Feb 2015 20:36:24 -0500 Subject: [PATCH 4/6] Factor out duplicate logic across priority() and Schema.validate(). --- schema.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/schema.py b/schema.py index 1e3c705..6463425 100644 --- a/schema.py +++ b/schema.py @@ -73,20 +73,23 @@ def validate(self, data): raise SchemaError('%s(%r) raised %r' % (f, data, x), self._error) +COMPARABLE, CALLABLE, VALIDATOR, TYPE, DICT, ITERABLE = range(6) + + def priority(s): - """Return priority for a give object.""" + """Return priority for a given object.""" if type(s) in (list, tuple, set, frozenset): - return 6 + return ITERABLE if type(s) is dict: - return 5 + return DICT if issubclass(type(s), type): - return 4 + return TYPE if hasattr(s, 'validate'): - return 3 + return VALIDATOR if callable(s): - return 2 + return CALLABLE else: - return 1 + return COMPARABLE class Schema(object): @@ -101,10 +104,11 @@ def __repr__(self): def validate(self, data): s = self._schema e = self._error - if type(s) in (list, tuple, set, frozenset): + flavor = priority(s) + if flavor == ITERABLE: data = Schema(type(s), error=e).validate(data) return type(s)(Or(*s, error=e).validate(d) for d in data) - if type(s) is dict: + if flavor == DICT: data = Schema(dict, error=e).validate(data) new = type(data)() # new - is a dict of the validated values x = None @@ -154,12 +158,12 @@ def validate(self, data): new[default.name] = default.default return new - if issubclass(type(s), type): + if flavor == TYPE: if isinstance(data, s): return data else: raise SchemaError('%r should be instance of %r' % (data, s), e) - if hasattr(s, 'validate'): + if flavor == VALIDATOR: try: return s.validate(data) except SchemaError as x: @@ -167,7 +171,7 @@ def validate(self, data): except BaseException as x: raise SchemaError('%r.validate(%r) raised %r' % (s, data, x), self._error) - if callable(s): + if flavor == CALLABLE: f = s.__name__ try: if s(data): From 17b059a124fdd04169ad541075a1f1472c5e26b1 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 26 Feb 2015 20:45:23 -0500 Subject: [PATCH 5/6] Refuse to construct a defaulted Optional for which we can't compute a single, static key. --- schema.py | 14 ++++++++------ test_schema.py | 3 +++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/schema.py b/schema.py index 6463425..3b83cf6 100644 --- a/schema.py +++ b/schema.py @@ -155,7 +155,7 @@ def validate(self, data): defaults = set(k for k in s if type(k) is Optional and hasattr(k, 'default')) - covered_optionals for default in defaults: - new[default.name] = default.default + new[default.key] = default.default return new if flavor == TYPE: @@ -199,9 +199,11 @@ def __init__(self, *args, **kwargs): default = kwargs.pop('default', MARKER) super(Optional, self).__init__(*args, **kwargs) if default is not MARKER: - # TODO: If I can't figure out a key name for myself, freak out. + # See if I can come up with a static key to use for myself: + if priority(self._schema) != COMPARABLE: + raise TypeError( + 'Optional keys with defaults must have simple, ' + 'predictable values, like literal strings or ints. ' + '"%r" is too complex.' % (self._schema,)) self.default = default - - @property - def name(self): - return self._schema + self.key = self._schema diff --git a/test_schema.py b/test_schema.py index 7a0178f..894aa4e 100644 --- a/test_schema.py +++ b/test_schema.py @@ -165,6 +165,9 @@ def test_dict_optional_defaults(): assert Schema({Optional('a', default=1): 11, basestring: 22}).validate({'b': 22}) == {'a': 1, 'b': 22} + with raises(TypeError): + Optional(And(str, Use(int)), default=7) + def test_complex(): s = Schema({'': And([Use(open)], lambda l: len(l)), From 7025552c0aa6eaf421e205173c88d218c758b3d2 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 26 Feb 2015 20:59:15 -0500 Subject: [PATCH 6/6] Make pep8 happy about an old test. --- test_schema.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test_schema.py b/test_schema.py index 894aa4e..4ec9993 100644 --- a/test_schema.py +++ b/test_schema.py @@ -152,7 +152,8 @@ def test_dict_optional_keys(): assert Schema({'a': 1, Optional('b'): 2}).validate( {'a': 1, 'b': 2}) == {'a': 1, 'b': 2} # Make sure Optionals are favored over types: - assert Schema({basestring: 1, Optional('b'): 2}).validate({'a': 1, 'b': 2}) == {'a': 1, 'b': 2} + assert Schema({basestring: 1, + Optional('b'): 2}).validate({'a': 1, 'b': 2}) == {'a': 1, 'b': 2} def test_dict_optional_defaults():