#!/usr/bin/env python3 """One interpreter start for every measurement 99-python3-semver 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 ~200 things to say about semver. Run as separate `python3 -c` invocations that would be hours of interpreter starts and nothing else; run here it is ONE start, and the work inside is a few hundred `re` matches over strings under 40 bytes plus integer and string comparisons. THE OUTPUT CONTRACT. One `key=value` line per measurement, keys emitted in file order, `probe_done=yes` last and `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. WHAT THIS PACKAGE IS, AND WHAT THAT MAKES THE CASES BELOW. semver implements Semantic Versioning 2.0.0 -- a SPECIFICATION, published at semver.org, whose whole content is a grammar and a precedence order. So unlike a parser library whose behaviour is its author's choice, nearly every case here has an external authority: the spec's own clauses 9, 10 and 11 decide what `1.0.0-alpha` must compare against `1.0.0`, and a red is a divergence from a published document rather than from this port's taste. The clause is named in the comment above each block, so a red can be adjudicated without reading semver's source. TWO THINGS THE SPEC DOES *NOT* DECIDE, and they are where implementations differ from each other: what `bump_prerelease` does to a version that has no prerelease, and what `Version.parse` accepts beyond the grammar. Those blocks say "semver's choice, not the spec's" and pin what 3.0.2 does, so a release that changed its mind reds here rather than in a caller. EVERY VALUE GOES THROUGH repr() OR AN EXPLICIT STRING. A Version's whole subject is the difference between a value and its rendering: `str(v)` is the version string, `repr(v)` names the class and every field, and `v.build` is `None` for a version with no build metadata but `''` is a *different* thing the grammar rejects. `repr` is what keeps `None` and `'None'` from collapsing. """ 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 repr, 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, so nothing is lost by not putting it on a key line the runner would parse as N stray results. """ try: emit(key, repr(fn())) except Exception: traceback.print_exc() emit(key, "raised:%s" % sys.exc_info()[0].__name__) def sval(key, fn): """Emit fn()'s result as text, for the cases whose subject IS the text. `str(Version.parse(s))` is the round trip the spec's grammar is about, and wrapping it in repr would put quotes round every one of them for nothing. """ try: emit(key, str(fn())) except Exception: traceback.print_exc() emit(key, "raised:%s" % sys.exc_info()[0].__name__) def raises(key, exc, fn): """Emit `yes` iff fn() raises exc -- the failure paths, stated positively. `no` when it returned, and the class name when it raised something else: three outcomes, one key, and the red says which of the two wrong ones happened. """ try: fn() except exc: emit(key, "yes") return except Exception: emit(key, "raised:%s" % sys.exc_info()[0].__name__) return emit(key, "no") _before = set(sys.modules) import semver # noqa: E402 from semver import Version # noqa: E402 _pulled = sorted({n.split(".")[0] for n in set(sys.modules) - _before}) # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Every behavioural key below is a statement about the # module named here, which is what ties them to the payload cases the suite # makes against the dpkg file list. emit("module_file", semver.__file__) # ...and it is a PACKAGE, not a module -- five submodules under one directory. # python3-docopt's opposite shape (one flat .py) is asserted the same way and # for the same reason: a release that flattened or split it would be a different # import surface, and nothing behavioural would notice. emit("is_package", "yes" if hasattr(semver, "__path__") else "no") emit("submodule_count", len([m for m in sys.modules if m.startswith("semver.")])) emit("submodules", ",".join(sorted(m.split(".", 1)[1] for m in sys.modules if m.startswith("semver.")))) # THE VERSION HAS THREE READINGS HERE, AND THEY ARE NOT THE SAME STATEMENT. # `semver.__version__` is the package's own text, in __about__.py; the installed # dist-info is what the packaging says; and SEMVER_SPEC_VERSION is the version # of the SPECIFICATION this release implements, which is 2.0.0 and has nothing # to do with either. Cross-checking the first two is what catches a recipe built # from a source tree that does not match its PV -- setuptools-scm derives the # dist version from git, and this recipe builds from a PyPI sdist where that # derivation has already happened, so the two agreeing is a fact about the # packaging rather than a tautology. emit("dunder_version", semver.__version__) emit("spec_version", semver.SEMVER_SPEC_VERSION) try: import importlib.metadata as _md emit("dist_version", _md.version("semver")) emit("version_agrees", "yes" if _md.version("semver") == semver.__version__ else "no") except Exception: emit("dist_version", "raised:%s" % sys.exc_info()[0].__name__) emit("version_agrees", "unmeasured") # THE PUBLISHED SURFACE. `__all__` is 30 names, and the shape is the story: TWO # classes (Version and its VersionInfo alias) and seven metadata strings against # twenty-one module-level functions that are ALL DEPRECATED -- the 3.x API is # the class, and the functions are the 2.x API kept alive. That ratio is why the # deprecation block further down exists at all. emit("all_count", len(semver.__all__)) emit("all_sorted_head", ",".join(sorted(semver.__all__)[:5])) for _n in ("Version", "VersionInfo", "parse", "compare", "match", "bump_major", "bump_minor", "bump_patch", "bump_prerelease", "bump_build", "finalize_version", "format_version", "replace", "max_ver", "min_ver", "parse_version_info", "SEMVER_SPEC_VERSION"): emit("has_%s" % _n, "yes" if _n in semver.__all__ else "no") # VersionInfo IS Version, not a subclass of it -- `VersionInfo = Version` at the # foot of version.py. A caller who isinstance-checks against one gets the other, # which is the point of the alias and worth pinning: a release that made it a # real subclass would change what `type(v) is Version` answers. emit("versioninfo_is_version", "yes" if semver.VersionInfo is Version else "no") # NO COMPILED EXTENSION, AND THAT IS WHY THIS PORT WORKS AT ALL. This image's # python3 is ONE STATIC BINARY with an empty lib-dynload and no dynamic loader # (BRINTOS-1476), so a module carrying a .so could not be imported. semver is # pure Python; stated as a fact about what was imported rather than left to be # inferred from the payload cases. emit("all_module_files_py", "yes" if all(getattr(m, "__file__", "x").endswith((".py", ".pyc")) for n, m in sys.modules.items() if n.startswith("semver") and getattr(m, "__file__", None)) else "no") # ...AND THE IMPORT PULLS IN NOTHING BUT THE STANDARD LIBRARY, which is why the # recipe declares no third-party target RDEPENDS and why this port waited on # nothing. Measured as the DELTA in sys.modules across the import above -- not # as a census of the interpreter, which would also count whatever the host's # site startup had already loaded and would answer differently on every image. emit("import_pulled", ",".join(_pulled)) # ...and the half of that list the LIBRARY, not the interpreter, is responsible # for. semver's own sources import argparse, inspect and typing at module scope # (plus re, sys, os and functools, which are loaded before this probe starts and # so never appear in a delta); everything else in the list above arrives # transitively through those three and is the stdlib's business. This key is the # assertion; the list above is the reading. Green is the empty string. _PULLED_REQUIRED = ("argparse", "inspect", "semver", "typing") emit("import_pulled_missing", ",".join(n for n in _PULLED_REQUIRED if n not in _pulled)) # ...and the same delta reduced to the assertion that actually matters, which is # stable across interpreters in a way the list above is not: NOTHING outside the # standard library was pulled in. The list is informative and would red on a # python3 that laid its own internals out differently; this key reds only if a # third-party module appeared, which is the fact the recipe's empty target # RDEPENDS depends on. emit("import_nonstdlib", ",".join(n for n in _pulled if n != "semver" and n.lstrip("_") not in sys.stdlib_module_names and n not in sys.stdlib_module_names)) # ------------------------------------------------------------------ parse --- # THE GRAMMAR, CLAUSE BY CLAUSE. Semantic Versioning 2.0.0 clause 2 gives the # shape `MAJOR.MINOR.PATCH`, clause 9 the optional `-prerelease` and clause 10 # the optional `+build`. Each fixture below is parsed and every one of the five # fields is asserted separately, because a parser that put the build metadata in # `prerelease` would round-trip to the same string and pass a str()-only test. FIX = [ ("plain", "1.2.3"), ("zeroes", "0.0.0"), ("pre", "1.2.3-alpha"), ("pre_num", "1.2.3-alpha.1"), ("build", "1.2.3+build.11"), ("pre_build", "1.2.3-alpha.beta+exp.sha.5114f85"), ("big", "10.20.30"), ("pre_hyphen", "1.0.0-x.7.z.92"), ("build_hyphen", "1.0.0+21AF26D3---117B344092BD"), ("rc", "1.0.0-rc.1+build.1"), ] for _n, _s in FIX: _v = Version.parse(_s) emit("f_%s_major" % _n, repr(_v.major)) emit("f_%s_minor" % _n, repr(_v.minor)) emit("f_%s_patch" % _n, repr(_v.patch)) emit("f_%s_pre" % _n, repr(_v.prerelease)) emit("f_%s_build" % _n, repr(_v.build)) # THE ROUND TRIP, which must return the INPUT UNCHANGED. Clause 2 makes the # rendering canonical -- there is no reordering, no zero-padding and no # normalisation a conforming implementation may apply -- so `str(parse(s))` # is `s` for every string the grammar accepts, and any difference is a # defect rather than a formatting choice. emit("f_%s_rt" % _n, str(_v)) # THE FIELD TYPES ARE PART OF THE CONTRACT. major/minor/patch are `int`; # prerelease and build are `str` when present and `None` when absent -- NOT the # empty string, which the grammar rejects outright (clause 9 forbids an empty # identifier). A caller doing `if v.build:` cannot tell those two apart, which # is why the type is asserted beside the value above. _v = Version.parse("1.2.3") emit("type_major", type(_v.major).__name__) emit("type_pre_absent", type(_v.prerelease).__name__) _v = Version.parse("1.2.3-a+b") emit("type_pre_present", type(_v.prerelease).__name__) emit("type_build_present", type(_v.build).__name__) # NUMERIC FIELDS DO NOT OVERFLOW: Python ints are arbitrary precision, so a # version far past any C implementation's range parses and compares correctly. # Clause 2 puts no bound on the numbers. val("huge_major", lambda: Version.parse("99999999999999999999.0.0").major) emit("huge_rt", str(Version.parse("99999999999999999999.0.0"))) # `optional_minor_and_patch` IS semver's OWN EXTENSION, not the spec's. Clause 2 # requires all three components, so `1.2` is not a semantic version; the # keyword argument accepts it anyway and defaults the missing parts to zero. # Pinned as an extension, with the default-off behaviour beside it, because a # caller who did not pass the flag must still be refused. emit("optional_mp", str(Version.parse("1.2", optional_minor_and_patch=True))) emit("optional_m", str(Version.parse("1", optional_minor_and_patch=True))) raises("optional_off_refused", ValueError, lambda: Version.parse("1.2")) # ------------------------------------------------------------ the failures --- # THE GRAMMAR'S REFUSALS, which are the half of a specification that is easiest # to get wrong because nothing exercises them by accident. # # `ValueError` is the whole of the failure contract: semver raises it directly # from `parse`, and `is_valid` is defined as `parse` with the except round it. # So a caller writing `except ValueError` catches everything, and there is no # library-specific exception class to miss -- the opposite shape from isodate's # ISO8601Error, and worth stating as a fact rather than as an absence. for _n, _s in [ ("missing_patch", "1.2"), # clause 2: all three are required ("missing_minor", "1"), ("leading_zero", "01.2.3"), # clause 2: no leading zeroes ("leading_zero_mn", "1.02.3"), ("leading_zero_pt", "1.2.03"), ("pre_leading_zero", "1.2.3-01"), # clause 9: same rule for numeric ids ("empty_pre", "1.2.3-"), # clause 9: identifiers MUST NOT be empty ("empty_pre_dot", "1.2.3-alpha."), ("empty_build", "1.2.3+"), # clause 10 ("negative", "-1.2.3"), ("four_parts", "1.2.3.4"), ("v_prefix", "v1.2.3"), # NOT semver; a tag convention only ("trailing_space", "1.2.3 "), ("leading_space", " 1.2.3"), ("empty", ""), ("words", "banana"), ("plus_only", "+"), ("pre_bad_char", "1.2.3-alpha_1"), # clause 9: [0-9A-Za-z-] only ]: raises("bad_%s" % _n, ValueError, lambda s=_s: Version.parse(s)) # THE PARSER IS ANCHORED AT BOTH ENDS, which is the thing to check and not to # assume: `_REGEX` is built with `re.VERBOSE` and ends in `$`, and `parse` uses # `.match`, so a leading prefix is required and a trailing tail is refused. The # `trailing_space`/`four_parts` cases above are that assertion; this one states # the anchoring positively so the reason is on the record rather than inferred # from a list of refusals. emit("regex_anchored_end", "yes" if Version._REGEX.pattern.rstrip().endswith("$") else "no") # `is_valid` IS THE PREDICATE FORM, and it must agree with `parse` on every # string above -- it is literally `parse` inside a try. Asserted anyway, because # the agreement is what a caller relies on when they use one to guard the other. emit("valid_good", "yes" if Version.is_valid("1.2.3-a+b") else "no") emit("valid_bad", "yes" if Version.is_valid("1.2") else "no") emit("valid_empty", "yes" if Version.is_valid("") else "no") # ...and it takes a STRING. Given a Version it raises rather than answering, # because `parse` type-checks its argument. Pinned because "validate whatever I # have" is the natural misuse. raises("valid_typeerror", TypeError, lambda: Version.is_valid(Version(1, 2, 3))) # ------------------------------------------------------------- precedence --- # CLAUSE 11, WHICH IS THE WHOLE REASON THIS LIBRARY EXISTS. The clause is four # rules, and each gets its own block below rather than being folded into one # sorted list -- a single sorted fixture passes if any two rules are wrong in # compensating directions. # # 11.2 major, minor, patch compared NUMERICALLY, in that order. for _n, _a, _b, _want in [ ("major", "2.0.0", "1.0.0", 1), ("minor", "1.1.0", "1.0.0", 1), ("patch", "1.0.1", "1.0.0", 1), ("equal", "1.0.0", "1.0.0", 0), ("lt", "1.0.0", "2.0.0", -1), # NUMERIC, NOT LEXICAL: 10 > 9 although "10" < "9" as text. This is the # single most common way a hand-rolled comparison is wrong. ("numeric_10", "1.10.0", "1.9.0", 1), ("numeric_2", "2.0.0", "10.0.0", -1), ]: emit("cmp_%s" % _n, str(Version.parse(_a).compare(_b))) emit("cmp_%s_want" % _n, str(_want)) # 11.3 A PRERELEASE HAS LOWER PRECEDENCE THAN THE ASSOCIATED NORMAL VERSION. # This is the rule that is easy to get backwards, because `"1.0.0-alpha" < # "1.0.0"` is also true as plain text and so the right answer for the wrong # reason -- while `"1.0.0-alpha" > "1.0.0"` would be what a naive tuple # comparison with `None` sorted low gives you. Asserted in both directions. emit("pre_lower", str(Version.parse("1.0.0-alpha").compare("1.0.0"))) emit("pre_lower_rev", str(Version.parse("1.0.0").compare("1.0.0-alpha"))) # 11.4 PRERELEASE IDENTIFIERS COMPARED FIELD BY FIELD, and the clause spells # out four sub-rules. This is the spec's OWN example chain, verbatim from # clause 11: # 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta # < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0 SPEC_CHAIN = ["1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0"] for _i in range(len(SPEC_CHAIN) - 1): emit("chain_%d" % _i, str(Version.parse(SPEC_CHAIN[_i]).compare(SPEC_CHAIN[_i + 1]))) # ...and the chain sorted from a SHUFFLED input, which is a different assertion: # the pairwise cases above would all pass for a comparison that was not # transitive, and `sorted` is what a caller actually reaches for. emit("chain_sorted", ",".join(str(v) for v in sorted(Version.parse(s) for s in reversed(SPEC_CHAIN)))) # 11.4.1 numeric identifiers compared numerically: beta.11 > beta.2, which as # text is the other way round. It is in the chain above; stated alone too, # because it is the sub-rule the chain would hide if it were the only failure. emit("pre_numeric", str(Version.parse("1.0.0-beta.11").compare("1.0.0-beta.2"))) # 11.4.2 alphanumeric identifiers compared in ASCII sort order. emit("pre_alpha", str(Version.parse("1.0.0-beta").compare("1.0.0-alpha"))) # 11.4.3 numeric identifiers ALWAYS have lower precedence than alphanumeric # ones -- so `1.0.0-1` < `1.0.0-alpha` even though 1 sorts before "alpha" for a # reason the clause does not rely on. emit("pre_num_lt_alpha", str(Version.parse("1.0.0-1").compare("1.0.0-alpha"))) emit("pre_num_lt_alpha2", str(Version.parse("1.0.0-999").compare("1.0.0-a"))) # 11.4.4 a LARGER SET of fields has higher precedence, if all the preceding # ones are equal. emit("pre_longer", str(Version.parse("1.0.0-alpha.1").compare("1.0.0-alpha"))) # 10. BUILD METADATA IS IGNORED IN PRECEDENCE ENTIRELY. Two versions differing # only in build compare EQUAL -- and remain distinguishable by their fields, # which is the pair of statements the clause actually makes and the reason this # block asserts both. An implementation that compared build metadata would get # `1.0.0+a` vs `1.0.0+b` wrong in a way no ordering test notices. emit("build_ignored", str(Version.parse("1.0.0+a").compare("1.0.0+b"))) emit("build_ignored_eq", "yes" if Version.parse("1.0.0+a") == Version.parse("1.0.0+b") else "no") emit("build_ignored_none", str(Version.parse("1.0.0+a").compare("1.0.0"))) emit("build_distinct_field", "%r|%r" % (Version.parse("1.0.0+a").build, Version.parse("1.0.0+b").build)) # ...AND THAT MAKES `==` DISAGREE WITH `str()`, which is the trap this rule # leaves for a caller: two versions that compare EQUAL render as DIFFERENT # strings, so a set of Versions and a set of their strings do not have the same # size. `__hash__` is `hash(self.to_tuple()[:4])` -- deliberately the first four # fields, build excluded -- so equal versions hash alike and the language's # invariant holds: the set keeps ONE of `1.0.0+a` and `1.0.0+b`, and WHICH one # it keeps is whichever was inserted first. Measured, because an implementation # that hashed all five would satisfy every ordering case above and silently # violate the invariant instead. emit("build_eq_but_str_differs", "yes" if (Version.parse("1.0.0+a") == Version.parse("1.0.0+b") and str(Version.parse("1.0.0+a")) != str(Version.parse("1.0.0+b"))) else "no") emit("build_hash_differs", "yes" if hash(Version.parse("1.0.0+a")) != hash(Version.parse("1.0.0+b")) else "no") emit("hash_tuple_len", str(len(Version.parse("1.0.0+a").to_tuple()))) emit("build_set_size", str(len({Version.parse("1.0.0+a"), Version.parse("1.0.0+b")}))) # THE RICH COMPARISONS, which are what a caller writes rather than `compare`. # They are generated from `compare` by a decorator, so they should agree with it # -- "should" being the reason to measure it. _a, _b = Version.parse("1.0.0-alpha"), Version.parse("1.0.0") for _op, _fn in [("lt", lambda: _a < _b), ("le", lambda: _a <= _b), ("gt", lambda: _a > _b), ("ge", lambda: _a >= _b), ("eq", lambda: _a == _b), ("ne", lambda: _a != _b)]: emit("op_%s" % _op, "yes" if _fn() else "no") # ...and they accept a STRING, a TUPLE and a DICT on the right, not only a # Version: `_comparator` coerces through `Version.parse`/the constructor. That # is semver's convenience, not the spec's, and it is why `v > "1.0.0"` works. emit("op_vs_str", "yes" if Version.parse("2.0.0") > "1.0.0" else "no") emit("op_vs_tuple", "yes" if Version.parse("2.0.0") > (1, 0, 0) else "no") emit("op_vs_dict", "yes" if Version.parse("2.0.0") > dict(major=1, minor=0, patch=0) else "no") # ...and refuses anything else with TypeError rather than answering NotImplemented # to a comparison the caller thought succeeded. raises("op_vs_int", TypeError, lambda: Version.parse("1.0.0") > 1) # `compare` TAKES THE SAME COERCIONS, and `max`/`min` over an iterable of # Versions follow from the rich comparisons for free -- asserted because # "get the newest of these" is the single commonest use of the whole package. emit("max_of", str(max(Version.parse(s) for s in SPEC_CHAIN))) emit("min_of", str(min(Version.parse(s) for s in SPEC_CHAIN))) # ------------------------------------------------------------------ bumps --- # THE FIVE BUMPS, each asserted for the component it CHANGES *and* for the # components it must RESET. The reset is the half that is easy to get wrong and # impossible to see in a test that only checks the changed field: a bump_minor # that left the patch alone gives `1.2.4` where `1.3.0` was meant, and every # assertion about the minor still passes. # # Clause 8 (major), 7 (minor) and 6 (patch) each say the lower components reset # to zero; clause 9's prerelease and clause 10's build are dropped by all three. for _n, _in, _fn in [ ("major", "1.2.3", lambda v: v.bump_major()), ("minor", "1.2.3", lambda v: v.bump_minor()), ("patch", "1.2.3", lambda v: v.bump_patch()), ("major_pre", "1.2.3-rc.1+b.2", lambda v: v.bump_major()), ("minor_pre", "1.2.3-rc.1+b.2", lambda v: v.bump_minor()), ("patch_pre", "1.2.3-rc.1+b.2", lambda v: v.bump_patch()), ("major_zero", "0.9.9", lambda v: v.bump_major()), ]: _r = _fn(Version.parse(_in)) emit("bump_%s" % _n, str(_r)) emit("bump_%s_fields" % _n, "%d|%d|%d|%r|%r" % (_r.major, _r.minor, _r.patch, _r.prerelease, _r.build)) # `bump_prerelease` IS SEMVER'S CHOICE, NOT THE SPEC'S -- clause 9 says nothing # about how to advance a prerelease, so every implementation invents one, and # this is the behaviour a release is most likely to change its mind about. # # WHAT 3.0.2 ACTUALLY DOES is not "append or increment a trailing counter": it # builds `.0` when there is no prerelease and then runs the WHOLE string # through `_increment_string`, which finds the LAST RUN OF DIGITS ANYWHERE in # it and adds one. That is a different rule, and the cases below are chosen to # separate the two -- `1.0.0-x.7.z.92` goes to `x.7.z.93` because 92 is the last # digit run, and a trailing-field rule would have to invent a new field. # # ITS COROLLARY IS A SILENT NO-OP, and it is the most useful thing this block # measures: a prerelease with NO digits in it has nothing to increment, so # `Version.parse("1.2.3-alpha").bump_prerelease()` returns `1.2.3-alpha` -- # unchanged, with no error. A release script looping "bump until it is new" # does not terminate, and one that bumps once and publishes overwrites the # release it just made. Upstream's behaviour in a third-party package, not this # platform's, and this project files nothing against third-party public # repositories -- so it is pinned here instead, and a release that fixes it reds # and says so. for _n, _in, _tok in [ ("none", "1.2.3", None), ("rc", "1.2.3-rc.1", None), ("rc9", "1.2.3-rc.9", None), ("noop_alpha", "1.2.3-alpha", None), ("last_digits", "1.0.0-x.7.z.92", None), ("token", "1.2.3", "alpha"), ("token_on", "1.2.3-alpha.1", "alpha"), ("drops_build", "1.2.3-rc.1+b", None), ]: _v = Version.parse(_in) emit("bumppre_%s" % _n, str(_v.bump_prerelease() if _tok is None else _v.bump_prerelease(_tok))) # THE THREE SPELLINGS OF "NO TOKEN" ARE NOT THREE ANSWERS. The signature # defaults to the string "rc"; an explicit `None` takes a separate branch that # builds "rc.0" and so lands in the same place; and the EMPTY STRING takes a # third branch that builds a bare "0" and gives `1.2.3-1`. Two of the three # agree and the third does not, which is exactly the shape a caller passing a # variable trips over, so all three are measured rather than reasoned about. emit("bumppre_explicit_none", str(Version.parse("1.2.3").bump_prerelease(None))) emit("bumppre_empty_token", str(Version.parse("1.2.3").bump_prerelease(""))) # `bump_build` FOLLOWS THE SAME SHAPE on the field the spec explicitly says is # ignored in precedence -- so a bumped build is a version that compares EQUAL to # the one it came from. That is not a defect; it is clause 10 working, and it is # asserted here so the point is on the record beside the bump. for _n, _in, _tok in [ ("none", "1.2.3", None), ("build", "1.2.3+build.1", None), ("named", "1.2.3+b.9", None), ("token", "1.2.3", "sha"), ("on_pre", "1.2.3-rc.1", None), ]: _v = Version.parse(_in) emit("bumpbuild_%s" % _n, str(_v.bump_build() if _tok is None else _v.bump_build(_tok))) emit("bumpbuild_compares_equal", str(Version.parse("1.2.3+b.1").compare(str(Version.parse("1.2.3+b.1").bump_build())))) # THE BUMPS RETURN A NEW OBJECT AND LEAVE THE ORIGINAL ALONE. Version is # immutable -- `__slots__` and property setters that raise -- so this is a # property of the class rather than of each bump, and it is asserted once here # and once against the setters below. _orig = Version.parse("1.2.3") _new = _orig.bump_minor() emit("bump_is_new_object", "yes" if _new is not _orig else "no") emit("bump_leaves_original", str(_orig)) # `next_version` IS THE ONE THAT KNOWS ABOUT PRERELEASES, and its whole point is # that it is NOT bump_: bumping the minor of `1.2.3-rc.1` gives `1.3.0`, # but the NEXT minor after a `1.3.0` prerelease is `1.3.0` itself -- the release # the prerelease was leading to. Getting this wrong skips a release. for _n, _in, _part in [ ("major_plain", "1.2.3", "major"), ("minor_plain", "1.2.3", "minor"), ("patch_plain", "1.2.3", "patch"), ("patch_on_pre", "1.2.3-rc.1", "patch"), ("minor_on_pre", "1.3.0-rc.1", "minor"), ("major_on_pre", "2.0.0-rc.1", "major"), ("pre_on_plain", "1.2.3", "prerelease"), ("pre_on_pre", "1.2.3-rc.1", "prerelease"), ]: val("next_%s" % _n, lambda s=_in, p=_part: str(Version.parse(s).next_version(p))) raises("next_bad_part", ValueError, lambda: Version.parse("1.2.3").next_version("banana")) # `build` is refused too, and by name: the docstring lists the parts it takes # and build is not one of them, because "the next build" is not a thing the # spec's ordering can answer. raises("next_build_refused", ValueError, lambda: Version.parse("1.2.3").next_version("build")) # `finalize_version` DROPS BOTH prerelease AND build -- the release a prerelease # was heading for. It is the inverse of the bumps' reset half, stated once. emit("finalize_pre", str(Version.parse("1.2.3-rc.1").finalize_version())) emit("finalize_both", str(Version.parse("1.2.3-rc.1+b.2").finalize_version())) emit("finalize_plain", str(Version.parse("1.2.3").finalize_version())) # ------------------------------------------------------------------ match --- # THE COMPARATOR EXPRESSIONS the docstring documents -- six operators, and the # bare-version form that means `==`. `match` is the API a caller reaches for # "does this version satisfy X", so its refusals matter as much as its hits. for _n, _v, _expr in [ ("ge_true", "2.0.0", ">=1.0.0"), ("ge_eq", "1.0.0", ">=1.0.0"), ("ge_false", "0.9.0", ">=1.0.0"), ("gt_false", "1.0.0", ">1.0.0"), ("gt_true", "1.0.1", ">1.0.0"), ("lt_true", "0.9.0", "<1.0.0"), ("le_eq", "1.0.0", "<=1.0.0"), ("eq_true", "4.0.4", "==4.0.4"), ("ne_true", "4.0.5", "!=4.0.4"), ("ne_false", "4.0.4", "!=4.0.4"), ("bare", "4.0.4", "4.0.4"), ("bare_false", "4.0.5", "4.0.4"), ("pre_vs_rel", "1.0.0-rc.1", "<1.0.0"), ("build_ign", "1.0.0+a", "==1.0.0+b"), ]: emit("match_%s" % _n, "yes" if Version.parse(_v).match(_expr) else "no") # THE REFUSALS. An expression with no operator and no leading digit is a # ValueError -- so `match("v1.0.0")` and `match("~1.0.0")` do NOT quietly answer # False, which is what a caller filtering a list would never notice. for _n, _expr in [("caret", "^1.0.0"), ("tilde", "~1.0.0"), ("v", "v1.0.0"), ("empty", ""), ("word", "banana")]: raises("match_bad_%s" % _n, ValueError, lambda e=_expr: Version.parse("1.0.0").match(e)) # ...and an expression whose OPERATOR is fine but whose VERSION is not raises # from the parse underneath, same exception class, different cause. Both are # ValueError, so a caller cannot tell "bad operator" from "bad version" -- # stated because the two need different fixes. raises("match_bad_version", ValueError, lambda: Version.parse("1.0.0").match(">=1.0")) # NOT A RANGE SYNTAX. semver implements the SPEC, which defines no ranges; # `^` and `~` above are npm's, and their refusal is the assertion that this # package is not npm-compatible and does not pretend to be. emit("no_range_syntax", "yes") # ------------------------------------------------- the rest of the surface --- # CONVERSIONS. `to_dict`/`to_tuple` are how a Version leaves the type, and both # carry all five fields including the Nones -- a dict that dropped absent keys # would round-trip through `Version(**d)` and silently lose nothing today and # something tomorrow. val("to_tuple", lambda: Version.parse("1.2.3-rc.1+b.2").to_tuple()) val("to_dict_keys", lambda: sorted(Version.parse("1.2.3").to_dict())) val("to_dict_absent", lambda: Version.parse("1.2.3").to_dict()["prerelease"]) emit("from_dict_rt", str(Version(**Version.parse("1.2.3-rc.1+b.2").to_dict()))) emit("from_tuple_rt", str(Version(*Version.parse("1.2.3-rc.1+b.2").to_tuple()))) # ...and iteration, which yields the same five in order, so `a, b, c, d, e = v` # works and is pinned as working. val("iter", lambda: tuple(Version.parse("1.2.3-rc.1+b.2"))) # INDEXING. `v[0]` is the major and `v[:3]` is the release triple -- a slice # with a step, or an out-of-range index, raises. The `[:4]` form is what # `is_compatible` uses internally, so it is not a curiosity. val("index_0", lambda: Version.parse("1.2.3-rc.1+b.2")[0]) val("index_slice", lambda: Version.parse("1.2.3-rc.1+b.2")[:3]) val("index_slice4", lambda: Version.parse("1.2.3-rc.1+b.2")[:4]) # A STEP SLICE IS NOT REFUSED -- it strides the five fields like any tuple # slice, so `v[::2]` is `(major, patch, build)` flattened of its Nones. There is # nothing meaningful a caller wants from that, and it is measured because the # docstring's talk of "the first N parts" reads as though the step were checked. val("index_step", lambda: Version.parse("1.2.3")[::2]) raises("index_negative", IndexError, lambda: Version.parse("1.2.3")[-1]) raises("index_oob", IndexError, lambda: Version.parse("1.2.3")[9]) # `replace` SETS FIELDS AND GOES THROUGH THE CONSTRUCTOR -- which is the # ordinary path and, per the block below, is NOT a validating one. An unknown # field name is the one thing it does refuse, and it refuses it with TypeError # from the constructor's own signature rather than with a check of its own. emit("replace_major", str(Version.parse("1.2.3").replace(major=9))) emit("replace_pre", str(Version.parse("1.2.3").replace(prerelease="rc.1"))) emit("replace_drop", str(Version.parse("1.2.3-rc.1").replace(prerelease=None))) raises("replace_unknown", TypeError, lambda: Version.parse("1.2.3").replace(banana=1)) # IMMUTABILITY. `__slots__` means no attribute can be added, and every property # setter raises AttributeError with its own message -- so the 2.x code that did # `v.major = 2` fails loudly instead of mutating a value other references share. def _setattr(name, value): def _f(): setattr(Version.parse("1.2.3"), name, value) return _f for _n in ("major", "minor", "patch", "prerelease", "build"): raises("immutable_%s" % _n, AttributeError, _setattr(_n, 9)) raises("immutable_new_attr", AttributeError, _setattr("banana", 1)) # THE CONSTRUCTOR DOES NOT VALIDATE, AND THIS IS THE FINDING OF THE PORT. # `Version.parse` is anchored, strict and refuses eighteen shapes above -- and # NONE of that is reached by `Version(...)`, which checks exactly one thing: # that the three numeric parts are not negative. Everything else is coerced or # stored as given. # # The consequence is a ROUND-TRIP BREAK reachable entirely through the public # API: `Version(1, 0, 0, prerelease="a_b")` is accepted, renders as # `1.0.0-a_b`, and `Version.parse` of that string RAISES -- clause 9 allows # only [0-9A-Za-z-] in an identifier. So the library produces a string it # refuses to read back, and a caller who builds versions from computed parts # (which is what `replace` is for) can write an invalid tag into a repository # with no error anywhere. `is_valid` on the rendering is the only check there # is, and nothing prompts a caller to make it. # # A FLOAT IS SILENTLY TRUNCATED for the same reason: the numeric parts go # through `int()`, so `Version(1.5, 0, 0)` is `1.0.0` rather than a TypeError. # `1.9` and `1.1` both become 1. # # Both are UPSTREAM's, in a third-party package, and this project files nothing # against third-party public repositories -- so they are pinned here as what # 3.0.2 does, with the negative-number refusal beside them as the control that # says the constructor does check something. raises("ctor_negative", ValueError, lambda: Version(-1, 0, 0)) raises("ctor_float_refused", TypeError, lambda: Version(1.5, 0, 0)) emit("ctor_float_truncates", str(Version(1.5, 0, 0))) raises("ctor_bad_pre_refused", ValueError, lambda: Version(1, 0, 0, prerelease="a_b")) emit("ctor_bad_pre_renders", str(Version(1, 0, 0, prerelease="a_b"))) # ...and the break stated as one case: what the constructor made, `parse` will # not read. emit("ctor_roundtrip_broken", "yes" if not Version.is_valid(str(Version(1, 0, 0, prerelease="a_b"))) else "no") # `replace` INHERITS IT, which is what turns a constructor curiosity into a # caller's problem: `replace` is the documented way to change one field. emit("replace_bad_pre", str(Version.parse("1.2.3").replace(prerelease="a_b"))) emit("ctor_defaults", str(Version(1))) emit("ctor_int_pre", str(Version(1, 0, 0, prerelease=1))) # `is_compatible` IS SEMVER'S OWN, and its docstring is explicit that it does # NOT check patches and that major-0 is incompatible with everything but itself # -- which is clause 4's "anything MAY change at any time" made executable. It # is also DIRECTIONAL, which is the trap: `a.is_compatible(b)` asks whether b # may replace a, so the two directions differ and a caller who wrote it the # other way round gets a wrong answer with no error. for _n, _a, _b in [ ("newer_minor", "1.0.0", "1.1.0"), ("older_minor", "1.1.0", "1.0.0"), ("same", "1.0.0", "1.0.0"), ("patch_ign", "1.0.0", "1.0.9"), ("major_diff", "1.0.0", "2.0.0"), ("zero_same", "0.1.0", "0.1.0"), ("zero_minor", "0.1.0", "0.2.0"), ("pre_differs", "1.0.0-rc.1", "1.1.0"), ("pre_same", "1.0.0-rc.1", "1.1.0-rc.1"), ]: emit("compat_%s" % _n, "yes" if Version.parse(_a).is_compatible(Version.parse(_b)) else "no") # ...and it refuses a STRING, unlike every comparison operator above, which # coerce one. Same package, two conventions; pinned because the inconsistency is # what a caller trips over. raises("compat_str_refused", TypeError, lambda: Version.parse("1.0.0").is_compatible("1.1.0")) # ---------------------------------------------------- the deprecated 2.x API --- # TWENTY-ODD MODULE-LEVEL FUNCTIONS, ALL DEPRECATED. They are the 2.x API kept # working, and `_deprecated.py` wraps each in a decorator that emits a # DeprecationWarning naming its replacement. Two things are asserted: that they # still give the RIGHT ANSWER (a caller on the old API is not silently broken) # and that they WARN (so the deprecation is real rather than documentation). # # The warning is the part that would go unnoticed: DeprecationWarning is # suppressed by default outside __main__, so nothing prints it in ordinary use # and only an explicit `catch_warnings` sees it -- which is what this does. import warnings # noqa: E402 emit("dep_parse", repr(sorted(semver.parse("1.2.3-rc.1+b.2").items()))) emit("dep_compare", str(semver.compare("1.0.0", "2.0.0"))) emit("dep_match", "yes" if semver.match("2.0.0", ">=1.0.0") else "no") emit("dep_bump_major", semver.bump_major("1.2.3")) emit("dep_bump_minor", semver.bump_minor("1.2.3")) emit("dep_bump_patch", semver.bump_patch("1.2.3")) emit("dep_bump_pre", semver.bump_prerelease("1.2.3")) emit("dep_bump_build", semver.bump_build("1.2.3")) emit("dep_finalize", semver.finalize_version("1.2.3-rc.1")) emit("dep_format", semver.format_version(1, 2, 3, "rc.1", "b.2")) emit("dep_max", semver.max_ver("1.0.0", "2.0.0")) emit("dep_min", semver.min_ver("1.0.0", "2.0.0")) emit("dep_replace", semver.replace("1.2.3", major=9)) val("dep_parse_version_info", lambda: type(semver.parse_version_info("1.2.3")).__name__) def _warns(fn): """Return the warning CLASS NAMES fn() emits, in order. The class is the measurement, not the fact that something warned: the two categories below are treated differently by every runtime that filters warnings, so "it warns" is not a complete answer. """ with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") fn() return [x.category.__name__ for x in w], [str(x.message) for x in w] # THE CATEGORY IS NOT THE SAME FOR ALL OF THEM, and that is a decision upstream # made rather than an inconsistency: most of the 2.x functions are a plain # `DeprecationWarning` -- going away in 3.x, use the class -- but `compare`, # `match` and their kin carry a `PendingDeprecationWarning` instead, because # whether the module-level comparison API should survive at all is still open # (upstream's issue #258, quoted in the warning's own text). The distinction # matters to a caller: `-W error::DeprecationWarning` turns one set into # failures and leaves the other running. for _n, _fn in [ ("parse", lambda: semver.parse("1.2.3")), ("compare", lambda: semver.compare("1.0.0", "2.0.0")), ("match", lambda: semver.match("2.0.0", ">=1.0.0")), ("bump_major", lambda: semver.bump_major("1.2.3")), ("format_version", lambda: semver.format_version(1, 2, 3)), ("max_ver", lambda: semver.max_ver("1.0.0", "2.0.0")), ]: _c, _m = _warns(_fn) emit("dep_warns_%s" % _n, ",".join(_c)) # The warning NAMES ITS REPLACEMENT, which is what makes the deprecation # actionable rather than a nag. Asserted on two, one from each category. _c, _m = _warns(lambda: semver.compare("1.0.0", "2.0.0")) emit("dep_warn_names_replacement", "yes" if _m and "semver.Version.compare" in _m[0] else "no") _c, _m = _warns(lambda: semver.parse("1.2.3")) emit("dep_warn_names_replacement_parse", "yes" if _m and "semver.Version.parse" in _m[0] else "no") # ...and the CLASS methods do NOT warn -- the 3.x API is the one to use, and a # suite that only checked "something warns" would pass with everything warning. _c, _m = _warns(lambda: Version.parse("1.2.3").compare("2.0.0")) emit("dep_class_quiet", "yes" if not _c else "no") # ----------------------------------------------------------------- the cli --- # `pysemver` IS A REAL PROGRAM THIS PACKAGE SHIPS -- setup.cfg declares the # console_script, so the recipe puts an executable in ${bindir} with a generated # shebang. The suite runs it as a PROCESS (that is the only way to measure a # shebang and an exit status); here the same code path is exercised in-process # through `semver.cli`, which is cheap and says whether a failure the suite sees # is the program or the plumbing round it. import io # noqa: E402 import contextlib # noqa: E402 def _cli(argv): """Run the CLI's main() on argv, returning `rc|stdout`. `main` returns its exit status rather than calling sys.exit, so this needs no SystemExit handling -- except for argparse's own failures, which do exit. """ buf = io.StringIO() try: with contextlib.redirect_stdout(buf): rc = semver.main(argv) except SystemExit as e: return "exit:%s|%s" % (e.code, _flat(buf.getvalue())) return "%s|%s" % (rc, _flat(buf.getvalue())) def _flat(text): """One line, always. The runner parses its input line by line, so a key whose value carried an embedded newline would land as one counted result plus N uncounted stray lines -- and argparse's usage text, which `pysemver` with no arguments prints, is twenty of them. The verdict would survive and the evidence is what goes missing, which is the same reasoning bst.sh applies to a red's detail. """ return " ".join(text.split()) emit("cli_bump_major", _cli(["bump", "major", "1.2.3"])) emit("cli_bump_minor", _cli(["bump", "minor", "1.2.3"])) emit("cli_compare_lt", _cli(["compare", "1.0.0", "2.0.0"])) emit("cli_compare_eq", _cli(["compare", "1.0.0", "1.0.0"])) emit("cli_check_good", _cli(["check", "1.2.3"])) emit("cli_nextver", _cli(["nextver", "1.2.3", "minor"])) # THE FAILURE PATH IS AN EXIT STATUS, not an exception: an invalid version makes # `check` return 1 with a message on stderr, which is what a shell script # guarding on `pysemver check "$v"` relies on. emit("cli_check_bad", _cli(["check", "1.2"])) # NO SUBCOMMAND IS argparse's OWN FAILURE, not the CLI's: it prints the usage # and raises SystemExit rather than returning a status, so the two shapes are # distinguished in the value. Only the first words of the usage are asserted by # the suite -- the rest is the module docstring argparse renders as the # description, and pinning all of it would red on a docstring edit. emit("cli_no_args_head", _cli([])[:40]) # ------------------------------------------------- upstream's own test data --- # UPSTREAM'S TESTS ARE NOT ON THIS IMAGE, and that is a decision with a reason # rather than an omission. The recipe does `inherit ptest` and its run-ptest # calls `pytest --automake`, but this distro sets DISTRO_FEATURES = "" # (conf/distro/brintos.conf), so ptest.bbclass never adds ${PN}-ptest to # PACKAGES at all -- the recipe-scope build produces no -ptest deb to install, # the same mechanism already recorded for attr, acl and python3-wcwidth in the # image recipe. Forcing one would put python3-pytest on the image, whose own # RDEPENDS names packages that are not ported and are not this ticket's. # # So the part of upstream's suite that needs no test framework is reproduced # here instead: `tests/test_parsing.py` and `tests/test_compare.py` carry # fixture TABLES, and a table is portable even when the runner that reads it is # not. These are the author's fixtures, run against this image's interpreter. UPSTREAM_VALID = [ "0.0.4", "1.2.3", "10.20.30", "1.1.2-prerelease+meta", "1.1.2+meta", "1.1.2+meta-valid", "1.0.0-alpha", "1.0.0-beta", "1.0.0-alpha.beta", "1.0.0-alpha.beta.1", "1.0.0-alpha.1", "1.0.0-alpha0.valid", "1.0.0-alpha.0valid", "1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay", "1.0.0-rc.1+build.1", "2.0.0-rc.1+build.123", "1.2.3-beta", "10.2.3-DEV-SNAPSHOT", "1.2.3-SNAPSHOT-123", "1.0.0", "2.0.0", "1.1.7", "2.0.0+build.1848", "2.0.1-alpha.1227", "1.0.0-alpha+beta", "1.2.3----RC-SNAPSHOT.12.9.1--.12+788", "1.2.3----R-S.12.9.1--.12+meta", "1.2.3----RC-SNAPSHOT.12.9.1--.12", "1.0.0+0.build.1-rc.10000aaa-kk-0.1", "99999999999999999999999.999999999999999999.99999999999999999", "1.0.0-0A.is.legal", ] _bad = [s for s in UPSTREAM_VALID if not Version.is_valid(s)] emit("upstream_valid_count", str(len(UPSTREAM_VALID))) emit("upstream_valid_rejected", ",".join(_bad)) # ...and the round trip over the whole table, which is one assertion covering # thirty-one strings: every one of them must render back to itself. _rt = [s for s in UPSTREAM_VALID if str(Version.parse(s)) != s] emit("upstream_valid_rt_broken", ",".join(_rt)) UPSTREAM_INVALID = [ "1", "1.2", "1.2.3-0123", "1.2.3-0123.0123", "1.1.2+.123", "+invalid", "-invalid", "-invalid+invalid", "-invalid.01", "alpha", "alpha.beta", "alpha.beta.1", "alpha.1", "alpha+beta", "alpha_beta", "alpha.", "alpha..", "beta", "1.0.0-alpha_beta", "-alpha.", "1.0.0-alpha..", "1.0.0-alpha..1", "1.0.0-alpha...1", "01.1.1", "1.01.1", "1.1.01", "1.2", "1.2.3.DEV", "1.2-SNAPSHOT", "1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788", "1.2-RC-SNAPSHOT", "-1.0.3-gamma+b7718", "+justmeta", "9.8.7+meta+meta", "9.8.7-whatever+meta+meta", ] _acc = [s for s in UPSTREAM_INVALID if Version.is_valid(s)] emit("upstream_invalid_count", str(len(UPSTREAM_INVALID))) emit("upstream_invalid_accepted", ",".join(_acc)) # ------------------------------------------------------------- the trailer --- # Emitted last, and the suite asserts both: `probe_done` says the probe reached # the end, `emitted_count` says nothing between the first key and this line was # skipped. Together they make every got:'' above readable as a missing KEY # rather than as a missing RUN. emit("emitted_count", COUNT + 1) sys.stdout.write("probe_done=yes\n")