#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-lazy. Emits `key=value' lines, one per line, in file order; the suite reads them with grep. A python3 start on this arm is tens of seconds and every measurement here is an attribute read on a small object -- microseconds beside the start -- so taking the start ONCE is the difference between a half-minute suite and a several-minute one. THE TRANSPORT RULES. The runner parses its input line by line, so no value may contain a NEWLINE; the suite splits on the FIRST `=', so a value containing `=' is safe and a key containing one is not. Nothing emitted below carries a memory address: every repr is of an int, a str, a bool or a small tuple of those. A `lazy' descriptor is never repr'd -- the class defines no `__repr__', so its repr WOULD carry an address and would differ on every run. WHAT THIS PACKAGE IS. `lazy' is a NON-DATA descriptor: one `__get__', no `__set__' and no `__delete__'. 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 CALLABLE 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, not the attribute's value. `plain_calls_after_5_reads' is the package in one line: five reads, one call. THREE THINGS BESIDES THE CACHE, and each is a place a release could break without moving a counter: * `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 -- see `doc_*' and `name_*' below. * `__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_*' below binds one descriptor under a second name and reads what the cache key becomes. * PRIVATE-NAME MANGLING. `__get__' re-derives Python's own mangling by hand (`name.startswith("__") and not name.endswith("__")' -> `___name'), so a lazy attribute spelled `__secret' caches under `_C__secret' and NOT under `__secret'. Asserted both ways round in `mangled_*'. `lazy.invalidate(inst, name)' is the only supported way to drop a cached value, and it is deliberately not `__delete__' -- adding one would make `lazy' a DATA descriptor and stop the instance dict shadowing it, which would cost the package its entire point. `invalidate_*' asserts what it does; `has_set' / `has_delete' assert that it is still the only way. THIS PROBE EXITS 0 EVEN WHEN THE IMPORT FAILS. `lazy' is pure Python, so an import failure would be a platform fact about the interpreter rather than about this package, and turning it into sixty empty-string mismatches would bury that fact rather than report it. The failure is reported as `import_ok=no' with the exception, the suite reds exactly one case for it, and every behavioural case is SKIPPED with that reason attached. WHERE THE EXPECTED VALUES COME FROM. Every value the suite compares against was MEASURED by running THIS FILE on an x86-64 control at the same version -- lazy 1.6 from the same PyPI sdist (sha256 7127324ec709e8324f08cb4611c1abe01776bda53bb9ce68dc5dfa46ca0ed3e9, the recipe's own), under CPython 3.12 -- and then re-run there against THIS BUILD's own .deb payload rather than the sdist. This package declares no ext_modules, reads no sizeof, packs no struct, imports nothing but `sys' and `functools' and branches on nothing but `sys.version_info'. There is nothing in it that can know the pointer width, so a difference between the two arms is a DEFECT and not a property. ONE VALUE IS EXPECTED TO DIFFER FROM THE CONTROL, and it is the one that says the transcript came from the wasm32 interpreter rather than from a host copy of the module: `py_maxsize_bits' -- 32 here, 64 on the control. Without it a suite run against a HOST copy of the module would pass in full, since every other value here is arm-independent by construction. NOTHING HERE SLEEPS, FORKS, THREADS, OR TOUCHES THE FILESYSTEM beyond the import itself. `lazy' has no clock, no lock and no I/O, so the suite reaches no device, no peer and no terminal -- which is what makes it safe to run inside a shared batch image's verify. """ import sys emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def err(fn, *args, **kw): """The exception TYPE NAME a call raises, or `none' when it returns. The name only: a message carries quoting and, for some builtins, the repr of an argument, neither of which is worth pinning across two arms. """ try: fn(*args, **kw) except BaseException as exc: return type(exc).__name__ return 'none' # ------------------------------------------------------------------ import --- # Pure Python and a two-module package, so this is expected to be uneventful -- # but it is the first thing that runs on this arm and every case below depends # on it. `lazy/__init__.py' re-exports the class out of `lazy/lazy.py', so the # name `lazy' is a PACKAGE and the name `lazy.lazy' is both a submodule and a # class. `pkg_is_package' and `class_module' below are what keep that legible. try: import lazy as lazypkg from lazy import lazy as lazyattr emit('import_ok', 'yes') emit('import_error', '') except BaseException as exc: emit('import_ok', 'no') emit('import_error', '%s: %s' % (type(exc).__name__, str(exc).replace('\n', ' ')[:200])) emit('emitted_count', str(len(emitted) + 1)) emit('probe_done', 'yes') sys.exit(0) import functools # noqa: E402 (after the gate, by design) # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Without this, every case below would be a statement about # whatever was first on sys.path. `pkg_file' differs between the control and the # target by construction, so the suite asserts it directly against the target's # own path rather than comparing it to a control value. emit('pkg_file', getattr(lazypkg, '__file__', '')) emit('pkg_is_package', 'yes' if hasattr(lazypkg, '__path__') else 'no') emit('submodule_file', getattr(sys.modules.get('lazy.lazy'), '__file__', 'no-module')) # THE NAME `lazy.lazy' IS BOUND TWICE and the second binding wins. `lazy/lazy.py' # is imported as a submodule -- which sets `lazypkg.lazy' to the MODULE -- and # then `from .lazy import lazy' REBINDS the same attribute to the CLASS. So # `lazypkg.lazy' is the class, and the module is reachable only through # `sys.modules'. Both halves pinned: a release that dropped the re-export would # leave `lazypkg.lazy' a module and every consumer's `from lazy import lazy' # would silently yield the wrong object. emit('pkg_lazy_is_class', 'yes' if isinstance(lazypkg.lazy, type) else 'no') emit('pkg_lazy_is_attr', 'yes' if lazypkg.lazy is lazyattr else 'no') emit('submodule_in_sys_modules', 'yes' if 'lazy.lazy' in sys.modules else 'no') emit('class_module', getattr(lazyattr, '__module__', 'no-attr')) emit('class_qualname', getattr(lazyattr, '__qualname__', 'no-attr')) # `__all__' IS PRESENT, and it names exactly one thing. Pinned as a list rather # than as a boolean because a release that grew a second export would otherwise # be invisible. emit('has_all', 'yes' if hasattr(lazypkg, '__all__') else 'no') emit('all_names', ','.join(getattr(lazypkg, '__all__', []))) emit('public_names', ','.join(sorted(n for n in vars(lazypkg) if not n.startswith('_')))) # THE DISTRIBUTION'S OWN VERSION. The module carries NO `__version__' -- the # version lives only in `setup.cfg' and reaches the target only as dist-info # metadata -- so `importlib.metadata' is the only thing that can state it, and a # missing `__version__' is pinned as such rather than left to look like a gap. emit('version_attr', getattr(lazypkg, '__version__', 'no-attr')) try: from importlib.metadata import version as _dv _dist = _dv('lazy') except BaseException as exc: _dist = 'ERR:%s' % type(exc).__name__ emit('dist_version', _dist) # ------------------------------------------------------ the descriptor kind --- # NON-DATA, and this is the load-bearing fact. With `__set__' present the # instance dict would NOT shadow the class attribute, every read would re-enter # `__get__', and the package would be a different package. Asserted as three # absences so that a release which added any one of them is a red that names # which. emit('has_get', 'yes' if hasattr(lazyattr, '__get__') else 'no') emit('has_set', 'yes' if hasattr(lazyattr, '__set__') else 'no') emit('has_delete', 'yes' if hasattr(lazyattr, '__delete__') else 'no') emit('has_set_name', 'yes' if hasattr(lazyattr, '__set_name__') else 'no') emit('has_invalidate', 'yes' if hasattr(lazyattr, 'invalidate') else 'no') emit('invalidate_is_classmethod', 'yes' if isinstance(vars(lazyattr).get('invalidate'), classmethod) else 'no') # ----------------------------------------------------- the plain descriptor --- # THE PACKAGE'S WHOLE PURPOSE: the wrapped function runs ONCE per instance. class C(object): def __init__(self): self.calls = 0 @lazyattr def foo(self): """the docstring, carried onto the descriptor""" self.calls += 1 return self.calls * 10 _c = C() # THE CACHE IS COLD BEFORE THE FIRST READ, and it is cold in the only place that # matters -- the instance dict. Asserted before anything reads `foo', because # every case after this one depends on the first read being the first. emit('cold_calls', str(_c.calls)) emit('cold_in_dict', 'yes' if 'foo' in _c.__dict__ else 'no') # FIVE READS, ONE CALL. This is the package in one case. _vals = [_c.foo for _ in range(5)] emit('plain_calls_after_5_reads', str(_c.calls)) emit('plain_values_all_equal', 'yes' if len(set(_vals)) == 1 else 'no') emit('plain_value', str(_vals[0])) # THE VALUE IS IN THE INSTANCE DICT, UNDER THE ATTRIBUTE'S OWN NAME, and it is # the value ITSELF and not a wrapper or a (value, timestamp) pair. This is the # mechanism the counter case above only implies: after this write the class # attribute is SHADOWED, so a later read is an ordinary dict lookup that never # reaches `__get__'. emit('plain_in_dict', 'yes' if 'foo' in _c.__dict__ else 'no') emit('plain_dict_value', str(_c.__dict__.get('foo'))) emit('plain_dict_value_is_int', 'yes' if type(_c.__dict__.get('foo')) is int else 'no') emit('plain_dict_keys', ','.join(sorted(_c.__dict__))) # AND THE READ AFTER THAT DOES NOT ENTER `__get__' AT ALL. Measured, not # inferred: the descriptor is swapped for one that would count an entry, and the # count stays at zero because the instance dict answers first. _entries = [0] class Counting(lazyattr): def __get__(self, inst, owner): _entries[0] += 1 return lazyattr.__get__(self, inst, owner) class D(object): def __init__(self): self.calls = 0 @Counting def foo(self): self.calls += 1 return 7 _d = D() _ = _d.foo emit('shadow_entries_after_first', str(_entries[0])) _ = _d.foo _ = _d.foo emit('shadow_entries_after_third', str(_entries[0])) emit('shadow_calls', str(_d.calls)) # SUBCLASSING WORKS, which is what `invalidate' being a classmethod on `cls' is # for -- and it is what upstream's own example file exercises. emit('subclass_is_lazy', 'yes' if issubclass(Counting, lazyattr) else 'no') # ----------------------------------------------------- access on the CLASS --- # `__get__' with `inst is None' returns the DESCRIPTOR, not the value and not an # error. So `C.foo' is introspectable -- which is what makes the `__doc__' and # `__name__' cases below reachable at all -- and reading it computes nothing. _desc = C.foo emit('class_access_is_descriptor', 'yes' if isinstance(_desc, lazyattr) else 'no') emit('class_access_type', type(_desc).__name__) emit('class_access_calls', str(_c.calls)) emit('class_access_stable', 'yes' if C.foo is _desc else 'no') # 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. emit('desc_doc', getattr(_desc, '__doc__', 'no-attr')) emit('desc_name', getattr(_desc, '__name__', 'no-attr')) emit('desc_qualname', getattr(_desc, '__qualname__', 'no-attr')) emit('desc_module', getattr(_desc, '__module__', 'no-attr')) emit('desc_has_wrapped', 'yes' if hasattr(_desc, '__wrapped__') else 'no') emit('desc_wrapped_is_func', 'yes' if getattr(_desc, '__wrapped__', None) is not None and type(getattr(_desc, '__wrapped__')).__name__ == 'function' else 'no') emit('desc_wrapped_name', getattr(getattr(_desc, '__wrapped__', None), '__name__', 'no-attr')) # THE DOCSTRING IS THE FUNCTION'S, NOT THE CLASS'S. `lazy' has a docstring of # its own ("lazy descriptor ...") and `functools.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 # case is for. emit('desc_doc_is_not_class_doc', 'yes' if _desc.__doc__ != lazyattr.__doc__ else 'no') emit('class_doc_first_line', (lazyattr.__doc__ or '').strip().split('\n')[0]) # WHAT `wraps' PUT THERE, as a set. `functools.WRAPPER_ASSIGNMENTS' is a # CPython-version-dependent tuple (3.13 adds `__type_params__'), so this is # pinned as what the descriptor's own `__dict__' ends up holding rather than as # the tuple, and `wraps_assignments' records the tuple beside it so that a # difference between the two arms names its own cause. emit('desc_dict_keys', ','.join(sorted(vars(_desc)))) emit('wraps_assignments', ','.join(functools.WRAPPER_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. _c2 = C() emit('second_cold_in_dict', 'yes' if 'foo' in _c2.__dict__ else 'no') _v2 = _c2.foo emit('second_calls', str(_c2.calls)) emit('second_value', str(_v2)) emit('second_value_equals_first', 'yes' if _v2 == _vals[0] else 'no') emit('first_calls_untouched', str(_c.calls)) emit('first_value_untouched', str(_c.__dict__.get('foo'))) emit('class_dict_has_no_value', 'yes' if isinstance(vars(C).get('foo'), lazyattr) else 'no') # ----------------------------------------------------------- invalidation --- # DELETING THE CACHED ENTRY FORCES EXACTLY ONE RECOMPUTATION. Done twice, by # the two routes that exist: `del inst.__dict__[name]' -- which is what # `invalidate' does underneath -- and `lazy.invalidate' itself. del _c2.__dict__['foo'] emit('del_clears_dict', 'no' if 'foo' in _c2.__dict__ else 'yes') _v3 = _c2.foo emit('del_calls_after_reread', str(_c2.calls)) emit('del_value_after_reread', str(_v3)) _ = _c2.foo _ = _c2.foo emit('del_calls_after_two_more_reads', str(_c2.calls)) _c3 = C() _ = _c3.foo lazyattr.invalidate(_c3, 'foo') emit('invalidate_clears_dict', 'no' if 'foo' in _c3.__dict__ else 'yes') emit('invalidate_calls_unchanged', str(_c3.calls)) _ = _c3.foo emit('invalidate_calls_after_reread', str(_c3.calls)) emit('invalidate_value_after_reread', str(_c3.__dict__.get('foo'))) # INVALIDATING AN UNCACHED ATTRIBUTE IS A NO-OP, not an error: the method guards # on `if name in inst.__dict__'. So `invalidate' is idempotent, which is what # makes it usable from a consumer that does not track whether the value was ever # read. emit('invalidate_twice', err(lazyattr.invalidate, _c3, 'foo')) lazyattr.invalidate(_c3, 'foo') emit('invalidate_uncached_again', err(lazyattr.invalidate, _c3, 'foo')) # INVALIDATING SOMETHING THAT IS NOT A LAZY ATTRIBUTE RAISES, and it raises for # TWO different reasons that are worth telling apart: a name bound to something # else on the class (`isinstance(..., cls)' fails) and a name bound to nothing at # all (`getattr(owner, name)' fails first). Both are AttributeError, and the # guard is what stops `invalidate' silently deleting an ordinary instance # attribute. _c3.plain_attr = 1 class NotLazy(object): other = 1 emit('invalidate_non_lazy', err(lazyattr.invalidate, NotLazy(), 'other')) emit('invalidate_missing_name', err(lazyattr.invalidate, _c3, 'nosuch')) # AND THE SUBCLASS GUARD IS `cls', NOT `lazy'. `Counting.invalidate' accepts a # `Counting' attribute; `lazy.invalidate' accepts it too, since `Counting' IS a # `lazy'. The asymmetry is the other way round, and it is what the classmethod # buys: `Counting.invalidate' REFUSES a plain `lazy' attribute. emit('subclass_invalidate_own', err(Counting.invalidate, _d, 'foo')) emit('base_invalidate_subclass_attr', err(lazyattr.invalidate, D(), 'foo')) emit('subclass_invalidate_base_attr', err(Counting.invalidate, C(), 'foo')) # --------------------------------------------------- private-name mangling --- # TWO MECHANISMS PRODUCE A MANGLED CACHE KEY, and only one of them is `lazy''s. # They are separated here because reading the ordinary case alone gets the # package wrong. # # * THE COMPILER'S. `def __secret' inside a class body binds the name # `_M__secret' in the class dict, so `__set_name__' is handed the ALREADY # mangled name and `__get__''s own mangling branch never fires -- the name it # sees starts with `_M', not with `__'. This is the path every ordinary use # of the decorator takes. # # * `__get__''s OWN, which re-derives the mangling by hand. It fires only when # the descriptor's `__name__' still starts with `__', which happens when # `__set_name__' was never called -- a descriptor built and assigned AFTER # the class body. `late_*' below is that path, and it is the only thing in # this package that reads `owner'. # # A dunder is excluded by the second half of the guard (`not endswith("__")'), # which is what keeps `__dunder__' cacheable at all. # # Each class below counts ITS OWN calls, one counter per attribute, so a case # reads a number about the attribute it names rather than a running total. class M(object): def __init__(self): self.secret_calls = 0 self.dunder_calls = 0 @lazyattr def __secret(self): self.secret_calls += 1 return 'shh' @lazyattr def __dunder__(self): self.dunder_calls += 1 return 'not mangled' def read_secret(self): return self.__secret def read_dunder(self): return self.__dunder__ _m = M() emit('mangled_value', str(_m.read_secret())) emit('mangled_calls', str(_m.secret_calls)) # THE CLASS DICT KEY IS ALREADY MANGLED -- the compiler's doing, before `lazy' # sees anything. Both this and `mangled_desc_name' are about the definition # site; `mangled_key_present' is about the cache. emit('mangled_class_key_present', 'yes' if '_M__secret' in vars(M) else 'no') emit('mangled_class_plain_key_absent', 'no' if '__secret' in vars(M) else 'yes') emit('mangled_desc_name', getattr(vars(M)['_M__secret'], '__name__', 'no-attr')) # ... and the FUNCTION under it kept the unmangled name, which is what would # have reached `__get__' had `__set_name__' not overwritten it. This pair is # the whole reason the `late_*' path below exists. emit('mangled_func_name', getattr(getattr(vars(M)['_M__secret'], '__wrapped__', None), '__name__', 'no-attr')) emit('mangled_key_present', 'yes' if '_M__secret' in _m.__dict__ else 'no') emit('mangled_plain_key_absent', 'no' if '__secret' in _m.__dict__ else 'yes') _ = _m.read_secret() emit('mangled_calls_after_second_read', str(_m.secret_calls)) # A DUNDER IS NOT MANGLED, by either mechanism, and it caches under its own name. emit('dunder_value', str(_m.read_dunder())) emit('dunder_calls', str(_m.dunder_calls)) emit('dunder_key_present', 'yes' if '__dunder__' in _m.__dict__ else 'no') emit('dunder_mangled_key_absent', 'no' if '_M__dunder__' in _m.__dict__ else 'yes') _ = _m.read_dunder() emit('dunder_calls_after_second_read', str(_m.dunder_calls)) # READING AN INHERITED PRIVATE LAZY ATTRIBUTE caches under the DEFINING class's # key, not the subclass's -- because the key was fixed by the compiler at # definition time and `__get__' never re-mangles it. `owner' is `MSub' here and # is ignored. Pinned because it is the natural place to expect the hand-rolled # mangling to matter and it does not. class MSub(M): pass _ms = MSub() _ = _ms.read_secret() emit('subclass_mangled_defining_key', 'yes' if '_M__secret' in _ms.__dict__ else 'no') emit('subclass_mangled_subclass_key', 'yes' if '_MSub__secret' in _ms.__dict__ else 'no') emit('subclass_mangled_calls', str(_ms.secret_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__' call, so its `__name__' is still # whatever `functools.wraps' copied off the function -- `__hidden', which starts # with `__' and does not end with one. `__get__' therefore mangles it against # `owner' and caches under `_N__hidden', while the attribute LOOKUP that reached # it used the key `__hidden'. # # So the instance dict NEVER SHADOWS this attribute: the lookup key stays # `__hidden' and the cache key is `_N__hidden', so every read re-enters # `__get__'. The cache still hits -- `__get__' looks the mangled key up itself # -- but it hits one level deeper than the ordinary case, which is exactly what # `late_calls_after_third' beside `shadow_entries_after_third' says. # # `invalidate' does NOT reach the same attribute, and that asymmetry is the one # rough edge in this package. It mangles the name and then looks the result up # on the CLASS (`getattr(owner, name)'), where the descriptor is bound under the # UNMANGLED `__hidden' -- so the guard raises before it can clear anything. # Pinned as the exception type it actually raises, in this package's own tree, # per no-public-upstream-reports. def _hidden_body(self): self.late_calls += 1 return 'late' _hidden_body.__name__ = '__hidden' class N(object): def __init__(self): self.late_calls = 0 setattr(N, '__hidden', lazyattr(_hidden_body)) _n = N() emit('late_desc_name', getattr(vars(N)['__hidden'], '__name__', 'no-attr')) emit('late_value', str(getattr(_n, '__hidden'))) emit('late_calls_after_first', str(_n.late_calls)) emit('late_mangled_key_present', 'yes' if '_N__hidden' in _n.__dict__ else 'no') emit('late_lookup_key_absent', 'no' if '__hidden' in _n.__dict__ else 'yes') _ = getattr(_n, '__hidden') _ = getattr(_n, '__hidden') emit('late_calls_after_third', str(_n.late_calls)) # And `invalidate' reaches the SAME key, because it carries the same mangling -- # which is what the branch is for. Called with the unmangled name, it clears # `_N__hidden'. emit('late_invalidate_error', err(lazyattr.invalidate, _n, '__hidden')) emit('late_invalidate_mangled_error', err(lazyattr.invalidate, _n, '_N__hidden')) emit('late_still_cached', 'yes' if '_N__hidden' in _n.__dict__ else 'no') # The route that DOES clear it is the instance dict itself, under the mangled # key -- which is what `invalidate' would have done had the guard passed. del _n.__dict__['_N__hidden'] _ = getattr(_n, '__hidden') emit('late_calls_after_manual_clear', str(_n.late_calls)) # --------------------------------------------------------------- renaming --- # `__set_name__' IS WHAT PICKS THE CACHE KEY, and it is called by the class # machinery with 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', and the cache key is `alias'. def body(self): """body's own docstring""" self.calls += 1 return 'v' class R(object): def __init__(self): self.calls = 0 alias = lazyattr(body) _r = R() emit('renamed_func_name', body.__name__) emit('renamed_desc_name', getattr(vars(R)['alias'], '__name__', 'no-attr')) emit('renamed_value', str(_r.alias)) emit('renamed_key_present', 'yes' if 'alias' in _r.__dict__ else 'no') emit('renamed_func_key_absent', 'no' if 'body' in _r.__dict__ else 'yes') emit('renamed_calls', str(_r.calls)) emit('renamed_doc', getattr(vars(R)['alias'], '__doc__', 'no-attr')) # `__set_name__' CALLED BY HAND MOVES THE KEY, which is what upstream's own # example file checks and the only reason the method is public. A descriptor # renamed after the fact caches under the new name -- so the OLD attribute # reference stops being served from the dict. Both stated. _desc_r = vars(R)['alias'] _desc_r.__set_name__(R, 'renamed') emit('setname_by_hand_name', getattr(_desc_r, '__name__', 'no-attr')) _r2 = R() _ = _r2.alias emit('setname_by_hand_key', 'yes' if 'renamed' in _r2.__dict__ else 'no') emit('setname_by_hand_old_key_absent', 'no' if 'alias' in _r2.__dict__ else 'yes') _desc_r.__set_name__(R, 'alias') # put it back for anything after this emit('setname_restored', getattr(_desc_r, '__name__', 'no-attr')) # ---------------------------------------------------------- inheritance --- # A SUBCLASS OVERRIDING A LAZY ATTRIBUTE and reading the base's through `super()' # 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 and the base's `__get__' still ran. Pinned # because it is surprising and because it is what the example file relies on. class Base(object): def __init__(self): self.base_calls = 0 self.sub_calls = 0 @lazyattr def foo(self): self.base_calls += 1 return 'base' class Sub(Base): @lazyattr def foo(self): self.sub_calls += 1 return 'sub+' + Base.foo.__get__(self, Base) _s = Sub() emit('inherit_value', str(_s.foo)) emit('inherit_sub_calls', str(_s.sub_calls)) emit('inherit_base_calls', str(_s.base_calls)) _ = _s.foo emit('inherit_sub_calls_after_second', str(_s.sub_calls)) emit('inherit_dict_keys', ','.join(sorted(k for k in _s.__dict__ if not k.endswith('_calls')))) # ------------------------------------------------------- the no-__dict__ arm --- # 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, so it is pinned as a # whole: it is the only string this package formats. class Slotted(object): __slots__ = () @lazyattr def foo(self): return 'never' try: _ = Slotted().foo _slot_err, _slot_msg = 'none', '' except BaseException as exc: _slot_err, _slot_msg = type(exc).__name__, str(exc).replace('\n', ' ') emit('slots_error', _slot_err) emit('slots_message', _slot_msg) # `invalidate' CARRIES THE SAME GUARD, checked before anything else it does. try: lazyattr.invalidate(Slotted(), 'foo') _slot_inv, _slot_inv_msg = 'none', '' except BaseException as exc: _slot_inv = type(exc).__name__ _slot_inv_msg = str(exc).replace('\n', ' ') emit('slots_invalidate_error', _slot_inv) emit('slots_invalidate_message', _slot_inv_msg) # ------------------------------------------------- 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 expected present -- and # `lazy[str]' is expected to be a subscript that produces a `types.GenericAlias' # and NOT a new class, which is what makes `@lazy[str]' usable as a decorator. emit('has_class_getitem', 'yes' if hasattr(lazyattr, '__class_getitem__') else 'no') try: _alias = lazyattr[str] _alias_type = type(_alias).__name__ _alias_origin = getattr(_alias, '__origin__', None) _alias_args = getattr(_alias, '__args__', ()) _alias_err = 'none' except BaseException as exc: _alias_type, _alias_origin, _alias_args = '', None, () _alias_err = type(exc).__name__ emit('class_getitem_error', _alias_err) emit('class_getitem_type', _alias_type) emit('class_getitem_origin_is_lazy', 'yes' if _alias_origin is lazyattr else 'no') emit('class_getitem_args', ','.join(getattr(a, '__name__', str(a)) for a in _alias_args)) # AND IT STILL DECORATES. `@lazy[str]' calls the alias, which forwards to # `lazy.__init__' -- so the subscripted form has to produce a working descriptor, # not merely a printable one. This is what upstream's `example.py' class `Y' # checks under mypy and nothing checks at runtime. try: class G(object): def __init__(self): self.calls = 0 @lazyattr[str] def foo(self): self.calls += 1 return 'g' _g = G() _gv = str(_g.foo) _ = _g.foo _g_calls = str(_g.calls) _g_is_desc = 'yes' if isinstance(vars(G)['foo'], lazyattr) else 'no' _g_err = 'none' except BaseException as exc: _gv, _g_calls, _g_is_desc = '', '', '' _g_err = type(exc).__name__ emit('subscripted_decorator_error', _g_err) emit('subscripted_value', _gv) emit('subscripted_calls', _g_calls) emit('subscripted_is_descriptor', _g_is_desc) # ------------------------------------------------------- the type stub ships --- # `py.typed' AND `lazy.pyi' ARE PAYLOAD, declared through # `[options.package_data]' in setup.cfg. The suite stats them on the target # filesystem; this is the same claim as the INTERPRETER sees it, which is what # says they landed inside the importable package rather than merely inside the # .deb. import os # noqa: E402 (after the gate, by design) _pkgdir = os.path.dirname(getattr(lazypkg, '__file__', '') or '.') emit('pkgdir_has_py_typed', 'yes' if os.path.isfile(os.path.join(_pkgdir, 'py.typed')) else 'no') emit('pkgdir_has_pyi', 'yes' if os.path.isfile(os.path.join(_pkgdir, 'lazy.pyi')) else 'no') emit('pkgdir_names', ','.join(sorted(n for n in os.listdir(_pkgdir) if n != '__pycache__'))) # THE EXAMPLES AND TESTS SUBPACKAGES ARE EXCLUDED from the wheel by # `[options.packages.find] exclude', so neither reaches the image. Asserted as # an absence: a release that stopped excluding them would ship `lazy.tests' onto # a production image, and nothing else here would notice. emit('no_tests_subpackage', 'no' if os.path.isdir(os.path.join(_pkgdir, 'tests')) else 'yes') emit('no_examples_subpackage', 'no' if os.path.isdir(os.path.join(_pkgdir, 'examples')) else 'yes') # ------------------------------------------------------------- interpreter --- # WHICH ARM THIS TRANSCRIPT IS FROM, so that a control run and a target run are # distinguishable in the capture without reading the surrounding log. # # `py_maxsize_bits' is the ONE value in this file the suite does NOT compare # against the control: it reads 64 there and must read 32 here. Without it a # suite run against a HOST copy of the module would pass in full, since every # other value here is arm-independent by construction. emit('py_version', '%d.%d' % sys.version_info[:2]) emit('py_maxsize_bits', str(sys.maxsize.bit_length() + 1)) emit('emitted_count', str(len(emitted) + 2)) emit('probe_done', 'yes') sys.exit(0)