. "$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 a hundred and thirty-odd # # measurements. The work inside each is attribute reads on # # 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, THREADS OR WAITS. `lazy' has # # no clock, no lock and no I/O, so there is no interval to # # wait out and no join to time out on. # # # # 1200 is 600s per start, ten times the ~60s a python3 # # start has measured at here and the same per-start budget # # 99-python3-annotated-types and 99-python3-cached-property # # both give their 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-lazy 1.6 (meta-openembedded/meta-python # recipes-devtools/python/python3-lazy_1.6.bb, `inherit pypi setuptools3', PyPI # sdist) on the wasm32-hwjs glibc image. # # ============================================================================ # WHAT THE PACKAGE DOES, AND WHAT THAT MAKES THE SUITE ASSERT. # ============================================================================ # # `lazy' is a NON-DATA descriptor -- one `__get__', no `__set__', no # `__delete__' -- and that single fact is the whole design. On the first read # `__get__' runs the wrapped function and writes the result into the INSTANCE's # `__dict__' under the attribute's own name; from then on the instance dict # SHADOWS the class attribute and later reads never enter `__get__' at all. # # So the interesting claim is never the VALUE -- it is WHEN THE WRAPPED FUNCTION # RUNS, and how many times. A decorator that recomputed on every read would # return the right answer every time and fail its entire purpose. Every # behavioural case below therefore reads a COUNTER that the wrapped function # increments. `plain-calls-after-5-reads' is the package in one line: five reads, # one call. # # `shadow-entries-after-third' is the mechanism the counter only implies. A # subclass of `lazy' counts entries into `__get__' itself, and after the first # read that count stops moving -- which is the difference between "the value was # cached" and "the descriptor was bypassed". Only the second is what a non-data # descriptor buys, and only the second is what makes a lazy attribute as cheap # as an ordinary one after the first read. # # ============================================================================ # THE THREE THINGS BESIDES THE CACHE. # ============================================================================ # # Each is a place a release could break without moving a counter, and each has # its own section: # # * `functools.wraps(func)(self)' in `__init__' copies the function's # `__module__', `__name__', `__qualname__', `__annotations__' and `__doc__' # onto the DESCRIPTOR INSTANCE and updates its `__dict__' from the # function's. That is what makes `C.foo.__doc__' the function's docstring # rather than the `lazy' class's own -- `desc-doc' and # `desc-doc-is-not-class-doc' are the pair, and the second is what would # notice `wraps' silently ceasing to run. # # * `__set_name__' then OVERWRITES `__name__' with the name the attribute was # bound under. In the ordinary decorator case the two agree, which is exactly # why they have to be measured apart: `renamed-*' binds one descriptor under # a second name and reads what the cache key becomes. # # * PRIVATE-NAME MANGLING, which is TWO mechanisms and not one -- see the next # block, because reading them as one gets the package wrong. # # ============================================================================ # THE TWO MANGLINGS, AND WHY THEY ARE ASSERTED APART. # ============================================================================ # # `lazy.__get__' re-derives Python's private-name mangling by hand: # `name.startswith("__") and not name.endswith("__")' -> `___name'. It is # natural to read that as the reason a lazy attribute spelled `__secret' caches # under `_M__secret'. IT IS NOT. # # * THE COMPILER does that. `def __secret' inside a class body binds # `_M__secret' in the class dict, so `__set_name__' is handed the ALREADY # mangled name and `__get__''s branch never fires -- the name it sees starts # with `_M', not with `__'. This is the path every ordinary use takes, and # `mangled-class-key-present' beside `mangled-func-name' is what says so: the # class key is mangled and the FUNCTION under it still is not. # # * `__get__''s OWN branch fires only when `__set_name__' was never called -- # a descriptor built and assigned AFTER the class body. `late-*' is that # path, and it is the only thing in this package that reads `owner'. # # The late path is worth its six cases because it behaves differently in a way # nothing else would catch: the lookup key stays `__hidden' while the cache key # is `_N__hidden', so the instance dict NEVER shadows the attribute and every # read re-enters `__get__'. It still caches -- `__get__' looks the mangled key up # itself -- but one level deeper than the ordinary case. # `late-calls-after-third' beside `shadow-entries-after-third' is that # difference, stated. # # `late-invalidate-error' is the one rough edge in the package, and it is # recorded rather than reported. `invalidate' mangles the name and then looks the # result up on the CLASS, where a late-assigned descriptor is bound under the # UNMANGLED name -- so the guard raises `AttributeError' before it can clear # anything, by EITHER spelling (`late-invalidate-mangled-error' is the other). # It is a defect in a third-party public project, which this team does not file # against (no-public-upstream-reports); it reaches nothing this image does, since # the late-assigned form is not how the decorator is used; and it is pinned so # that a release which fixes it is a red that says exactly what changed. # # ============================================================================ # WHAT INVALIDATION IS, AND WHY IT IS NOT `__delete__'. # ============================================================================ # # `lazy.invalidate(inst, name)' is a classmethod, and it is the only supported # way to drop a cached value. It is deliberately not a `__delete__': adding one # would make `lazy' a DATA descriptor, the instance dict would stop shadowing it, # and the package would lose its entire point. `has-set' and `has-delete' assert # those two absences for that reason, and `del-*' asserts that the raw route -- # `del inst.__dict__[name]', which is what `invalidate' does underneath -- forces # exactly one recomputation and no more. # # The classmethod is what makes the guard subclass-aware, and the asymmetry is # asserted both ways round: `lazy.invalidate' accepts a subclass's attribute # (`base-invalidate-subclass-attr'), and the subclass's own refuses a plain # `lazy' one (`subclass-invalidate-base-attr'). That is the whole reason # upstream's docstring calls invalidation a violation of the lazy contract that # "a subclass of lazy may however have a contract" for. # # ============================================================================ # THE PACKAGING, AND THE ONE THING IT SHIPS THAT NOTHING IMPORTS. # ============================================================================ # # `[options.packages.find] exclude' in setup.cfg drops `lazy.examples' and # `lazy.tests' from the wheel, so neither reaches the image -- asserted as an # absence in `no-tests-subpackage' / `no-examples-subpackage', because a release # that stopped excluding them would ship a test suite onto a production image and # nothing else here would notice. # # `[options.package_data]' names `lazy.pyi' and `py.typed', so the TYPE STUB # ships. Nothing on this image reads it -- there is no mypy here -- and it is # pinned anyway, in two places: on the filesystem (`pyi-present', `py-typed') # and as the interpreter sees it (`pkgdir-has-pyi'), which is what says it landed # INSIDE the importable package rather than merely inside the .deb. # # THE DECLARED DEPENDENCY IS `python3-core' AND NOTHING ELSE, and that is # correct rather than short: the module imports `sys' and `functools', both of # which are in python3-core on this image. Recorded rather than repaired -- # trimming or extending an upstream recipe's RDEPENDS to restate what the image # already provides would be a divergence carried for ever, for no change in what # installs. # # NO BBAPPEND, NO PATCH, NO SITE ANSWER. `bitbake python3-lazy' completed 1603 of # 1603 tasks with zero ERROR lines against a warm sstate, 2026-09-02. P=python3-lazy # ============================================================================ # 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 PKG=$SITE/lazy DI=$SITE/lazy-1.6.dist-info PKGLIST=/var/lib/dpkg/info/python3-lazy.list PROBE=$BST_DATA/python3-lazy-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-lazy.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ---------------------------------------------------------------- payload --- # WHERE THE PACKAGING SAYS THE PAYLOAD LANDS. These run before any import, so a # red here says the .deb is wrong rather than that the descriptor misbehaves. check $P/site-packages-dir test -d "$SITE" # IT IS A PACKAGE, NOT A MODULE -- the opposite of this image's other descriptor # port (99-python3-cached-property, one file). setup.cfg declares `packages = # find:', so the payload is a DIRECTORY with an `__init__.py' that re-exports the # class out of a submodule. Asserted BOTH ways -- the directory is there and a # top-level `lazy.py' beside it is not -- because a release that collapsed to one # module would satisfy `import' and every behavioural case below while relocating # the whole payload. check $P/package-dir test -d "$PKG" check $P/no-top-module test ! -e "$SITE/lazy.py" check $P/init-present test -s "$PKG/__init__.py" check $P/module-present test -s "$PKG/lazy.py" # THE SIZES ARE PINNED RATHER THAN FLOORED: a truncated or partially written # payload passes `test -s' and fails only here. Each number is what the wheel's # own RECORD declares, and each shipped file was verified byte-identical to the # sdist's. check_eq $P/init-size 129 "$(wc -c < "$PKG/__init__.py" 2>/dev/null | tr -d ' ')" # BST-REDIRECT-OK: forked -- inside $( ) check_eq $P/module-size 1869 "$(wc -c < "$PKG/lazy.py" 2>/dev/null | tr -d ' ')" # BST-REDIRECT-OK: forked -- inside $( ) # THE TYPE STUB AND ITS MARKER, both declared through `[options.package_data]'. # Nothing on this image reads either -- there is no mypy here -- and they are the # only payload files that are not code or metadata, so a release that stopped # shipping them would be invisible without these two. check_eq $P/pyi-size 787 "$(wc -c < "$PKG/lazy.pyi" 2>/dev/null | tr -d ' ')" # BST-REDIRECT-OK: forked -- inside $( ) check_eq $P/py-typed-size 65 "$(wc -c < "$PKG/py.typed" 2>/dev/null | tr -d ' ')" # BST-REDIRECT-OK: forked -- inside $( ) # THE EXCLUDED SUBPACKAGES. `[options.packages.find] exclude' names both, and # this is the filesystem half of the claim the probe's `no-*-subpackage' makes # through the interpreter. check $P/no-tests-dir test ! -d "$PKG/tests" check $P/no-examples-dir test ! -d "$PKG/examples" # 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 "$PKG" -name '*.so' 2>/dev/null)" # BST-REDIRECT-OK: forked -- inside $( ) # The byte-compiled copies are part of the payload and are why `import' on this # target is a read rather than a parse. They land in the PACKAGE's own # `__pycache__' rather than site-packages' -- which is what a package does, and # is the other half of `no-top-module' above. check $P/pycache-init test -s "$PKG/__pycache__/__init__.cpython-312.pyc" check $P/pycache-module test -s "$PKG/__pycache__/lazy.cpython-312.pyc" # NO EXECUTABLE. setup.cfg 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/lazy # --------------------------------------------------------------- dist-info --- check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -f "$DI/METADATA" check $P/dist-info-record test -s "$DI/RECORD" check $P/dist-info-wheel test -f "$DI/WHEEL" check_out $P/dist-info-version "Version: 1.6" cat "$DI/METADATA" check_out $P/dist-info-name "Name: lazy" cat "$DI/METADATA" # THE LICENSE LANDS AT THE ROOT of dist-info, not under `licenses/'. That is # setuptools' older layout, and it is where the recipe's LIC_FILES_CHKSUM md5 # ultimately points. It is pinned because it is the one packaging detail this # build does NOT share with a modern host toolchain: a control install under # setuptools 84 writes `licenses/LICENSE' and `Metadata-Version: 2.4', and this # build writes `LICENSE' and `2.1'. Both are recorded so that a setuptools bump # in the layer is a legible red rather than a mystery. check $P/license-file test -s "$DI/LICENSE" check $P/no-licenses-dir test ! -d "$DI/licenses" check_out $P/metadata-version "Metadata-Version: 2.1" cat "$DI/METADATA" # `top_level.txt' NAMES THE PACKAGE. Distribution and import name coincide here, # unlike most of this image's Python ports, and this file is the only place in # the payload that states the mapping at all. check_out $P/top-level "lazy" cat "$DI/top_level.txt" # PURELIB, STATED BY THE PACKAGING ITSELF -- and the wheel is UNIVERSAL: # setup.cfg carries `[bdist_wheel] universal = true', so bdist_wheel stamps TWO # tags, `py2-none-any' AND `py3-none-any'. The py2 tag is dead weight on an image # whose only interpreter is 3.12, but it is what the package claims about itself, # and a release that dropped py2 support would change it. Both tags are pinned # separately so that losing one is one red rather than a rewrite. check_out $P/wheel-purelib "Root-Is-Purelib: true" cat "$DI/WHEEL" check_out $P/wheel-tag-py2 "Tag: py2-none-any" cat "$DI/WHEEL" check_out $P/wheel-tag-py3 "Tag: py3-none-any" cat "$DI/WHEEL" check_out $P/wheel-generator "Generator: bdist_wheel (0.42.0)" cat "$DI/WHEEL" # NOTHING IS REQUIRED AT RUNTIME, and the METADATA says so in a way that needs # reading carefully: there ARE three `Requires-Dist' lines, and every one of them # is behind an `extra ==' marker (`docs' and `mypy'). So the unconditional # requirement set is empty while the line count is not, which is why this is # pinned as a COUNT of three plus a count of zero unmarked lines rather than as # an absence. A release that promoted one of those to a hard dependency would # move the second number and nothing else here. check_eq $P/meta-requires-count "3" \ "$(grep -c '^Requires-Dist' "$DI/METADATA" 2>/dev/null)" # BST-REDIRECT-OK: forked -- inside $( ) check_eq $P/meta-no-hard-requires "0" \ "$(grep '^Requires-Dist' "$DI/METADATA" 2>/dev/null | grep -vc 'extra ==')" # BST-REDIRECT-OK: forked -- inside $( ) check_out $P/meta-extra-docs "Provides-Extra: docs" cat "$DI/METADATA" check_out $P/meta-extra-mypy "Provides-Extra: mypy" cat "$DI/METADATA" # ------------------------------------------------------------ the .deb file --- # The installed file list, read once into $W. The copy is what the loop below # redirects from: a stdin redirect the top-level shell performs on the virtiofs # root makes dup2(2) return 513 and ENDS the suite silently (brintos/linux#73), # and $W is on tmpfs. cat "$PKGLIST" > "$W/pkgf" 2>/dev/null || : > "$W/pkgf" check $P/pkg-list-present test -s "$W/pkgf" # Every payload path is under site-packages and is source, bytecode, a type stub # or metadata. Anything else means the build produced something this port did not # expect and did not measure. `.pyi' and `py.typed' are admitted for the stub # pinned above. _bad="" while IFS= read -r _f; do case "$_f" in ''|/usr/lib/python3.12/site-packages) continue ;; */) continue ;; esac [ -d "$_f" ] && continue case "$_f" in *.py|*.pyc|*.pyi) ;; */py.typed) ;; */METADATA|*/WHEEL|*/RECORD|*/LICENSE) ;; */top_level.txt) ;; *) _bad="$_bad $_f" ;; esac done < "$W/pkgf" check_eq $P/pkg-only-expected "" "$_bad" # ------------------------------------------------------------- dpkg status --- sed -n '/^Package: python3-lazy$/,/^$/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-arch "Architecture: wasm32" cat "$W/st" check_out $P/pkg-version "Version: 1.6-r0" cat "$W/st" # THE DEPENDENCY LINE, and it is SHORT because the module needs nothing else: # `sys' and `functools' are both in python3-core on this image. Pinned as the # whole line rather than as a substring so that an added dependency is a red # here rather than a silent change in what installs. grep '^Depends:' "$W/st" > "$W/dep" 2>/dev/null || : > "$W/dep" check $P/dep-line test -s "$W/dep" check_out $P/dep-python3-core "python3-core" cat "$W/dep" check_eq $P/dep-exact "Depends: python3-core" \ "$(cat "$W/dep" 2>/dev/null)" # BST-REDIRECT-OK: forked -- inside $( ) # ------------------------------------------------------------------ import --- # ONE CASE OF ITS OWN, and the first of two python3 starts. Pure Python, so this # is expected to be uneventful -- it is here so that an import failure is legible # without reading a probe transcript. check $P/import python3 -c 'from lazy import lazy' # ------------------------------------------------------------------- probe --- if [ ! -f "$PROBE" ]; then bst_notok $P/probe-present "probe missing at $PROBE" exit 0 fi bst_ok $P/probe-present # THE SECOND START, and the one that carries the suite. Bounded at 900s inside # the 1200s suite budget so a hung probe is reported as a probe overrun with the # last line it printed, rather than as a suite timeout that says nothing about # where it stopped. # # The probe EXITS 0 even when the import fails, so this case passing means "the # probe ran", not "the package 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 package did not import. Skip -- not red -- every behavioural case, each # with the reason attached: they are all one fact, and a hundred 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="lazy did not import: $(pv import_error)" for _c in probe-count pkg-file pkg-is-package submodule-file \ pkg-lazy-is-class pkg-lazy-is-attr submodule-in-sys-modules \ class-module class-qualname has-all all-names public-names \ version-attr dist-version \ has-get has-set has-delete has-set-name has-invalidate \ invalidate-is-classmethod \ cold-calls cold-in-dict plain-calls-after-5-reads \ plain-values-all-equal plain-value plain-in-dict plain-dict-value \ plain-dict-value-is-int plain-dict-keys \ shadow-entries-after-first shadow-entries-after-third shadow-calls \ subclass-is-lazy \ class-access-is-descriptor class-access-type class-access-calls \ class-access-stable \ desc-doc desc-name desc-qualname desc-module desc-has-wrapped \ desc-wrapped-is-func desc-wrapped-name desc-doc-is-not-class-doc \ class-doc-first-line desc-dict-keys wraps-assignments \ second-cold-in-dict second-calls second-value \ second-value-equals-first first-calls-untouched \ first-value-untouched class-dict-has-no-value \ del-clears-dict del-calls-after-reread del-value-after-reread \ del-calls-after-two-more-reads \ invalidate-clears-dict invalidate-calls-unchanged \ invalidate-calls-after-reread invalidate-value-after-reread \ invalidate-twice invalidate-uncached-again invalidate-non-lazy \ invalidate-missing-name subclass-invalidate-own \ base-invalidate-subclass-attr subclass-invalidate-base-attr \ mangled-value mangled-calls mangled-class-key-present \ mangled-class-plain-key-absent mangled-desc-name mangled-func-name \ mangled-key-present mangled-plain-key-absent \ mangled-calls-after-second-read \ dunder-value dunder-calls dunder-key-present \ dunder-mangled-key-absent dunder-calls-after-second-read \ subclass-mangled-defining-key subclass-mangled-subclass-key \ subclass-mangled-calls \ late-desc-name late-value late-calls-after-first \ late-mangled-key-present late-lookup-key-absent \ late-calls-after-third late-invalidate-error \ late-invalidate-mangled-error late-still-cached \ late-calls-after-manual-clear \ renamed-func-name renamed-desc-name renamed-value \ renamed-key-present renamed-func-key-absent renamed-calls \ renamed-doc setname-by-hand-name setname-by-hand-key \ setname-by-hand-old-key-absent setname-restored \ inherit-value inherit-sub-calls inherit-base-calls \ inherit-sub-calls-after-second inherit-dict-keys \ slots-error slots-message slots-invalidate-error \ slots-invalidate-message \ has-class-getitem class-getitem-error class-getitem-type \ class-getitem-origin-is-lazy class-getitem-args \ subscripted-decorator-error subscripted-value subscripted-calls \ subscripted-is-descriptor \ pkgdir-has-py-typed pkgdir-has-pyi pkgdir-names \ no-tests-subpackage no-examples-subpackage \ py-version py-maxsize-bits; 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 be indistinguishable from one that ran whole. check_eq $P/probe-count "136" "$(pv emitted_count)" # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. The paths are the target's, not the control's, so they are # asserted directly rather than compared. check_eq $P/pkg-file "$PKG/__init__.py" "$(pv pkg_file)" check_eq $P/pkg-is-package "yes" "$(pv pkg_is_package)" check_eq $P/submodule-file "$PKG/lazy.py" "$(pv submodule_file)" # THE NAME `lazy.lazy' IS BOUND TWICE and the second binding wins. `lazy/lazy.py' # is imported as a submodule -- which sets `lazy.lazy' to the MODULE -- and then # `from .lazy import lazy' REBINDS the same attribute to the CLASS. So # `lazy.lazy' is the class and the module is reachable only through # `sys.modules'. All three pinned: a release that dropped the re-export would # leave `lazy.lazy' a module, and every consumer's `from lazy import lazy' would # silently yield the wrong object while still importing cleanly. check_eq $P/pkg-lazy-is-class "yes" "$(pv pkg_lazy_is_class)" check_eq $P/pkg-lazy-is-attr "yes" "$(pv pkg_lazy_is_attr)" check_eq $P/submodule-in-sys-modules "yes" "$(pv submodule_in_sys_modules)" check_eq $P/class-module "lazy.lazy" "$(pv class_module)" check_eq $P/class-qualname "lazy" "$(pv class_qualname)" # THERE IS AN `__all__' AND IT NAMES ONE THING. Pinned as its contents rather # than as a boolean, beside the surface a star-import would ACTUALLY reach -- # which is larger, because `from __future__ import absolute_import' leaves the # name `absolute_import' bound in the package namespace. Both are what a consumer # sees, and the pair is what would make a release that grew a second export one # red rather than a rewrite. check_eq $P/has-all "yes" "$(pv has_all)" check_eq $P/all-names "lazy" "$(pv all_names)" check_eq $P/public-names "absolute_import,lazy" "$(pv public_names)" # THE VERSION LIVES ONLY IN THE METADATA. The module carries no `__version__' at # all -- setup.cfg is the only place 1.6 is written -- so `importlib.metadata' is # the only thing that can state it on target. The absence is pinned as such # rather than left to look like a gap in the port. check_eq $P/version-attr "no-attr" "$(pv version_attr)" check_eq $P/dist-version "1.6" "$(pv dist_version)" # ------------------------------------------------------ the descriptor kind --- # NON-DATA, and this is the load-bearing fact of the whole package. With # `__set__' present the instance dict would NOT shadow the class attribute, every # read would re-enter `__get__', and `shadow-entries-after-third' below would # climb. Asserted as three separate absences so that a release which added any # one of them is a red that names which. check_eq $P/has-get "yes" "$(pv has_get)" check_eq $P/has-set "no" "$(pv has_set)" check_eq $P/has-delete "no" "$(pv has_delete)" check_eq $P/has-set-name "yes" "$(pv has_set_name)" check_eq $P/has-invalidate "yes" "$(pv has_invalidate)" check_eq $P/invalidate-is-classmethod "yes" "$(pv invalidate_is_classmethod)" # ----------------------------------------------------- the plain descriptor --- # THE CACHE IS COLD BEFORE THE FIRST READ, asserted before anything reads the # attribute -- every case after this one depends on the first read being the # first. check_eq $P/cold-calls "0" "$(pv cold_calls)" check_eq $P/cold-in-dict "no" "$(pv cold_in_dict)" # THE PACKAGE IN ONE CASE: five reads, ONE call. check_eq $P/plain-calls-after-5-reads "1" "$(pv plain_calls_after_5_reads)" check_eq $P/plain-values-all-equal "yes" "$(pv plain_values_all_equal)" check_eq $P/plain-value "10" "$(pv plain_value)" # THE VALUE IS IN THE INSTANCE DICT, UNDER THE ATTRIBUTE'S OWN NAME, and it is # the value ITSELF -- not a wrapper and not a (value, timestamp) pair, which is # what a TTL-carrying cache on this image stores instead. This is the mechanism # the counter case only implies. check_eq $P/plain-in-dict "yes" "$(pv plain_in_dict)" check_eq $P/plain-dict-value "10" "$(pv plain_dict_value)" check_eq $P/plain-dict-value-is-int "yes" "$(pv plain_dict_value_is_int)" check_eq $P/plain-dict-keys "calls,foo" "$(pv plain_dict_keys)" # AND THE READS AFTER THE FIRST DO NOT ENTER `__get__' AT ALL. Measured, not # inferred: a subclass counts entries and the count stops at one while three # reads happen. This is the difference between "cached" and "bypassed", and only # the second is what makes a lazy attribute as cheap as an ordinary one. check_eq $P/shadow-entries-after-first "1" "$(pv shadow_entries_after_first)" check_eq $P/shadow-entries-after-third "1" "$(pv shadow_entries_after_third)" check_eq $P/shadow-calls "1" "$(pv shadow_calls)" check_eq $P/subclass-is-lazy "yes" "$(pv subclass_is_lazy)" # ----------------------------------------------------- access on the CLASS --- # `__get__' with `inst is None' returns the DESCRIPTOR -- not the value, not an # error. So the attribute is introspectable, which is what makes the `desc-*' # cases below reachable at all, and reading it computes nothing: # `class-access-calls' is the counter afterwards. check_eq $P/class-access-is-descriptor "yes" "$(pv class_access_is_descriptor)" check_eq $P/class-access-type "lazy" "$(pv class_access_type)" check_eq $P/class-access-calls "1" "$(pv class_access_calls)" check_eq $P/class-access-stable "yes" "$(pv class_access_stable)" # THE WRAPPED FUNCTION SURVIVES THE DECORATION. `functools.wraps' copies these # off the function in `__init__'; `__set_name__' then overwrites `__name__' with # the bound name, which here agrees with the function's. `renamed-*' below is # what separates the two. check_eq $P/desc-doc "the docstring, carried onto the descriptor" "$(pv desc_doc)" check_eq $P/desc-name "foo" "$(pv desc_name)" check_eq $P/desc-qualname "C.foo" "$(pv desc_qualname)" check_eq $P/desc-module "__main__" "$(pv desc_module)" check_eq $P/desc-has-wrapped "yes" "$(pv desc_has_wrapped)" check_eq $P/desc-wrapped-is-func "yes" "$(pv desc_wrapped_is_func)" check_eq $P/desc-wrapped-name "foo" "$(pv desc_wrapped_name)" # THE DOCSTRING IS THE FUNCTION'S, NOT THE CLASS'S. `lazy' has a docstring of its # own and `wraps' shadows it per instance. Pinned as an inequality as well as a # value, because the two would only ever coincide if `wraps' stopped running -- # which is precisely the regression this pair is for, and which no counter case # would notice. check_eq $P/desc-doc-is-not-class-doc "yes" "$(pv desc_doc_is_not_class_doc)" check_eq $P/class-doc-first-line "lazy descriptor" "$(pv class_doc_first_line)" # WHAT `wraps' PUT THERE, as a set, beside the tuple it read. # `functools.WRAPPER_ASSIGNMENTS' is CPython-version-dependent (3.13 adds # `__type_params__'), so the descriptor's own `__dict__' is pinned rather than # the tuple, and the tuple is recorded next to it -- which is what makes a # difference between the two arms name its own cause instead of reading as a # defect in this package. `_lazy__func' is the descriptor's own private slot, # name-mangled by the compiler inside class `lazy'. check_eq $P/desc-dict-keys \ "__annotations__,__doc__,__module__,__name__,__qualname__,__type_params__,__wrapped__,_lazy__func" \ "$(pv desc_dict_keys)" check_eq $P/wraps-assignments \ "__module__,__name__,__qualname__,__doc__,__annotations__,__type_params__" \ "$(pv wraps_assignments)" # ------------------------------------------------ instances are independent --- # A SECOND INSTANCE COMPUTES ITS OWN. The cache is in the INSTANCE dict, so # nothing about the first instance's read reaches the second -- and the first is # asserted untouched afterwards, which is the half that would go red if the cache # had drifted onto the class. `class-dict-has-no-value' is that same claim read # off the class: what sits there is still the descriptor. check_eq $P/second-cold-in-dict "no" "$(pv second_cold_in_dict)" check_eq $P/second-calls "1" "$(pv second_calls)" check_eq $P/second-value "10" "$(pv second_value)" check_eq $P/second-value-equals-first "yes" "$(pv second_value_equals_first)" check_eq $P/first-calls-untouched "1" "$(pv first_calls_untouched)" check_eq $P/first-value-untouched "10" "$(pv first_value_untouched)" check_eq $P/class-dict-has-no-value "yes" "$(pv class_dict_has_no_value)" # ----------------------------------------------------------- invalidation --- # DELETING THE CACHED ENTRY FORCES EXACTLY ONE RECOMPUTATION -- one, and then it # settles again. Asserted by both routes that exist: the raw # `del inst.__dict__[name]', which is what `invalidate' does underneath, and # `lazy.invalidate' itself. check_eq $P/del-clears-dict "yes" "$(pv del_clears_dict)" check_eq $P/del-calls-after-reread "2" "$(pv del_calls_after_reread)" check_eq $P/del-value-after-reread "20" "$(pv del_value_after_reread)" check_eq $P/del-calls-after-two-more-reads "2" "$(pv del_calls_after_two_more_reads)" check_eq $P/invalidate-clears-dict "yes" "$(pv invalidate_clears_dict)" check_eq $P/invalidate-calls-unchanged "1" "$(pv invalidate_calls_unchanged)" check_eq $P/invalidate-calls-after-reread "2" "$(pv invalidate_calls_after_reread)" check_eq $P/invalidate-value-after-reread "20" "$(pv invalidate_value_after_reread)" # INVALIDATING AN UNCACHED ATTRIBUTE IS A NO-OP, not an error: the method guards # on `if name in inst.__dict__'. So it is idempotent, which is what makes it # usable from a consumer that does not track whether the value was ever read. check_eq $P/invalidate-twice "none" "$(pv invalidate_twice)" check_eq $P/invalidate-uncached-again "none" "$(pv invalidate_uncached_again)" # AND IT REFUSES ANYTHING THAT IS NOT A LAZY ATTRIBUTE, for two different reasons # that both surface as AttributeError: a name bound to something else on the # class fails the `isinstance' guard, and a name bound to nothing at all fails # the `getattr' before it. The guard is what stops `invalidate' silently deleting # an ordinary instance attribute. check_eq $P/invalidate-non-lazy "AttributeError" "$(pv invalidate_non_lazy)" check_eq $P/invalidate-missing-name "AttributeError" "$(pv invalidate_missing_name)" # THE GUARD IS `cls', NOT `lazy' -- which is the whole reason `invalidate' is a # classmethod. The asymmetry runs one way: the base accepts a subclass's # attribute, and the subclass refuses the base's. check_eq $P/subclass-invalidate-own "none" "$(pv subclass_invalidate_own)" check_eq $P/base-invalidate-subclass-attr "none" "$(pv base_invalidate_subclass_attr)" check_eq $P/subclass-invalidate-base-attr "AttributeError" "$(pv subclass_invalidate_base_attr)" # --------------------------------------------------- private-name mangling --- # THE COMPILER'S MANGLING, not this package's -- see THE TWO MANGLINGS in the # header. `def __secret' binds `_M__secret' in the CLASS dict, `__set_name__' is # handed that already-mangled name, and `__get__''s own branch never fires. # `mangled-func-name' is what says so: the function underneath still carries the # UNMANGLED `__secret', which is exactly the name that would have reached # `__get__' had `__set_name__' not overwritten it. check_eq $P/mangled-value "shh" "$(pv mangled_value)" check_eq $P/mangled-calls "1" "$(pv mangled_calls)" check_eq $P/mangled-class-key-present "yes" "$(pv mangled_class_key_present)" check_eq $P/mangled-class-plain-key-absent "yes" "$(pv mangled_class_plain_key_absent)" check_eq $P/mangled-desc-name "_M__secret" "$(pv mangled_desc_name)" check_eq $P/mangled-func-name "__secret" "$(pv mangled_func_name)" check_eq $P/mangled-key-present "yes" "$(pv mangled_key_present)" check_eq $P/mangled-plain-key-absent "yes" "$(pv mangled_plain_key_absent)" check_eq $P/mangled-calls-after-second-read "1" "$(pv mangled_calls_after_second_read)" # A DUNDER IS NOT MANGLED, by either mechanism -- `not endswith("__")' is the # second half of `__get__''s guard, and without it a name like `__dunder__' would # cache under a key Python's own compiler never produces, so the two would never # meet and the value would be recomputed on every read. check_eq $P/dunder-value "not mangled" "$(pv dunder_value)" check_eq $P/dunder-calls "1" "$(pv dunder_calls)" check_eq $P/dunder-key-present "yes" "$(pv dunder_key_present)" check_eq $P/dunder-mangled-key-absent "yes" "$(pv dunder_mangled_key_absent)" check_eq $P/dunder-calls-after-second-read "1" "$(pv dunder_calls_after_second_read)" # READING AN INHERITED PRIVATE LAZY ATTRIBUTE caches under the DEFINING class's # key, not the subclass's -- the key was fixed by the compiler at definition time # and `__get__' never re-mangles it, so `owner' is ignored here. Pinned because # this is the natural place to expect the hand-rolled mangling to matter, and it # does not. check_eq $P/subclass-mangled-defining-key "yes" "$(pv subclass_mangled_defining_key)" check_eq $P/subclass-mangled-subclass-key "no" "$(pv subclass_mangled_subclass_key)" check_eq $P/subclass-mangled-calls "1" "$(pv subclass_mangled_calls)" # ------------------------------------------------- the LATE-ASSIGNED path --- # THE ONLY WAY INTO `__get__''s OWN MANGLING: a descriptor assigned onto a class # AFTER the class body gets no `__set_name__', so its `__name__' is still the # `__hidden' that `wraps' copied off the function. # # The lookup key stays `__hidden' and the cache key becomes `_N__hidden', so the # instance dict NEVER SHADOWS this attribute and every read re-enters `__get__'. # It still caches -- `late-calls-after-third' is 1 -- but one level deeper than # the ordinary case, which is what this reads beside `shadow-entries-after-third' # above. check_eq $P/late-desc-name "__hidden" "$(pv late_desc_name)" check_eq $P/late-value "late" "$(pv late_value)" check_eq $P/late-calls-after-first "1" "$(pv late_calls_after_first)" check_eq $P/late-mangled-key-present "yes" "$(pv late_mangled_key_present)" check_eq $P/late-lookup-key-absent "yes" "$(pv late_lookup_key_absent)" check_eq $P/late-calls-after-third "1" "$(pv late_calls_after_third)" # AND `invalidate' CANNOT REACH IT, by either spelling. It mangles the name and # then looks the result up on the CLASS, where this descriptor is bound under the # unmangled one -- so the guard raises before it can clear anything, and passing # the mangled name directly mangles nothing and misses the same way. See THE TWO # MANGLINGS in the header for why this is pinned rather than filed. check_eq $P/late-invalidate-error "AttributeError" "$(pv late_invalidate_error)" check_eq $P/late-invalidate-mangled-error "AttributeError" "$(pv late_invalidate_mangled_error)" check_eq $P/late-still-cached "yes" "$(pv late_still_cached)" check_eq $P/late-calls-after-manual-clear "2" "$(pv late_calls_after_manual_clear)" # --------------------------------------------------------------- renaming --- # `__set_name__' PICKS THE CACHE KEY, and it is handed the name the attribute was # BOUND under -- not the function's. Binding one descriptor under a second name # is how the two are separated: the function is still `body', the cache key is # `alias', and the docstring came from the function either way. check_eq $P/renamed-func-name "body" "$(pv renamed_func_name)" check_eq $P/renamed-desc-name "alias" "$(pv renamed_desc_name)" check_eq $P/renamed-value "v" "$(pv renamed_value)" check_eq $P/renamed-key-present "yes" "$(pv renamed_key_present)" check_eq $P/renamed-func-key-absent "yes" "$(pv renamed_func_key_absent)" check_eq $P/renamed-calls "1" "$(pv renamed_calls)" check_eq $P/renamed-doc "body's own docstring" "$(pv renamed_doc)" # CALLED BY HAND IT MOVES THE KEY, which is what upstream's own example file # checks and the only reason the method is public. Both halves stated: the new # key is written and the old one is not. check_eq $P/setname-by-hand-name "renamed" "$(pv setname_by_hand_name)" check_eq $P/setname-by-hand-key "yes" "$(pv setname_by_hand_key)" check_eq $P/setname-by-hand-old-key-absent "yes" "$(pv setname_by_hand_old_key_absent)" check_eq $P/setname-restored "alias" "$(pv setname_restored)" # ------------------------------------------------------------- inheritance --- # A SUBCLASS OVERRIDING A LAZY ATTRIBUTE and reaching the base's through the # base descriptor is upstream's own example, and it is the one case where two # descriptors share an instance dict. They share the KEY too -- both are named # `foo' -- so the subclass's write is what lands, while the base's `__get__' did # run. Pinned because it is surprising and because the example file relies on it. check_eq $P/inherit-value "sub+base" "$(pv inherit_value)" check_eq $P/inherit-sub-calls "1" "$(pv inherit_sub_calls)" check_eq $P/inherit-base-calls "1" "$(pv inherit_base_calls)" check_eq $P/inherit-sub-calls-after-second "1" "$(pv inherit_sub_calls_after_second)" check_eq $P/inherit-dict-keys "foo" "$(pv inherit_dict_keys)" # ----------------------------------------------------------- no __dict__ --- # AN INSTANCE WITH `__slots__' HAS NOWHERE TO CACHE, and `__get__' says so # explicitly rather than letting the write fail with whatever the interpreter # would have raised. The message names the OWNER class and is the only string # this package formats, so it is pinned whole. `invalidate' carries the same # guard, checked before anything else it does. check_eq $P/slots-error "AttributeError" "$(pv slots_error)" check_eq $P/slots-message "'Slotted' object has no attribute '__dict__'" "$(pv slots_message)" check_eq $P/slots-invalidate-error "AttributeError" "$(pv slots_invalidate_error)" check_eq $P/slots-invalidate-message "'Slotted' object has no attribute '__dict__'" "$(pv slots_invalidate_message)" # ------------------------------------------------- the generic-alias branch --- # `__class_getitem__ = classmethod(GenericAlias)' is the ONE thing in this module # behind a `sys.version_info' test, so it is the one thing whose presence is a # statement about the interpreter this port runs on rather than about the # package. 3.12 is over the 3.9 gate, so it is present -- and `lazy[str]' is a # `types.GenericAlias' and NOT a new class, which is what makes `@lazy[str]' # usable as a decorator at all. check_eq $P/has-class-getitem "yes" "$(pv has_class_getitem)" check_eq $P/class-getitem-error "none" "$(pv class_getitem_error)" check_eq $P/class-getitem-type "GenericAlias" "$(pv class_getitem_type)" check_eq $P/class-getitem-origin-is-lazy "yes" "$(pv class_getitem_origin_is_lazy)" check_eq $P/class-getitem-args "str" "$(pv class_getitem_args)" # AND THE SUBSCRIPTED FORM STILL DECORATES. `@lazy[str]' calls the alias, which # forwards to `lazy.__init__', so it has to produce a WORKING descriptor and not # merely a printable one. Upstream checks this under mypy and nothing checks it # at runtime -- these four are that check. check_eq $P/subscripted-decorator-error "none" "$(pv subscripted_decorator_error)" check_eq $P/subscripted-value "g" "$(pv subscripted_value)" check_eq $P/subscripted-calls "1" "$(pv subscripted_calls)" check_eq $P/subscripted-is-descriptor "yes" "$(pv subscripted_is_descriptor)" # ------------------------------------------------------- the payload, again --- # THE SAME CLAIM AS THE INTERPRETER SEES IT. The `pyi-size' / `py-typed-size' # cases above stat the filesystem; these say the same files are inside the # IMPORTABLE package -- which is what separates "in the .deb" from "on the import # path". `pkgdir-names' is the whole directory listing, so a payload that grew a # file is one red rather than none. check_eq $P/pkgdir-has-py-typed "yes" "$(pv pkgdir_has_py_typed)" check_eq $P/pkgdir-has-pyi "yes" "$(pv pkgdir_has_pyi)" check_eq $P/pkgdir-names "__init__.py,lazy.py,lazy.pyi,py.typed" "$(pv pkgdir_names)" check_eq $P/no-tests-subpackage "yes" "$(pv no_tests_subpackage)" check_eq $P/no-examples-subpackage "yes" "$(pv no_examples_subpackage)" # ------------------------------------------------------------- interpreter --- # WHICH ARM THIS TRANSCRIPT IS FROM. `py-maxsize-bits' is the ONE value here the # suite does not take from the control -- it reads 64 there and MUST read 32 on # this arm. Without it a suite run against a HOST copy of the module would pass # in full, since every other value in the probe is arm-independent by # construction. check_eq $P/py-version "3.12" "$(pv py_version)" check_eq $P/py-maxsize-bits "32" "$(pv py_maxsize_bits)" rm -rf "$W"