. "$BST_LIB" # BST-TIMEOUT: 1200 # THREE python3 starts, and they are the whole cost. # # Everything else reads the filesystem or dpkg with shell # # builtins, or greps one probe transcript. # # # # The three: a bare `import classes', a second import whose # # STDERR is the assertion, and the probe -- which takes ONE # # start for 95 measurements, the package's own 50 doctests # # among them. This package ships NO executable, so unlike # # 99-python3-docopt there is no shipped program to run as a # # real process and no exit status to assert: nothing here # # needs a fourth start. # # # # The work inside a start is a few hundred dict lookups and # # isinstance() calls over values of two or three elements, # # plus doctest over 21 KB of docstrings -- 0.03s on the # # x86-64 control. Microseconds beside an interpreter start # # on this arm, which is tens of seconds before the first # # line of the program runs. # # # # 1200 is 400s per start, against the ~60s a python3 start # # has measured at here. The start dominates and nothing in # # this file is quadratic in anything. # # # # NOT MEASURED ON TARGET YET: this port's first on-target # # run is the batch verify. The measured elapsed goes in the # # test plan rather than in this comment, which would cost an # # image build to keep the shipped suite byte-identical. # # python3-classes 0.4.1 (meta-openembedded/meta-python # recipes-devtools/python/python3-classes_0.4.1.bb, `inherit pypi setuptools3', # PyPI sdist) on the wasm32-hwjs glibc image. # # ============================================================================ # WHAT THE PACKAGE DOES. # ============================================================================ # # One function, dispatched on the runtime type of its FIRST argument, with the # implementations registered separately from the definition: # # @typeclass # def to_json(instance) -> str: ... # # @to_json.instance(int) # def _int(instance: int) -> str: return str(instance) # # It is functools.singledispatch's problem solved a second time, with two things # singledispatch does not have -- a registry keyed by PROTOCOL rather than by # type, and a `delegate' registry whose membership test runs on every call, for # concrete generics like `List[int]' that no type key can express. Underneath, # the mro fallback IS singledispatch's: `_typeclass.py` imports the private # `functools._find_impl` and uses it unchanged. # # ============================================================================ # EVERY DISPATCH CASE ASSERTS WHICH IMPLEMENTATION RAN, NOT A VALUE. # ============================================================================ # # The whole subject here is that one call reaches one of several functions. A # case that asserted `to_json(1) == "1"' would pass if the call had reached the # default implementation and that had also returned "1" -- so every instance the # probe registers returns a tag naming ITSELF (`int:1', `list-of-int:3', # `exact-str:s'), and the assertion is on the tag. A dispatch that went to the # wrong branch cannot pass by returning an equal value. # # ============================================================================ # THE THREE REGISTRIES AND THEIR ORDER, MEASURED RATHER THAN ASSUMED. # ============================================================================ # # `__call__' tries delegates, then the type cache, then exact types, then # protocols, then the mro. The docstring states the order; the cases below # measure it, each with a value that matches TWO registries at once so that the # answer says which one won: # # delegate_beats_exact `[1]' matches the ListOfInt delegate AND the exact # `list' instance. Answer: the delegate. # protocol_exact_wins `"s"' matches the exact `str' instance AND nothing # else; a `str' registered exactly is reached without # the protocol registry being walked. # disp_bool_via_mro `True' has no instance of its own and `int' does. # Answer: the int implementation, through _find_impl. # # ============================================================================ # `.supports()` IS ORDER-DEPENDENT, AND THAT IS THIS PORT'S ONE FINDING. # ============================================================================ # # `typeclass.supports(X)` returns True for a type the typeclass does NOT # support, once a call for that type has already raised. Two lines cause it: # `__call__' caches the failure -- # # impl = self._dispatch(...) or default_implementation # self._dispatch_cache[instance_type] = impl # # -- and `.supports' opens with `if instance_type in self._dispatch_cache: # return True'. Its comment reads "if some type is already in the cache, it # means that it is not a delegate", which is true and is not the question being # answered. # # So a caller who guards a call with `.supports' has a guard that is correct # until anything else calls the same typeclass with the same unsupported type, # after which every such value goes through to a NotImplementedError. The # `TypeGuard' in its signature makes it read like a total predicate. # # UPSTREAM'S, NOT brintOS'S, AND NOT THIS ARM'S. The x86-64 control answers # identically. It is a defect in a third-party public package, and this project # files nothing against third-party public repositories -- so it is PINNED here # instead, in the `guard-*' cases, and a release that fixes it reds and says so. # # Every other `supports_*' key in the probe is emitted BEFORE any failing call # on the same typeclass, deliberately, so that this one defect is measured in # one place rather than smeared across a dozen cases that would each be right # for the wrong reason. # # ============================================================================ # WHAT IS PURE PYTHON HERE, AND WHY IT IS THE WHOLE REASON THIS PORTS. # ============================================================================ # # 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 however well it built. `classes' ships none -- the built wheel # says `Root-Is-Purelib: true' and its RECORD names no .so -- and the ONE # RDEPENDS outside the interpreter, python3-typing-extensions, is pure Python # too and has been on this image since BRINTOS-1745. So this port waited on # nothing and needed no bbappend, no patch, and no site answer: there is no # configure step and no C for one to reach. # # ============================================================================ # THE MYPY PLUGIN SHIPS AND CANNOT BE IMPORTED, WHICH IS CORRECT. # ============================================================================ # # 26 of the package's 29 .py files are `classes.contrib.mypy' -- a mypy plugin, # which is what makes the library's types check at all. Every module under it # imports mypy, which this image does not install, so none of them can be # imported here. That is not a defect: it is type-check-time code that a target # never runs, and the wheel ships it because PEP 561 packages ship their plugin # beside the library. It is asserted as a fact rather than left for a later # reader to find as a surprise -- `contrib-mypy-import' is the case, and # `contrib-package-imports' beside it is what locates the failure at the plugin # rather than at `classes.contrib'. # # ============================================================================ # THE PACKAGE'S OWN DOCTESTS ARE THE UPSTREAM SUITE. # ============================================================================ # # `classes' ships no test package -- setup.py names only the library and the # plugin -- but `_typeclass.py`'s docstrings are executable, and they ARE the # specification: the module docstring walks basic dispatch, the protocol # registry and a user-defined protocol, and `Supports' and `AssociatedType' # carry their own. 50 of them run inside the probe's own interpreter start. # # The ATTEMPTED count is asserted beside the failure count, because a doctest # run that collected nothing reports zero failures and reads green -- which is # what a stripped-docstring build would produce, taking the whole of this # evidence with it. # # The on-target result is not yet measured: the batch verify is this port's # first run on wasm32-hwjs. P=python3-classes # THE CANARY, which asserts nothing about python3-classes. # # A suite's FIRST BST result line is lost between the guest's printf and the # host's read -- per-suite, deterministic, and NOT a defect in the host tool # (BRINTOS-1149 established that and filed it as linux-in-the-browser#177). The # harness computes its pass count as total-minus-failures, so THE DROPPED LINE # IS COUNTED AS A PASS. A nothing-case therefore stands in that position and the # eaten line costs nothing. It sits ahead of the `require' guards deliberately, # because a `require' that skips would emit its skip as the first line and be # eaten too. # # Remove this case when linux-in-the-browser#177 is fixed. bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKGDIR=$SITE/classes DI=$SITE/classes-0.4.1.dist-info PKGLIST=/var/lib/dpkg/info/python3-classes.list PROBE=$BST_DATA/python3-classes-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-classes.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ------------------------------------------------------------- the payload --- # Where the package has to land. `import classes' further down is the functional # statement; these say WHICH copy answered, so a package importing out of some # other path on sys.path is distinguishable from one importing out of what this # image installed. check $P/site-packages-dir test -d "$SITE" # A PACKAGE, NOT A MODULE, and only three files of it are the library. A release # that flattened it would change the import surface without any behavioural case # noticing. check $P/package-dir test -d "$PKGDIR" check $P/package-init test -s "$PKGDIR/__init__.py" check $P/module-typeclass test -s "$PKGDIR/_typeclass.py" check $P/module-registry test -s "$PKGDIR/_registry.py" check $P/no-flat-module test ! -f "$SITE/classes.py" # The sizes are pinned rather than floored: a truncated or partially written # payload passes `test -s' and fails only here. Byte for byte as the sdist ships # them -- there is no patch of ours, so the installed file IS upstream's. check_eq $P/typeclass-size 19612 \ "$(wc -c < "$PKGDIR/_typeclass.py" 2>/dev/null | tr -d ' ')" check_eq $P/registry-size 1917 \ "$(wc -c < "$PKGDIR/_registry.py" 2>/dev/null | tr -d ' ')" check_eq $P/init-size 326 \ "$(wc -c < "$PKGDIR/__init__.py" 2>/dev/null | tr -d ' ')" # PEP 561's marker, and it is EMPTY BY DEFINITION -- `test -f', never `test -s'. # Its whole content is its existence: it is what tells a type checker that the # inline annotations in this package are to be trusted. A case using `test -s' # here would red on a correct package forever. check $P/py-typed test -f "$PKGDIR/py.typed" check_eq $P/py-typed-empty 0 \ "$(wc -c < "$PKGDIR/py.typed" 2>/dev/null | tr -d ' ')" # The precompiled bytecode for the three library modules, which `import-quiet' # below turns on. Asserted as present and non-empty rather than pinned, since a # .pyc header carries build-time state the source does not. check $P/pycache-init test -s "$PKGDIR/__pycache__/__init__.cpython-312.pyc" check $P/pycache-typeclass test -s "$PKGDIR/__pycache__/_typeclass.cpython-312.pyc" check $P/pycache-registry test -s "$PKGDIR/__pycache__/_registry.cpython-312.pyc" # THE MYPY PLUGIN SHIPS. See the header: it is 26 of the 29 .py files and none # of them is importable here. These say it is present to not be importable -- # an absence would otherwise make `contrib-mypy-import' pass for the wrong # reason, since a missing module raises an ImportError too. check $P/contrib-dir test -d "$PKGDIR/contrib" check $P/contrib-init test -s "$PKGDIR/contrib/__init__.py" check $P/contrib-mypy-dir test -d "$PKGDIR/contrib/mypy" check $P/contrib-plugin test -s "$PKGDIR/contrib/mypy/classes_plugin.py" # Four of the five subpackages mark themselves with a ZERO-BYTE `__init__.py', # which is the ordinary idiom and not a truncated install -- so presence is the # assertion for those, and the size is pinned beside it rather than left to # `test -s' to conflate an empty marker with a missing file. Only # `validate_instance' puts code in its own `__init__.py'. for _d in features semanal typeops validation; do check $P/contrib-subpkg-$_d test -f "$PKGDIR/contrib/mypy/$_d/__init__.py" check_eq $P/contrib-subpkg-$_d-size "0" \ "$(wc -c < "$PKGDIR/contrib/mypy/$_d/__init__.py" 2>/dev/null | tr -d ' ')" done check $P/contrib-subpkg-validation-validate_instance \ test -s "$PKGDIR/contrib/mypy/validation/validate_instance/__init__.py" # ------------------------------------------------------------- the package --- # dpkg KNOWS the package. Every case above would look identical if some other # package had installed the same files; this is the one that says # python3-classes is a real package this image installed rather than an # IMAGE_INSTALL line that resolved to nothing. sed -n '/^Package: python3-classes$/,/^$/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: 0.4.1-r0" cat "$W/st" check_out $P/pkg-arch "Architecture: wasm32" cat "$W/st" # THE DEPENDENCIES, as dpkg recorded them, and there are exactly two. The recipe # declares python3-typing-extensions and the pep517 machinery adds python3-core; # each is asserted by name, because "the Depends line is non-empty" would pass # with either missing. python3-typing-extensions is the interesting one -- it is # the only thing outside the interpreter this package needs, it is imported at # `classes' import time by _typeclass.py, and it has been on this image since # BRINTOS-1745. grep '^Depends:' "$W/st" > "$W/dep" 2>/dev/null || : > "$W/dep" check $P/pkg-depends-line test -s "$W/dep" check_out $P/pkg-depends-core "python3-core" cat "$W/dep" check_out $P/pkg-depends-typing-extensions "python3-typing-extensions" cat "$W/dep" # ...AND NOT mypy, which the shipped plugin imports. That is the RDEPENDS shape # the header describes, stated as a fact rather than left to be inferred from # `contrib-mypy-import' failing. check_eq $P/pkg-no-mypy-depend "" \ "$(grep -o 'python3-mypy' "$W/dep" 2>/dev/null)" # NO COMPILED EXTENSION ANYWHERE THE PACKAGE PUT ANYTHING. On this image a .so # could not be loaded at all, so this is the case that fails a version bump # which grew one -- at install rather than at `import'. # # BRINTOS-WORKAROUND: brintos/linux#73 remove -- read `$PKGLIST' directly when # the redirect stops failing. The list is copied to tmpfs first and never # redirected from the rootfs: a top-level stdin redirect on the virtiofs root # makes dup2(2) return 513 and ash EXITS, ending the suite silently. cat "$PKGLIST" > "$W/pkgf" 2>/dev/null || : > "$W/pkgf" check $P/pkg-list-present test -s "$W/pkgf" check_eq $P/pkg-no-so "" \ "$(grep -E '\.so($|\.)' "$W/pkgf" 2>/dev/null)" # ...AND NOTHING IN ${bindir}. classes declares no `scripts' and no # `console_scripts': it is a library with no CLI, so there is no executable and # no shebang to assert. Stated as a fact about the package rather than left to # be inferred from an absent case. check_eq $P/pkg-no-bin "" \ "$(grep -E '^/usr/s?bin/' "$W/pkgf" 2>/dev/null)" # THE FILE COUNTS, which are what catch a payload that changed shape without # changing any name this file spells out. Measured in the .deb this build # produced: 29 .py, one .pyc for each of them, the five dist-info files, and # `py.typed' -- 64 regular files in all. Three of the 29 are the library and the # other 26 are the mypy plugin, which is the ratio the header describes. _py=0; _pyc=0; _reg=0 while IFS= read -r _f; do [ -f "$_f" ] || continue _reg=$((_reg + 1)) case "$_f" in *.py) _py=$((_py + 1)) ;; *.pyc) _pyc=$((_pyc + 1)) ;; esac done < "$W/pkgf" check_eq $P/pkg-py-count 29 "$_py" check_eq $P/pkg-pyc-count 29 "$_pyc" check_eq $P/pkg-file-count 64 "$_reg" # EVERY REGULAR FILE THE PACKAGE SHIPS IS PYTHON OR ITS METADATA. The cases # above name the failures that are expected; this one catches the shape nobody # predicted -- a binary, a .pyd, a stray data file -- without having to # enumerate what it might be. # # A dpkg .list names DIRECTORIES too, and without a trailing slash, so each # entry is tested for being a regular file first. _bad="" while IFS= read -r _f; do [ -f "$_f" ] || continue case "$_f" in *.py|*.pyc) continue ;; */py.typed) continue ;; */__pycache__/*) continue ;; */*.dist-info/*) continue ;; esac _bad="$_bad $_f" done < "$W/pkgf" check_eq $P/pkg-only-python "" "$_bad" # ----------------------------------------------------------- the dist-info --- # The installed metadata. It is the ONLY statement of the version this package # carries: `classes' defines no `__version__' -- asserted below as # `has-dunder-version' -- so unlike python3-docopt there is no second reading to # cross-check against, and the suite says so rather than leaving the absent case # to read as an oversight. check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -s "$DI/METADATA" check $P/dist-info-record test -s "$DI/RECORD" check_out $P/dist-info-version "Version: 0.4.1" cat "$DI/METADATA" check_out $P/dist-info-name "Name: classes" cat "$DI/METADATA" # top_level.txt names the ONE importable this package installs; a wheel that # renamed the package would change it here first. check_out $P/dist-info-top-level "classes" cat "$DI/top_level.txt" # PURE PYTHON, AS THE WHEEL ITSELF DECLARES IT -- the packaging-side statement of # `pkg-no-so' above: no platform tag, no extension module, nothing that could # need the absent dynamic loader. check_out $P/dist-info-purelib "Root-Is-Purelib: true" cat "$DI/WHEEL" check_out $P/dist-info-tag "Tag: py3-none-any" cat "$DI/WHEEL" # THE LICENCE TEXT SHIPS, which is not automatic -- python3-isodate's dist-info # carries none. This recipe's LIC_FILES_CHKSUM is over the sdist's LICENSE and # the wheel carries it too, so there is a file here to check. check $P/dist-info-license test -s "$DI/LICENSE" check_out $P/dist-info-license-bsd "Redistribution and use in source and binary forms" \ cat "$DI/LICENSE" # RECORD lists every file the wheel wrote. Read the package's OWN manifest for # the no-.so claim as well as the filesystem, for the case where the two # disagree -- and the read's success is asserted first, since an absence # produced by a listing that did not happen is not one. check_eq $P/record-no-so "" \ "$(grep '\.so' "$DI/RECORD" 2>/dev/null)" # Pinned, so a wheel that grew or lost an artifact is read here rather than # discovered by some other case failing to mention it. 35 in the wheel this # build produced: 29 .py, py.typed, and the five dist-info entries including # RECORD's own self-reference. check_eq $P/record-entries 35 \ "$(grep -c . "$DI/RECORD" 2>/dev/null)" # --------------------------------------------------------- the interpreter --- # THE IMPORT, ON ITS OWN, BEFORE THE PROBE. If classes cannot be imported then # every probe case below reds at once and each names a different symptom; this # case is what says the cause is the import. It is also the cheapest possible # statement of the deliverable -- and, because _typeclass.py imports # typing_extensions at module level, the cheapest statement that # python3-typing-extensions is really here. check $P/import python3 -c 'import classes' # ...AND THE IMPORT SAYS NOTHING WHILE DOING IT. A statement about the shipped # bytecode rather than about style: a module compiled from SOURCE at import time # would emit whatever diagnostics 3.12 raises over this code, and the package # ships a precompiled .pyc precisely so the compiler never runs. A red here # means the shipped .pyc was REJECTED -- a stale hash, a python3 minor bump that # changed the magic, or a missing __pycache__ -- and every import on this image # is paying to recompile three modules. check_eq $P/import-quiet "" \ "$(python3 -c 'import classes' 2>&1)" # ---------------------------------------------------------------- the 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 # ONE interpreter start for everything that follows. bst_probe bounds it and # keeps BOTH streams: a probe that dies early otherwise leaves every case below # reporting got:'' and naming a symptom each, with the actual traceback nowhere. # The bound is generous because a python3 start on this arm is tens of seconds # before any work begins. bst_probe $P/probe-run "$W/probe" 600 python3 "$PROBE" OUT="$W/probe" # Read a single key. The probe emits each key at most once, so head -n 1 is # defensive rather than load-bearing; the `^' and `=' anchors keep `supports_int' # distinguishable from `supports_int_after'. pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } # THE EVIDENCE PLANE. If the probe emitted nothing, every case below is reading # an absent file: each would red with got:'' and the set would read exactly like # a broken library, when what actually happened is that no measurement was # taken. The word `unscorable' is PARSED -- brintos_local_verify re-measures a # suite that prints it on a fresh guest rather than trusting a shared boot's # numbers (BRINTOS-1644) -- so it goes in this structural check, not a comment. # # The cases below still print their reds: withdraw, do not hide. This case is # what says what those reds measure. 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 # ...and it ran to the END. Every key is emitted in file order, so a probe that # died two thirds of the way through would leave the earlier cases green and the # later ones reading got:'' -- which looks like a partial library rather than a # partial run. This is the case that tells those apart, and `emitted-count' is # what catches a key skipped in the middle. check_eq $P/probe-complete "yes" "$(pv probe_done)" check_eq $P/emitted-count "95" "$(pv emitted_count)" # The module answered out of the file this image installed, not from a stray # copy on sys.path -- which is what makes the payload cases above and the # behavioural cases below statements about the SAME classes. check_eq $P/module-answered "$PKGDIR/__init__.py" "$(pv module_file)" # ...and so did its one dependency. python3-typing-extensions installs a single # file at the root of site-packages (BRINTOS-1745), and this is what says the # `Depends' line above resolved to it rather than to something else on the path. check_eq $P/typing-extensions-answered "$SITE/typing_extensions.py" \ "$(pv typing_extensions_file)" # ------------------------------------------------------------- the identity --- check_eq $P/is-package "yes" "$(pv is_package)" # THE PUBLISHED SURFACE IS THREE NAMES, and `__init__.py' is 326 bytes of # `as'-imports for exactly that reason: the module docstring says the aliasing # is there so mypy's implicit_reexport rule accepts them. check_eq $P/public-names "AssociatedType,Supports,typeclass" "$(pv public_names)" check_eq $P/typeclass-module "classes._typeclass" "$(pv typeclass_module)" check_eq $P/submodule-typeclass "classes._typeclass" "$(pv submodule_typeclass)" check_eq $P/submodule-registry "classes._registry" "$(pv submodule_registry)" # The .pyc answered, not the .py -- the interpreter-side half of the # `pycache-*' cases above. check_eq $P/cached-bytecode "yes" "$(pv cached_bytecode)" # THE VERSION HAS EXACTLY ONE SOURCE, and that is worth stating rather than # discovering: no `__version__' attribute, so the dist-info read through # importlib.metadata is the whole of it. check_eq $P/has-dunder-version "no" "$(pv has_dunder_version)" check_eq $P/dist-version "0.4.1" "$(pv dist_version)" check_eq $P/dist-name "classes" "$(pv dist_name)" # ONE declared dependency, and it is typing-extensions. NORMALIZED, not pinned: # the Requires-Dist TEXT is written by whichever setuptools built the wheel -- # `typing_extensions (>=3.10,<5.0)' from the PyPI wheel, `typing-extensions # <5.0,>=3.10' from this build of the same sdist -- and neither spelling is a # fact about classes. That it is declared, and that it is the only one, is. check_eq $P/dist-requires-count "1" "$(pv dist_requires_count)" check_eq $P/dist-requires-te "yes" "$(pv dist_requires_te)" # ------------------------------------------------------- the typeclass object check_eq $P/tc-type "_TypeClass" "$(pv tc_type)" check_eq $P/tc-str '' "$(pv tc_str)" # NO `__repr__' OF ITS OWN, which is why every case here reads `str()'. object's # repr carries the instance address, so a case asserting it would red at random # on every boot. Asserted as a property of the class rather than worked around # in silence. check_eq $P/tc-has-repr "no" "$(pv tc_has_repr)" check_eq $P/tc-is-final "yes" "$(pv tc_is_final)" # The six slots ARE the object: signature, associated type, three registries and # the dispatch cache. `__slots__' with no `__dict__' is also why a typeclass # cannot be given attributes at run time. check_eq $P/tc-slots \ "_signature,_associated_type,_delegates,_exact_types,_protocols,_dispatch_cache" \ "$(pv tc_slots)" check_eq $P/tc-registries-empty "0,0,0" "$(pv tc_registries_empty)" # -------------------------------------------------- the default implementation # A typeclass with no instances is a function that raises, and the message names # the argument's `__qualname__'. This is the negative the package is built # around, and it is measured on the EMPTY typeclass before any registration -- # so a later red here cannot be a registration that leaked. check_eq $P/bare-supports-int "False" "$(pv tc_bare_supports_int)" check_eq $P/bare-int \ "NotImplementedError: Missing matched typeclass instance for type: int" \ "$(pv tc_bare_int)" # `None' is not special to the default implementation: it reports `NoneType', # the qualname of its type, like anything else. The contrast is with # `.instance(None)' below, where None IS special. check_eq $P/bare-none \ "NotImplementedError: Missing matched typeclass instance for type: NoneType" \ "$(pv tc_bare_none)" # --------------------------------------------------- two concrete instances -- # The ticket's core assertion: a value of each registered type reaches its OWN # implementation, and a value of an unregistered type raises. check_eq $P/dispatch-int "int:1" "$(pv disp_int)" check_eq $P/dispatch-str "str:a" "$(pv disp_str)" # `True' has no instance and `int' does, so it reaches the int implementation # through `functools._find_impl' -- singledispatch's mro walk, imported private # and used unchanged. The VALUE is what proves it: `int:1', not a bool case. check_eq $P/dispatch-bool-via-mro "int:1" "$(pv disp_bool_via_mro)" check_eq $P/dispatch-float-unmatched \ "NotImplementedError: Missing matched typeclass instance for type: float" \ "$(pv disp_float_unmatched)" # `.supports' for each, asked while the dispatch cache is still clean -- see the # header, and `guard-*' below for what happens when it is not. check_eq $P/supports-int "True" "$(pv supports_int)" check_eq $P/supports-str "True" "$(pv supports_str)" check_eq $P/supports-bool "True" "$(pv supports_bool)" check_eq $P/supports-float-clean "False" "$(pv supports_float_clean)" # `.instance' RETURNS THE IMPLEMENTATION UNCHANGED, which is what makes the # module-level name still callable and what makes stacking work. check_eq $P/instance-returns-impl "int:7" "$(pv instance_returns_impl)" # ----------------------------------------- the arguments after the first one -- # Dispatch is on the FIRST argument only; the rest are forwarded untouched, # positional and keyword alike. A typeclass whose signature took only the # dispatched argument would pass every other case in this file. check_eq $P/args-positional "n:5" "$(pv args_positional)" check_eq $P/args-keyword "n=5" "$(pv args_keyword)" # ------------------------------------------------------ stacked registration -- # The README registers one implementation for two types by stacking `.instance' # decorators. It works because of `instance-returns-impl' above, and the # registry size is what says two registrations really happened rather than one # overwriting the other. check_eq $P/stacked-int "number:2" "$(pv stacked_int)" check_eq $P/stacked-float "number:2.5" "$(pv stacked_float)" check_eq $P/stacked-registry-size "2" "$(pv stacked_registry_size)" # ---------------------------------------------- what `.instance' will accept -- # Exactly one of exact_type / protocol= / delegate=. Both refusals are measured, # because a package that silently accepted two would dispatch through a registry # the caller did not choose. check_eq $P/instance-no-argument \ "ValueError: At least one argument to \`.instance\` is required" \ "$(pv inst_no_argument)" check_eq $P/instance-two-arguments \ "ValueError: Only a single argument can be applied to \`.instance\`" \ "$(pv inst_two_arguments)" check_eq $P/invalid-arguments-msg \ "Only a single argument can be applied to \`.instance\`" \ "$(pv invalid_arguments_msg)" # `None' IS NOT THE SENTINEL -- a private `DefaultValueType' is -- so # `.instance(None)' is a REGISTRATION, for `type(None)'. That distinction is the # whole reason the sentinel exists, and without it a caller could not register # None at all. check_eq $P/default-value-name "DefaultValueType" "$(pv default_value_name)" check_eq $P/instance-none-registers "none-case" "$(pv inst_none_registers_nonetype)" check_eq $P/instance-none-key "NoneType" "$(pv inst_none_registry_key)" check_eq $P/instance-none-leaves-int \ "NotImplementedError: Missing matched typeclass instance for type: int" \ "$(pv inst_none_leaves_int)" # A CONCRETE GENERIC CANNOT BE AN EXACT TYPE. The refusal comes from # `isinstance(object(), typ)' inside `.instance' -- at REGISTRATION, not at the # first dispatch, which is the useful half: the error arrives where the mistake # was written. This is the failure `delegate=' exists to work around. check_eq $P/instance-generic-alias \ "TypeError: Subscripted generics cannot be used with class and instance checks" \ "$(pv inst_generic_alias)" # -------------------------------- the container path: a delegate for List[int] # The documented way to dispatch on a concrete generic: a delegate class whose # metaclass answers `isinstance', registered with `delegate='. `_dispatch_delegate' # runs it on EVERY call, ahead of the cache, because a delegate's answer cannot # be keyed by type -- `type([1]) is type(["a"])'. check_eq $P/delegate-registry-size "1" "$(pv delegate_registry_size)" # Both `.supports' answers with a clean cache. They agree, and the reason is # worth having: a list of strings does not match the delegate, but it DOES match # the exact `list' instance, so `True' here is not the delegate answering. check_eq $P/delegate-supports-ints "True" "$(pv delegate_supports_ints)" check_eq $P/delegate-supports-strs "True" "$(pv delegate_supports_strs)" check_eq $P/delegate-matches "list-of-int:3" "$(pv delegate_matches)" # The same runtime type, a different implementation -- which is the thing no # type-keyed dispatcher can do and the whole reason this registry exists. check_eq $P/delegate-falls-through "list:2" "$(pv delegate_falls_through)" # THE PRECEDENCE STATEMENT: `list' IS registered exactly, and the delegate still # wins for the values it claims. check_eq $P/delegate-beats-exact "list-of-int:1" "$(pv delegate_beats_exact)" # A DELEGATE IS NEVER CACHED, by construction. These two calls come after the # cache has been populated by the ones above; if the delegate's answer had been # cached under `list', one of them would now carry the other's tag. check_eq $P/delegate-recheck-ints "list-of-int:2" "$(pv delegate_recheck_ints)" check_eq $P/delegate-recheck-strs "list:1" "$(pv delegate_recheck_strs)" # ...and the cache holds exactly the ONE entry the exact `list' instance put # there, which is the same claim measured from the other side. check_eq $P/delegate-cache-size "1" "$(pv delegate_cache_size)" # -------------------------------------------------------- the protocol path -- # `protocol=' dispatches on structure rather than identity. The ticket's # accept/reject pair: a class implementing the protocol reaches the instance, # one that does not raises. check_eq $P/protocol-registry-size "1" "$(pv protocol_registry_size)" check_eq $P/protocol-supports-yes "True" "$(pv protocol_supports_yes)" # The reject half of `.supports', asked BEFORE the call that would poison the # cache. This is the case `guard-*' below shows turning into `True'. check_eq $P/protocol-supports-no "False" "$(pv protocol_supports_no)" check_eq $P/protocol-accepts "protocol:present" "$(pv protocol_accepts)" check_eq $P/protocol-rejects \ "NotImplementedError: Missing matched typeclass instance for type: WithoutField" \ "$(pv protocol_rejects)" # EXACT BEATS PROTOCOL, measured rather than assumed: `_dispatch' reads # `_exact_types' before it walks `_protocols'. check_eq $P/protocol-exact-wins "exact-str:s" "$(pv protocol_exact_wins)" # The stdlib's own protocols work identically, which is what makes this registry # useful without writing a Protocol first. check_eq $P/protocol-stdlib-sized "sized:2" "$(pv protocol_stdlib_sized)" check_eq $P/protocol-stdlib-unsized \ "NotImplementedError: Missing matched typeclass instance for type: int" \ "$(pv protocol_stdlib_unsized)" # --------------------------------------------- associated types and Supports -- # The second way to define a typeclass: `@typeclass(SomeAssociatedType)'. At RUN # TIME the associated type does two things and no more -- it appears in `str()', # and it is what `Supports[...]' names in an annotation. Everything else it does # is mypy-time. That is asserted rather than left as a gap, because a reader of # the docs could reasonably expect more. check_eq $P/assoc-tc-type "_TypeClass" "$(pv assoc_tc_type)" check_eq $P/assoc-str '' "$(pv assoc_str)" check_eq $P/assoc-dispatch "assoc-int:1" "$(pv assoc_dispatch)" check_eq $P/assoc-unmatched \ "NotImplementedError: Missing matched typeclass instance for type: float" \ "$(pv assoc_unmatched)" # THE `Supports' ACCEPT/REJECT PAIR. A function annotated `Supports[ToJson]' is # not narrowed or checked by anything at run time: the call reaches the # typeclass, and the TYPECLASS is what accepts or refuses. The reject half # therefore carries the typeclass's message, not one from `Supports' -- which is # the fact worth having. check_eq $P/supports-annotation-accepts "assoc-int:1" "$(pv supports_annotation_accepts)" check_eq $P/supports-annotation-rejects \ "NotImplementedError: Missing matched typeclass instance for type: float" \ "$(pv supports_annotation_rejects)" check_eq $P/supports-annotation-repr \ "classes._typeclass.Supports[__main__.ToJson]" \ "$(pv supports_annotation_repr)" check_eq $P/supports-is-final "yes" "$(pv supports_is_final)" check_eq $P/supports-slots "()" "$(pv supports_slots)" check_eq $P/supports-alias-origin "Supports" "$(pv supports_alias_origin)" # THE VARIADIC-GENERIC HACK is the one thing `AssociatedType' does at run time. # `__class_getitem__' forces `__parameters__' to the arity being subscripted and # puts it back in a `finally', so one class subscripts with any number of type # variables. THE RESTORATION is what needs a case: a leaked `__parameters__' # would break the NEXT subscription rather than the one that leaked it, so # `params-after' is the case that would catch it and `params-before' is what # gives it something to be equal to. check_eq $P/assoc-params-before "1" "$(pv assoc_params_before)" check_eq $P/assoc-variadic-one "AssociatedType" "$(pv assoc_variadic_one)" check_eq $P/assoc-variadic-three "3" "$(pv assoc_variadic_three)" check_eq $P/assoc-params-after "1" "$(pv assoc_params_after)" check_eq $P/assoc-slots "()" "$(pv assoc_slots)" # ...and a SUBCLASS keeps the arity it was declared with, which is the form the # docs actually tell a caller to write. check_eq $P/assoc-subclass-params "2" "$(pv assoc_subclass_params)" # ------------------------------------------------------------ re-registration # REGISTERING A TYPE THAT ALREADY HAS AN INSTANCE REPLACES IT, SILENTLY. # `.instance' decorator is a bare `registry[typ] = implementation': no # exception, no warning, no diagnostic anywhere, and the package's docs do not # say this either way. Pinned so a release that starts refusing -- which would # be the defensible choice -- reds here and says so, rather than changing which # implementation runs in somebody's program. check_eq $P/rereg-before "first" "$(pv rereg_before)" check_eq $P/rereg-warnings "0" "$(pv rereg_warnings)" # THE CACHE IS INVALIDATED, and that is the half a naive replacement would get # wrong. `rereg-before' populated `_dispatch_cache[int]' with the FIRST # implementation; the decorator clears the whole cache, so this call reaches the # second. Without the clear it would still answer `first' and the replacement # would be invisible until the process restarted. check_eq $P/rereg-after "second" "$(pv rereg_after)" check_eq $P/rereg-registry-size "1" "$(pv rereg_registry_size)" check_eq $P/rereg-registry-impl "_rereg_second" "$(pv rereg_registry_impl)" # The same invalidation on the ordinary path: a FAILED dispatch caches # `default_implementation' under the type, and registering that type afterwards # must displace it. Without this, a program that called before registering would # keep raising for the rest of its life. check_eq $P/cache-float-before \ "NotImplementedError: Missing matched typeclass instance for type: float" \ "$(pv cache_float_before)" check_eq $P/cache-float-after "float:1.5" "$(pv cache_float_after)" # A WeakKeyDictionary, so a registered type that goes away does not keep its # entry alive. Named because it is what makes `_dispatch_cache' safe to leave # unbounded. check_eq $P/cache-is-weak "WeakKeyDictionary" "$(pv cache_is_weak)" # ---------------------------------- the `.supports()' ordering defect, pinned # See the header for the mechanism and for why nothing is filed against it. # Three cases, in order, on a typeclass used for nothing else so that the CALL # is the only variable: # # before `.supports("a")' is False, correctly -- no `str' instance. # call the call raises, and caches `default_implementation' under `str'. # after `.supports("a")' is now True. Nothing was registered in between. # # `guard-supports-after' asserting `True' is asserting the DEFECT. It is written # that way on purpose: a suite that asserted the correct answer would red on # every boot and say nothing, while this one reds exactly when upstream fixes it. check_eq $P/guard-supports-before "False" "$(pv guard_supports_before)" check_eq $P/guard-call-raises \ "NotImplementedError: Missing matched typeclass instance for type: str" \ "$(pv guard_call_raises)" check_eq $P/guard-supports-after "True" "$(pv guard_supports_after)" # The supported type is unaffected, so this is a False that became True and not # a predicate that stopped working altogether. check_eq $P/guard-supports-int "True" "$(pv guard_supports_int)" # ...and the CALL still raises. So it is `.supports' that now disagrees with the # typeclass, rather than the dispatch having changed underneath both. check_eq $P/guard-call-still-raises \ "NotImplementedError: Missing matched typeclass instance for type: str" \ "$(pv guard_call_still_raises)" # ------------------------------------------- the package's own doctests, run -- # See the header. 50 executable examples in `_typeclass.py`'s docstrings, run # inside the probe's own interpreter start; they are the package's real # specification and it ships no other tests. # # THE ATTEMPTED COUNT IS THE ASSERTION, not just the failure total: a run that # collected nothing reports zero failures and reads green. # # Any failure's name and diff go to the probe's STDERR, which bst_probe keeps at # .err -- so a red here is diagnosable without a second boot. check_eq $P/doctest-attempted "50" "$(pv doctest_attempted)" check_eq $P/doctest-failed "0" "$(pv doctest_failed)" # ------------------------------------------------ the mypy plugin, unloaded -- # It ships (`contrib-*' above) and it cannot be imported here, because every # module under it imports mypy. Correct, not a defect -- see the header. # # THE IMPORT LAYER, NOT THE CLASS OR THE MESSAGE. A missing mypy raises # `ModuleNotFoundError'; a present-but-mismatched mypy raises plain # `ImportError' naming something inside it. Neither is a fact about `classes', # so the probe normalizes both to `importerror' and this asserts that. check_eq $P/contrib-mypy-import "importerror" "$(pv contrib_mypy_import)" # ...and `classes.contrib' itself DOES import, which is what locates the failure # at the plugin rather than at the subpackage above it. check_eq $P/contrib-package-imports "classes.contrib" "$(pv contrib_package_imports)" rm -rf "$W"