"""Drive versiontools' two halves -- the tuple and the string -- and report what they produced. One interpreter start does the whole measurement: a `python3` start on this arm costs tens of seconds, so every question the suite asks is answered from this run's output rather than from a second process. Output is one `key=value` per line. The suite greps keys; it never parses this file. A key is emitted at most once, and a key that could not be measured is simply absent -- the suite's `probe-output` and `probe-complete` cases are what tell a reader that an absent key means a dead probe rather than a false answer. NOTHING HERE READS A CLOCK, A DEVICE, A PEER OR A TERMINAL. Every fixture below is written by this file into a fresh temporary directory, so every value the suite asserts is a constant of the source rather than of the machine or the day. THE TWO HALVES ARE MEASURED SEPARATELY, ON PURPOSE. A `versiontools.Version` is a `tuple` subclass that ALSO renders itself as a PEP 440-shaped string, and the two are independent: `__str__` is overridden and `__repr__` is NOT, so a Version compares, unpacks and hashes exactly as the plain five-tuple does while printing as something else entirely. A suite that only asserted `str()` would leave a broken `__new__` green, and one that only compared tuples would leave a broken formatter green. WHAT IS NOT MEASURED HERE, AND WHY. `Version.vcs` queries bzr/git/hg through entry points, and none of those three tools is on this image -- each plugin's `from_source_tree` swallows its own ImportError and logs at DEBUG, so the query returns None rather than failing. The suite therefore asserts the NO-VCS answer (`1.3.dev`, with no revision suffix) and does not attempt to build a repository to get the other one. That is the arm this image will always take. """ import os import sys import tempfile import versiontools from versiontools import Version, format_version # --------------------------------------------------------------------------- # THE SOURCE-TREE HEURISTIC, MEASURED AT MODULE LEVEL BECAUSE THAT IS THE ONLY # PLACE IT FIRES. # # `Version.__new__` calls `_find_source_tree()`, which walks the caller frames # looking for a `` frame whose CURRENT SOURCE LINE contains the literal # text `__version__`. It is a TEXT match on the line, not a check that anything # is being assigned -- the comment at the end of the next statement is what # makes it fire, and that is exactly the point worth pinning: the detection is # lexical, so a project that spells its version variable anything else gets no # source tree and therefore no VCS suffix, silently. # # Constructed here, at module level, this Version's `_source_tree` is the # directory holding THIS FILE. The same construction inside `main()` below # yields None, and the suite asserts both. _MODULE_LEVEL = Version(9, 9, 9) # __version__ PROBE_DIR = os.path.dirname(os.path.abspath(__file__)) def emit(key, value): print("%s=%s" % (key, value)) def yn(b): return "yes" if b else "no" def caught(fn): """Run `fn` and describe what it raised, as `Type: message`, or `no-raise`. Used ONLY where the message is versiontools' own text. Where CPython writes part of it -- an ImportError's "No module named", an AttributeError's wording, `int()`'s "invalid literal" -- the whole string is a moving target across interpreter releases, and the two helpers below are used instead so that a python3 bump cannot turn a working port red. """ try: fn() except Exception as e: return "%s: %s" % (type(e).__name__, e) return "no-raise" def caught_type(fn): """The exception TYPE `fn` raised, or `no-raise`.""" try: fn() except Exception as e: return type(e).__name__ return "no-raise" def caught_starts(fn, prefix): """`yes` iff `fn` raised and the message opens with versiontools' own text.""" try: fn() except Exception as e: return yn(str(e).startswith(prefix)) return "no-raise" def main(): emit("module_file", getattr(versiontools, "__file__", "")) # The third statement of the no-extension negative, from inside the # interpreter. See the suite header: on this image a .so could not be # imported at all, so a package that grew one would not merely be slower. emit("module_is_py", yn(str(getattr(versiontools, "__file__", "")).endswith(".py"))) from versiontools import setuptools_hooks emit("hooks_file", getattr(setuptools_hooks, "__file__", "")) # THE INSTALLED VERSION, read through importlib.metadata -- the packaging # side's statement of it. try: from importlib.metadata import version as _mdversion emit("version", _mdversion("versiontools")) except Exception as e: emit("version_error", "%s: %s" % (type(e).__name__, e)) # ...and the module's own `__version__`, which is a plain five-tuple, not a # Version. The package eats its own cooking only at setup() time; the # attribute itself is the ordinary tuple the class exists to replace. emit("self_version_tuple", repr(tuple(versiontools.__version__))) emit("self_version_is_plain_tuple", yn(type(versiontools.__version__) is tuple)) # The two ends of the package meeting: its own tuple, rendered by its own # formatter, must be the version the recipe shipped. emit("self_version_str", str(Version.from_tuple(versiontools.__version__))) # ------------------------------------------------------- the API --------- # Named one attribute at a time: `format_version` missing and # `from_expression` missing are different breakages, and a single "the API # is there" key would report them identically. for name in ("Version", "format_version", "handle_version"): emit("api_" + name.lower(), yn(hasattr(versiontools, name))) for name in ("from_tuple", "from_tuple_and_hint", "from_expression"): emit("api_" + name, yn(hasattr(Version, name))) emit("api_hook_version", yn(hasattr(setuptools_hooks, "version"))) # `versiontools.handle_version` IS the setuptools hook, re-exported by the # last line of `__init__.py`. A second copy on sys.path would satisfy every # other key here and fail only this one. emit( "api_handle_is_hook", yn(getattr(versiontools, "handle_version", None) is setuptools_hooks.version), ) # ------------------------------------------------- the entry points ------ # versiontools does nothing by being importable. It works because setuptools # finds `version` in the `distutils.setup_keywords` group and calls what it # points at, and because `versiontools.vcs_integration` advertises the three # VCS plugins. A package installed with its entry-point metadata dropped # imports perfectly and is silently inert, which is the failure these keys # catch. try: from importlib.metadata import entry_points as _eps kw = [e for e in _eps(group="distutils.setup_keywords") if e.name == "version"] emit("ep_kw_count", str(len(kw))) if kw: emit("ep_kw_value", kw[0].value) loaded = kw[0].load() emit("ep_kw_loads", "yes") emit("ep_kw_is_hook", yn(loaded is setuptools_hooks.version)) vcs = sorted(e.name for e in _eps(group="versiontools.vcs_integration")) emit("ep_vcs_names", ",".join(vcs)) except Exception as e: emit("ep_error", "%s: %s" % (type(e).__name__, e)) # ================================================== THE TUPLE HALF ======== v = Version(1, 2, 3) emit("tuple_isinstance", yn(isinstance(v, tuple))) emit("tuple_len", str(len(v))) # THE DEFAULTS. `micro` is 0 and `releaselevel` is "final", so a two-argument # construction is a released x.y -- the shape most callers write. emit("tuple_eq_plain", yn(v == (1, 2, 3, "final", 0))) emit("tuple_unpack", ",".join(str(x) for x in v)) # A Version hashes as its tuple, so it can key a dict a plain tuple also # keys. `__hash__` is inherited precisely because `__eq__` is not overridden. emit("tuple_hash_eq", yn(hash(v) == hash((1, 2, 3, "final", 0)))) # `__repr__` IS NOT OVERRIDDEN -- this is the plain tuple repr, and it is a # different string from `str()`. Pinned because a future release adding a # `__repr__` would change what every traceback and every `%r` in a consumer # prints. emit("tuple_repr", repr(v)) emit("tuple_repr_is_not_str", yn(repr(v) != str(v))) # The five named fields, each an `operator.itemgetter` property over the # tuple. Named separately so a property wired to the wrong index is # identifiable rather than merely "something is off". emit("f_major", str(v.major)) emit("f_minor", str(v.minor)) emit("f_micro", str(v.micro)) emit("f_releaselevel", str(v.releaselevel)) emit("f_serial", str(v.serial)) # STRINGS ARE COERCED TO int, silently -- the documented behaviour, and the # reason `Version("1", "2")` is not a type error. sv = Version("4", "5", "6") emit("coerce_str", ",".join(str(x) for x in sv)) emit("coerce_str_types", ",".join(type(x).__name__ for x in sv)) # ----------------------------------------------------- the ordering ------ # THE ORDER IS PLAIN TUPLE ORDER, which means element 3 -- the release level # -- is compared AS A STRING. That happens to give the semantically right # answer for the four levels the ticket names, because "alpha" < "beta" < # "candidate" < "final" lexicographically; the serial then breaks ties # within a level and the micro orders the releases. order = [ (1, 2, 9, "final", 0), (1, 3, 0, "alpha", 1), (1, 3, 0, "alpha", 2), (1, 3, 0, "beta", 2), (1, 3, 0, "candidate", 1), (1, 3, 0, "final", 0), (1, 3, 1, "final", 0), ] shuffled = [order[3], order[6], order[0], order[5], order[1], order[4], order[2]] got = sorted(Version.from_tuple(t) for t in shuffled) emit("order_sorted", "|".join(str(x) for x in got)) emit("order_is_declared", yn([tuple(x) for x in got] == order)) # ...and the same statement one comparison at a time, so a red says WHICH # neighbouring pair inverted rather than only that the list came out wrong. emit("order_alpha_lt_beta", yn(Version(1, 3, 0, "alpha", 1) < Version(1, 3, 0, "beta", 1))) emit("order_beta_lt_cand", yn(Version(1, 3, 0, "beta", 1) < Version(1, 3, 0, "candidate", 1))) emit("order_cand_lt_final", yn(Version(1, 3, 0, "candidate", 1) < Version(1, 3, 0, "final", 0))) emit("order_serial_ties", yn(Version(1, 3, 0, "alpha", 1) < Version(1, 3, 0, "alpha", 2))) # THE ONE PLACE THE LEXICAL ORDER AND THE SEMANTIC ORDER DISAGREE. "dev" is # the EARLIEST level the class permits and it sorts between "candidate" and # "final", because c < d < f. This is a property of the package, not a # defect of the port, and it is pinned here so that a consumer sorting # release-level strings is not surprised by it later. emit("order_dev_after_cand", yn(Version(1, 3, 0, "candidate", 1) < Version(1, 3, 0, "dev", 0))) emit("order_dev_before_final", yn(Version(1, 3, 0, "dev", 0) < Version(1, 3, 0, "final", 0))) # ================================================= THE STRING HALF ======== # Every row of the table in `Version.__str__`'s docstring, asserted against # a literal. A formatting change cannot pass unnoticed. emit("s_final_two", str(Version(1, 2, 0, "final", 0))) emit("s_final_three", str(Version(1, 2, 3, "final", 0))) emit("s_alpha", str(Version(1, 3, 0, "alpha", 1))) emit("s_beta", str(Version(1, 3, 0, "beta", 1))) emit("s_candidate", str(Version(1, 3, 0, "candidate", 1))) # No VCS on this image, so no revision suffix -- see the module docstring. emit("s_dev", str(Version(1, 3, 0, "dev", 0))) # A ZERO MICRO IS OMITTED and a non-zero one is not: this pair is the whole # of that branch, and asserting only one of them would leave a formatter # that always dropped (or always kept) the micro half green. emit("s_zero_micro", str(Version(2, 0, 0, "final", 0))) emit("s_nonzero_micro", str(Version(2, 0, 1, "final", 0))) # A serial above 1 is rendered as the number, not as a repeated token. emit("s_serial_10", str(Version(1, 3, 0, "beta", 10))) emit("s_repr", repr(Version(1, 3, 0, "candidate", 1))) # THE TOKEN TABLE, read directly. `s_alpha`/`s_beta`/`s_candidate` going # wrong could mean the table changed or the formatter did; this key # separates the two. "dev" and "final" are deliberately ABSENT from it -- # "final" renders as nothing at all and "dev" takes its own branch. tok = Version._RELEASELEVEL_TO_TOKEN emit("token_map", ",".join("%s=%s" % (k, tok[k]) for k in sorted(tok))) # `format_version` is the same formatter reached without naming the class, # and it takes a Version or a plain five-tuple. emit("fv_tuple", format_version((1, 4, 0, "beta", 2))) emit("fv_version", format_version(Version(1, 4, 0, "beta", 2))) # ---------------------------------------------------- the round trips ---- rt = Version.from_tuple((1, 3, 0, "candidate", 7)) emit("rt_from_tuple", ",".join(str(x) for x in rt)) emit("rt_from_tuple_str", str(rt)) emit("rt_from_tuple_eq", yn(tuple(rt) == (1, 3, 0, "candidate", 7))) # A Version fed back through from_tuple is an equal Version -- the identity # a consumer relies on when it round-trips a version through a config file. emit("rt_idempotent", yn(tuple(Version.from_tuple(tuple(rt))) == tuple(rt))) # ------------------------------------------------ the source-tree flag --- # See the module header. The lexical heuristic fires at module level, on the # line carrying the text `__version__`, and does not fire here. emit("src_tree_module", str(_MODULE_LEVEL._source_tree)) emit("src_tree_module_is_probe_dir", yn(_MODULE_LEVEL._source_tree == PROBE_DIR)) emit("src_tree_function", str(Version(1, 0, 0)._source_tree)) # ...and with no source tree there is no VCS to query, which is the arm # `s_dev` above took. emit("vcs_in_function", str(Version(1, 0, 0, "dev", 0).vcs)) # ================================================ THE FIXTURE PACKAGES ==== work = tempfile.mkdtemp(prefix="versiontools-fixture-") sys.path.insert(0, work) with open(os.path.join(work, "vtfixture.py"), "w") as fp: fp.write("__version__ = (3, 1, 4, 'candidate', 2)\nrelease = (0, 9, 12, 'final', 0)\n") with open(os.path.join(work, "vtfinal.py"), "w") as fp: fp.write("__version__ = (1, 0, 0, 'final', 0)\n") # THE `__version__`-STRING PARSER. `from_expression` imports the module the # expression names and reads the version out of it -- `__version__` by # default, or whatever follows a colon. fe = Version.from_expression("vtfixture") emit("expr_default", ",".join(str(x) for x in fe)) emit("expr_default_str", str(fe)) fen = Version.from_expression("vtfixture:release") emit("expr_named", ",".join(str(x) for x in fen)) emit("expr_named_str", str(fen)) # An empty identifier after the colon means `__version__` too -- the # documented "allow people not to include the identifier separator" arm. emit("expr_empty_identifier", str(Version.from_expression("vtfixture:"))) # `from_tuple_and_hint` supplies the source tree the lexical heuristic could # not find. Called from inside this function the heuristic yields None, so # the hint is what answers -- and the answer is the directory of the module # handed as the hint. import vtfixture hinted = Version.from_tuple_and_hint(vtfixture.__version__, hint=vtfixture) emit("hint_str", str(hinted)) emit("hint_source_tree_is_work", yn(hinted._source_tree == work)) # ------------------------------------------------------ the error paths -- # Each raises rather than producing a version string nobody can compare. emit("err_releaselevel", caught(lambda: Version(1, 2, 3, "nope", 0))) emit("err_negative_major", caught(lambda: Version(-1, 2, 3))) emit("err_negative_serial", caught(lambda: Version(1, 2, 3, "beta", -1))) # A pre-release with serial 0 is refused: `1.3b0` would sort before every # `1.3bN` and read as "no serial at all". emit("err_serial_zero_alpha", caught(lambda: Version(1, 2, 3, "alpha", 0))) emit("err_serial_zero_beta", caught(lambda: Version(1, 2, 3, "beta", 0))) # ...and `final`/`dev` with serial 0 are the ordinary case, not an error. emit("ok_serial_zero_final", caught(lambda: Version(1, 2, 3, "final", 0))) # CPython writes this one's message, so only the type is asserted. emit("err_not_an_int", caught_type(lambda: Version("x", 2, 3))) # THE WRONG-LENGTH TUPLE, AND WHERE THE GUARD ACTUALLY LIVES. It is in # `format_version`, which requires exactly five items -- NOT in # `from_tuple`, which is `cls(*t)` and therefore accepts a THREE-element # tuple happily by falling back on the defaults. A suite that asserted the # guard on `from_tuple` would be asserting something the package does not # do; both arms are pinned here so a reader knows which call to make. emit("err_fv_short", caught(lambda: format_version((1, 2, 3)))) emit("err_fv_long", caught(lambda: format_version((1, 2, 3, "final", 0, 7)))) emit("err_fv_not_tuple", caught(lambda: format_version("1.2.3"))) # ...and this one's, which names `__new__`'s arity in CPython's wording. emit("err_ft_long", caught_type(lambda: Version.from_tuple((1, 2, 3, "final", 0, 7)))) emit("ft_short_no_raise", caught(lambda: Version.from_tuple((1, 2, 3)))) emit("ft_short_value", str(Version.from_tuple((1, 2, 3)))) # THE TWO from_expression FAILURES. versiontools writes the opening of each # message and appends CPython's own -- so the type and versiontools' prefix # are asserted, and the interpreter's half is not. emit("err_expr_import", caught_type(lambda: Version.from_expression("vt_nosuch_xyz"))) emit( "err_expr_import_msg", caught_starts( lambda: Version.from_expression("vt_nosuch_xyz"), "Unable to import 'vt_nosuch_xyz'", ), ) emit("err_expr_attr", caught_type(lambda: Version.from_expression("vtfixture:missing"))) emit( "err_expr_attr_msg", caught_starts( lambda: Version.from_expression("vtfixture:missing"), "Unable to access 'missing' in 'vtfixture'", ), ) # ============================================= THE setup.py INTEGRATION === # ASSERTED ON TARGET RATHER THAN ASSUMED. versiontools' whole reason to # exist on a build host is that `setup(version=":versiontools:pkg")` reaches # the hook below and the metadata comes out carrying the version the tuple # describes. # # The hook is driven twice: directly, with a duck-typed distribution, which # is what says the hook itself works; and through a real # `setuptools.dist.Distribution`, which is what says the entry-point wiring # this image installed actually reaches it. from versiontools.setuptools_hooks import version as hook class _Meta(object): def __init__(self, v="unset"): self.version = v class _Dist(object): def __init__(self, v="unset"): self.metadata = _Meta(v) d = _Dist() hook(d, "version", ":versiontools:vtfixture") emit("hook_magic", d.metadata.version) # A PLAIN VERSION STRING IS LEFT ALONE. The hook is offered every `setup()` # in the process, so a version assigned here would rewrite the version of a # package that never opted in. "unset" is the fixture's sentinel and seeing # it come back unchanged IS the assertion. d = _Dist() hook(d, "version", "1.2.3") emit("hook_plain", d.metadata.version) d = _Dist() hook(d, "version", None) emit("hook_none", d.metadata.version) # THE `value == 0` ARM, which the hook's own comment calls odd: setuptools # sometimes hands the hook a 0 instead of the string, and the real value is # then read back off `dist.metadata.version`. d = _Dist(":versiontools:vtfinal") hook(d, "version", 0) emit("hook_zero", d.metadata.version) # A bad expression is re-raised as the error setuptools understands, not as # the ValueError from_expression raised. emit("hook_bad", caught_type(lambda: hook(_Dist(), "version", ":versiontools:vt_nosuch_xyz"))) # ...and the message keeps versiontools' text with the magic tag PEELED OFF # and the leading ": " stripped -- the hook's own reformatting, which is the # part of it worth pinning. emit( "hook_bad_msg", caught_starts( lambda: hook(_Dist(), "version", ":versiontools:vt_nosuch_xyz"), "Unable to import 'vt_nosuch_xyz'", ), ) # THE REAL DISTRIBUTION. This is the end-to-end statement: setuptools finds # the `distutils.setup_keywords` entry point in the metadata this image # installed, calls versiontools, and the distribution's version is the one # the fixture's tuple describes. # # NOTE THE NORMALISATION. versiontools renders a candidate as `c2`; # setuptools then rewrites it to PEP 440's `rc2` on its way into the # metadata. So `hook_magic` and `dist_version` DIFFER, by design and at # setuptools' hand, and both are pinned -- reading either as the other's # value is the mistake this pair prevents. try: from setuptools.dist import Distribution dist = Distribution( dict(name="vtfixture", version=":versiontools:vtfixture", py_modules=["vtfixture"]) ) emit("dist_version", dist.metadata.version) dist_final = Distribution( dict(name="vtfinal", version=":versiontools:vtfinal", py_modules=["vtfinal"]) ) emit("dist_version_final", dist_final.metadata.version) # AND THE METADATA IT PRODUCES, on disk. `dist.metadata.version` is an # attribute; this is the file a consumer of the sdist actually reads. out = os.path.join(work, "pkginfo") os.mkdir(out) dist.metadata.write_pkg_info(out) got = {} with open(os.path.join(out, "PKG-INFO")) as fp: for line in fp: if ":" in line: k, _, val = line.partition(":") got.setdefault(k.strip(), val.strip()) emit("pkginfo_name", got.get("Name", "")) emit("pkginfo_version", got.get("Version", "")) except Exception as e: emit("dist_error", "%s: %s" % (type(e).__name__, e)) emit("done", "yes") if __name__ == "__main__": try: main() except Exception as e: emit("fatal", "%s: %s" % (type(e).__name__, e)) sys.exit(1)