#!/usr/bin/env python3 """One interpreter start for every measurement 99-python3-classes makes. WHY ONE PROCESS. A python3 start on wasm32-hwjs is tens of seconds before the first line of a program runs, and this suite has ~100 things to say about `classes'. Run as separate `python3 -c' invocations that would be an hour of interpreter starts and nothing else; run here it is ONE start, and the work inside is a few hundred dictionary lookups and isinstance() calls. THE OUTPUT CONTRACT. One `key=value' line per measurement, keys emitted in file order, `probe_done=yes' last with `emitted_count=N' beside it. The suite asserts both, so a probe that died two thirds of the way through is distinguishable from a library that returned the wrong answers -- without them the early cases stay green and the later ones read got:'' , which looks like a partial library. EVERY DISPATCH CASE EMITS WHICH IMPLEMENTATION RAN, NOT JUST A VALUE. The whole subject of this package is that one call reaches one of several functions chosen by the runtime type of its first argument, so a case whose value merely happened to match would say nothing. Each instance below returns a tag naming itself, and the assertion is on the tag. ORDER IS LOAD-BEARING IN ONE PLACE AND THE FILE SAYS SO WHERE IT IS. `_TypeClass.__call__` writes `default_implementation` into `_dispatch_cache` when nothing matched, and `.supports()` returns True for anything already in that cache -- so a `.supports(X)` asked AFTER a failed call for X answers the opposite of a `.supports(X)` asked before it. Every `supports_*' key below is therefore emitted before any failing call on the same typeclass, and the behaviour itself is measured deliberately in `THE .supports() CACHE DEFECT'. NOTHING HERE IS TIMED, RANDOM, OR ADDRESS-BEARING. `_TypeClass` has no `__repr__` of its own -- `str()` is defined and `repr()` falls through to object's, which carries the instance address and would red at random -- so `str()` is what is measured and `repr()` is never emitted. """ import sys import traceback COUNT = 0 def emit(key, value): global COUNT COUNT += 1 sys.stdout.write("%s=%s\n" % (key, value)) def val(key, fn): """Emit fn()'s value, or `raised:' -- never a traceback. A case that expects a value and gets an exception name still reds, and the class name is what says which. The traceback goes to stderr, which bst_probe keeps at .err. """ try: emit(key, fn()) except Exception: traceback.print_exc() emit(key, "raised:%s" % sys.exc_info()[0].__name__) def raises(key, fn): """Emit `: ' for a call that is REQUIRED to raise. A call that returns instead emits `returned:', which reds -- an absent exception must not be able to look like a passing case. """ try: result = fn() except Exception as exc: # noqa: BLE001 emit(key, "%s: %s" % (type(exc).__name__, exc)) else: emit(key, "returned:%r" % (result,)) def raises_importerror(key, fn): """Emit `importerror' for a call required to fail at an import. NEITHER THE MESSAGE NOR THE EXACT CLASS IS OURS TO PIN. A missing module raises `ModuleNotFoundError'; a module that is present but broken raises plain `ImportError' -- so an image WITH mypy and an image without it differ in the class name for a reason that is nothing to do with `classes'. What is a fact about `classes' is that this import fails at the import layer and not somewhere else, so that is what is emitted. """ try: result = fn() except ImportError: emit(key, "importerror") except Exception: # noqa: BLE001 traceback.print_exc() emit(key, "raised:%s" % sys.exc_info()[0].__name__) else: emit(key, "returned:%r" % (result,)) # ============================================================ the identity === import classes # noqa: E402 from classes import AssociatedType, Supports, typeclass # noqa: E402 from classes import _registry, _typeclass # noqa: E402 emit("module_file", classes.__file__) emit("is_package", "yes" if hasattr(classes, "__path__") else "no") emit("has_dunder_version", "yes" if hasattr(classes, "__version__") else "no") emit("public_names", ",".join(sorted( n for n in vars(classes) if not n.startswith("_")))) emit("typeclass_module", typeclass.__module__) emit("submodule_typeclass", _typeclass.__name__) emit("submodule_registry", _registry.__name__) # The .pyc answered, not the .py. `classes' ships precompiled bytecode and this # is the statement that the import read it; a red here means every import on # this image is recompiling the package's source. emit("cached_bytecode", "yes" if getattr( classes, "__cached__", None) else "no") try: from importlib.metadata import metadata, version emit("dist_version", version("classes")) emit("dist_name", metadata("classes")["Name"]) # NORMALIZED, NOT PINNED. The Requires-Dist text is written by whichever # setuptools built the wheel -- `typing_extensions (>=3.10,<5.0)' from the # PyPI wheel, `typing-extensions<5.0,>=3.10' from a modern build of the # same sdist -- and neither spelling is a fact about `classes'. What IS one # is that the dependency is declared and that it is the only one. _reqs = metadata("classes").get_all("Requires-Dist") or [] emit("dist_requires_count", str(len(_reqs))) emit("dist_requires_te", "yes" if any( "typing" in req and "extensions" in req for req in _reqs) else "no") except Exception: # noqa: BLE001 traceback.print_exc() emit("dist_version", "raised") emit("dist_name", "raised") emit("dist_requires_count", "raised") emit("dist_requires_te", "raised") # THE ONE RUNTIME DEPENDENCY, IMPORTED BY THE PACKAGE ITSELF. _typeclass.py # imports TypeGuard and final from typing_extensions at module level, so a # `classes' that imported at all has already proved typing_extensions is here; # this names it so that a red points at the dependency and not at classes. import typing_extensions # noqa: E402 emit("typing_extensions_file", typing_extensions.__file__) # ================================================== the simplest typeclass === # The definition alone, before any instance: a typeclass with no instances is a # function that raises, and that is the documented default implementation. @typeclass def to_json(instance) -> str: """Example typeclass, defined from a plain function.""" emit("tc_type", type(to_json).__name__) val("tc_str", lambda: str(to_json)) # `_TypeClass` defines no `__repr__`, so repr() is object's and carries an # address. Measured as a FACT about the class, not as a value. emit("tc_has_repr", "yes" if "__repr__" in vars(type(to_json)) else "no") emit("tc_is_final", "yes" if getattr( type(to_json), "__final__", False) else "no") emit("tc_slots", ",".join(type(to_json).__slots__)) emit("tc_registries_empty", "%d,%d,%d" % ( len(to_json._exact_types), len(to_json._protocols), len(to_json._delegates))) # `.supports` on the empty typeclass, BEFORE the call that raises -- see the # module docstring on why that order is not a stylistic choice. emit("tc_bare_supports_int", repr(to_json.supports(1))) # The negative the whole package is built around: nothing matched, and the # message names the type's `__qualname__`. raises("tc_bare_int", lambda: to_json(1)) raises("tc_bare_none", lambda: to_json(None)) # =============================================== two concrete registrations == # The ticket's core assertion: a value of each registered type reaches its OWN # implementation. Every instance returns a tag naming itself, so a dispatch that # reached the wrong function cannot pass by returning an equal value. @to_json.instance(int) def _to_json_int(instance: int) -> str: return "int:%d" % instance @to_json.instance(str) def _to_json_str(instance: str) -> str: return "str:%s" % instance val("disp_int", lambda: to_json(1)) val("disp_str", lambda: to_json("a")) # bool is a SUBCLASS of int and has no instance of its own, so it reaches the # int implementation through the mro fallback -- `_find_impl`, the same # machinery `functools.singledispatch` uses. The value proves which branch # answered: `int:1`, not a bool case. val("disp_bool_via_mro", lambda: to_json(True)) # `.supports` for a type with no instance and no registered base, asked while # the cache is still clean. emit("supports_int", repr(to_json.supports(1))) emit("supports_str", repr(to_json.supports("a"))) emit("supports_bool", repr(to_json.supports(True))) emit("supports_float_clean", repr(to_json.supports(1.5))) # ...and the call itself still raises for it. raises("disp_float_unmatched", lambda: to_json(1.5)) # The decorator RETURNS THE IMPLEMENTATION unchanged, so the module-level name # it was applied to is still callable directly. That is what makes stacking # `.instance` decorators work, which the package's own README does. val("instance_returns_impl", lambda: _to_json_int(7)) # ------------------------------------------- the arguments after the first -- # Dispatch is on the FIRST argument only; everything after it is forwarded # untouched, positional and keyword alike. @typeclass def described(instance, prefix, sep=":") -> str: """Typeclass whose signature carries more than the dispatched argument.""" @described.instance(int) def _described_int(instance: int, prefix, sep=":") -> str: return "%s%s%d" % (prefix, sep, instance) val("args_positional", lambda: described(5, "n")) val("args_keyword", lambda: described(5, "n", sep="=")) # ----------------------------------------------------- stacked registration - # The README registers one implementation for two types by stacking decorators. # It works because `.instance`'s decorator returns the implementation unchanged. @typeclass def stacked(instance) -> str: """Typeclass registered for two types by one stacked implementation.""" @stacked.instance(int) @stacked.instance(float) def _stacked_number(instance) -> str: return "number:%r" % instance val("stacked_int", lambda: stacked(2)) val("stacked_float", lambda: stacked(2.5)) emit("stacked_registry_size", str(len(stacked._exact_types))) # ================================================ the registration argument == # `.instance` takes exactly one of exact_type / protocol= / delegate=, and # `classes._registry.choose_registry` is the one place that is enforced. Both # refusals are measured, because a package that silently accepted two would # dispatch through a registry the caller did not choose. raises("inst_no_argument", lambda: to_json.instance()) raises("inst_two_arguments", lambda: to_json.instance(int, protocol=object)) emit("invalid_arguments_msg", _registry.INVALID_ARGUMENTS_MSG) # `None` is not the default sentinel -- a private `DefaultValue` type is -- so # `.instance(None)` is a REGISTRATION, for `type(None)`. The distinction is the # whole reason the sentinel exists, and this is the case that measures it. emit("default_value_name", _registry.DefaultValue.__name__) @typeclass def nullable(instance) -> str: """Typeclass used to register `None` as a type.""" @nullable.instance(None) def _nullable_none(instance) -> str: return "none-case" val("inst_none_registers_nonetype", lambda: nullable(None)) emit("inst_none_registry_key", list(nullable._exact_types)[0].__name__) raises("inst_none_leaves_int", lambda: nullable(1)) # A CONCRETE GENERIC CANNOT BE AN EXACT TYPE, and the refusal comes from # `isinstance(object(), typ)` inside `.instance` -- at REGISTRATION, not at the # first dispatch. This is the failure `delegate=` exists to work around, so it # is measured immediately before the delegate that works. from typing import List # noqa: E402 raises("inst_generic_alias", lambda: to_json.instance(List[int])) # ============================================ the container / delegate path == # The documented way to dispatch on a CONCRETE generic: a delegate class whose # metaclass answers `isinstance`, registered with `delegate=`. # `_dispatch_delegate` runs it on every call, ahead of the cache, because a # delegate's answer cannot be keyed by type -- `type([1]) is type(['a'])`. class _ListOfIntMeta(type): def __instancecheck__(cls, other) -> bool: return ( isinstance(other, list) and bool(other) and all(isinstance(item, int) and not isinstance(item, bool) for item in other) ) class ListOfInt(metaclass=_ListOfIntMeta): """Delegate standing for `List[int]`.""" @typeclass def summarize(instance) -> str: """Typeclass exercising the delegate and exact registries together.""" @summarize.instance(delegate=ListOfInt) def _summarize_list_of_int(instance) -> str: return "list-of-int:%d" % len(instance) @summarize.instance(list) def _summarize_list(instance) -> str: return "list:%d" % len(instance) emit("delegate_registry_size", str(len(summarize._delegates))) # Both `.supports` answers while the cache is clean. They are the same for a # reason worth stating: a delegate is consulted by `.supports` too, and a list # of strings still matches the EXACT `list` instance. emit("delegate_supports_ints", repr(summarize.supports([1, 2]))) emit("delegate_supports_strs", repr(summarize.supports(["a"]))) # A list of ints matches the delegate, which is tried FIRST. val("delegate_matches", lambda: summarize([1, 2, 3])) # A list that is not all ints falls through the delegate to the exact `list` # instance -- the same call, the same runtime type, a different implementation. val("delegate_falls_through", lambda: summarize(["a", "b"])) # ...and this is the precedence statement itself: `list` IS registered exactly, # and the delegate still wins for the values it claims. val("delegate_beats_exact", lambda: summarize([1])) # A delegate is never cached, by construction: two calls with the same runtime # type take different branches, which a type-keyed cache could not express. # Measured by calling both ways round after the cache has been populated -- if # the delegate result had been cached under `list`, one of these two would now # answer with the other's tag. val("delegate_recheck_ints", lambda: summarize([4, 5])) val("delegate_recheck_strs", lambda: summarize(["c"])) emit("delegate_cache_size", str(len(summarize._dispatch_cache))) # ==================================================== the protocol registry == # `protocol=` dispatches on structure rather than on identity, and it is tried # AFTER every exact type. The ticket's accept/reject pair is here: a class that # implements the protocol reaches the instance, one that does not raises. from typing_extensions import Protocol, runtime_checkable # noqa: E402 @runtime_checkable class HasField(Protocol): field: str @typeclass def render(instance) -> str: """Typeclass registered against a user-defined runtime protocol.""" @render.instance(protocol=HasField) def _render_has_field(instance) -> str: return "protocol:%s" % instance.field @render.instance(str) def _render_str(instance: str) -> str: return "exact-str:%s" % instance class WithField(object): field: str = "present" class WithoutField(object): other: str = "absent" emit("protocol_registry_size", str(len(render._protocols))) # Both `.supports` answers before either call -- the reject half is the one the # cache would otherwise turn into a False that reads True. emit("protocol_supports_yes", repr(render.supports(WithField()))) emit("protocol_supports_no", repr(render.supports(WithoutField()))) val("protocol_accepts", lambda: render(WithField())) raises("protocol_rejects", lambda: render(WithoutField())) # EXACT BEATS PROTOCOL, measured rather than assumed: `_dispatch` reads # `_exact_types` before it walks `_protocols`. val("protocol_exact_wins", lambda: render("s")) # The stdlib's own protocols work the same way, which is what makes this # registry useful without writing a Protocol first. from typing import Sized # noqa: E402 @typeclass def sized_or_not(instance) -> str: """Typeclass registered against a stdlib protocol.""" @sized_or_not.instance(protocol=Sized) def _sized(instance) -> str: return "sized:%d" % len(instance) val("protocol_stdlib_sized", lambda: sized_or_not([1, 2])) raises("protocol_stdlib_unsized", lambda: sized_or_not(1)) # ================================================= associated types, Supports # The second way to define a typeclass: `@typeclass(SomeAssociatedType)`. At # RUNTIME the associated type does two things and no more -- it appears in # `str()`, and it is what `Supports[...]` names in an annotation. Everything # else it does is mypy-time, and this suite says so rather than leaving the # absence to be read as a gap. class ToJson(AssociatedType): """Associated type for the typeclass below.""" @typeclass(ToJson) def assoc_json(instance) -> str: """Typeclass defined WITH an associated type.""" @assoc_json.instance(int) def _assoc_json_int(instance: int) -> str: return "assoc-int:%d" % instance emit("assoc_tc_type", type(assoc_json).__name__) val("assoc_str", lambda: str(assoc_json)) val("assoc_dispatch", lambda: assoc_json(1)) raises("assoc_unmatched", lambda: assoc_json(1.5)) def convert(instance: Supports[ToJson]) -> str: """A function annotated with `Supports`, exactly as the docs write it.""" return assoc_json(instance) # `Supports[ToJson]` IS ONLY AN ANNOTATION HERE. The call below is not narrowed # or checked at runtime by anything: it reaches the typeclass, and the typeclass # is what accepts or refuses. That is the accept/reject pair for the `Supports` # form, and the reject half comes from the typeclass rather than from # `Supports` -- which is the fact worth having, since a reader of the docs could # reasonably expect the annotation to do the refusing. val("supports_annotation_accepts", lambda: convert(1)) raises("supports_annotation_rejects", lambda: convert(1.5)) emit("supports_annotation_repr", str(convert.__annotations__["instance"])) emit("supports_is_final", "yes" if getattr( Supports, "__final__", False) else "no") emit("supports_slots", repr(Supports.__slots__)) emit("supports_alias_origin", Supports[ToJson].__origin__.__name__) # THE VARIADIC-GENERIC HACK, which is the one thing AssociatedType does at run # time. `__class_getitem__` forces `__parameters__` to the count being # subscripted and puts it back afterwards, so a single class subscripts with any # arity -- and the restoration is what a case has to check, since a leaked # `__parameters__` would break the NEXT subscription rather than this one. from typing import TypeVar # noqa: E402 _A = TypeVar("_A") _B = TypeVar("_B") _C = TypeVar("_C") emit("assoc_params_before", str(len(AssociatedType.__parameters__))) val("assoc_variadic_one", lambda: AssociatedType[_A].__origin__.__name__) val("assoc_variadic_three", lambda: str(len(AssociatedType[_A, _B, _C].__args__))) emit("assoc_params_after", str(len(AssociatedType.__parameters__))) emit("assoc_slots", repr(AssociatedType.__slots__)) class WithTwo(AssociatedType[_A, _B]): """Associated type subscripted with two parameters.""" emit("assoc_subclass_params", str(len(WithTwo.__parameters__))) # ====================================================== re-registration ====== # What happens when a type that ALREADY has an instance is registered again. # # MEASURED, NOT DOCUMENTED. `.instance`'s decorator is a bare # `registry[typ] = implementation`, so the second registration REPLACES the # first, silently: no exception, no warning, no diagnostic anywhere, and the # package's docs do not say this either way. The cases below pin the behaviour # so a release that starts refusing -- which would be the defensible choice -- # reds here and says so, rather than changing which implementation runs in # somebody's program. @typeclass def rereg(instance) -> str: """Typeclass used to measure re-registration of one type.""" @rereg.instance(int) def _rereg_first(instance: int) -> str: return "first" val("rereg_before", lambda: rereg(1)) def _rereg_capture(): import warnings with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") @rereg.instance(int) def _rereg_second(instance: int) -> str: return "second" return str(len(caught)) val("rereg_warnings", _rereg_capture) # THE CACHE IS INVALIDATED, and that is the half a naive replacement would get # wrong. `rereg(1)` above populated `_dispatch_cache[int]` with the FIRST # implementation; `.instance`'s decorator clears the whole cache, so the call # below reaches the second. Without the clear this would still answer `first` # and the replacement would be invisible until the process restarted. val("rereg_after", lambda: rereg(1)) emit("rereg_registry_size", str(len(rereg._exact_types))) # ...and the first implementation is simply gone from the registry, rather than # kept behind the second. emit("rereg_registry_impl", rereg._exact_types[int].__name__) # ------------------------------------------- the cache, on the ordinary path # The same invalidation for a type registered for the FIRST time after a failed # dispatch: `to_json(1.5)` raised above and put `default_implementation` in the # cache under `float`. Registering float now must displace it. raises("cache_float_before", lambda: to_json(1.5)) @to_json.instance(float) def _to_json_float(instance: float) -> str: return "float:%r" % instance val("cache_float_after", lambda: to_json(1.5)) emit("cache_is_weak", type(to_json._dispatch_cache).__name__) # =============================================== THE .supports() CACHE DEFECT # `.supports(X)` ANSWERS True FOR A TYPE THE TYPECLASS DOES NOT SUPPORT, once a # call for that type has already raised. Measured here on a typeclass used for # nothing else, so the order is the only variable. # # The mechanism is two lines in _typeclass.py. `__call__` writes # # impl = self._dispatch(...) or default_implementation # self._dispatch_cache[instance_type] = impl # # -- caching the FAILURE as though it were a resolution -- and `.supports` opens # with `if instance_type in self._dispatch_cache: return True`, whose comment # says "if some type is already in the cache, it means that it is not a # delegate", which is true and is not the question being answered. # # The consequence for a caller is that `.supports` is order-dependent: guard a # call with it and the guard is correct until something else calls the typeclass # with the same unsupported type, after which it lets every such value through # to a NotImplementedError. `TypeGuard` in its signature makes it read like a # total predicate. # # UPSTREAM'S, NOT brintOS'S, and not this arm's -- the x86-64 control answers # identically, and this project files nothing against third-party public # repositories. Pinned so a release that fixes it reds here and says so. @typeclass def guarded(instance) -> str: """Typeclass used for nothing but the .supports() ordering measurement.""" @guarded.instance(int) def _guarded_int(instance: int) -> str: return "guarded-int" emit("guard_supports_before", repr(guarded.supports("a"))) raises("guard_call_raises", lambda: guarded("a")) emit("guard_supports_after", repr(guarded.supports("a"))) # The supported type is unaffected either way -- so the defect is a False that # became True, not a predicate that stopped working. emit("guard_supports_int", repr(guarded.supports(1))) # And the call still raises, so it is `.supports` that disagrees with the # typeclass rather than the dispatch that changed. raises("guard_call_still_raises", lambda: guarded("a")) # ================================================ upstream's own doctests ==== # `classes` ships no test package -- setup.py names only the library and its # mypy plugin -- but `_typeclass.py`'s docstrings are executable and are the # package's real specification: the module docstring alone walks the basic # dispatch, the protocol registry and a user-defined protocol. # # THE ATTEMPTED COUNT IS THE ASSERTION, not just the failure total. A doctest # run that collected NOTHING reports zero failures and reads green, which is # what a stripped-docstring build (`python3 -OO`, or a .pyc compiled with it) # would produce -- and it would take the whole of this evidence with it. try: import doctest _dt_attempted = 0 _dt_failed = 0 for _mod in (classes, _typeclass, _registry): _res = doctest.testmod(_mod, verbose=False, report=False) _dt_attempted += _res.attempted _dt_failed += _res.failed emit("doctest_attempted", str(_dt_attempted)) emit("doctest_failed", str(_dt_failed)) except Exception: # noqa: BLE001 traceback.print_exc() emit("doctest_attempted", "raised") emit("doctest_failed", "raised") # ================================================ the mypy plugin, unloaded == # `classes.contrib.mypy` is shipped and is NOT importable on this image: every # module under it imports mypy, which is not installed. That is correct and is # not a defect -- the plugin is type-check-time code that a target never runs -- # but it is the one part of the payload that cannot be imported, so it is # measured rather than left for a later reader to discover as a surprise. # # THE IMPORT LAYER, NOT THE CLASS OR THE MESSAGE. On this image the import # fails at `import mypy` with a ModuleNotFoundError; on a host that HAS mypy it # fails inside mypy with a plain ImportError naming something else entirely. # Neither is a fact about `classes`, so what is asserted is that it fails as an # import at all -- and, beside it, that the subpackage above it loads, which is # what locates the failure at the plugin rather than at `classes.contrib`. raises_importerror("contrib_mypy_import", lambda: __import__("classes.contrib.mypy.classes_plugin")) val("contrib_package_imports", lambda: __import__("classes.contrib").contrib.__name__) emit("emitted_count", str(COUNT + 1)) emit("probe_done", "yes")