#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-cerberus. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds, so taking one start for every measurement here is the difference between a one-minute run and a half-hour one. WHAT THIS PACKAGE DOES, AND THEREFORE WHAT IS MEASURED. `cerberus' validates a Python mapping against a schema that is itself a mapping. `Validator.validate' answers True or False and `validator.errors' says what failed and WHERE -- so a suite that only scored the verdict would be testing a boolean and calling it a validator. Every rule below is therefore asserted on BOTH sides: one document that must validate, and one that must not, with the error structure asserted as a literal rather than merely checked for truthiness. THE ERROR STRUCTURE IS THE CONTRACT, so it is what is emitted. `errors' is a nested dict whose keys are the PATH to the offending field -- a field name at the top, an index inside a list, a nested dict below a `schema' rule -- and whose leaves are message strings. `enc()' renders one as sorted JSON so that a change in message text, in nesting, or in which field an error is filed under all show up as a different literal. NOTHING HERE REACHES THE OUTSIDE. Every fixture is a dict this file writes; there is no file, no socket, no subprocess and no terminal. That is what makes this port close on its suite with no BRINTOS-780 follow-on. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline -- `enc()' emits compact JSON and asserts that. And no value may depend on the encoding stdout happens to be in: `json.dumps' defaults to `ensure_ascii=True', so every value below is ASCII. """ import json import sys import warnings emitted = [] def emit(key, value): text = str(value) if '\n' in text or '\r' in text: text = 'MULTILINE-VALUE-REFUSED' emitted.append(key) print('%s=%s' % (key, text)) def enc(obj): """A stable one-line rendering of an errors dict or a normalised document. `sort_keys' because a dict's iteration order is not part of cerberus's contract, and `default=str' so that a date or a set leaf renders rather than raising -- a probe that died formatting one value would cost every case after it.""" return json.dumps(obj, sort_keys=True, separators=(',', ':'), default=str) def call(fn, *a, **kw): """fn(*a) as a string, or `!ExceptionName' if it raised. By exception NAME rather than message: the name is the contract a caller codes against, the message is upstream's prose.""" try: return fn(*a, **kw) except Exception as exc: return '!' + type(exc).__name__ try: import cerberus except Exception as exc: print('import_error=%s: %s' % (type(exc).__name__, exc)) print('probe_done=no') sys.exit(0) V = cerberus.Validator def run(schema, doc, **kw): """(verdict, errors, normalised document) for one validation. Every behavioural case in this file goes through here, so a schema that is itself rejected surfaces as `!SchemaError' in the verdict rather than as a traceback that ends the probe.""" try: v = V(schema, **kw) except Exception as exc: return '!' + type(exc).__name__, '!' + type(exc).__name__, '!' try: ok = v.validate(doc) except Exception as exc: return '!' + type(exc).__name__, '!' + type(exc).__name__, '!' return ('yes' if ok else 'no'), enc(v.errors), enc(v.document) def case(key, schema, doc, **kw): """Emit `' (the verdict) and `_errors' (the error structure).""" ok, errors, _doc = run(schema, doc, **kw) emit(key, ok) emit(key + '_errors', errors) def case_doc(key, schema, doc, **kw): """As `case', plus `_doc' -- the NORMALISED document. Used wherever the rule's whole point is what it does to the document rather than to the verdict: default, default_setter, coerce, rename, purge.""" ok, errors, out = run(schema, doc, **kw) emit(key, ok) emit(key + '_errors', errors) emit(key + '_doc', out) # ============================================================ identity ======= # WHICH COPY ANSWERED. Every case below is a statement about the package named # here. emit('module_file', getattr(cerberus, '__file__', 'none')) emit('is_package', 'yes' if hasattr(cerberus, '__path__') else 'no') emit('version_attr', getattr(cerberus, '__version__', 'missing')) # `__version__' IS NOT A LITERAL IN THE SOURCE. cerberus/__init__.py asks # importlib.metadata for the version of the INSTALLED DISTRIBUTION and falls # back to the string `unknown' when the dist-info is not found. So this one # case measures the packaging as much as the package: a rootfs that shipped the # modules without `Cerberus-1.3.5.dist-info' would still import and would report # `unknown' here. emit('version_from_metadata', call(__import__('importlib.metadata', fromlist=['version']).version, 'Cerberus')) emit('dunder_all', ','.join(sorted(getattr(cerberus, '__all__', [])))) _ns = {} exec('from cerberus import *', _ns) emit('star_names', ','.join(sorted(n for n in _ns if not n.startswith('__')))) for _n in ('Validator', 'SchemaError', 'DocumentError', 'TypeDefinition', 'schema_registry', 'rules_set_registry'): emit('has_' + _n.lower(), 'yes' if hasattr(cerberus, _n) else 'no') # THE EXCEPTION HIERARCHY a caller has to catch, and it is FLATTER THAN IT # LOOKS. Both classes derive straight from Exception -- neither is a ValueError, # despite both being raised for what is, in ordinary Python terms, a bad value. # So `except ValueError' around a Validator construction catches nothing, and a # caller who wants to catch the pair without naming them has only `Exception'. emit('schemaerror_bases', ','.join(c.__name__ for c in cerberus.SchemaError.__mro__[1:])) emit('documenterror_bases', ','.join(c.__name__ for c in cerberus.DocumentError.__mro__[1:])) emit('schemaerror_is_valueerror', 'yes' if issubclass(cerberus.SchemaError, ValueError) else 'no') emit('documenterror_is_valueerror', 'yes' if issubclass(cerberus.DocumentError, ValueError) else 'no') # THE RULE TABLES ARE CLASS ATTRIBUTES, and they are the package's advertised # surface. Asserted whole and sorted, so a version bump that adds or drops a # rule says so here once instead of as a scatter of behavioural reds. emit('types_mapping', ','.join(sorted(V.types_mapping))) emit('validation_rules', ','.join(sorted(V.validation_rules))) emit('normalization_rules', ','.join(sorted(V.normalization_rules))) emit('mandatory_validations', ','.join(V.mandatory_validations)) emit('priority_validations', ','.join(V.priority_validations)) # ============================================================ the types ====== # ONE VALID AND ONE INVALID DOCUMENT FOR EVERY BUILT-IN TYPE. The invalid side # carries the error message verbatim, because `must be of type' is what a # caller greps for and it is generated from the rule's own name. import datetime as _dt # noqa: E402 _TYPE_OK = { 'string': 'x', 'integer': 3, 'float': 1.5, 'number': 3, 'boolean': True, 'dict': {}, 'list': [], 'set': set([1]), 'date': _dt.date(2026, 9, 8), 'datetime': _dt.datetime(2026, 9, 8), 'binary': b'\x00', 'container': [], } _TYPE_BAD = { 'string': 1, 'integer': 'x', 'float': 'x', 'number': 'x', 'boolean': 'x', 'dict': [], 'list': {}, 'set': [], 'date': 'x', 'datetime': 'x', 'binary': 'x', 'container': 1, } for _t in sorted(_TYPE_OK): case('type_%s_ok' % _t, {'a': {'type': _t}}, {'a': _TYPE_OK[_t]}) case('type_%s_bad' % _t, {'a': {'type': _t}}, {'a': _TYPE_BAD[_t]}) # `integer' AND `number' DISAGREE ABOUT True, AND THIS IS THE MOST SURPRISING # ANSWER IN THE PACKAGE. In Python `bool' is a subclass of `int', and cerberus # fixes that for `number' -- its TypeDefinition names bool in `excluded_types' # -- but NOT for `integer', whose excluded_types is empty. So True validates as # an integer and fails as a number, in the same document, against the same # value. A caller who wants True rejected has to ask for `number', which is the # opposite of what the names suggest. case('type_integer_accepts_bool', {'a': {'type': 'integer'}}, {'a': True}) case('type_number_rejects_bool', {'a': {'type': 'number'}}, {'a': True}) emit('integer_excluded_types', ','.join(sorted(t.__name__ for t in V.types_mapping['integer'].excluded_types)) or '') emit('number_excluded_types', ','.join(sorted(t.__name__ for t in V.types_mapping['number'].excluded_types)) or '') # A LIST OF TYPES IS AN OR. case('type_list_first', {'a': {'type': ['string', 'integer']}}, {'a': 'x'}) case('type_list_second', {'a': {'type': ['string', 'integer']}}, {'a': 3}) case('type_list_neither', {'a': {'type': ['string', 'integer']}}, {'a': 1.5}) # ============================================================ required ======= case('required_present', {'a': {'type': 'string', 'required': True}}, {'a': 'x'}) case('required_missing', {'a': {'type': 'string', 'required': True}}, {}) # ABSENT IS NOT INVALID unless `required' says so -- the default is False. case('optional_missing', {'a': {'type': 'string'}}, {}) # `require_all' TURNS EVERY FIELD REQUIRED without touching the schema. case('require_all', {'a': {'type': 'string'}, 'b': {'type': 'string'}}, {'a': 'x'}, require_all=True) # ============================================================ nullable ======= # None IS REJECTED BY DEFAULT, and `nullable' is a MANDATORY validation -- it is # consulted even for a field whose schema is empty, which is why it appears in # `mandatory_validations' above. case('nullable_default', {'a': {'type': 'string'}}, {'a': None}) case('nullable_true', {'a': {'type': 'string', 'nullable': True}}, {'a': None}) # ...and with `nullable' the TYPE RULE IS SKIPPED for None rather than applied # to NoneType. case('nullable_true_wrong_type', {'a': {'type': 'string', 'nullable': True}}, {'a': 1}) # ============================================================ empty ========== case('empty_default', {'a': {'type': 'string'}}, {'a': ''}) case('empty_false', {'a': {'type': 'string', 'empty': False}}, {'a': ''}) case('empty_false_nonempty', {'a': {'type': 'string', 'empty': False}}, {'a': 'x'}) # ================================================= default, default_setter === # THE VERDICT IS NOT THE POINT HERE -- THE DOCUMENT IS. `validate' normalises # first and `validator.document' is what a caller is meant to take away; a # suite that scored only True would not notice a default that never landed. case_doc('default_applied', {'a': {'type': 'string', 'default': 'd'}}, {}) case_doc('default_not_overriding', {'a': {'type': 'string', 'default': 'd'}}, {'a': 'given'}) # A PRESENT None IS NOT REPLACED by the default -- only a MISSING key is. case_doc('default_none_kept', {'a': {'type': 'string', 'default': 'd', 'nullable': True}}, {'a': None}) case_doc('default_setter', {'a': {'type': 'integer'}, 'b': {'type': 'integer', 'default_setter': lambda doc: doc['a'] * 2}}, {'a': 21}) # A default_setter THAT RAISES is reported as a normalisation error against the # field, and the field is left out of the document rather than half-set. case_doc('default_setter_raises', {'b': {'default_setter': lambda doc: doc['missing']}}, {}) # ============================================================ coerce ========= case_doc('coerce_int', {'a': {'type': 'integer', 'coerce': int}}, {'a': '42'}) case_doc('coerce_str', {'a': {'type': 'string', 'coerce': str}}, {'a': 42}) # COERCION RUNS BEFORE VALIDATION, which is the whole reason it exists: the # type rule sees the coerced value. case_doc('coerce_then_type_fails', {'a': {'type': 'integer', 'coerce': float}}, {'a': '1.5'}) # A COERCER THAT RAISES does not propagate -- it becomes an error on the field # and the ORIGINAL value stays in the document. case_doc('coerce_raises', {'a': {'type': 'integer', 'coerce': int}}, {'a': 'nope'}) # A CHAIN is applied left to right. case_doc('coerce_chain', {'a': {'type': 'integer', 'coerce': [str.strip, int]}}, {'a': ' 7 '}) # ============================================================ allowed ======== case('allowed_ok', {'a': {'allowed': ['x', 'y']}}, {'a': 'x'}) case('allowed_bad', {'a': {'allowed': ['x', 'y']}}, {'a': 'z'}) # AGAINST A LIST VALUE `allowed' CONSTRAINS EVERY MEMBER, and the message names # every offending member rather than the first. case('allowed_list_ok', {'a': {'type': 'list', 'allowed': ['x', 'y']}}, {'a': ['x', 'y']}) case('allowed_list_bad', {'a': {'type': 'list', 'allowed': ['x', 'y']}}, {'a': ['x', 'z', 'q']}) case('forbidden_ok', {'a': {'forbidden': ['x']}}, {'a': 'y'}) case('forbidden_bad', {'a': {'forbidden': ['x']}}, {'a': 'x'}) # ============================================================ min / max ====== case('min_ok', {'a': {'type': 'integer', 'min': 2}}, {'a': 2}) case('min_bad', {'a': {'type': 'integer', 'min': 2}}, {'a': 1}) case('max_ok', {'a': {'type': 'integer', 'max': 4}}, {'a': 4}) case('max_bad', {'a': {'type': 'integer', 'max': 4}}, {'a': 5}) # BOTH BOUNDS ARE INCLUSIVE, and a value outside the range reports only the # bound it actually crossed -- there is one error, not two. case('minmax_over', {'a': {'type': 'integer', 'min': 2, 'max': 4}}, {'a': 9}) case('minmax_under', {'a': {'type': 'integer', 'min': 2, 'max': 4}}, {'a': 1}) # min/max ARE `<' AND `>' ON THE VALUES, not a numeric rule -- they work on any # ordered type, dates included. case('min_date_ok', {'a': {'type': 'date', 'min': _dt.date(2026, 1, 1)}}, {'a': _dt.date(2026, 9, 8)}) case('min_date_bad', {'a': {'type': 'date', 'min': _dt.date(2026, 1, 1)}}, {'a': _dt.date(2025, 1, 1)}) # AN INCOMPARABLE VALUE IS SILENTLY ACCEPTED, which is a trap: `min' catches # the TypeError from `'x' < 2' and files NO error, so a `min' rule with no # `type' rule beside it passes everything it cannot compare. The rule is only # as strong as the `type' that guards it. case('min_type_mismatch', {'a': {'min': 2}}, {'a': 'x'}) # ============================================= minlength / maxlength ========= case('minlength_ok', {'a': {'type': 'string', 'minlength': 2}}, {'a': 'ab'}) case('minlength_bad', {'a': {'type': 'string', 'minlength': 2}}, {'a': 'a'}) case('maxlength_ok', {'a': {'type': 'string', 'maxlength': 3}}, {'a': 'abc'}) case('maxlength_bad', {'a': {'type': 'string', 'maxlength': 3}}, {'a': 'abcd'}) # THEY ARE `len()' RULES, not string rules -- a list is measured the same way. case('minlength_list_bad', {'a': {'type': 'list', 'minlength': 2}}, {'a': [1]}) # ============================================================ regex ========== case('regex_ok', {'a': {'type': 'string', 'regex': '^[0-9]+$'}}, {'a': '123'}) case('regex_bad', {'a': {'type': 'string', 'regex': '^[0-9]+$'}}, {'a': 'ab'}) # THE PATTERN IS ANCHORED AT BOTH ENDS WHETHER THE CALLER ASKED OR NOT. # `_validate_regex' is `re.match', so the start is anchored as in any OE # regex rule -- but it also APPENDS `$' to any pattern that does not already # end in one (validator.py:1433). So `[0-9]+' is compiled as `[0-9]+$' and # matches neither a prefix nor an infix: a caller porting a `re.search' # pattern here gets a rule that fires on everything. case('regex_implicit_end_anchor', {'a': {'type': 'string', 'regex': '[0-9]+'}}, {'a': '12ab'}) case('regex_not_search', {'a': {'type': 'string', 'regex': '[0-9]+'}}, {'a': 'ab12'}) case('regex_whole_match', {'a': {'type': 'string', 'regex': '[0-9]+'}}, {'a': '12'}) # A NON-STRING VALUE SKIPS THE RULE ENTIRELY rather than failing it -- # `_validate_regex' returns early on anything that is not a str, so `regex' # without `type' is another rule that passes what it cannot examine. case('regex_non_string_skipped', {'a': {'regex': '^[0-9]+$'}}, {'a': 12}) # ============================================================ dependencies === case('dependencies_met', {'a': {'dependencies': 'b'}, 'b': {}}, {'a': 1, 'b': 2}) case('dependencies_missing', {'a': {'dependencies': 'b'}, 'b': {}}, {'a': 1}) # A DEPENDENCY IS ONLY CHECKED WHEN THE DEPENDENT FIELD IS PRESENT. case('dependencies_absent_ok', {'a': {'dependencies': 'b'}, 'b': {}}, {'b': 2}) # A MAPPING dependency constrains the other field's VALUE, not just its # presence. case('dependencies_value_ok', {'a': {'dependencies': {'b': ['yes']}}, 'b': {}}, {'a': 1, 'b': 'yes'}) case('dependencies_value_bad', {'a': {'dependencies': {'b': ['yes']}}, 'b': {}}, {'a': 1, 'b': 'no'}) # ============================================================ excludes ======= case('excludes_ok', {'a': {'excludes': 'b'}, 'b': {}}, {'a': 1}) case('excludes_bad', {'a': {'excludes': 'b'}, 'b': {}}, {'a': 1, 'b': 2}) # `excludes' IS NOT SYMMETRIC. It is checked only when the field DECLARING it # is present, so with the rule on `b' alone a document carrying only `a' is # accepted -- and the same document with `b' added is not. case('excludes_one_sided_ok', {'a': {}, 'b': {'excludes': 'a'}}, {'a': 1}) case('excludes_one_sided_bad', {'a': {}, 'b': {'excludes': 'a'}}, {'a': 1, 'b': 2}) # ============================================================ sub-document === _SUB = {'a': {'type': 'dict', 'schema': {'b': {'type': 'integer', 'required': True}, 'c': {'type': 'string'}}}} case('schema_dict_ok', _SUB, {'a': {'b': 1, 'c': 'x'}}) # THE PATH IS WHAT MATTERS: the error is filed under `a' and then under `b', # so a caller can point at the offending field rather than at the document. case('schema_dict_bad', _SUB, {'a': {'b': 'x'}}) case('schema_dict_missing_inner', _SUB, {'a': {'c': 'x'}}) # TWO LEVELS DEEP, because one level cannot show that the nesting recurses. case('schema_nested_two_deep', {'a': {'type': 'dict', 'schema': {'b': {'type': 'dict', 'schema': {'c': {'type': 'integer'}}}}}}, {'a': {'b': {'c': 'x'}}}) # ============================================================ list items ===== # THE SAME `schema' KEYWORD APPLIED TO A LIST validates every ITEM, and the # path key is the INDEX. case('schema_list_ok', {'a': {'type': 'list', 'schema': {'type': 'integer'}}}, {'a': [1, 2]}) case('schema_list_bad', {'a': {'type': 'list', 'schema': {'type': 'integer'}}}, {'a': [1, 'x', 3]}) # A LIST OF DICTS is where the two nestings meet. case('schema_list_of_dicts', {'a': {'type': 'list', 'schema': {'type': 'dict', 'schema': {'b': {'type': 'integer'}}}}}, {'a': [{'b': 1}, {'b': 'x'}]}) # `items' IS POSITIONAL and `schema' is not: `items' fixes the LENGTH too. case('items_ok', {'a': {'type': 'list', 'items': [{'type': 'string'}, {'type': 'integer'}]}}, {'a': ['x', 1]}) case('items_wrong_type', {'a': {'type': 'list', 'items': [{'type': 'string'}, {'type': 'integer'}]}}, {'a': ['x', 'y']}) case('items_wrong_length', {'a': {'type': 'list', 'items': [{'type': 'string'}]}}, {'a': ['x', 'y']}) # `contains' asks only that a member is there. case('contains_ok', {'a': {'type': 'list', 'contains': 'x'}}, {'a': ['x', 'y']}) case('contains_bad', {'a': {'type': 'list', 'contains': 'x'}}, {'a': ['y']}) # `keysrules' / `valuesrules' are the mapping equivalents. # `keysrules' ALSO SHOWS THE IMPLICIT `$' from above: `^k' becomes `^k$', so # the key `k1' -- which any reader would call a match -- fails alongside `x2'. case('keysrules_bad', {'a': {'type': 'dict', 'keysrules': {'type': 'string', 'regex': '^k'}}}, {'a': {'k1': 1, 'x2': 2}}) case('keysrules_ok', {'a': {'type': 'dict', 'keysrules': {'type': 'string', 'regex': '^k.*'}}}, {'a': {'k1': 1, 'k2': 2}}) case('valuesrules_bad', {'a': {'type': 'dict', 'valuesrules': {'type': 'integer'}}}, {'a': {'k': 'x'}}) # ==================================================== anyof/oneof/allof/noneof _ANY = {'a': {'anyof': [{'type': 'integer'}, {'type': 'string'}]}} case('anyof_first', _ANY, {'a': 1}) case('anyof_second', _ANY, {'a': 'x'}) # THE FAILING SIDE REPORTS EVERY BRANCH, keyed by its POSITION in the list -- # `anyof definition 0', `anyof definition 1'. That is the structure a caller # has to walk, and it is why the error leaf here is a list whose second member # is a dict rather than a flat list of strings. case('anyof_neither', _ANY, {'a': 1.5}) _ONE = {'a': {'oneof': [{'type': 'integer', 'min': 0}, {'type': 'integer', 'min': 10}]}} case('oneof_exactly_one', _ONE, {'a': 5}) # TWO MATCHES IS A FAILURE for oneof -- the rule that catches an overlapping # schema, and the one a caller most often means `anyof' for. case('oneof_two_match', _ONE, {'a': 20}) case('oneof_none_match', _ONE, {'a': 'x'}) _ALL = {'a': {'allof': [{'type': 'integer'}, {'min': 10}]}} case('allof_ok', _ALL, {'a': 20}) case('allof_one_fails', _ALL, {'a': 1}) _NONE = {'a': {'noneof': [{'type': 'string'}, {'max': 0}]}} case('noneof_ok', _NONE, {'a': 5}) case('noneof_one_matches', _NONE, {'a': 'x'}) # ============================================================ allow_unknown == # BOTH SETTINGS, because the default is the strict one and a caller who does not # know that gets an `unknown field' they did not ask for. _ONE_FIELD = {'a': {'type': 'string'}} case('unknown_default', _ONE_FIELD, {'a': 'x', 'z': 1}) case('unknown_allowed', _ONE_FIELD, {'a': 'x', 'z': 1}, allow_unknown=True) # `allow_unknown' CAN ITSELF BE A SCHEMA -- unknown keys are then validated # against it rather than waved through. case('unknown_schema_ok', _ONE_FIELD, {'a': 'x', 'z': 1}, allow_unknown={'type': 'integer'}) case('unknown_schema_bad', _ONE_FIELD, {'a': 'x', 'z': 'q'}, allow_unknown={'type': 'integer'}) # ...and as a RULE inside a sub-document it applies only there. case('unknown_nested_rule', {'a': {'type': 'dict', 'allow_unknown': True, 'schema': {'b': {'type': 'integer'}}}}, {'a': {'b': 1, 'z': 'q'}}) # `purge_unknown' REMOVES rather than rejects, which is a document change and is # asserted as one. case_doc('purge_unknown', _ONE_FIELD, {'a': 'x', 'z': 1}, purge_unknown=True) # ============================================================ rename ========= case_doc('rename', {'old': {'rename': 'new'}, 'new': {'type': 'integer'}}, {'old': 1}) # ============================================================ readonly ======= case('readonly_present', {'a': {'readonly': True}}, {'a': 1}) case('readonly_absent', {'a': {'readonly': True}}, {}) # ==================================================== a custom rule ========== # THE EXTENSION POINT THE PACKAGE EXISTS FOR. A `_validate_' method on a # Validator subclass becomes a rule, and its DOCSTRING is the rule's schema -- # cerberus parses it, which is why the docstring below is not decoration and # why omitting it is a SchemaError rather than a silent no-op. class MyValidator(V): def _validate_is_odd(self, is_odd, field, value): """{'type': 'boolean'}""" if is_odd and not value % 2: self._error(field, 'must be an odd number') def _check_with_not_thirteen(self, field, value): if value == 13: self._error(field, 'thirteen is not allowed') emit('custom_rule_registered', 'yes' if 'is_odd' in MyValidator.validation_rules else 'no') # THE SUBCLASS DOES NOT DISTURB THE BASE -- the rule tables are built per class. emit('base_rule_table_unchanged', 'no' if 'is_odd' in V.validation_rules else 'yes') _mv = MyValidator({'a': {'type': 'integer', 'is_odd': True}}) emit('custom_rule_pass', 'yes' if _mv.validate({'a': 7}) else 'no') emit('custom_rule_pass_errors', enc(_mv.errors)) emit('custom_rule_fail', 'yes' if _mv.validate({'a': 8}) else 'no') emit('custom_rule_fail_errors', enc(_mv.errors)) # `check_with' REACHES THE SAME MECHANISM by name rather than by rule keyword. _cw = MyValidator({'a': {'type': 'integer', 'check_with': 'not_thirteen'}}) emit('check_with_pass', 'yes' if _cw.validate({'a': 12}) else 'no') emit('check_with_fail', 'yes' if _cw.validate({'a': 13}) else 'no') emit('check_with_fail_errors', enc(_cw.errors)) # A CUSTOM TYPE is the other half of the extension point: `types_mapping' takes # a TypeDefinition and the `type' rule then accepts the name. class ObjValidator(V): types_mapping = V.types_mapping.copy() types_mapping['objectid'] = cerberus.TypeDefinition('objectid', (bytes,), ()) _ov = ObjValidator({'a': {'type': 'objectid'}}) emit('custom_type_pass', 'yes' if _ov.validate({'a': b'\x01'}) else 'no') emit('custom_type_fail', 'yes' if _ov.validate({'a': 'x'}) else 'no') emit('custom_type_fail_errors', enc(_ov.errors)) emit('typedefinition_fields', ','.join(cerberus.TypeDefinition._fields)) # ==================================================== schema errors ========== # A MALFORMED SCHEMA IS REJECTED AT DEFINITION TIME, not at validation time -- # the error is in the caller's own code and cerberus refuses to carry it # forward. Each of these raises SchemaError from the CONSTRUCTOR. emit('schema_unknown_rule', call(V, {'a': {'nosuchrule': 1}})) emit('schema_bad_type_name', call(V, {'a': {'type': 'nosuchtype'}})) emit('schema_rule_wrong_type', call(V, {'a': {'minlength': 'two'}})) emit('schema_not_a_mapping', call(V, {'a': 'not-a-mapping'})) # A CUSTOM RULE WITHOUT A DOCSTRING IS NOT AN ERROR -- it is a UserWarning and # the rule then accepts any arguments at all, because there is no schema to # check them against. So the docstring is the rule's own schema and omitting it # silently widens the rule rather than breaking it. Both halves measured: the # constructor succeeds, and the warning is caught here rather than left to leak # onto the probe's stderr. with warnings.catch_warnings(record=True) as _w: warnings.simplefilter('always') _NoDoc = type('NoDoc', (V,), {'_validate_bare': lambda self, c, f, v: None}) _nodoc = call(_NoDoc, {'a': {'bare': True}}) emit('schema_custom_rule_no_docstring', '!' + _nodoc if isinstance(_nodoc, str) and _nodoc.startswith('!') else 'constructed') emit('schema_custom_rule_no_docstring_warning', str(_w[0].message) if _w else '') # THE MESSAGE NAMES THE FIELD AND THE RULE, which is what makes it actionable. try: V({'a': {'nosuchrule': 1}}) emit('schema_error_message', 'no-error') except cerberus.SchemaError as exc: emit('schema_error_message', str(exc)) # A SCHEMA IS ALSO CHECKED WHEN SET LATE, not only in the constructor. _late = V() emit('schema_late_assignment', call(setattr, _late, 'schema', {'a': {'bad': 1}})) # A DOCUMENT THAT IS NOT A MAPPING IS A DocumentError, which is the OTHER # exception and the reason both exist: one is about the caller's schema, the # other about the caller's data. emit('document_not_a_mapping', call(V({'a': {}}).validate, 'not-a-mapping')) emit('document_none', call(V({'a': {}}).validate, None)) # ==================================================== registries ============= # A NAMED SCHEMA can be referenced from another schema by name, which is how a # recursive definition is written at all. cerberus.schema_registry.add('person', {'name': {'type': 'string'}}) _reg = V({'p': {'type': 'dict', 'schema': 'person'}}) emit('registry_ok', 'yes' if _reg.validate({'p': {'name': 'x'}}) else 'no') emit('registry_bad', 'yes' if _reg.validate({'p': {'name': 1}}) else 'no') emit('registry_bad_errors', enc(_reg.errors)) cerberus.rules_set_registry.add('positive', {'type': 'integer', 'min': 1}) _reg2 = V({'n': 'positive'}) emit('rules_set_ok', 'yes' if _reg2.validate({'n': 5}) else 'no') emit('rules_set_bad', 'yes' if _reg2.validate({'n': 0}) else 'no') # ==================================================== error introspection ==== # `_errors' CARRIES THE PATH AS A TUPLE, which is the machine-readable form of # what `errors' renders as nesting. A caller building their own report walks # this, so the shape is asserted rather than left to the prose rendering. _ev = V({'a': {'type': 'dict', 'schema': {'b': {'type': 'integer'}}}}) _ev.validate({'a': {'b': 'x'}}) # THE TOP-LEVEL ENTRY IS THE `schema' RULE ITSELF, and the leaf a caller wants # is one level down in `child_errors'. That indirection is exactly what # `errors' flattens into nesting, so both levels are asserted here. emit('error_path', enc([list(e.document_path) for e in _ev._errors])) emit('error_rule', enc([e.rule for e in _ev._errors])) emit('error_schema_path', enc([list(e.schema_path) for e in _ev._errors])) _child = [c for e in _ev._errors for c in e.child_errors] emit('error_child_path', enc([list(c.document_path) for c in _child])) emit('error_child_rule', enc([c.rule for c in _child])) # `validated()' RETURNS THE DOCUMENT OR None -- the one-call form, and the only # API here that signals failure by return value rather than by a flag. emit('validated_ok', enc(V({'a': {'type': 'integer'}}).validated({'a': 1}))) emit('validated_bad', 'None' if V({'a': {'type': 'integer'}}).validated({'a': 'x'}) is None else 'not-None') # `normalized()' NORMALISES WITHOUT VALIDATING, so a document that would fail # still comes back normalised. emit('normalized_only', enc(V({'a': {'type': 'integer', 'default': 9}, 'b': {'type': 'integer'}}).normalized({'b': 'x'}))) # THE VALIDATOR IS REUSABLE AND ITS STATE IS RESET per call -- a second # validate() must not carry the first one's errors. _reuse = V({'a': {'type': 'integer'}}) _reuse.validate({'a': 'x'}) _reuse.validate({'a': 1}) emit('reuse_errors_cleared', enc(_reuse.errors)) # ==================================================== the update path ======== # `update=True' SKIPS `required', which is what makes a PATCH-shaped document # validatable against the same schema as a POST-shaped one. case('update_skips_required', {'a': {'type': 'string', 'required': True}, 'b': {'type': 'integer'}}, {'b': 1}, ) _upd = V({'a': {'type': 'string', 'required': True}, 'b': {'type': 'integer'}}) emit('update_true', 'yes' if _upd.validate({'b': 1}, update=True) else 'no') emit('update_true_errors', enc(_upd.errors)) emit('emitted_count', len(emitted)) print('probe_done=yes')