. "$BST_LIB" # BST-TIMEOUT: 1200 # TWO python3 starts, and they are the whole cost. Every # # other case below reads the filesystem or dpkg with shell # # builtins, or greps the output of the second start. # # # # The two are: a bare `import' (one case, so that an import # # failure is legible without reading a transcript) and the # # probe, which takes ONE start for forty-odd measurements. # # The work inside each is attribute lookups and method calls # # on a handful of small objects -- microseconds beside an # # interpreter start on this arm, which is tens of seconds # # before the first line of the program runs. # # # # NOTHING HERE SLEEPS, forks a child, opens a socket or # # starts a thread. The package is one method and the probe # # exercises it with objects it builds itself, so there is no # # wall-clock in the suite at all beyond the two starts. # # # # 1200 is 600s per start, ten times the ~60s a python3 start # # has measured at here and the same per-start budget # # 99-python3-cached-property gives its two. # # # # A suite-timeout red is a batch-wide cost, so if the two # # starts prove slower than that on target the RIGHT fix is # # this number, not fewer cases. # # # # 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-visitor 0.1.3 (meta-openembedded/meta-python # recipes-devtools/python/python3-visitor_0.1.3.bb, `inherit pypi setuptools3', # PyPI sdist) on the wasm32-hwjs glibc image. # # ============================================================================ # WHAT THE PACKAGE DOES, AND WHAT THAT MAKES THE SUITE ASSERT. # ============================================================================ # # The whole package is one class with one method. `Visitor.visit(node)' walks # `type(node).mro()' and calls the first `visit_' it finds on itself, # returning that method's return value; exhausting the MRO raises # `NotImplementedError'. There is no registry, no decorator and no state. # # So the interesting claim is never WHAT a visit returns -- the methods are the # caller's own and return whatever they like. It is WHICH METHOD THE WALK PICKS, # and every behavioural case below is therefore written to distinguish one # candidate method from another that a naive implementation would have chosen # instead. A case whose visitor has only one plausible method proves nothing and # is not worth its line. # # ---------------------------------------------------------------------------- # THERE IS NO `generic_visit'. THE MRO IS THE FALLBACK. # ---------------------------------------------------------------------------- # # This is the one thing about the package a reader is most likely to get wrong, # and this ticket's own description got it wrong: it specified a `generic_visit' # fallback and a documented error when a visitor lacks one. The shipped 0.1.3 # has no such attribute -- `generic-visit-absent' below is the witness, asserted # on both the class and an instance so that neither a class attribute nor a # `__getattr__' could supply one unnoticed. # # What plays that role instead is `visit_object'. `object' is the last entry in # every MRO, so a visitor that defines `visit_object' catches every node that # nothing more specific matched -- and one that does not gets the # `NotImplementedError'. `catchall-orphan' and `no-method-raises' are those two # halves, and `catchall-not-greedy' is what says the catch-all does not shadow a # specific method it sits behind in the MRO. # # ---------------------------------------------------------------------------- # THE TWO BRANCHES OF `visit', AND THE INCONSISTENCY BETWEEN THEM. # ---------------------------------------------------------------------------- # # `visit' begins `if isinstance(node, type): mro = node.mro()'. So handed a # CLASS OBJECT it walks that class's own MRO rather than its metaclass's, and # `visit_Expr' is reached for the class `Lit' itself. `class-node-dispatch' and # `instance-node-dispatch' assert the two branches against the same class. # # The failure path does NOT share that branch. The message is built from # `node.__class__.__name__', which for a class object is its METACLASS -- so a # failed visit of the class `Orphan' reports `No visitation method visit_type', # naming a method the walk never looked for. `class-node-error' pins that # wording. It is the package's own behaviour rather than a defect of this port, # it is asserted so a future release that fixed it is NOTICED rather than # silently changing what a caller's error handling sees, and it is measured -- # not inferred from the source. # # ---------------------------------------------------------------------------- # WHY A `None' ATTRIBUTE IS ITS OWN CASE. # ---------------------------------------------------------------------------- # # The loop reads `if meth is None: continue' -- `continue', not `break'. So an # attribute BOUND TO None is not a match and the walk carries on up the MRO, # which is how a subclass disables one level and falls through to its base's # method rather than raising. `none-attr-skipped' is the difference between the # two keywords, and it is invisible from every other case in this suite. # # ---------------------------------------------------------------------------- # WHERE THE EXPECTATIONS COME FROM. # ---------------------------------------------------------------------------- # # Every behavioural value asserted below was MEASURED on an x86-64 control at # the same version -- visitor 0.1.3 from the sdist whose sha256 the recipe pins, # under CPython 3.12.3, the same 3.12 this image ships. The control was then # re-run against THIS BUILD's own .deb payload and produced a byte-identical # transcript apart from the module's path, and that payload's `__init__.py' is # byte-identical to the sdist's. So a red here is this arm, not a version skew # and not a repackaging. # # THE RECURSION IS THE CALLER'S, NOT THE PACKAGE'S. `visit' dispatches exactly # one node and returns; it never descends. `walk-*' below is therefore a test of # the shape a real user writes -- a `visit_Tree' that loops over its children # calling `self.visit' -- and it asserts that every node is reached EXACTLY ONCE # and in the order that walk defines. It is included because "reaches every node # once" is what the ticket asks for, and because a dispatcher that re-entered a # node would satisfy every single-node case above. P=python3-visitor # ============================================================================ # The suite proper. # ============================================================================ # # `suite-canary' is emitted FIRST and unconditionally. A suite that dies before # its first result line is indistinguishable from one that was never scheduled, # so this one line is what separates "ran and found nothing" from "did not run". bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKGDIR=$SITE/visitor MOD=$PKGDIR/__init__.py DI=$SITE/visitor-0.1.3.dist-info PKGLIST=/var/lib/dpkg/info/python3-visitor.list PROBE=$BST_DATA/python3-visitor-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-visitor.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ---------------------------------------------------------------- payload --- # WHERE THE PACKAGING SAYS THE MODULE LANDS. These run before any import, so a # red here says the .deb is wrong rather than that the module misbehaves. check $P/site-packages-dir test -d "$SITE" check $P/package-dir test -d "$PKGDIR" check $P/module-present test -s "$MOD" # IT IS A PACKAGE, NOT A SINGLE MODULE. setup.py declares `find_packages()' # rather than `py_modules=', so the payload is a DIRECTORY with an `__init__.py' # and there is no top-level `visitor.py' beside it. Asserted BOTH ways, because # a release that flattened to one module would satisfy `import' and every # behavioural case below while relocating the whole payload. check $P/not-a-single-module test ! -e "$SITE/visitor.py" # THE SIZE IS PINNED RATHER THAN FLOORED: a truncated or partially written # payload passes `test -s' and fails only here. 2159 is the sdist's own byte # count, it is what the wheel's RECORD declares, and the shipped file was # verified byte-identical to the sdist's. check_eq $P/module-size 2159 \ "$(wc -c < "$MOD" 2>/dev/null | tr -d ' ')" # BST-REDIRECT-OK: forked -- inside $( ) # THE PACKAGE IS ONE FILE. `find_packages()' found exactly one package and it # contains only `__init__.py', so a release that grew a submodule is noticed # here rather than going unexercised by a suite that only ever imports the top # level. check_eq $P/package-py-count 1 \ "$(find "$PKGDIR" -maxdepth 1 -name '*.py' 2>/dev/null | wc -l | tr -d ' ')" # NO COMPILED EXTENSION SHIPS, and this is the case that would notice a release # that grew one. On this image a `.so' in site-packages is not a speed-up, it is # an ImportError: python3 here is one static binary with an empty lib-dynload # (BRINTOS-1476). Asserted over the payload rather than over the recipe. check_eq $P/no-so "" "$(find "$PKGDIR" -name '*.so' 2>/dev/null)" # The byte-compiled copy is part of the payload and is why `import' on this # target is a read rather than a parse of 2 KB of source. It lands in the # PACKAGE's own `__pycache__' rather than site-packages' -- which is what a # package does, as against the top-level module of `99-python3-cached-property' # -- and is pinned by path for that reason. check $P/pycache-present test -s "$PKGDIR/__pycache__/__init__.cpython-312.pyc" # NO EXECUTABLE. setup.py declares no `entry_points' and no `scripts', so # nothing lands in /usr/bin. Asserted so a future release that started shipping # one is noticed rather than silently unexercised. check $P/no-bin-script test ! -e /usr/bin/visitor # ------------------------------------------------------------- dist-info --- # The installed metadata, which is what `importlib.metadata' answers from and # the only carrier of the version -- the module itself has no `__version__' # (`version-attr' below asserts that absence). check $P/dist-info-dir test -d "$DI" check $P/dist-info-record test -s "$DI/RECORD" check $P/dist-info-metadata test -s "$DI/METADATA" check_eq $P/dist-info-top-level "visitor" \ "$(cat "$DI/top_level.txt" 2>/dev/null | tr -d ' \r')" # The LICENSE ships in the dist-info. The recipe's LIC_FILES_CHKSUM checksums # PKG-INFO's `License: MIT' line at build time; this is the runtime half -- # the file is actually on the image rather than merely having been checked. check $P/license-present test -s "$DI/LICENSE" # --------------------------------------------------------------- dpkg --- # The package is REGISTERED, not merely unpacked. A payload that arrived some # other way would pass every path case above and fail here. check $P/dpkg-list test -s "$PKGLIST" check $P/dpkg-lists-module grep -q "^$MOD\$" "$PKGLIST" # ------------------------------------------------------------------ import --- # ONE case for the bare import, so that an import failure is legible from the # console without reading the probe's transcript. check $P/import python3 -c 'import visitor' # ------------------------------------------------------------------- probe --- if [ ! -s "$PROBE" ]; then bst_notok $P/probe-present "probe missing at $PROBE" exit 0 fi bst_ok $P/probe-present # 900s, inside the 1200s suite budget so a hung probe is reported as a probe # overrun with the cases named rather than as a bare suite timeout. # # The probe EXITS 0 even when the import fails, so this case passing means "the # probe ran", not "the module works" -- `import-ok' below is what carries that. bst_probe $P/probe-run "$W/probe" 900 python3 "$PROBE" OUT="$W/probe" # pv : the value the probe emitted for , or empty. `head -n 1' because # a key is emitted once and a duplicate would otherwise concatenate; `cut -f2-' # because a value may legitimately contain `='. pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } # The probe reached its end. Every case below reads a value it printed, so a # probe that stopped early would otherwise turn into a run of empty-string # mismatches that all name the wrong thing. check_eq $P/probe-complete "yes" "$(pv probe_done)" _imp=$(pv import_ok) check_eq $P/import-ok "yes" "$_imp" if [ "$_imp" != "yes" ]; then # The module did not import. Skip -- not red -- every behavioural case, each # with the reason attached: they are all one fact, and thirty reds would bury # it rather than report it. `import' and `import-ok' above are the two reds # that carry the verdict, and the design doc attributes them. _why="visitor did not import: $(pv import_error)" for _c in probe-count module-file module-name is-package has-visitor \ visitor-is-class visitor-qualname visitor-module visitor-base \ version-attr has-all generic-visit-absent-class \ generic-visit-absent-instance public-methods \ exact-binop fallback-lit-to-expr exact-node basic-order \ node-identity self-identity returns-none returns-false \ returns-zero returns-empty-list catchall-orphan \ catchall-not-greedy no-method-raises error-mro \ class-node-dispatch instance-node-dispatch class-node-error \ none-attr-skipped child-inherits child-overrides \ parent-unchanged diamond-mro diamond-picks-left \ diamond-then-right walk-return walk-order walk-count \ walk-unique-count; do bst_skip $P/$_c "$_why" done exit 0 fi # The probe emitted every key it intended to. A probe that returned 0 having # skipped a section would otherwise leave those cases reading empty strings. check_eq $P/probe-count "44" "$(pv emitted_count)" # --------------------------------------------------------- module surface --- # WHERE THE IMPORT ACTUALLY CAME FROM. Pinned to the site-packages path so an # import satisfied by a stray copy on `sys.path' -- a leftover in the cwd, a # second install -- is a red here rather than a green suite testing the wrong # file. check_eq $P/module-file "$MOD" "$(pv module_file)" check_eq $P/module-name "visitor" "$(pv module_name)" check_eq $P/is-package "yes" "$(pv is_package)" check_eq $P/has-visitor "yes" "$(pv has_Visitor)" check_eq $P/visitor-is-class "yes" "$(pv Visitor_is_class)" check_eq $P/visitor-qualname "Visitor" "$(pv Visitor_qualname)" check_eq $P/visitor-module "visitor" "$(pv Visitor_module)" # IT IS A PLAIN `object' SUBCLASS -- no metaclass, no ABC, no registry. A # release that introduced one would change how `visit_' methods are found. check_eq $P/visitor-base "yes" "$(pv Visitor_base_is_object)" # NO `__version__' AND NO `__all__'. Both are asserted as ABSENCES because both # are what a reader assumes is there: the version lives only in the dist-info # (`dist-info-dir' above), and `from visitor import *' exports by the leading- # underscore rule rather than by a declared list. check_eq $P/version-attr "no" "$(pv has_version_attr)" check_eq $P/has-all "no" "$(pv has_all)" # THE FALLBACK HOOK THIS PACKAGE DOES NOT HAVE. See the header: the MRO is the # fallback and `visit_object' is the catch-all. Asserted on the class AND on an # instance, so that neither a class attribute nor a `__getattr__' could supply # one unnoticed. check_eq $P/generic-visit-absent-class "no" "$(pv has_generic_visit_on_class)" check_eq $P/generic-visit-absent-instance "no" "$(pv has_generic_visit_on_instance)" # THE PUBLIC SURFACE IS ONE METHOD. Pinned as an exact list rather than a # membership test, so a release that added a second entry point is noticed here # rather than shipping unexercised. check_eq $P/public-methods "visit" "$(pv public_methods)" # ------------------------------------------------------- dispatch basics --- # EXACT CLASS MATCH WINS. The visitor holds a method for BinOp and for both of # its ancestors, so a green here says the walk stops at the FIRST match rather # than running every match it finds or falling through to the most general. check_eq $P/exact-binop "R:BinOp" "$(pv exact_binop)" # MRO FALLBACK, ONE STEP. `Lit' has no `visit_Lit'; its base `Expr' has one. # This is the case the ticket described as `generic_visit' and it is in fact # ordinary MRO ascent. check_eq $P/fallback-lit-to-expr "R:Expr" "$(pv fallback_lit_to_expr)" check_eq $P/exact-node "R:Node" "$(pv exact_node)" # EXACTLY ONE METHOD RAN PER VISIT, and in the order the three visits were made. # The three cases above each read a return value, which a dispatcher that ALSO # called the ancestors' methods would still produce; this reads the visitor's # own log and is what rules that out. check_eq $P/basic-order "BinOp,Expr,Node" "$(pv basic_order)" # THE NODE REACHES THE METHOD and it is the same object -- not a copy, not the # class. Asserted by identity rather than equality, because a `__eq__' could # fake the latter. check_eq $P/node-identity "yes" "$(pv node_identity)" # `self' IS THE VISITOR THE CALL WAS MADE ON -- the methods are bound, so a # visitor's per-instance state is reachable from inside a visit. `walk-order' # below depends on this and would be inexplicable without it. check_eq $P/self-identity "yes" "$(pv self_identity)" # THE RETURN VALUE IS THE METHOD'S, UNALTERED. All four values below are FALSY, # which is the point: `return meth(node) or None', `return meth(node) or self' # and every other plausible corruption is invisible against a truthful return # and caught by exactly these. check_eq $P/returns-none "None" "$(pv returns_none)" check_eq $P/returns-false "False" "$(pv returns_false)" check_eq $P/returns-zero "0" "$(pv returns_zero)" check_eq $P/returns-empty-list "[]" "$(pv returns_empty_list)" # ------------------------------------------------------ the catch-all case --- # `visit_object' CATCHES WHAT NOTHING ELSE MATCHED, because `object' ends every # MRO. This is the package's `generic_visit' under another name. check_eq $P/catchall-orphan "object" "$(pv catchall_orphan)" # ...AND IT DOES NOT SHADOW A MORE SPECIFIC MATCH. The same visitor, a node its # `visit_Expr' covers: a walk that consulted `visit_object' first -- or that # scanned the visitor's own attributes rather than the node's MRO -- would # answer `object' here. check_eq $P/catchall-not-greedy "expr" "$(pv catchall_not_greedy)" # -------------------------------------------------- no method: the error --- # EXHAUSTING THE MRO RAISES, with the documented message naming the method that # was not found. Both the type and the exact wording are pinned: caller code # greps this string, and the wording is also what `class-node-error' below is # measured against. check_eq $P/no-method-raises \ "NotImplementedError:No visitation method visit_Orphan" \ "$(pv no_method_raises)" # THE ANCESTRY a caller's `except' clause sees. `NotImplementedError' derives # from `RuntimeError', which is the non-obvious half -- an `except RuntimeError' # in caller code catches a failed visit, and an `except ValueError' does not. check_eq $P/error-mro \ "NotImplementedError,RuntimeError,Exception,BaseException" \ "$(pv error_mro)" # ------------------------------------------- visiting a CLASS, not an instance --- # HANDED A CLASS, `visit' WALKS THAT CLASS'S OWN MRO. `isinstance(node, type)' # selects `node.mro()', so the class object `Lit' reaches `visit_Expr' and the # node the method receives IS the class -- `expr:Lit', not `expr:instance'. The # visitor also defines `visit_type', which is what the OTHER reading of this # branch would have called, so the two answers are distinguishable. check_eq $P/class-node-dispatch "expr:Lit" "$(pv class_node_dispatch)" # The instance of the same class takes the other branch, for contrast. check_eq $P/instance-node-dispatch "expr:instance" "$(pv instance_node_dispatch)" # THE ERROR MESSAGE AND THE DISPATCH DISAGREE FOR A CLASS NODE. Dispatch used # `node.mro()' above; the message is built from `node.__class__.__name__', which # for a class object is its METACLASS. So a failed visit of the class `Orphan' # reports `visit_type' -- naming a method the walk never looked for, and NOT # `visit_Orphan' as the instance case does. # # This is the package's own behaviour, not a defect of this port and not an # artefact of this arm: it is measured identically on the x86-64 control. It is # asserted so that a future release which fixed it is NOTICED, rather than # silently changing the string a caller's error handling reads. check_eq $P/class-node-error "No visitation method visit_type" \ "$(pv class_node_error)" # ------------------------------------------------- a None attribute is skipped --- # `if meth is None: continue' -- CONTINUE, NOT BREAK. A visitor that binds # `visit_Lit = None' to disable that level falls through to its base's # `visit_Expr' rather than raising `NotImplementedError'. The two keywords are # indistinguishable from every other case in this suite. check_eq $P/none-attr-skipped "expr-via-none-skip" "$(pv none_attr_skipped)" # ------------------------------------------------------ visitor subclassing --- # The lookup is a plain `getattr' on `self', so visitor inheritance is ORDINARY # ATTRIBUTE INHERITANCE. Three cases, because the interesting claim is the # interaction: the child INHERITS what it did not redefine, its OVERRIDE is what # a node both cover reaches, and the parent is UNAFFECTED -- which is what rules # out an implementation that had mutated a shared registry on the base class. check_eq $P/child-inherits "parent-node" "$(pv child_inherits)" check_eq $P/child-overrides "child-expr" "$(pv child_overrides)" check_eq $P/parent-unchanged "parent-expr" "$(pv parent_unchanged)" # ------------------------------------------------ multiple inheritance / C3 --- # THE WALK IS `type(node).mro()', SO IT FOLLOWS C3 LINEARISATION -- not # declaration order, and not a recursive left-to-right scan of `__bases__'. # `diamond-mro' pins the linearisation itself so the next two cases are read # against a stated order rather than an assumed one. check_eq $P/diamond-mro "Diamond,Left,Right,Base,object" "$(pv diamond_mro)" # A visitor holding a method for Left, Right AND Base reaches LEFT's, because # Left precedes Right in that linearisation. check_eq $P/diamond-picks-left "left" "$(pv diamond_picks_left)" # ...and with Left's method REMOVED the same node reaches Right's rather than # Base's. This is the case that makes the one above evidence: alone, `left' # is equally consistent with a walk that never got past the first base. check_eq $P/diamond-then-right "right" "$(pv diamond_then_right)" # ------------------------------------------------------------ recursive walk --- # THE RECURSION IS THE CALLER'S. `visit' dispatches one node and returns, so # this section tests the shape a real user writes: a `visit_Tree' that loops # over its children calling `self.visit', and a `visit_Leaf' that does not. # # The tree has 8 nodes over 3 levels, mixing both node types at each level, and # `Leaf' subclasses `Tree' -- so every leaf ALSO matches `visit_Tree' and is # reached by `visit_Leaf' only because the MRO prefers it. A dispatcher with # the fallback order reversed would recurse into the leaves and log `T:' for # them, which `walk-order' below distinguishes. check_eq $P/walk-return "root" "$(pv walk_return)" # EVERY NODE EXACTLY ONCE, IN PRE-ORDER, children left to right. This single # string carries the order, the node types and the tree shape together. check_eq $P/walk-order \ "T:root,T:a,L:a1,L:a2,L:b,T:c,T:c1,L:c1x" "$(pv walk_order)" # The count, asserted separately from the order: 8 entries for 8 nodes, and 8 # DISTINCT entries. A walk that re-entered a node -- the failure the ticket # names -- would push the first past 8 while the second stayed there, so the # pair distinguishes a duplicate visit from a missing one. check_eq $P/walk-count "8" "$(pv walk_count)" check_eq $P/walk-unique-count "8" "$(pv walk_unique_count)" rm -rf "$W"