. "$BST_LIB" # BST-TIMEOUT: 1200 # THREE python3 starts, and nothing else. # # # # The three are: a bare `import', a second one whose STDERR # # is the assertion (`import-quiet'), and the probe, which # # takes ONE start for all 312 measurements. # # # # This suite is PURE COMPUTATION. It forks nothing, opens # # no socket, writes no file the probe did not make in # # memory, and reaches no device -- every fixture is a dict # # built in the probe itself. So the cost here is the # # interpreter start and nothing else, and 1200 is 400s per # # start, the per-start budget the neighbouring python3 # # suites use. # # # # NOT MEASURED ON TARGET YET: this port's first on-target # # run is the batch verify, and the measured elapsed # # replaces this note in the test plan rather than in this # # comment, which would cost an image build to keep the # # shipped suite byte-identical. # # python3-cerberus 1.3.5 (meta-openembedded meta-python/recipes-devtools/python, # python3-cerberus_1.3.5.bb, `inherit pypi python_poetry_core', PyPI sdist) on # the wasm32-hwjs glibc image. # # ============================================================================ # WHAT THE PACKAGE IS, AND THEREFORE WHAT HAS TO BE TESTED. # ============================================================================ # # `cerberus' validates a Python MAPPING against a schema that is itself a # MAPPING. `Validator.validate(document)' answers True or False, and # `validator.errors' says WHAT failed and WHERE. Validation has exactly two # outcomes, so a suite that scored only the verdict would be testing a boolean # and calling it a validator. # # EVERY RULE HERE IS THEREFORE ASSERTED ON BOTH SIDES -- one document that must # validate and one that must not -- AND THE ERROR STRUCTURE IS ASSERTED AS A # LITERAL. `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. The probe renders one as # sorted compact JSON, so a change in message text, in nesting, or in which # field an error is filed under all surface as a different literal. # # Where a rule's whole point is what it does to the DOCUMENT rather than to the # verdict -- `default', `default_setter', `coerce', `rename', `purge_unknown' -- # the normalised document is asserted too. A default that never landed would # otherwise pass. # # NOTHING HERE REACHES THE OUTSIDE. No file, no socket, no subprocess, no # terminal, no peer. That is what makes this port close on its suite, with no # manual browser pass and no BRINTOS-780 follow-on. # # ============================================================================ # THE PORT TURNS ON ONE IMPORT, AND IT IS NOT ONE OF cerberus's OWN. # ============================================================================ # # cerberus/platform.py line 44 is # # import importlib.metadata as importlib_metadata # # and that one line is why `Depends' on this image lists TEN packages where # meta-python's recipe declares ONE. `importlib.metadata' is in python3-core, # but its own module-scope imports are not: it takes `csv', `zipfile' and # `email'; `importlib.resources' takes `tempfile'; and `email' then takes # `random', `socket' and `datetime', `quopri', `base64', `string' and # `calendar'. Two more are cerberus's own -- `pprint' in errors.py and # `datetime' in validator.py. # # meta-python declares none of them, so the stock package reads `Depends: # python3-core' and cannot be imported on an image that does not happen to carry # the other nine splits. The bbappend in # recipes-devtools/python/python3-cerberus_%.bbappend adds them and carries the # measurement; `pkg-depends-exact' below is the assertion, and it is exact # rather than a substring precisely so that a split going missing says which. # # ============================================================================ # PURE PYTHON, WHICH IS WHY IT PORTS AT ALL. # ============================================================================ # # This image's python3 is ONE STATIC BINARY with an empty lib-dynload and no # dynamic loader (BRINTOS-1476), so a module carrying a compiled extension could # not be imported here whatever it built to. cerberus ships six .py files and no # C -- no ext_modules, no configure step, no compile step -- so there is nothing # on this arm for an override to reach, and the recipe is meta-python's # unmodified. `pkg-no-so' and `pkg-only-python' assert that negative from the # packaged file list, as a REGRESSION TRIPWIRE against a version bump rather # than as a statement about today. # # ============================================================================ # SEVEN BEHAVIOURS A CALLER WILL GET WRONG. THESE ARE THE POINT OF THE SUITE. # ============================================================================ # # True IS AN integer AND IS NOT A number. # `bool' subclasses `int' in Python. cerberus corrects for it # in the `number' TypeDefinition, whose `excluded_types' names # bool, and NOT in `integer', whose excluded_types is empty. # So `{'type': 'integer'}' accepts True and # `{'type': 'number'}' rejects it -- the opposite of what the # two names suggest to anyone who has not read the table. # # A regex RULE IS ANCHORED AT BOTH ENDS. # `_validate_regex' uses `re.match', which anchors the start, # and then APPENDS `$' to any pattern that does not already # end in one (validator.py:1433). A caller porting a # `re.search' pattern gets a rule that matches nothing: # `[0-9]+' will not match `12ab' and will not match `ab12'. # The same `$' is what makes the key `k1' fail a # `keysrules' pattern of `^k'. # # A regex OR min RULE PASSES WHAT IT CANNOT EXAMINE. # `regex' returns early on a non-string; `min' swallows the # TypeError from comparing a string with an integer and files # no error. Both rules are only as strong as the `type' rule # standing beside them, and a schema that omits `type' has # holes exactly there. # # `oneof' FAILS ON TWO MATCHES, AND SAYS SO IN A SHORTER STRUCTURE. # That is the rule's purpose -- catching an overlapping schema # -- and it is what callers reach for when they meant `anyof'. # When it fails because two branches matched, there are NO # per-branch messages, because no branch produced an error. So # the errors leaf is a one-element list here and a two-element # list when nothing matched. # # A COERCER THAT RAISES PRODUCES TWO ERRORS, NOT ONE. # The exception is caught and filed against the field, the # ORIGINAL value stays in the document, and the type rule then # fires on that original value. A caller rendering # `errors[field]' gets a list of two. # # `excludes' IS NOT SYMMETRIC. # It is checked only when the field DECLARING it is present, # so putting it on one side of a pair leaves the other # direction unguarded. # # SchemaError AND DocumentError ARE NOT ValueErrors. # Both derive straight from Exception. `except ValueError' # around a Validator construction catches nothing. # # ============================================================================ # HOW THE CASES ARE PRODUCED. # ============================================================================ # # One interpreter start does all 312 measurements: data/python3-cerberus-probe.py # emits `key=value' lines and this file reads them back with `pv'. A python3 # start on this arm is tens of seconds, so this is the difference between a # one-minute run and a half-hour one. `probe-complete' and `probe-count' are the # probe's own bookends -- a probe that died two thirds of the way through would # otherwise leave the remaining cases scoring against an empty string, which # `check_eq' reads as a WRONG answer rather than as an ABSENT one. P=python3-cerberus # The canary is first so that a suite which dies before its own first assertion # is distinguishable from one that never started. bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKG=$SITE/cerberus DI=$SITE/Cerberus-1.3.5.dist-info PKGLIST=/var/lib/dpkg/info/python3-cerberus.list PROBE=$BST_DATA/python3-cerberus-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-cerberus-suite.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ---------------------------------------------------------------- layout ----- # A PACKAGE, NOT A FLAT MODULE -- six modules under one directory, which is what # pyproject.toml's `[tool.setuptools.packages.find] include = ["cerberus"]' # selects. check $P/site-packages-dir test -d "$SITE" check $P/package-dir test -d "$PKG" check $P/mod-init test -s "$PKG/__init__.py" check $P/mod-errors test -s "$PKG/errors.py" check $P/mod-platform test -s "$PKG/platform.py" check $P/mod-schema test -s "$PKG/schema.py" check $P/mod-utils test -s "$PKG/utils.py" check $P/mod-validator test -s "$PKG/validator.py" # THE BENCHMARK AND TEST SUBPACKAGES ARE NOT PACKAGED. pyproject.toml excludes # `*.benchmarks' and `*.tests' by name, and the sdist carries neither, so unlike # python3-aniso8601 next door there is no upstream unittest tree riding along in # the runtime .deb. check $P/no-tests-subpackage test ! -d "$PKG/tests" check $P/no-benchmarks-subpackage test ! -d "$PKG/benchmarks" # BYTECODE IS SHIPPED, one .pyc per module. It is what keeps the import off the # compile path on an image whose site-packages is read-only, and its absence # would be invisible in every other case here. check $P/pycache-init test -s "$PKG/__pycache__/__init__.cpython-312.pyc" check $P/pycache-validator test -s "$PKG/__pycache__/validator.cpython-312.pyc" check $P/pycache-errors test -s "$PKG/__pycache__/errors.cpython-312.pyc" # ------------------------------------------------------------- the .deb ------ cat "$PKGLIST" > "$W/pkgf" 2>/dev/null || : > "$W/pkgf" check $P/pkg-list-present test -s "$W/pkgf" # PURE PYTHON, AND NOT MERELY "NO .so OF ITS OWN" -- see the header. A # regression tripwire for a version bump, not a statement about today. check_eq $P/pkg-no-so "" \ "$(grep -E '\.so($|\.)' "$W/pkgf" 2>/dev/null)" # NOTHING LANDS IN /usr/bin. pyproject.toml declares no `[project.scripts]' and # no entry points, so cerberus is a library and only a library. check_eq $P/pkg-no-bin "" \ "$(grep -E '^/usr/(s?bin)/' "$W/pkgf" 2>/dev/null)" # ...and nothing but Python in the payload, which is the same claim from the # other side. The dist-info's AUTHORS and LICENSE are the two exceptions and are # named rather than pattern-matched, so a data file appearing beside them would # still be caught. _bad="" while IFS= read -r _f; do [ -f "$_f" ] || continue case "$_f" in *.py|*.pyc) continue ;; */__pycache__/*) continue ;; */*.dist-info/*) continue ;; esac _bad="$_bad $_f" done < "$W/pkgf" check_eq $P/pkg-only-python "" "$_bad" # THE DIST-INFO IS NAMED FOR THE DISTRIBUTION AND KEEPS ITS CAPITAL. The # distribution is `Cerberus' and the import name is `cerberus', so the directory # is `Cerberus-1.3.5.dist-info' -- unnormalised, because bdist_wheel 0.42.0 # still spelt it that way. `version-from-metadata' above depends on # importlib.metadata finding it under that spelling. check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -f "$DI/METADATA" check_out $P/dist-info-name "Name: Cerberus" cat "$DI/METADATA" check_out $P/dist-info-version "Version: 1.3.5" cat "$DI/METADATA" check $P/dist-info-record test -s "$DI/RECORD" check $P/dist-info-license test -s "$DI/LICENSE" check $P/dist-info-authors test -s "$DI/AUTHORS" # THE WHEEL METADATA SAYS WHAT BUILT IT, and it is NOT poetry. The recipe # inherits `python_poetry_core', but pyproject.toml declares # `build-backend = "setuptools.build_meta"' and python_pep517.bbclass drives # `python3 -m build', which reads the backend out of pyproject.toml rather than # off the bbclass. So the inherit contributes one unused native DEPENDS and # nothing else -- and this is the case that measures it rather than reasoning # about it. check_out $P/dist-info-generator "Generator: bdist_wheel" cat "$DI/WHEEL" check_out $P/dist-info-purelib "Root-Is-Purelib: true" cat "$DI/WHEEL" check_out $P/dist-info-tag-py3 "Tag: py3-none-any" cat "$DI/WHEEL" check_eq $P/dist-info-no-py2-tag "" \ "$(grep '^Tag: py2' "$DI/WHEEL" 2>/dev/null)" check_out $P/dist-info-top-level "cerberus" cat "$DI/top_level.txt" # UPSTREAM'S ONE DECLARED DEPENDENCY IS CONDITIONAL AND DOES NOT APPLY HERE: # pyproject.toml asks for `importlib-metadata; python_version < "3.8"', and this # image is 3.12. So the package correctly has NO third-party runtime dependency, # and the METADATA records the marker rather than the requirement. check_eq $P/metadata-no-unconditional-requires "" \ "$(grep '^Requires-Dist:' "$DI/METADATA" 2>/dev/null | grep -v 'python_version')" sed -n '/^Package: python3-cerberus$/,/^$/p' /var/lib/dpkg/status > "$W/st" 2>/dev/null check $P/pkg-known test -s "$W/st" check_out $P/pkg-installed "Status: install ok installed" cat "$W/st" check_out $P/pkg-version "Version: 1.3.5-r0" cat "$W/st" # THE RDEPENDS IS ASSERTED EXACTLY, and every one of the nine added splits is # load-bearing -- see the header. A lean image that dropped any of them would # say so here once, by name, rather than presenting as a library that cannot be # imported. grep '^Depends:' "$W/st" > "$W/dep" 2>/dev/null || : > "$W/dep" check $P/pkg-depends-line test -s "$W/dep" check_eq $P/pkg-depends-exact \ "Depends: python3-compression, python3-core, python3-datetime, python3-email, python3-io, python3-math, python3-mime, python3-netclient, python3-pprint, python3-stringold" \ "$(cat "$W/dep")" # ---------------------------------------------------------------- import ----- check $P/import python3 -c 'import cerberus' # A DeprecationWarning, a SyntaxWarning from a module compiled on the fly, or a # stray print on import would all be invisible to the case above, which scores # only the exit status. errors.py carries two docstrings with invalid escape # sequences and CPython 3.12 warns about them AT COMPILE TIME -- so this case is # also the assertion that the shipped .pyc files are being used rather than # rebuilt. Expected output: nothing at all. check_eq $P/import-quiet "" \ "$(python3 -c 'import cerberus' 2>&1)" # `python3 -OO' IS REFUSED BY THE PACKAGE ITSELF. platform.py raises RuntimeError # at optimization level 2, because level 2 strips docstrings and a cerberus # custom rule's docstring IS its schema. Asserted because it is a supported, # documented refusal rather than a failure of this image. check_eq $P/import-oo-refused "RuntimeError" \ "$(python3 -OO -c 'import cerberus' 2>&1 | sed -n 's/^\([A-Za-z]*\):.*/\1/p' | tail -n 1)" # ----------------------------------------------------------------- probe ----- if [ ! -f "$PROBE" ]; then bst_notok $P/probe-present "$PROBE did not ship" rm -rf "$W" exit 0 fi bst_ok $P/probe-present bst_probe $P/probe-run "$W/probe" 900 python3 "$PROBE" OUT="$W/probe" pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } if [ -s "$OUT" ]; then bst_ok $P/probe-output else bst_notok $P/probe-output "the probe emitted nothing -- python3 did not start, or died before its first line; every CASE below is unscorable" fi # THE PROBE'S OWN BOOKENDS -- see the header. `probe-import' is separate because # the probe reports an import failure as data rather than dying, so that a # missing runtime split is ONE named red instead of 312 unscorable ones. check_eq $P/probe-import "" "$(pv import_error)" check_eq $P/probe-complete "yes" "$(pv probe_done)" check_eq $P/probe-count "312" "$(pv emitted_count)" # WHICH COPY ANSWERED. Every behavioural case below is a statement about the # module named here. check_eq $P/module-answered "$PKG/__init__.py" "$(pv module_file)" # ============================================================ identity ======== # WHICH COPY ANSWERED, and what it advertises. `module-answered' above already # named the file; these say what is in it. check_eq $P/is-package yes "$(pv is_package)" # `__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'. So these two cases measure the PACKAGING: a # rootfs that shipped the modules without `Cerberus-1.3.5.dist-info' would # import cleanly and report `unknown' here, and nothing else in the suite would # notice. check_eq $P/version-attr 1.3.5 "$(pv version_attr)" check_eq $P/version-from-metadata 1.3.5 "$(pv version_from_metadata)" # `__all__' EXISTS, so a star-import leaks exactly six names -- and `__version__' # is IN `__all__' but is filtered out of `star-names' by the dunder rule, which # is why the two lists differ by one. check_eq $P/dunder-all DocumentError,SchemaError,TypeDefinition,Validator,__version__,rules_set_registry,schema_registry "$(pv dunder_all)" check_eq $P/star-names DocumentError,SchemaError,TypeDefinition,Validator,rules_set_registry,schema_registry "$(pv star_names)" check_eq $P/has-validator yes "$(pv has_validator)" check_eq $P/has-schemaerror yes "$(pv has_schemaerror)" check_eq $P/has-documenterror yes "$(pv has_documenterror)" check_eq $P/has-typedefinition yes "$(pv has_typedefinition)" check_eq $P/has-schema-registry yes "$(pv has_schema_registry)" check_eq $P/has-rules-set-registry yes "$(pv has_rules_set_registry)" # THE EXCEPTION HIERARCHY 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. `except ValueError' around a Validator # construction catches nothing here. check_eq $P/schemaerror-bases Exception,BaseException,object "$(pv schemaerror_bases)" check_eq $P/documenterror-bases Exception,BaseException,object "$(pv documenterror_bases)" check_eq $P/schemaerror-is-valueerror no "$(pv schemaerror_is_valueerror)" check_eq $P/documenterror-is-valueerror no "$(pv documenterror_is_valueerror)" # THE RULE TABLES, WHOLE AND SORTED. They are the package's advertised surface, # so a version bump that adds or drops a rule says so here once rather than as a # scatter of behavioural reds nobody can group. check_eq $P/types-mapping binary,boolean,container,date,datetime,dict,float,integer,list,number,set,string "$(pv types_mapping)" check_eq $P/validation-rules allof,allow_unknown,allowed,anyof,check_with,contains,dependencies,empty,excludes,forbidden,items,keysrules,max,maxlength,meta,min,minlength,noneof,nullable,oneof,readonly,regex,require_all,required,schema,type,valuesrules "$(pv validation_rules)" check_eq $P/normalization-rules coerce,default,default_setter,purge_unknown,rename,rename_handler "$(pv normalization_rules)" check_eq $P/mandatory-validations nullable "$(pv mandatory_validations)" check_eq $P/priority-validations nullable,readonly,type,empty "$(pv priority_validations)" # ============================================================ the types ======= # ONE VALID AND ONE INVALID DOCUMENT FOR EVERY BUILT-IN TYPE, with the error # structure asserted rather than merely `False'. `must be of type' is # generated from the type's own name, so the twelve messages are the twelve # names and a renamed type would show up as twelve reds. check_eq $P/type-binary-ok yes "$(pv type_binary_ok)" check_eq $P/type-binary-ok-errors '{}' "$(pv type_binary_ok_errors)" check_eq $P/type-binary-bad no "$(pv type_binary_bad)" check_eq $P/type-binary-bad-errors '{"a":["must be of binary type"]}' "$(pv type_binary_bad_errors)" check_eq $P/type-boolean-ok yes "$(pv type_boolean_ok)" check_eq $P/type-boolean-ok-errors '{}' "$(pv type_boolean_ok_errors)" check_eq $P/type-boolean-bad no "$(pv type_boolean_bad)" check_eq $P/type-boolean-bad-errors '{"a":["must be of boolean type"]}' "$(pv type_boolean_bad_errors)" check_eq $P/type-container-ok yes "$(pv type_container_ok)" check_eq $P/type-container-ok-errors '{}' "$(pv type_container_ok_errors)" check_eq $P/type-container-bad no "$(pv type_container_bad)" check_eq $P/type-container-bad-errors '{"a":["must be of container type"]}' "$(pv type_container_bad_errors)" check_eq $P/type-date-ok yes "$(pv type_date_ok)" check_eq $P/type-date-ok-errors '{}' "$(pv type_date_ok_errors)" check_eq $P/type-date-bad no "$(pv type_date_bad)" check_eq $P/type-date-bad-errors '{"a":["must be of date type"]}' "$(pv type_date_bad_errors)" check_eq $P/type-datetime-ok yes "$(pv type_datetime_ok)" check_eq $P/type-datetime-ok-errors '{}' "$(pv type_datetime_ok_errors)" check_eq $P/type-datetime-bad no "$(pv type_datetime_bad)" check_eq $P/type-datetime-bad-errors '{"a":["must be of datetime type"]}' "$(pv type_datetime_bad_errors)" check_eq $P/type-dict-ok yes "$(pv type_dict_ok)" check_eq $P/type-dict-ok-errors '{}' "$(pv type_dict_ok_errors)" check_eq $P/type-dict-bad no "$(pv type_dict_bad)" check_eq $P/type-dict-bad-errors '{"a":["must be of dict type"]}' "$(pv type_dict_bad_errors)" check_eq $P/type-float-ok yes "$(pv type_float_ok)" check_eq $P/type-float-ok-errors '{}' "$(pv type_float_ok_errors)" check_eq $P/type-float-bad no "$(pv type_float_bad)" check_eq $P/type-float-bad-errors '{"a":["must be of float type"]}' "$(pv type_float_bad_errors)" check_eq $P/type-integer-ok yes "$(pv type_integer_ok)" check_eq $P/type-integer-ok-errors '{}' "$(pv type_integer_ok_errors)" check_eq $P/type-integer-bad no "$(pv type_integer_bad)" check_eq $P/type-integer-bad-errors '{"a":["must be of integer type"]}' "$(pv type_integer_bad_errors)" check_eq $P/type-list-ok yes "$(pv type_list_ok)" check_eq $P/type-list-ok-errors '{}' "$(pv type_list_ok_errors)" check_eq $P/type-list-bad no "$(pv type_list_bad)" check_eq $P/type-list-bad-errors '{"a":["must be of list type"]}' "$(pv type_list_bad_errors)" check_eq $P/type-number-ok yes "$(pv type_number_ok)" check_eq $P/type-number-ok-errors '{}' "$(pv type_number_ok_errors)" check_eq $P/type-number-bad no "$(pv type_number_bad)" check_eq $P/type-number-bad-errors '{"a":["must be of number type"]}' "$(pv type_number_bad_errors)" check_eq $P/type-set-ok yes "$(pv type_set_ok)" check_eq $P/type-set-ok-errors '{}' "$(pv type_set_ok_errors)" check_eq $P/type-set-bad no "$(pv type_set_bad)" check_eq $P/type-set-bad-errors '{"a":["must be of set type"]}' "$(pv type_set_bad_errors)" check_eq $P/type-string-ok yes "$(pv type_string_ok)" check_eq $P/type-string-ok-errors '{}' "$(pv type_string_ok_errors)" check_eq $P/type-string-bad no "$(pv type_string_bad)" check_eq $P/type-string-bad-errors '{"a":["must be of string type"]}' "$(pv type_string_bad_errors)" # `integer' AND `number' DISAGREE ABOUT True, AND IT IS THE MOST SURPRISING # ANSWER IN THE PACKAGE. `bool' is a subclass of `int' in Python; cerberus fixes # that for `number', whose TypeDefinition names bool in `excluded_types', and # NOT for `integer', whose excluded_types is empty. So True validates as an # integer and fails as a number, against the same value in the same document. A # caller who wants True rejected has to ask for `number' -- the opposite of what # the two names suggest. check_eq $P/type-integer-accepts-bool yes "$(pv type_integer_accepts_bool)" check_eq $P/type-integer-accepts-bool-errors '{}' "$(pv type_integer_accepts_bool_errors)" check_eq $P/type-number-rejects-bool no "$(pv type_number_rejects_bool)" check_eq $P/type-number-rejects-bool-errors '{"a":["must be of number type"]}' "$(pv type_number_rejects_bool_errors)" check_eq $P/integer-excluded-types '' "$(pv integer_excluded_types)" check_eq $P/number-excluded-types bool "$(pv number_excluded_types)" # A LIST OF TYPE NAMES IS AN OR, and the failure message carries the list's repr # rather than a joined string. check_eq $P/type-list-first yes "$(pv type_list_first)" check_eq $P/type-list-first-errors '{}' "$(pv type_list_first_errors)" check_eq $P/type-list-second yes "$(pv type_list_second)" check_eq $P/type-list-second-errors '{}' "$(pv type_list_second_errors)" check_eq $P/type-list-neither no "$(pv type_list_neither)" check_eq $P/type-list-neither-errors "{\"a\":[\"must be of ['string', 'integer'] type\"]}" "$(pv type_list_neither_errors)" # ============================================================ required ======== # ABSENT IS NOT INVALID unless `required' says so, and `require_all' turns every # field required without touching the schema. check_eq $P/required-present yes "$(pv required_present)" check_eq $P/required-present-errors '{}' "$(pv required_present_errors)" check_eq $P/required-missing no "$(pv required_missing)" check_eq $P/required-missing-errors '{"a":["required field"]}' "$(pv required_missing_errors)" check_eq $P/optional-missing yes "$(pv optional_missing)" check_eq $P/optional-missing-errors '{}' "$(pv optional_missing_errors)" check_eq $P/require-all no "$(pv require_all)" check_eq $P/require-all-errors '{"b":["required field"]}' "$(pv require_all_errors)" # ============================================================ nullable ======== # None IS REJECTED BY DEFAULT. `nullable' is the package's one MANDATORY # validation -- consulted even for a field whose schema is empty -- and when it # is set the type rule is SKIPPED for None rather than applied to NoneType. check_eq $P/nullable-default no "$(pv nullable_default)" check_eq $P/nullable-default-errors '{"a":["null value not allowed"]}' "$(pv nullable_default_errors)" check_eq $P/nullable-true yes "$(pv nullable_true)" check_eq $P/nullable-true-errors '{}' "$(pv nullable_true_errors)" check_eq $P/nullable-true-wrong-type no "$(pv nullable_true_wrong_type)" check_eq $P/nullable-true-wrong-type-errors '{"a":["must be of string type"]}' "$(pv nullable_true_wrong_type_errors)" # ============================================================ empty =========== # An empty string passes by default; `empty: False' is what refuses it. check_eq $P/empty-default yes "$(pv empty_default)" check_eq $P/empty-default-errors '{}' "$(pv empty_default_errors)" check_eq $P/empty-false no "$(pv empty_false)" check_eq $P/empty-false-errors '{"a":["empty values not allowed"]}' "$(pv empty_false_errors)" check_eq $P/empty-false-nonempty yes "$(pv empty_false_nonempty)" check_eq $P/empty-false-nonempty-errors '{}' "$(pv empty_false_nonempty_errors)" # ================================================= default, default_setter ==== # THE VERDICT IS NOT THE POINT HERE -- THE DOCUMENT IS. `validate' normalises # first and `validator.document' is what a caller takes away, so every case in # this section asserts the normalised document as well. A suite that scored only # `True' would not notice a default that never landed. # # A PRESENT None IS NOT REPLACED: only a MISSING key gets the default. # # A default_setter THAT RAISES is reported against its own field and the field # is left OUT of the document rather than half-set -- and the message it # produces blames `Circular dependencies of default setters', which is upstream # resolving "this setter did not succeed" to its one named cause. Asserted # verbatim because a caller grepping for it will grep for this. check_eq $P/default-applied yes "$(pv default_applied)" check_eq $P/default-applied-errors '{}' "$(pv default_applied_errors)" check_eq $P/default-applied-doc '{"a":"d"}' "$(pv default_applied_doc)" check_eq $P/default-not-overriding yes "$(pv default_not_overriding)" check_eq $P/default-not-overriding-errors '{}' "$(pv default_not_overriding_errors)" check_eq $P/default-not-overriding-doc '{"a":"given"}' "$(pv default_not_overriding_doc)" check_eq $P/default-none-kept yes "$(pv default_none_kept)" check_eq $P/default-none-kept-errors '{}' "$(pv default_none_kept_errors)" check_eq $P/default-none-kept-doc '{"a":null}' "$(pv default_none_kept_doc)" check_eq $P/default-setter yes "$(pv default_setter)" check_eq $P/default-setter-errors '{}' "$(pv default_setter_errors)" check_eq $P/default-setter-doc '{"a":21,"b":42}' "$(pv default_setter_doc)" check_eq $P/default-setter-raises no "$(pv default_setter_raises)" check_eq $P/default-setter-raises-errors "{\"b\":[\"default value for 'b' cannot be set: Circular dependencies of default setters.\"]}" "$(pv default_setter_raises_errors)" check_eq $P/default-setter-raises-doc '{}' "$(pv default_setter_raises_doc)" # ============================================================ coerce ========== # COERCION RUNS BEFORE VALIDATION, which is the whole reason it exists: the type # rule sees the COERCED value, so `coerce: float' under `type: integer' fails on # the float it just produced. # # A COERCER THAT RAISES does not propagate. It becomes an error on the field, # the ORIGINAL value stays in the document, and the type rule then fires as well # -- so one bad coercion produces TWO messages, which is the shape a caller # rendering errors has to expect. check_eq $P/coerce-int yes "$(pv coerce_int)" check_eq $P/coerce-int-errors '{}' "$(pv coerce_int_errors)" check_eq $P/coerce-int-doc '{"a":42}' "$(pv coerce_int_doc)" check_eq $P/coerce-str yes "$(pv coerce_str)" check_eq $P/coerce-str-errors '{}' "$(pv coerce_str_errors)" check_eq $P/coerce-str-doc '{"a":"42"}' "$(pv coerce_str_doc)" check_eq $P/coerce-then-type-fails no "$(pv coerce_then_type_fails)" check_eq $P/coerce-then-type-fails-errors '{"a":["must be of integer type"]}' "$(pv coerce_then_type_fails_errors)" check_eq $P/coerce-then-type-fails-doc '{"a":1.5}' "$(pv coerce_then_type_fails_doc)" check_eq $P/coerce-raises no "$(pv coerce_raises)" check_eq $P/coerce-raises-errors "{\"a\":[\"field 'a' cannot be coerced: invalid literal for int() with base 10: 'nope'\",\"must be of integer type\"]}" "$(pv coerce_raises_errors)" check_eq $P/coerce-raises-doc '{"a":"nope"}' "$(pv coerce_raises_doc)" check_eq $P/coerce-chain yes "$(pv coerce_chain)" check_eq $P/coerce-chain-errors '{}' "$(pv coerce_chain_errors)" check_eq $P/coerce-chain-doc '{"a":7}' "$(pv coerce_chain_doc)" # ==================================================== allowed / forbidden ===== # AGAINST A LIST VALUE `allowed' CONSTRAINS EVERY MEMBER, and the message names # every offending member as a tuple rather than stopping at the first. check_eq $P/allowed-ok yes "$(pv allowed_ok)" check_eq $P/allowed-ok-errors '{}' "$(pv allowed_ok_errors)" check_eq $P/allowed-bad no "$(pv allowed_bad)" check_eq $P/allowed-bad-errors '{"a":["unallowed value z"]}' "$(pv allowed_bad_errors)" check_eq $P/allowed-list-ok yes "$(pv allowed_list_ok)" check_eq $P/allowed-list-ok-errors '{}' "$(pv allowed_list_ok_errors)" check_eq $P/allowed-list-bad no "$(pv allowed_list_bad)" check_eq $P/allowed-list-bad-errors "{\"a\":[\"unallowed values ('z', 'q')\"]}" "$(pv allowed_list_bad_errors)" check_eq $P/forbidden-ok yes "$(pv forbidden_ok)" check_eq $P/forbidden-ok-errors '{}' "$(pv forbidden_ok_errors)" check_eq $P/forbidden-bad no "$(pv forbidden_bad)" check_eq $P/forbidden-bad-errors '{"a":["unallowed value x"]}' "$(pv forbidden_bad_errors)" # ============================================================ min / max ======= # BOTH BOUNDS ARE INCLUSIVE, and a value outside the range reports only the # bound it actually crossed -- one error, not two. # # THEY ARE `<' AND `>' ON THE VALUES, not a numeric rule, so they work on any # ordered type and `min-date-bad' says so with a date. # # AN INCOMPARABLE VALUE IS SILENTLY ACCEPTED, and that is the trap: `min' # swallows the TypeError from `'x' < 2' and files NO error. A `min' rule with no # `type' rule beside it passes everything it cannot compare. check_eq $P/min-ok yes "$(pv min_ok)" check_eq $P/min-ok-errors '{}' "$(pv min_ok_errors)" check_eq $P/min-bad no "$(pv min_bad)" check_eq $P/min-bad-errors '{"a":["min value is 2"]}' "$(pv min_bad_errors)" check_eq $P/max-ok yes "$(pv max_ok)" check_eq $P/max-ok-errors '{}' "$(pv max_ok_errors)" check_eq $P/max-bad no "$(pv max_bad)" check_eq $P/max-bad-errors '{"a":["max value is 4"]}' "$(pv max_bad_errors)" check_eq $P/minmax-over no "$(pv minmax_over)" check_eq $P/minmax-over-errors '{"a":["max value is 4"]}' "$(pv minmax_over_errors)" check_eq $P/minmax-under no "$(pv minmax_under)" check_eq $P/minmax-under-errors '{"a":["min value is 2"]}' "$(pv minmax_under_errors)" check_eq $P/min-date-ok yes "$(pv min_date_ok)" check_eq $P/min-date-ok-errors '{}' "$(pv min_date_ok_errors)" check_eq $P/min-date-bad no "$(pv min_date_bad)" check_eq $P/min-date-bad-errors '{"a":["min value is 2026-01-01"]}' "$(pv min_date_bad_errors)" check_eq $P/min-type-mismatch yes "$(pv min_type_mismatch)" check_eq $P/min-type-mismatch-errors '{}' "$(pv min_type_mismatch_errors)" # =================================================== minlength / maxlength ==== # `len()' RULES, not string rules -- a list is measured exactly the same way. check_eq $P/minlength-ok yes "$(pv minlength_ok)" check_eq $P/minlength-ok-errors '{}' "$(pv minlength_ok_errors)" check_eq $P/minlength-bad no "$(pv minlength_bad)" check_eq $P/minlength-bad-errors '{"a":["min length is 2"]}' "$(pv minlength_bad_errors)" check_eq $P/maxlength-ok yes "$(pv maxlength_ok)" check_eq $P/maxlength-ok-errors '{}' "$(pv maxlength_ok_errors)" check_eq $P/maxlength-bad no "$(pv maxlength_bad)" check_eq $P/maxlength-bad-errors '{"a":["max length is 3"]}' "$(pv maxlength_bad_errors)" check_eq $P/minlength-list-bad no "$(pv minlength_list_bad)" check_eq $P/minlength-list-bad-errors '{"a":["min length is 2"]}' "$(pv minlength_list_bad_errors)" # ============================================================ regex =========== # THE PATTERN IS ANCHORED AT BOTH ENDS WHETHER THE CALLER ASKED OR NOT. # `_validate_regex' is `re.match', which anchors the start, and it also APPENDS # `$' to any pattern not already ending in one (validator.py:1433). So `[0-9]+' # compiles 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. # # A NON-STRING VALUE SKIPS THE RULE ENTIRELY -- `_validate_regex' returns early # on anything that is not a str, so `regex' without `type' is the second rule in # this file that passes what it cannot examine. check_eq $P/regex-ok yes "$(pv regex_ok)" check_eq $P/regex-ok-errors '{}' "$(pv regex_ok_errors)" check_eq $P/regex-bad no "$(pv regex_bad)" check_eq $P/regex-bad-errors "{\"a\":[\"value does not match regex '^[0-9]+\$'\"]}" "$(pv regex_bad_errors)" check_eq $P/regex-implicit-end-anchor no "$(pv regex_implicit_end_anchor)" check_eq $P/regex-implicit-end-anchor-errors "{\"a\":[\"value does not match regex '[0-9]+'\"]}" "$(pv regex_implicit_end_anchor_errors)" check_eq $P/regex-not-search no "$(pv regex_not_search)" check_eq $P/regex-not-search-errors "{\"a\":[\"value does not match regex '[0-9]+'\"]}" "$(pv regex_not_search_errors)" check_eq $P/regex-whole-match yes "$(pv regex_whole_match)" check_eq $P/regex-whole-match-errors '{}' "$(pv regex_whole_match_errors)" check_eq $P/regex-non-string-skipped yes "$(pv regex_non_string_skipped)" check_eq $P/regex-non-string-skipped-errors '{}' "$(pv regex_non_string_skipped_errors)" # ======================================================== dependencies ======== # A DEPENDENCY IS ONLY CHECKED WHEN THE DEPENDENT FIELD IS PRESENT, and a # MAPPING dependency constrains the other field's VALUE rather than just its # presence. check_eq $P/dependencies-met yes "$(pv dependencies_met)" check_eq $P/dependencies-met-errors '{}' "$(pv dependencies_met_errors)" check_eq $P/dependencies-missing no "$(pv dependencies_missing)" check_eq $P/dependencies-missing-errors "{\"a\":[\"field 'b' is required\"]}" "$(pv dependencies_missing_errors)" check_eq $P/dependencies-absent-ok yes "$(pv dependencies_absent_ok)" check_eq $P/dependencies-absent-ok-errors '{}' "$(pv dependencies_absent_ok_errors)" check_eq $P/dependencies-value-ok yes "$(pv dependencies_value_ok)" check_eq $P/dependencies-value-ok-errors '{}' "$(pv dependencies_value_ok_errors)" check_eq $P/dependencies-value-bad no "$(pv dependencies_value_bad)" check_eq $P/dependencies-value-bad-errors "{\"a\":[\"depends on these values: {'b': ['yes']}\"]}" "$(pv dependencies_value_bad_errors)" # ============================================================ excludes ======== # `excludes' IS NOT SYMMETRIC: it is checked only when the field DECLARING it is # present. With the rule on `b' alone, a document carrying only `a' is accepted # and the same document with `b' added is not -- and the message is phrased from # the declaring field's side both times. check_eq $P/excludes-ok yes "$(pv excludes_ok)" check_eq $P/excludes-ok-errors '{}' "$(pv excludes_ok_errors)" check_eq $P/excludes-bad no "$(pv excludes_bad)" check_eq $P/excludes-bad-errors "{\"a\":[\"'b' must not be present with 'a'\"]}" "$(pv excludes_bad_errors)" check_eq $P/excludes-one-sided-ok yes "$(pv excludes_one_sided_ok)" check_eq $P/excludes-one-sided-ok-errors '{}' "$(pv excludes_one_sided_ok_errors)" check_eq $P/excludes-one-sided-bad no "$(pv excludes_one_sided_bad)" check_eq $P/excludes-one-sided-bad-errors "{\"b\":[\"'a' must not be present with 'b'\"]}" "$(pv excludes_one_sided_bad_errors)" # ======================================================== sub-documents ======= # THE PATH IS WHAT MATTERS. An error inside a sub-document is filed under the # outer field and then under the inner one, so a caller can point at the # offending field rather than at the document -- and the nesting recurses, which # `schema-nested-two-deep' is here to show rather than assume. check_eq $P/schema-dict-ok yes "$(pv schema_dict_ok)" check_eq $P/schema-dict-ok-errors '{}' "$(pv schema_dict_ok_errors)" check_eq $P/schema-dict-bad no "$(pv schema_dict_bad)" check_eq $P/schema-dict-bad-errors '{"a":[{"b":["must be of integer type"]}]}' "$(pv schema_dict_bad_errors)" check_eq $P/schema-dict-missing-inner no "$(pv schema_dict_missing_inner)" check_eq $P/schema-dict-missing-inner-errors '{"a":[{"b":["required field"]}]}' "$(pv schema_dict_missing_inner_errors)" check_eq $P/schema-nested-two-deep no "$(pv schema_nested_two_deep)" check_eq $P/schema-nested-two-deep-errors '{"a":[{"b":[{"c":["must be of integer type"]}]}]}' "$(pv schema_nested_two_deep_errors)" # ========================================================= list items ========= # THE SAME `schema' KEYWORD APPLIED TO A LIST validates every ITEM, and the path # key is the INDEX. It arrives as the JSON object key "1" because that is what a # dict keyed by the integer 1 renders as -- the key is an int in the errors # structure itself. check_eq $P/schema-list-ok yes "$(pv schema_list_ok)" check_eq $P/schema-list-ok-errors '{}' "$(pv schema_list_ok_errors)" check_eq $P/schema-list-bad no "$(pv schema_list_bad)" check_eq $P/schema-list-bad-errors '{"a":[{"1":["must be of integer type"]}]}' "$(pv schema_list_bad_errors)" check_eq $P/schema-list-of-dicts no "$(pv schema_list_of_dicts)" check_eq $P/schema-list-of-dicts-errors '{"a":[{"1":[{"b":["must be of integer type"]}]}]}' "$(pv schema_list_of_dicts_errors)" # `items' IS POSITIONAL AND FIXES THE LENGTH, which is the difference between it # and `schema'; `contains' asks only that a member is there; and # `keysrules'/`valuesrules' are the mapping equivalents. # # `keysrules-bad' ALSO SHOWS THE IMPLICIT `$' from the regex section above: the # pattern `^k' becomes `^k$', so the key `k1' -- which any reader would call a # match -- fails alongside `x2'. `keysrules-ok' is the same schema written to # mean what the reader thought. check_eq $P/items-ok yes "$(pv items_ok)" check_eq $P/items-ok-errors '{}' "$(pv items_ok_errors)" check_eq $P/items-wrong-type no "$(pv items_wrong_type)" check_eq $P/items-wrong-type-errors '{"a":[{"1":["must be of integer type"]}]}' "$(pv items_wrong_type_errors)" check_eq $P/items-wrong-length no "$(pv items_wrong_length)" check_eq $P/items-wrong-length-errors '{"a":["length of list should be 1, it is 2"]}' "$(pv items_wrong_length_errors)" check_eq $P/contains-ok yes "$(pv contains_ok)" check_eq $P/contains-ok-errors '{}' "$(pv contains_ok_errors)" check_eq $P/contains-bad no "$(pv contains_bad)" check_eq $P/contains-bad-errors "{\"a\":[\"missing members {'x'}\"]}" "$(pv contains_bad_errors)" check_eq $P/keysrules-bad no "$(pv keysrules_bad)" check_eq $P/keysrules-bad-errors "{\"a\":[{\"k1\":[\"value does not match regex '^k'\"],\"x2\":[\"value does not match regex '^k'\"]}]}" "$(pv keysrules_bad_errors)" check_eq $P/keysrules-ok yes "$(pv keysrules_ok)" check_eq $P/keysrules-ok-errors '{}' "$(pv keysrules_ok_errors)" check_eq $P/valuesrules-bad no "$(pv valuesrules_bad)" check_eq $P/valuesrules-bad-errors '{"a":[{"k":["must be of integer type"]}]}' "$(pv valuesrules_bad_errors)" # ========================================= anyof / oneof / allof / noneof ===== # 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. # # TWO MATCHES IS A FAILURE for `oneof' -- the rule that catches an overlapping # schema, and the one callers most often reach for when they meant `anyof'. When # it fails because two branches matched there are no per-branch messages at all, # because no branch produced an error; `oneof-two-match-errors' is that shorter # structure, and it is a different shape from `oneof-none-match-errors'. check_eq $P/anyof-first yes "$(pv anyof_first)" check_eq $P/anyof-first-errors '{}' "$(pv anyof_first_errors)" check_eq $P/anyof-second yes "$(pv anyof_second)" check_eq $P/anyof-second-errors '{}' "$(pv anyof_second_errors)" check_eq $P/anyof-neither no "$(pv anyof_neither)" check_eq $P/anyof-neither-errors '{"a":["no definitions validate",{"anyof definition 0":["must be of integer type"],"anyof definition 1":["must be of string type"]}]}' "$(pv anyof_neither_errors)" check_eq $P/oneof-exactly-one yes "$(pv oneof_exactly_one)" check_eq $P/oneof-exactly-one-errors '{}' "$(pv oneof_exactly_one_errors)" check_eq $P/oneof-two-match no "$(pv oneof_two_match)" check_eq $P/oneof-two-match-errors '{"a":["none or more than one rule validate"]}' "$(pv oneof_two_match_errors)" check_eq $P/oneof-none-match no "$(pv oneof_none_match)" check_eq $P/oneof-none-match-errors '{"a":["none or more than one rule validate",{"oneof definition 0":["must be of integer type"],"oneof definition 1":["must be of integer type"]}]}' "$(pv oneof_none_match_errors)" check_eq $P/allof-ok yes "$(pv allof_ok)" check_eq $P/allof-ok-errors '{}' "$(pv allof_ok_errors)" check_eq $P/allof-one-fails no "$(pv allof_one_fails)" check_eq $P/allof-one-fails-errors "{\"a\":[\"one or more definitions don't validate\",{\"allof definition 1\":[\"min value is 10\"]}]}" "$(pv allof_one_fails_errors)" check_eq $P/noneof-ok yes "$(pv noneof_ok)" check_eq $P/noneof-ok-errors '{}' "$(pv noneof_ok_errors)" check_eq $P/noneof-one-matches no "$(pv noneof_one_matches)" check_eq $P/noneof-one-matches-errors '{"a":["one or more definitions validate"]}' "$(pv noneof_one_matches_errors)" # ======================================================== allow_unknown ======= # BOTH SETTINGS, because the default is the strict one and a caller who does not # know that gets an `unknown field' they never asked for. # # `allow_unknown' CAN ITSELF BE A SCHEMA, in which case unknown keys are # validated against it rather than waved through; as a RULE inside a # sub-document it applies only there; and `purge_unknown' REMOVES rather than # rejects, which is a document change and is asserted as one. check_eq $P/unknown-default no "$(pv unknown_default)" check_eq $P/unknown-default-errors '{"z":["unknown field"]}' "$(pv unknown_default_errors)" check_eq $P/unknown-allowed yes "$(pv unknown_allowed)" check_eq $P/unknown-allowed-errors '{}' "$(pv unknown_allowed_errors)" check_eq $P/unknown-schema-ok yes "$(pv unknown_schema_ok)" check_eq $P/unknown-schema-ok-errors '{}' "$(pv unknown_schema_ok_errors)" check_eq $P/unknown-schema-bad no "$(pv unknown_schema_bad)" check_eq $P/unknown-schema-bad-errors '{"z":["must be of integer type"]}' "$(pv unknown_schema_bad_errors)" check_eq $P/unknown-nested-rule yes "$(pv unknown_nested_rule)" check_eq $P/unknown-nested-rule-errors '{}' "$(pv unknown_nested_rule_errors)" check_eq $P/purge-unknown yes "$(pv purge_unknown)" check_eq $P/purge-unknown-errors '{}' "$(pv purge_unknown_errors)" check_eq $P/purge-unknown-doc '{"a":"x"}' "$(pv purge_unknown_doc)" # ============================================================ rename ========== check_eq $P/rename yes "$(pv rename)" check_eq $P/rename-errors '{}' "$(pv rename_errors)" check_eq $P/rename-doc '{"new":1}' "$(pv rename_doc)" # ============================================================ readonly ======== check_eq $P/readonly-present no "$(pv readonly_present)" check_eq $P/readonly-present-errors '{"a":["field is read-only"]}' "$(pv readonly_present_errors)" check_eq $P/readonly-absent yes "$(pv readonly_absent)" check_eq $P/readonly-absent-errors '{}' "$(pv readonly_absent_errors)" # ==================================================== the extension point ===== # A `_validate_' METHOD ON A SUBCLASS BECOMES A RULE, and its DOCSTRING is # that rule's own schema -- cerberus parses it. So the docstring is not # decoration, and the subclass's rule table is built per class: the base # Validator is left untouched, which `base-rule-table-unchanged' asserts because # a leak there would make every other suite on this image non-deterministic. # # `check_with' reaches the same mechanism by NAME rather than by rule keyword. check_eq $P/custom-rule-registered yes "$(pv custom_rule_registered)" check_eq $P/base-rule-table-unchanged yes "$(pv base_rule_table_unchanged)" check_eq $P/custom-rule-pass yes "$(pv custom_rule_pass)" check_eq $P/custom-rule-pass-errors '{}' "$(pv custom_rule_pass_errors)" check_eq $P/custom-rule-fail no "$(pv custom_rule_fail)" check_eq $P/custom-rule-fail-errors '{"a":["must be an odd number"]}' "$(pv custom_rule_fail_errors)" check_eq $P/check-with-pass yes "$(pv check_with_pass)" check_eq $P/check-with-fail no "$(pv check_with_fail)" check_eq $P/check-with-fail-errors '{"a":["thirteen is not allowed"]}' "$(pv check_with_fail_errors)" # A CUSTOM TYPE is the other half: `types_mapping' takes a TypeDefinition and # the `type' rule then accepts the new name, with the ordinary `must be of # type' message generated from it. check_eq $P/custom-type-pass yes "$(pv custom_type_pass)" check_eq $P/custom-type-fail no "$(pv custom_type_fail)" check_eq $P/custom-type-fail-errors '{"a":["must be of objectid type"]}' "$(pv custom_type_fail_errors)" check_eq $P/typedefinition-fields name,included_types,excluded_types "$(pv 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, and the # message names the field and the rule, which is what makes it actionable. # # A SCHEMA SET LATE IS CHECKED TOO, so the guarantee is about the attribute # rather than about the constructor. # # A CUSTOM RULE WITHOUT A DOCSTRING IS THE EXCEPTION AND IT 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. Omitting the docstring silently WIDENS the # rule rather than breaking it, which is worth one case of its own. # # A DOCUMENT THAT IS NOT A MAPPING IS A DocumentError -- the other exception, # and the reason both exist: one is about the caller's schema, the other about # the caller's data. check_eq $P/schema-unknown-rule '!SchemaError' "$(pv schema_unknown_rule)" check_eq $P/schema-bad-type-name '!SchemaError' "$(pv schema_bad_type_name)" check_eq $P/schema-rule-wrong-type '!SchemaError' "$(pv schema_rule_wrong_type)" check_eq $P/schema-not-a-mapping '!SchemaError' "$(pv schema_not_a_mapping)" check_eq $P/schema-custom-rule-no-docstring constructed "$(pv schema_custom_rule_no_docstring)" check_eq $P/schema-custom-rule-no-docstring-warning "No validation schema is defined for the arguments of rule 'bare'" "$(pv schema_custom_rule_no_docstring_warning)" check_eq $P/schema-error-message "{'a': [{'nosuchrule': ['unknown rule']}]}" "$(pv schema_error_message)" check_eq $P/schema-late-assignment '!SchemaError' "$(pv schema_late_assignment)" check_eq $P/document-not-a-mapping '!DocumentError' "$(pv document_not_a_mapping)" check_eq $P/document-none '!DocumentError' "$(pv document_none)" # ============================================================ registries ====== # A NAMED SCHEMA can be referenced from another schema by name, which is how a # recursive definition is written at all. Both registries are module-level # singletons, so these two cases also assert that this image's copy has writable # ones rather than a frozen import. check_eq $P/registry-ok yes "$(pv registry_ok)" check_eq $P/registry-bad no "$(pv registry_bad)" check_eq $P/registry-bad-errors '{"p":[{"name":["must be of string type"]}]}' "$(pv registry_bad_errors)" check_eq $P/rules-set-ok yes "$(pv rules_set_ok)" check_eq $P/rules-set-bad no "$(pv rules_set_bad)" # ==================================================== error introspection ===== # `_errors' CARRIES THE PATH AS A TUPLE, which is the machine-readable form of # what `errors' renders as nesting. 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, so both levels are asserted. check_eq $P/error-path '[["a"]]' "$(pv error_path)" check_eq $P/error-rule '["schema"]' "$(pv error_rule)" check_eq $P/error-schema-path '[["a","schema"]]' "$(pv error_schema_path)" check_eq $P/error-child-path '[["a","b"]]' "$(pv error_child_path)" check_eq $P/error-child-rule '["type"]' "$(pv error_child_rule)" # `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. # `normalized()' normalises WITHOUT validating, so a document that would fail # still comes back with its defaults applied. # # THE VALIDATOR IS REUSABLE and its state is reset per call: a second validate() # must not carry the first one's errors, which is the bug a caller who holds one # Validator for the life of a process would meet first. check_eq $P/validated-ok '{"a":1}' "$(pv validated_ok)" check_eq $P/validated-bad None "$(pv validated_bad)" check_eq $P/normalized-only '{"a":9,"b":"x"}' "$(pv normalized_only)" check_eq $P/reuse-errors-cleared '{}' "$(pv reuse_errors_cleared)" # ========================================================== 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. The pair here is the # same document and the same schema, differing only in that flag. check_eq $P/update-skips-required no "$(pv update_skips_required)" check_eq $P/update-skips-required-errors '{"a":["required field"]}' "$(pv update_skips_required_errors)" check_eq $P/update-true yes "$(pv update_true)" check_eq $P/update-true-errors '{}' "$(pv update_true_errors)" rm -rf "$W"