. "$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 ninety-odd measurements. # # The work inside each is attribute reads on small objects # # and one driven clock -- microseconds beside an interpreter # # start on this arm, which is tens of seconds before the # # first line of the program runs. # # # # NOTHING HERE SLEEPS. The TTL cases drive a rebound clock # # rather than waiting one out, so the expiry measurements # # cost nothing at all -- see THE CLOCK IS DRIVEN below. # # # # The probe's four threads are joined with a FIVE SECOND # # bound each, so the worst case the thread section can add # # is 20s, and on this arm it adds that (the joins all time # # out -- see THE THREADED VARIANT below). # # # # 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 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-cached-property 1.5.2 (meta-openembedded/meta-python # recipes-devtools/python/python3-cached-property_1.5.2.bb, `inherit pypi # setuptools3', PyPI sdist) on the wasm32-hwjs glibc image. # # ============================================================================ # WHAT THE PACKAGE DOES, AND WHAT THAT MAKES THE SUITE ASSERT. # ============================================================================ # # A decorator that turns a method into a property computed ONCE per instance and # thereafter served out of that instance's `__dict__'. So the interesting claim # is never the VALUE -- it is WHEN THE WRAPPED CALLABLE RUNS. 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 method # increments, not the property's value. `plain-calls-after-5-reads' is the # package in one line: five reads, one call. # # Three variants ship, and they differ in ways that are invisible from the value # and load-bearing from the counter: # # * `cached_property' -- a NON-data descriptor. After the first read # the instance dict SHADOWS it, so later reads # never enter `__get__' at all. # * `threaded_cached_property' -- the same, plus an RLock and a `setdefault', # so a race has one winner. # * `cached_property_with_ttl' -- a DATA descriptor, which is what lets it # expire: with `__set__' present the instance # dict does NOT shadow it, so every read is a # `__get__' that can check a timestamp. # # The `*-has-set' / `*-has-delete' pairs below are what distinguish those three, # and they are asserted because a release that added `__set__' to the plain class # would keep every value AND every counter case green while making each read a # descriptor call again. # # ============================================================================ # THE CLOCK IS DRIVEN, NOT SLEPT ON. # ============================================================================ # # `cached_property_with_ttl.__get__' calls the module-global `time' in # `cached_property''s own namespace, so the probe REBINDS `cp.time' to a counter # it advances by hand. Upstream's own tests reach for `freezegun' for exactly # this reason; that is a test dependency this image does not have and does not # need. # # It is not only cheaper than sleeping, it is a strictly better measurement. A # sleeping test can show that a value EXPIRED; it cannot show that the value did # not expire EARLY, because it never observes the interval it did not wait for. # The driven clock asserts both sides of the boundary -- and the boundary is # exact: the test is `self.ttl < now - last_updated', STRICTLY less, so at # precisely ttl the value is still fresh. `ttl-calls-at-boundary' and # `ttl-calls-past-boundary' are the off-by-one, both ways round. # # ============================================================================ # THE THREADED VARIANT -- WHAT IS ASSERTED, AND WHAT CANNOT BE. # ============================================================================ # # BRINTOS-2056 asks for the threaded variant to be asserted to "compute once # across several threads reading the same instance concurrently". THAT IS NOT # MEASURABLE ON THIS ARM, and saying so is not a shortfall in the port. # pthread_create does not produce a RUNNING thread here: the module imports, the # lock and thread-local machinery work, and a started thread never runs its body # (brintos/linux-in-the-browser#85 -- the same gap `99-python3''s own # `python3/thread-start-refused' case asserts, and the reason # `99-python3-tqdm' and `99-python3-bmaptool' cite it too). # # A case that started four threads here and found the counter at 1 would be # measuring a platform gap and reporting it as a working cache. So the section # splits in two, and the split is the honest one: # # * THE INVARIANT, asserted unconditionally: however many readers actually ran, # the callable ran ONCE and every reader saw the same value. That is true on # BOTH arms -- on the control because the lock and `setdefault' work, here # because the threads never ran and the main thread's own read is the only # one. It is a real assertion about the cache, and it is arm-independent. # # * THE WITNESS, `thread-ran', which records WHICH of those two worlds produced # the invariant. It is `no' here and `yes' on the control. Asserted as `no' # and ATTRIBUTED to #85 rather than left to look like a pass -- so if that # gap is ever closed this case goes red and says exactly what changed, which # is the regression signal worth having. # # The concurrency claim itself WAS measured, on the control, where four threads # read one instance and the counter finished at 1 (`thread-seen-count' 4, # `threaded-calls' 1). That is recorded in the design doc as a control result, # not offered here as a target one. # # ============================================================================ # WHERE THE EXPECTED VALUES COME FROM. # ============================================================================ # # MEASURED ON AN x86-64 CONTROL AT THE SAME VERSION, not read off the source. # The control ran THIS SUITE'S OWN PROBE against cached-property 1.5.2 unpacked # from the same PyPI sdist the recipe fetches (sha256 9fa5755838ee...89a130), # under CPython 3.12 -- the same interpreter minor this image ships. The control # was then RE-RUN against THIS BUILD's own .deb payload rather than the sdist, # and produced a BYTE-IDENTICAL transcript apart from the module's path: the # wheel install alters nothing. The packaging cases below could not be measured # on the control and come from that .deb directly (`dpkg-deb -c' and # `dpkg-deb -x' on python3-cached-property_1.5.2-r0_wasm32.deb). # # Four control results are worth naming because they are not what the source or # this ticket reads like: # # * A `cached_property' OVER AN `async def' RAISES AttributeError ON FIRST # READ. `__get__' routes a coroutine function to `_wrap_in_coroutine', whose # body carries `@asyncio.coroutine' -- REMOVED from asyncio in Python 3.11. # The name resolves when the wrapper is built, i.e. at read time, so the # module imports cleanly and only a caller who wraps an async method is hit. # CPython 3.12 against a package last released in 2020; reproduced # identically on the control, so NOTHING to do with this arm. # # * A ttl OF ZERO NEVER EXPIRES. `self.ttl and self.ttl < ...' short-circuits # on a falsy ttl and `0' is falsy, so `ttl=0' -- which reads like "expire # immediately" -- in fact means "cache for ever". `ttl-zero-calls' is 1 # after the clock jumps 98999 seconds. # # * A DESCRIPTOR BOUND UNDER A NAME OTHER THAN ITS FUNCTION'S NEVER CACHES. # There is no `__set_name__' on these classes, so the cache key is # `func.__name__' while the read looks up the ATTRIBUTE name -- they differ, # the lookup misses, and the property recomputes on every access in silence. # `misnamed-calls-after-3-reads' is 3, not 1. Upstream's documented usage is # the decorator form, where the two names coincide by construction. # # * THERE IS NO `__all__'. So `from cached_property import *' exports the # module's incidental imports too -- `threading', `time', `wraps', `asyncio' # are all public names of this module. Pinned as an absence AND as the # resulting surface. # # ============================================================================ # THE DECLARED DEPENDENCY IS SHORT, AND THE IMPORT IS NOT GUARDED. # ============================================================================ # # The .deb's control stanza reads `Depends: python3-core' and nothing else -- # setup.py declares no `install_requires', and the wheel's METADATA carries ZERO # `Requires-Dist' lines. But the module's FIRST three lines import `functools', # `time' and `threading' UNGUARDED (only `asyncio' is wrapped in try/except). # # On this image `threading' is a SEPARATE sub-package of the ported python3 # (BRINTOS-1476), so `import cached_property' depends on `python3-threading' # being installed and the packaging never says so. It works here because that # sub-package is already on IMAGE_INSTALL for other reasons -- not because # anything arranged it for this port. # # Both halves are asserted, and they are asserted as a PAIR: `dep-line' pins the # short declared set, `dep-no-threading' pins the omission, and # `dep-threading-installed' pins the fact that carries the import anyway. An # image that dropped python3-threading would turn `import' red with nothing else # to explain it; this trio is what makes that legible in one reading. # # 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. P=python3-cached-property # ============================================================================ # 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 MOD=$SITE/cached_property.py DI=$SITE/cached_property-1.5.2.dist-info PKGLIST=/var/lib/dpkg/info/python3-cached-property.list PROBE=$BST_DATA/python3-cached-property-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-cached-property.$$ 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/module-present test -s "$MOD" # IT IS ONE FILE, NOT A DIRECTORY. setup.py declares `py_modules=["cached_property"]' # rather than `packages=', so the payload is a single top-level module sitting # beside site-packages. Asserted BOTH ways -- the file is there and the directory # is not -- because a release that became a package would satisfy `import' and # every behavioural case below while relocating the whole payload. check $P/not-a-package test ! -d "$SITE/cached_property" # THE SIZE IS PINNED RATHER THAN FLOORED: a truncated or partially written # payload passes `test -s' and fails only here. 4324 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 4324 \ "$(wc -c < "$MOD" 2>/dev/null | tr -d ' ')" # BST-REDIRECT-OK: forked -- inside $( ) # 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 "$SITE" -maxdepth 1 -name 'cached_property*.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 4 KB of source. It lands in # site-packages' OWN `__pycache__' rather than a package-local one, which is # what a top-level module does -- pinned by path for that reason. check $P/pycache-present test -s "$SITE/__pycache__/cached_property.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/cached-property # --------------------------------------------------------------- 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.5.2" cat "$DI/METADATA" check_out $P/dist-info-name "Name: cached-property" cat "$DI/METADATA" # THE LICENSE AND THE AUTHORS FILE BOTH LAND AT THE ROOT of dist-info -- not # under `licenses/', which is where hatchling puts them and where # 99-python3-annotated-types pins its own. This is setuptools' older layout, and # it is where the recipe's LIC_FILES_CHKSUM md5 ultimately points. # # AUTHORS.rst is there because setup.py sets `include_package_data=True' and # MANIFEST.in names it: a non-licence document carried into the installed # metadata. Pinned so the payload census below has a reason to expect it. check $P/license-file test -s "$DI/LICENSE" check $P/authors-file test -s "$DI/AUTHORS.rst" # `top_level.txt' NAMES THE MODULE, with the UNDERSCORE spelling. The # distribution is `cached-property' and the import name is `cached_property'; # this file is the only place in the payload that states the mapping. check_out $P/top-level "cached_property" cat "$DI/top_level.txt" # PURELIB, STATED BY THE PACKAGING ITSELF -- and the wheel is UNIVERSAL, which # is the one place this port's packaging differs from every other pure-Python # port on this image. setup.cfg carries `[wheel] universal = 1', 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, and the METADATA says so by saying nothing: setup.py # declares no `install_requires', so there is not one `Requires-Dist' line. # Asserted as a COUNT of zero rather than as an absent string, because a # `check_out' cannot express "no line matches". # # This is the wheel's half of the dependency pair; `dep-no-threading' below is # the .deb's, and `dep-threading-installed' is why the import works anyway. See # THE DECLARED DEPENDENCY IS SHORT in the header. check_eq $P/meta-no-requires "0" \ "$(grep -c '^Requires-Dist' "$DI/METADATA" 2>/dev/null)" # BST-REDIRECT-OK: forked -- inside $( ) # ------------------------------------------------------------ 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 or metadata. # Anything else means the build produced something this port did not expect and # did not measure. `.rst' is admitted for the AUTHORS file 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) ;; */METADATA|*/WHEEL|*/RECORD|*/INSTALLER|*/LICENSE|*/LICENSE.*) ;; */top_level.txt|*/AUTHORS.rst) ;; *) _bad="$_bad $_f" ;; esac done < "$W/pkgf" check_eq $P/pkg-only-expected "" "$_bad" # ------------------------------------------------------------- dpkg status --- sed -n '/^Package: python3-cached-property$/,/^$/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" # THE DEPENDENCY TRIO. See THE DECLARED DEPENDENCY IS SHORT in the header -- # these three are one finding written as three cases, and reading any of them # alone gets it wrong. 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" # THE OMISSION, pinned. The module imports `threading' unguarded at top level and # the packaging never declares it. Asserted as an absence so that a later recipe # which DID declare it is a visible change rather than a silent one. check_eq $P/dep-no-threading "" \ "$(grep -o 'python3-threading' "$W/dep" 2>/dev/null)" # BST-REDIRECT-OK: forked -- inside $( ) # AND THE FACT THAT CARRIES THE IMPORT ANYWAY. python3-threading is on this # image's IMAGE_INSTALL for reasons that have nothing to do with this port. If it # ever left, `import' below would go red and this case is what would say why. sed -n '/^Package: python3-threading$/,/^$/p' /var/lib/dpkg/status > "$W/thr" 2>/dev/null check_out $P/dep-threading-installed "Status: install ok installed" cat "$W/thr" # ------------------------------------------------------------------ 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 'import cached_property' # ------------------------------------------------------------------- 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 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 seventy 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="cached_property did not import: $(pv import_error)" for _c in probe-count module-file is-module version-attr author-attr \ email-attr license-attr dist-version version-agrees \ has-all public-names asyncio-live \ plain-computes-once plain-values-equal plain-value plain-in-dict \ plain-dict-value plain-dict-keys plain-class-access plain-doc \ plain-func-name plain-has-set plain-has-delete \ plain-del-clears plain-del-recomputes plain-del-value \ plain-del-recomputes-once second-instance-value \ second-instance-calls first-instance-untouched \ assign-read-back assign-dict-value assign-no-recompute \ assign-before-read-value assign-before-read-never-calls \ misnamed-never-caches misnamed-dict-keys misnamed-key \ async-class-built async-error \ thread-refused thread-ran thread-seen-count \ threaded-computes-once \ threaded-values-equal threaded-value threaded-in-dict \ threaded-dict-value threaded-has-lock threaded-lock-type \ threaded-lock-acquires threaded-has-set threaded-has-delete \ threaded-del-recomputes \ ttl-has-set ttl-has-delete ttl-computes-once ttl-dict-is-tuple \ ttl-dict-len ttl-dict-value ttl-not-expired-early \ ttl-not-expired-at-boundary ttl-expired-past-boundary \ ttl-value-after-expiry ttl-zero-never-expires \ ttl-none-never-expires ttl-bare-never-expires ttl-bare-ttl-none \ ttl-del-clears ttl-del-twice-ok ttl-del-recomputes \ ttl-set-dict-value ttl-set-read-back ttl-set-no-compute \ ttl-set-then-expires ttl-set-calls-after-expiry \ ttl-name ttl-module \ tttl-is-subclass tttl-has-lock tttl-lock-type \ tttl-computes-once tttl-expires \ alias-cached-property-ttl alias-timed-cached-property \ alias-threaded-cached-property-ttl \ alias-timed-threaded-cached-property \ 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 "94" "$(pv emitted_count)" # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. The path is the target's, not the control's, so it is # asserted directly rather than compared. check_eq $P/module-file "$MOD" "$(pv module_file)" # A MODULE, NOT A PACKAGE -- the payload half of this is `not-a-package' above; # this is the same claim as the interpreter sees it (`no __path__'). check_eq $P/is-module "no" "$(pv is_package)" # The dunders setup.py does NOT read: `__version__' is the one it imports, and # the other three are carried in the source and nowhere else. `version-agrees' is # the case that says the wheel was cut from the source this module is. check_eq $P/version-attr "1.5.2" "$(pv version_attr)" check_eq $P/author-attr "Daniel Greenfeld" "$(pv author_attr)" check_eq $P/email-attr "pydanny@gmail.com" "$(pv email_attr)" check_eq $P/license-attr "BSD" "$(pv license_attr)" check_eq $P/dist-version "1.5.2" "$(pv dist_version)" check_eq $P/version-agrees "yes" "$(pv version_agrees)" # THERE IS NO `__all__', so the star-import surface is whatever the module # happens to bind -- including the `threading', `time', `wraps' and `asyncio' it # imported. Both halves pinned: the absence, and what the absence produces. check_eq $P/has-all "no" "$(pv has_all)" check_eq $P/public-names \ "asyncio,cached_property,cached_property_ttl,cached_property_with_ttl,threaded_cached_property,threaded_cached_property_ttl,threaded_cached_property_with_ttl,threading,time,timed_cached_property,timed_threaded_cached_property,wraps" \ "$(pv public_names)" # `asyncio' IMPORTED, so the coroutine branch of `cached_property.__get__' is # LIVE on this image -- which is what makes `async-error' below a real finding # rather than dead code. The module tolerates its absence by design; this says # which of the two worlds the transcript is from. check_eq $P/asyncio-live "yes" "$(pv asyncio_live)" # ------------------------------------------------- the plain cached_property --- # THE PACKAGE IN ONE CASE: five reads, ONE call. check_eq $P/plain-computes-once "1" "$(pv plain_calls_after_5_reads)" check_eq $P/plain-values-equal "yes" "$(pv plain_values_all_equal)" check_eq $P/plain-value "10" "$(pv plain_value)" # THE CACHE LIVES IN THE INSTANCE `__dict__', UNDER THE FUNCTION'S NAME, and it # is the RAW value -- no wrapper, no box. This is the mechanism the whole package # rests on, and the three cases separate the three claims: the key is there, the # value beside it is the property's own, and nothing else was added to the # instance. 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-keys "calls,prop" "$(pv plain_dict_keys)" # CLASS ACCESS RETURNS THE DESCRIPTOR ITSELF (`obj is None' -> `return self'), # which is what lets `Klass.prop.func' be reached at all. check_eq $P/plain-class-access "yes" "$(pv plain_class_access_is_descriptor)" check_eq $P/plain-doc "the docstring, carried onto the descriptor" "$(pv plain_doc)" check_eq $P/plain-func-name "prop" "$(pv plain_func_name)" # IT IS A NON-DATA DESCRIPTOR, and that is not a detail -- it is the reason the # instance-dict entry SHADOWS the descriptor on every later read, which is what # makes a cache hit free rather than a `__get__' call. A release that added # `__set__' would keep every case above green and silently route every read back # through the descriptor. Contrast `ttl-has-set' below, which is `yes' by design. check_eq $P/plain-has-set "no" "$(pv plain_has_set)" check_eq $P/plain-has-delete "no" "$(pv plain_has_delete)" # `del obj.prop' FORCES EXACTLY ONE RECOMPUTATION. There is no `__delete__' here, # so the `del' removes the instance-dict entry directly and the descriptor # becomes visible again. Asserted as a counter and asserted to be ONE: a `del' # that left the cache in place, and one that dropped it twice, both fail here. check_eq $P/plain-del-clears "no" "$(pv plain_after_del_in_dict)" check_eq $P/plain-del-recomputes "2" "$(pv plain_calls_after_del)" check_eq $P/plain-del-value "20" "$(pv plain_value_after_del)" check_eq $P/plain-del-recomputes-once "2" "$(pv plain_calls_after_del_second_read)" # A SECOND INSTANCE COMPUTES INDEPENDENTLY. The cache is per-instance because it # lives in the instance dict; a decorator that cached on the DESCRIPTOR -- which # is per-class, and shared -- would pass every case above and leak one object's # value into every other. `first-instance-untouched' is the other half: computing # for the second must not disturb the first. check_eq $P/second-instance-value "10" "$(pv second_instance_value)" check_eq $P/second-instance-calls "1" "$(pv second_instance_calls)" check_eq $P/first-instance-untouched "2" "$(pv first_instance_calls_unchanged)" # ASSIGNING OVER THE PROPERTY REPLACES THE CACHED VALUE -- the documented # consequence of caching in the instance dictionary. With no `__set__' on the # descriptor, `obj.prop = x' is an ordinary store into the very slot the cache # uses, so the next read serves `x' and the function never runs again. check_eq $P/assign-read-back "999" "$(pv assign_read_back)" check_eq $P/assign-dict-value "999" "$(pv assign_dict_value)" check_eq $P/assign-no-recompute "1" "$(pv assign_calls)" # ASSIGNING BEFORE THE FIRST READ means the function NEVER runs. Same mechanism, # and the case that shows the cache is not merely overwritten but PRE-EMPTED. check_eq $P/assign-before-read-value "7" "$(pv assign_before_read_value)" check_eq $P/assign-before-read-never-calls "0" "$(pv assign_before_read_calls)" # A DESCRIPTOR BOUND UNDER ANOTHER NAME NEVER CACHES. No `__set_name__' on this # class, so the cache key is `func.__name__' while the read looks up the # ATTRIBUTE name; they differ, the lookup misses every time, and the property # recomputes silently. THREE reads, THREE calls -- the exact inverse of # `plain-computes-once', and the reason the decorator form is the only supported # one. Measured on the control, not deduced. check_eq $P/misnamed-never-caches "3" "$(pv misnamed_calls_after_3_reads)" check_eq $P/misnamed-dict-keys "_compute,calls" "$(pv misnamed_dict_keys)" check_eq $P/misnamed-key "yes" "$(pv misnamed_cached_under_func_name)" # AN `async def' PROPERTY RAISES AttributeError ON FIRST READ, because # `_wrap_in_coroutine' is decorated with `@asyncio.coroutine' -- removed from # asyncio in Python 3.11. The class BUILDS fine (the decorator does not touch the # function), so the failure arrives at read time and only for a caller who wraps # an async method. Reproduced identically on the x86-64 control: CPython 3.12 # against a package last released in 2020, and NOT a property of this arm. check_eq $P/async-class-built "yes" "$(pv async_class_built)" check_eq $P/async-error "AttributeError" "$(pv async_error)" # ---------------------------------------------------- the threaded variant --- # See THE THREADED VARIANT in the header for why this section is shaped as an # invariant plus a witness rather than as a concurrency assertion. # # THE WITNESS. `thread-ran' is `no' HERE and `yes' on the x86-64 control: a # started thread never runs its body on this arm # (brintos/linux-in-the-browser#85). This case is ATTRIBUTED, not accidental -- # it asserts the platform gap so that closing the gap turns it red and says so. # `thread-refused' is the ROBUST form of the witness, and it is the one to read. # This repo's own prose carries two readings of #85 -- `start()' raising, and # `start()' returning a thread whose body never runs -- and they imply different # values for the MECHANISM. This case is `yes' here under either reading and `no' # on the control under neither, so it asserts the platform fact without asserting # a mechanism nobody has measured on this arm. check_eq $P/thread-refused "yes" "$(pv thread_refused)" check_eq $P/thread-ran "no" "$(pv thread_ran)" check_eq $P/thread-seen-count "0" "$(pv thread_seen_count)" # `thread_start_error' and `thread_alive_after_join' are DELIBERATELY NOT # ASSERTED. They distinguish the two readings above, they read `none' and `0' on # the x86-64 control, and they have never been measured on wasm32-hwjs -- so a # pinned value here would be a prediction in the grammar of a result, which is # the one thing this suite must not contain. The probe still EMITS them, so the # batch verify's capture records which mechanism this arm uses; once that is # read, a later port can assert it and this comment can go. # THE INVARIANT, and it holds on both arms: however many readers ran, the # callable ran ONCE and every reader saw the same value. check_eq $P/threaded-computes-once "1" "$(pv threaded_calls)" check_eq $P/threaded-values-equal "yes" "$(pv threaded_values_all_equal)" check_eq $P/threaded-value "100" "$(pv threaded_value)" check_eq $P/threaded-in-dict "yes" "$(pv threaded_in_dict)" check_eq $P/threaded-dict-value "100" "$(pv threaded_dict_value)" # THE LOCK IS REAL AND IT IS REENTRANT. `RLock' rather than `Lock' because the # wrapped callable may itself read the same property; a `Lock' would deadlock # where this one recurses. The type name is the assertion, and `lock-acquires' # is the case that says the lock machinery WORKS here even though threads do not # -- which is the precise shape of #85 and worth pinning separately from it. check_eq $P/threaded-has-lock "yes" "$(pv threaded_has_lock)" check_eq $P/threaded-lock-type "RLock" "$(pv threaded_lock_type)" check_eq $P/threaded-lock-acquires "yes" "$(pv threaded_lock_acquires)" # ALSO A NON-DATA DESCRIPTOR, for the same reason the plain one is: after the # first read the instance dict shadows it and the lock is never taken again. check_eq $P/threaded-has-set "no" "$(pv threaded_has_set)" check_eq $P/threaded-has-delete "no" "$(pv threaded_has_delete)" check_eq $P/threaded-del-recomputes "2" "$(pv threaded_calls_after_del)" # --------------------------------------------------------- the TTL variant --- # IT IS A DATA DESCRIPTOR -- the opposite of the two above, and the reason it can # expire at all. With `__set__' and `__delete__' present the instance dict does # NOT shadow it, so every read is a `__get__' that gets the chance to check a # timestamp. A cache that could expire but was never consulted again would be # the bug this pair catches, and it is invisible from any value. check_eq $P/ttl-has-set "yes" "$(pv ttl_has_set)" check_eq $P/ttl-has-delete "yes" "$(pv ttl_has_delete)" check_eq $P/ttl-computes-once "1" "$(pv ttl_calls_after_2_reads)" # THE STORED SHAPE IS A `(value, timestamp)' PAIR, not a bare value -- the one # place the three variants' on-instance representations differ, and what a # consumer poking at `__dict__' directly would trip over. The timestamp is # 1000.0 because the probe drove the clock there; see THE CLOCK IS DRIVEN. check_eq $P/ttl-dict-is-tuple "yes" "$(pv ttl_dict_is_tuple)" check_eq $P/ttl-dict-len "2" "$(pv ttl_dict_len)" check_eq $P/ttl-dict-value "(1, 1000.0)" "$(pv ttl_dict_value)" # THE EXPIRY BOUNDARY, BOTH WAYS ROUND, on a clock the probe drives. The test is # `self.ttl < now - last_updated', STRICTLY less, so at exactly ttl the value is # still fresh and only past it does the callable run again. An implementation # that used `<=' would pass `not-expired-early' and fail `not-expired-at-boundary' # -- which is the whole reason the boundary is asserted rather than approached. # No test that slept could establish the middle case at all. check_eq $P/ttl-not-expired-early "1" "$(pv ttl_calls_before_expiry)" check_eq $P/ttl-not-expired-at-boundary "1" "$(pv ttl_calls_at_boundary)" check_eq $P/ttl-expired-past-boundary "2" "$(pv ttl_calls_past_boundary)" check_eq $P/ttl-value-after-expiry "2" "$(pv ttl_value_after_expiry)" # A ttl OF ZERO NEVER EXPIRES, and a ttl of `None' never expires either -- both # by the same short circuit in `self.ttl and self.ttl < ...', which never # evaluates the comparison for a FALSY ttl. `ttl=0' reads like "expire # immediately" and means "cache for ever"; the clock jumps 98999 seconds between # the two reads and the counter stays at 1. Pinned because it is exactly the kind # of thing a later release fixes, and a consumer relying on either reading needs # to know which one is true. check_eq $P/ttl-zero-never-expires "1" "$(pv ttl_zero_calls)" check_eq $P/ttl-none-never-expires "1" "$(pv ttl_none_calls)" # THE BARE DECORATOR FORM. `__init__' takes `ttl' positionally and asks whether # it is CALLABLE, so applied bare the decorated FUNCTION arrives where a ttl was # expected and is recognised as the function. `@cached_property_with_ttl' and # `@cached_property_with_ttl(ttl=N)' are one class entered two ways -- and the # bare one has `ttl is None', hence no expiry at all. check_eq $P/ttl-bare-never-expires "1" "$(pv ttl_bare_calls)" check_eq $P/ttl-bare-ttl-none "yes" "$(pv ttl_bare_ttl_is_none)" # `__delete__' POPS, AND POPS FORGIVINGLY -- `obj.__dict__.pop(name, None)', so # deleting an already-deleted property is a no-op where an ordinary `del' would # raise AttributeError. `del-twice-ok' asserts an ABSENCE of an exception, which # is the whole difference from the plain variant. check_eq $P/ttl-del-clears "no" "$(pv ttl_after_del_in_dict)" check_eq $P/ttl-del-twice-ok "none" "$(pv ttl_del_twice_error)" check_eq $P/ttl-del-recomputes "2" "$(pv ttl_calls_after_del)" # `__set__' STORES A PAIR AND STAMPS IT WITH `now'. So assigning over a TTL # property is NOT the same operation as assigning over a plain one: there it # replaces a bare value that lives for ever, here it writes a freshly timestamped # tuple that will itself expire. Both halves asserted -- the value served # immediately, and the expiry that overtakes it eleven seconds later. check_eq $P/ttl-set-dict-value "(42, 2000.0)" "$(pv ttl_set_dict_value)" check_eq $P/ttl-set-read-back "42" "$(pv ttl_set_read_back)" check_eq $P/ttl-set-no-compute "0" "$(pv ttl_set_calls)" check_eq $P/ttl-set-then-expires "1" "$(pv ttl_set_then_expires)" check_eq $P/ttl-set-calls-after-expiry "1" "$(pv ttl_set_calls_after_expiry)" # THE METADATA IS COPIED OFF THE FUNCTION by `_prepare_func', and `__name__' is # load-bearing rather than cosmetic: it is the dict key this variant reads and # writes. `__module__' is `__main__' because the probe defines its classes there. check_eq $P/ttl-name "prop" "$(pv ttl_name)" check_eq $P/ttl-module "__main__" "$(pv ttl_module)" # ------------------------------------------------- the threaded TTL variant --- # A SUBCLASS, and the only thing it adds is the lock around the parent's # `__get__'. Asserted as a subclass rather than re-measured in full: if it is the # subclass it says it is, every TTL case above already covers it. The two # behavioural cases are there to show the inherited path is actually reached. check_eq $P/tttl-is-subclass "yes" "$(pv tttl_is_subclass)" check_eq $P/tttl-has-lock "yes" "$(pv tttl_has_lock)" check_eq $P/tttl-lock-type "RLock" "$(pv tttl_lock_type)" check_eq $P/tttl-computes-once "1" "$(pv tttl_calls_after_2_reads)" check_eq $P/tttl-expires "2" "$(pv tttl_calls_after_expiry)" # ----------------------------------------------------------------- aliases --- # Four extra module-level names, `is'-identical to the two classes rather than # subclasses or wrappers -- so a consumer importing any of them gets the same # object and the same behaviour. Pinned by IDENTITY because that is the claim: a # release that made one a thin subclass would keep every behavioural case green. check_eq $P/alias-cached-property-ttl "yes" "$(pv alias_cached_property_ttl)" check_eq $P/alias-timed-cached-property "yes" "$(pv alias_timed_cached_property)" check_eq $P/alias-threaded-cached-property-ttl "yes" \ "$(pv alias_threaded_cached_property_ttl)" check_eq $P/alias-timed-threaded-cached-property "yes" \ "$(pv alias_timed_threaded_cached_property)" # ------------------------------------------------------------- interpreter --- # `py-maxsize-bits' is one of the two values the suite does NOT take from the # control: it reads 64 there and must read 32 here. Together with `thread-ran' # above it is what says the transcript came from the wasm32 interpreter -- every # other value in this file is arm-independent by construction, so without the # pair a suite run against a HOST copy of the module would pass in full. check_eq $P/py-version "3.12" "$(pv py_version)" check_eq $P/py-maxsize-bits "32" "$(pv py_maxsize_bits)" rm -rf "$W"