#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-semantic-version. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds while every assertion here is a regex match over a string under 40 bytes and the tuple comparisons it feeds, so the start is the entire cost of the suite and taking it once is the difference between a half-minute run and a ten-minute one. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline. Every value below is a single line; lists are joined on commas and a version's components on `|'. And no value may depend on the encoding stdout happens to be in. That is not free here: `__author__' carries an `e' with an acute accent, so it is reported through `ascii()' -- which escapes it -- rather than through `repr()', which in Python 3 would leave a printable non-ASCII character exactly as it is. WHY THE WEIGHT IS ON PRECEDENCE AND ON THE RANGE LANGUAGE. The SemVer grammar is small and this image already carries an implementation of it in python3-semver. What distinguishes this package is shape rather than rules: a version is an OBJECT with rich comparison, and the two range dialects (`SimpleSpec', `NpmSpec') are its own. So the parse is asserted once per field and the bulk of the probe is ordering, the partial mode that is this library's extension to the specification, and the point at which the two dialects disagree. WHAT WAS MEASURED RATHER THAN ASSUMED. Three things here are the opposite of the obvious reading and each is asserted because of it: the two dialects agree on every expression they BOTH accept and diverge only over prereleases; `Spec()' is deprecated in the documentation but emits no warning; and comparing a partial version with a full one raises `TypeError' instead of ordering. """ import sys import warnings emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) try: import semantic_version from semantic_version import Version, SimpleSpec, NpmSpec except Exception as exc: # pragma: no cover print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) # The partial mode below warns once per construction. Asserted explicitly in # `partial_warned', then silenced so the probe's stderr stays empty and a real # diagnostic on target is not lost in a wall of expected noise. warnings.simplefilter('ignore', DeprecationWarning) def val(fn, *args, **kwargs): """The result as a string, or the exception NAME if one is raised. One helper for both because several cases below exist precisely to say WHICH of the two happens -- `partial_lt_full' raises where `partial_lt_partial' answers, and a helper that could only report one of them would need the answer written into the probe.""" try: return str(fn(*args, **kwargs)) except Exception as exc: return type(exc).__name__ def comp(v): """`major.minor.patch|prerelease|build', with `None' spelled out. Not `str(v)': that elides an absent field, so a case's SHAPE would depend on its value -- and an absent component is the whole point of the partial mode, so it must not be indistinguishable from a zero.""" def f(x): return 'None' if x is None else str(x) def t(x): return 'None' if x is None else '.'.join(str(i) for i in x) return '%s.%s.%s|%s|%s' % (f(v.major), f(v.minor), f(v.patch), t(v.prerelease), t(v.build)) # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Every behavioural case below is a statement about the # package named here; without these it would be a statement about whatever was # first on sys.path. emit('package_file', semantic_version.__file__) emit('base_file', semantic_version.base.__file__) emit('is_package', 'yes' if hasattr(semantic_version, '__path__') else 'no') # THE VERSION BY TWO ROUTES THAT ARE NOT THE SAME ROUTE. `__init__.py' sets # `__version__' from `importlib.metadata.version()' inside a `try:', falling # back to `pkg_resources' only on `ImportError' -- i.e. only on Python < 3.8. # This image is 3.12, so the fallback is DEAD CODE here and the recipe's # `RDEPENDS += python3-pkg-resources' buys nothing at import time. Both are # emitted so the suite can assert they agree: they read the same dist-info, and # a disagreement would mean two installs. emit('dunder_version', semantic_version.__version__) try: from importlib.metadata import version as _mdversion emit('metadata_version', _mdversion('semantic_version')) except Exception as exc: emit('metadata_version', type(exc).__name__) # NON-ASCII, AS AN ASCII ESCAPE. See the docstring: emitting the character # itself would make this a statement about the target's stdout encoding rather # than about the package. `ascii()' and NOT `repr()' -- Python 3's `repr' # leaves a printable non-ASCII character alone, so it would have emitted the # `e'-acute verbatim and defeated the point. emit('author_ascii', ascii(semantic_version.__author__)) # THE EXPORTED SURFACE. There is no `__all__' -- `__init__.py' names its # imports on one `from .base import' line -- so that line IS the public API and # it is asserted by presence, name by name. for _n in ('compare', 'match', 'validate', 'SimpleSpec', 'NpmSpec', 'Spec', 'SpecItem', 'Version'): emit('has_' + _n, 'yes' if hasattr(semantic_version, _n) else 'no') # ------------------------------------------------- every field, once, apart --- # ONE INPUT WITH NO TWO FIELDS ALIKE -- 1.2.3-rc.4+build.5 -- so a transposition # is visible. Asserted field by field rather than as one joined string: a # joined line reds as a single case whichever field moved, and these five are # the whole of what a `Version' holds. _full = Version('1.2.3-rc.4+build.5') emit('full_major', _full.major) emit('full_minor', _full.minor) emit('full_patch', _full.patch) emit('full_prerelease', ','.join(_full.prerelease)) emit('full_build', ','.join(_full.build)) emit('full_str', str(_full)) emit('full_partial', 'yes' if _full.partial else 'no') # THE PRERELEASE AND BUILD ARE TUPLES OF STRINGS, not one string and not a # list. A caller who indexed `v.prerelease[0]' expecting `'r'' has a type # error none of the value cases above can see. emit('prerelease_type', type(_full.prerelease).__name__) emit('build_type', type(_full.build).__name__) emit('prerelease_item_type', type(_full.prerelease[0]).__name__) # ---------------------------------------------------------- the round trip --- # EVERY DOCUMENTED SHAPE, parsed and printed back. The assertion is that the # output is the INPUT, byte for byte: this package normalises nothing, so a # release that started dropping a `+build' or zero-padding a field would show # up here and nowhere else. for _k, _s in (('plain', '1.2.3'), ('zeros', '0.0.0'), ('big', '10.20.30'), ('pre_alpha', '1.0.0-alpha'), ('pre_dotted', '1.0.0-alpha.1'), ('pre_numeric', '1.0.0-0.3.7'), ('pre_hyphen', '1.0.0-x-y-z.-'), ('build_only', '1.0.0+20130313144700'), ('build_dotted', '1.0.0+exp.sha.5114f85'), ('both', '1.0.0-beta+exp.sha.5114f85')): emit('round_' + _k, str(Version(_s))) # --------------------------------------------------------------- precedence --- # THE ORDERING THE SPECIFICATION FIXES, as one sorted list -- semver.org's own # worked example, extended with the two rules a caller is most likely to get # wrong. Asserted as a sorted list rather than as pairs because the finding is # the ORDER, and a pair-at-a-time reading cannot show a rule applying across # the whole set. # # PRERELEASE LOWERS PRECEDENCE: `1.0.0-alpha' sorts BELOW `1.0.0', the opposite # of a plain string compare. # # A NUMERIC PRERELEASE IDENTIFIER COMPARES NUMERICALLY and always sorts below # an alphanumeric one -- so `1.0.0-2' < `1.0.0-11' (not the string order) and # `1.0.0-2' < `1.0.0-alpha'. _UNSORTED = [ '1.0.0', '1.0.0-rc.1', '1.0.0-beta.11', '1.0.0-beta.2', '1.0.0-beta', '1.0.0-alpha.beta', '1.0.0-alpha.1', '1.0.0-alpha', '1.0.0-11', '1.0.0-2', '2.0.0', '1.1.0', '1.0.1', ] emit('sorted_versions', ','.join(str(v) for v in sorted(Version(s) for s in _UNSORTED))) # BUILD METADATA IS IGNORED IN PRECEDENCE -- which has a consequence a caller # rarely expects. Two versions differing only in build metadata are NOT `==', # and are neither `<' nor `>' either: they are INCOMPARABLE. The three `no's # together are the finding; any one of them alone is unremarkable. _b1 = Version('1.0.0+build.1') _b2 = Version('1.0.0+build.2') emit('build_eq', 'yes' if _b1 == _b2 else 'no') emit('build_lt', 'yes' if _b1 < _b2 else 'no') emit('build_gt', 'yes' if _b1 > _b2 else 'no') # THE PACKAGE KEEPS TWO KEYS AND ONLY ONE OF THEM IS PRECEDENCE. # `precedence_key' INCLUDES the build metadata; `_cmp_precedence_key', which is # what `__lt__' actually compares, drops it. So the public attribute named # after precedence is not the one precedence uses, and a caller who sorted on # `precedence_key' -- the obvious reading of that name -- would order by build # metadata, which the specification says carries no order at all. emit('build_precedence_key_eq', 'yes' if _b1.precedence_key == _b2.precedence_key else 'no') emit('build_cmp_key_eq', 'yes' if _b1._cmp_precedence_key == _b2._cmp_precedence_key else 'no') # ...and the same against a version with no build at all, which is the pair # that catches a caller using `==' to mean "the same release". _b0 = Version('1.0.0') emit('build_vs_none_eq', 'yes' if _b0 == _b1 else 'no') emit('build_vs_none_lt', 'yes' if _b0 < _b1 else 'no') emit('build_vs_none_gt', 'yes' if _b0 > _b1 else 'no') # `compare' IS THE MODULE-LEVEL SHORTHAND, string in and the C convention out. # `compare_build_zero' is the case to read: given two versions that differ only # in build metadata it returns the SENTINEL `NotImplemented', not 0 and not an # exception. A caller passing that to `sorted(key=...)' or testing `< 0' gets # a truthy object rather than an ordering, so the incomparability above leaks # out of the operators and into this function's return value. for _k, _a, _b in (('lt', '1.0.0', '2.0.0'), ('gt', '2.0.0', '1.0.0'), ('eq', '1.0.0', '1.0.0'), ('pre_lt', '1.0.0-alpha', '1.0.0'), ('build_zero', '1.0.0+a', '1.0.0+b')): emit('compare_' + _k, semantic_version.compare(_a, _b)) # ----------------------------------------------------------- the partial mode --- # THIS LIBRARY'S OWN EXTENSION to the specification, and the reason it is not # interchangeable with python3-semver. `Version(s, partial=True)' accepts an # INCOMPLETE version and leaves the missing components `None' -- it does not # default them to zero, which is the mistake these value cases are aimed at. for _k, _s in (('major_only', '1'), ('major_minor', '1.2'), ('full', '1.2.3'), ('with_pre', '1.2.3-rc.1'), ('trailing_dash', '1.2.3-')): emit('partial_' + _k, comp(Version(_s, partial=True))) # The same strings WITHOUT the flag. Without this pair the cases above would # read as "the parser is lenient" rather than as "a second mode exists". emit('strict_major_only', val(Version, '1')) emit('strict_major_minor', val(Version, '1.2')) emit('partial_str', str(Version('1.2', partial=True))) emit('partial_flag', 'yes' if Version('1.2', partial=True).partial else 'no') # A PARTIAL VERSION DOES NOT ORDER AGAINST A FULL ONE. `__lt__' compares # `_cmp_precedence_key' element by element; a missing component is `None', so # the comparison reaches `None < int' and raises a bare `TypeError' out of the # tuple compare rather than answering. # # This is the one refusal in the package that is NOT a `ValueError', so a # caller who sorted a mixed list inside `except ValueError' is not protected by # it. Asserted from both directions, because either alone could be read as an # ordering result rather than as a raise. emit('partial_lt_full', val(lambda: Version('1.2', partial=True) < Version('1.2.0'))) emit('full_lt_partial', val(lambda: Version('1.2.0') < Version('1.2', partial=True))) # Equality does NOT raise -- `__eq__' compares the tuples for identity rather # than for order -- so the two operators disagree about whether these values # are comparable at all. emit('partial_eq_full', val(lambda: 'yes' if Version('1.2', partial=True) == Version('1.2.0') else 'no')) # Two partials of the same shape DO order, which is what says the mode is not # simply broken: the `None's line up and never meet an `int'. emit('partial_lt_partial', val(lambda: 'yes' if Version('1.2', partial=True) < Version('1.3', partial=True) else 'no')) # THE MODE IS DEPRECATED AND SAYS SO, once per construction, on stderr -- # naming `SimpleSpec('1.x.x')' as the replacement. Asserted because it is the # only thing telling a caller the extension above is on its way out, and # because a suite reading only stdout would never see it. Compare # `spec_warned' below, where the documentation says deprecated and the code # says nothing at all. with warnings.catch_warnings(record=True) as _pw: warnings.simplefilter('always') Version('1.2', partial=True) emit('partial_warned', 'yes' if any( issubclass(x.category, DeprecationWarning) for x in _pw) else 'no') emit('partial_warning_count', len(_pw)) # `coerce' IS A THIRD MODE, and a LOSSY one: it takes something that is not a # SemVer version and makes one up. The two to read are `four_field' and # `trailing' -- a fourth numeric component is not dropped and not an error, it # is moved into BUILD METADATA. Build metadata carries no precedence, so the # distinction those strings were drawing survives as TEXT and is lost to every # comparison: `coerce("0.1.2.3")' and `coerce("0.1.2.4")' are not `==' (build # is part of equality) and are neither `<' nor `>' either. They are # INCOMPARABLE -- which is very unlikely to be what a caller reaching for # `coerce' on a four-field version number meant. for _k, _s in (('two_field', '2'), ('three_field', '2.1'), ('four_field', '0.1.2.3'), ('trailing', '1.2.3.4-rc.1'), ('already', '1.2.3')): emit('coerce_' + _k, str(Version.coerce(_s))) _c3, _c4 = Version.coerce('0.1.2.3'), Version.coerce('0.1.2.4') emit('coerce_four_field_eq', 'yes' if _c3 == _c4 else 'no') emit('coerce_four_field_lt', 'yes' if _c3 < _c4 else 'no') emit('coerce_four_field_gt', 'yes' if _c3 > _c4 else 'no') # ------------------------------------------------------- the range language --- # ONE FIXED CANDIDATE LIST for both dialects, carrying four prereleases # because that is where they diverge (below). The admitted set is emitted in # the list's order, so each case states both WHICH versions matched and that # filtering does not reorder. _CAND = [ '0.9.0', '0.9.1', '1.0.0-alpha', '1.0.0-beta', '1.0.0', '1.0.1', '1.1.0', '1.5.0-rc.1', '1.5.0', '1.9.9', '2.0.0-rc.1', '2.0.0', '2.1.0', '3.0.0', ] _CANDV = [Version(s) for s in _CAND] def matched(spec): return ','.join(str(v) for v in _CANDV if v in spec) # EVERY OPERATOR THE SIMPLE DIALECT HAS, each with its `select()' -- which # returns the HIGHEST admitted candidate, not the first. for _k, _e in (('gte', '>=1.0.0'), ('gt', '>1.0.0'), ('lt', '<2.0.0'), ('lte', '<=1.1.0'), ('eq', '==1.0.0'), ('ne', '!=1.0.0'), ('bare', '1.0.0'), ('tilde', '~1.0.0'), ('caret', '^1.0.0'), ('caret_zero', '^0.9.0'), ('wild_minor', '1.*'), ('wild_patch', '1.0.*'), ('wild_all', '*'), ('and', '>=1.0.0,<2.0.0'), ('and_three', '>=1.0.0,<2.0.0,!=1.1.0')): _spec = SimpleSpec(_e) emit('simple_%s_match' % _k, matched(_spec)) _sel = _spec.select(_CANDV) emit('simple_%s_select' % _k, 'None' if _sel is None else str(_sel)) # A SPEC THAT ADMITS NOTHING: `select' returns `None' rather than raising, which # is the only way a caller learns the difference between "no candidate matched" # and "the spec was bad". _none = SimpleSpec('>=9.0.0') emit('simple_empty_match', matched(_none)) emit('simple_empty_select', 'None' if _none.select(_CANDV) is None else 'not-None') # ------------------------------------------ where the two dialects DO differ --- # NOT WHERE IT LOOKS. MEASURED: for every expression BOTH dialects accept -- # `^1.0.0', `~1.0.0', `^0.9.0', `1.0.*', a bare `1.0.0' -- they admit the # IDENTICAL set once prereleases are out of the candidate list. The caret's # `0.x' special case, which is the usual place to look for a divergence, is the # same in both. `caret_zero_agree' and `tilde_agree' assert that agreement # directly, so a reader is told the obvious guess was checked and is wrong. emit('caret_zero_agree', 'yes' if matched(SimpleSpec('^0.9.0')) == matched(NpmSpec('^0.9.0')) else 'no') emit('tilde_agree', 'yes' if matched(SimpleSpec('~1.0.0')) == matched(NpmSpec('~1.0.0')) else 'no') # THE DIVERGENCE IS PRERELEASES, and in the source it is one constructor # argument. `Range' carries a `prerelease_policy'; `SimpleSpec' builds its # ranges with the default `PRERELEASE_NATURAL' and `NpmSpec' with # `PRERELEASE_SAMEPATCH'. # # NATURAL a prerelease orders like any other version, so a bound admits it # whenever the ordering does. # SAMEPATCH a prerelease is considered ONLY where the bound's own # major.minor.patch is the same; every other prerelease is refused # before the operator is reached at all. # # So `>=1.0.0' admits `1.5.0-rc.1' and `2.0.0-rc.1' in the simple dialect and # neither in the npm one, and `*' -- which reads as "anything" -- is not the # same set in the two. # # Both halves of each pair are emitted rather than one difference, because the # finding is which dialect does what, and a single "they differ" line would not # say. for _k, _e in (('gte', '>=1.0.0'), ('gt', '>1.0.0'), ('lt', '<2.0.0'), ('caret', '^1.0.0'), ('star', '*'), ('pre_bound', '>=1.0.0-alpha')): emit('simple_pre_%s' % _k, matched(SimpleSpec(_e))) emit('npm_pre_%s' % _k, matched(NpmSpec(_e))) # `<2.0.0' IS THE SHARPEST OF THEM, because it is the one place the two swap # sides. The npm dialect ADMITS `2.0.0-rc.1' into a range whose stated upper # bound is `2.0.0'; the simple dialect -- permissive about prereleases # everywhere else -- REFUSES it. # # Both follow from the policies above. Under SAMEPATCH the candidate's patch # matches the bound's, so it is considered and then simply ordered: it is less # than `2.0.0', so it is in. Under NATURAL `OP_LT' carries an explicit # carve-out -- a prerelease of the bound's OWN version is excluded -- so that # `<1.2.3' does not quietly admit `1.2.3-a1'. # # A caller reading `<2.0.0' as "nothing from the 2.x line" is wrong in exactly # one dialect, and it is not the one the rest of this section would suggest. emit('npm_lt_admits_rc', 'yes' if Version('2.0.0-rc.1') in NpmSpec('<2.0.0') else 'no') emit('simple_lt_admits_rc', 'yes' if Version('2.0.0-rc.1') in SimpleSpec('<2.0.0') else 'no') # ------------------------------- the grammar each dialect will not parse --- # THE OTHER HALF OF "two languages": npm's own range syntax -- a hyphen range, # a SPACE-separated conjunction, and `||' -- is not the simple dialect's, which # takes only comma-joined comparators. Each is asserted from both sides, # because a refusal alone does not say the other dialect accepts it. for _k, _e in (('hyphen', '1.0.0 - 2.0.0'), ('space_and', '>=1.0.0 <2.0.0'), ('or', '1.0.0 || 3.0.0')): emit('simple_refuses_' + _k, val(SimpleSpec, _e)) emit('npm_' + _k + '_match', matched(NpmSpec(_e))) # ------------------------------------------------------- the module helpers --- # `validate' RETURNS A BOOL AND NEVER RAISES, which is what makes it usable as # a gate where `Version()' is not. `validate_partial' is the case to read: a # partial version is NOT valid, so this function answers about the strict mode # only and cannot be used to gate the partial one. for _k, _s in (('full', '1.2.3'), ('with_pre', '1.2.3-rc.1'), ('with_build', '1.2.3+b'), ('partial', '1.2'), ('leading_zero', '01.2.3'), ('empty', ''), ('junk', 'not a version')): emit('validate_' + _k, 'yes' if semantic_version.validate(_s) else 'no') # `match' IS `Version in SimpleSpec' SPELLED AS A FUNCTION -- strings in, bool # out -- the shorthand a caller reaches for before discovering the objects. for _k, _spec, _v in (('in', '>=1.0.0', '1.2.0'), ('out', '>=1.0.0', '0.9.0'), ('caret', '^1.0.0', '1.9.9')): emit('match_' + _k, 'yes' if semantic_version.match(_spec, _v) else 'no') # -------------------------------------------------------- the failure path --- # EVERY REFUSAL IS A `ValueError' -- a bad version and a bad spec alike, and # they are raised from different code paths. The NAME is the whole of the # assertion: a release that let one escape as a `TypeError' or an # `AttributeError' would break every caller's `except ValueError' and change # nothing a value case could see. The one exception to the rule is # `partial_lt_full' above, which is why it is asserted the same way. for _k, _s in (('empty', ''), ('junk', 'not a version'), ('leading_zero', '01.2.3'), ('negative', '-1.2.3'), ('too_few', '1.2'), ('too_many', '1.2.3.4'), ('empty_pre', '1.2.3-'), ('bad_pre_char', '1.2.3-rc_1'), ('trailing_dot', '1.2.3.')): emit('err_version_' + _k, val(Version, _s)) for _k, _s in (('empty', ''), ('junk', 'not a spec'), ('bad_op', '=>1.0.0'), ('dangling', '>=1.0.0,'), ('bad_version', '>=1.x.y')): emit('err_spec_' + _k, val(SimpleSpec, _s)) emit('err_npm_junk', val(NpmSpec, 'not a spec')) # ...asserted once from the CATCHING side too, so the names above are not the # only evidence that a built-in `except ValueError' works. try: Version('nope') emit('version_caught_as_valueerror', 'no-raise') except ValueError as exc: emit('version_caught_as_valueerror', type(exc).__name__) # ---------------------------------------------- the deprecated legacy names --- # `Spec' AND `SpecItem' ARE THE PRE-2.7 API and are still exported, so a caller # following an old README gets a working object rather than an `ImportError'. # # MEASURED, AND THE OPPOSITE OF THE PARTIAL MODE ABOVE: `Spec()' is documented # as deprecated and emits NO warning -- `spec_warned' is `no'. It returns a # `LegacySpec', not a `SimpleSpec', so a caller doing `isinstance(s, # SimpleSpec)' after building one is told nothing by the type either. The two # together mean nothing at runtime says this API is going away, which is # exactly what `partial_warned' shows the package doing correctly elsewhere. with warnings.catch_warnings(record=True) as _w: warnings.simplefilter('always') _legacy = semantic_version.Spec('>=1.0.0') emit('spec_returns', type(_legacy).__name__) emit('spec_warned', 'yes' if any( issubclass(x.category, DeprecationWarning) for x in _w) else 'no') # It still WORKS, which is what makes the silence above worth stating rather # than reporting as a break. emit('spec_equivalent', 'yes' if Version('1.2.0') in semantic_version.Spec('>=1.0.0') else 'no') # ------------------------------ the submodule that ships and cannot be run --- # UPSTREAM SHIPS ITS DJANGO INTEGRATION INSIDE THE PACKAGE, so # `django_fields.py' and its precompiled `.pyc' land in site-packages on this # image. It does `import django' at module level and this image carries no # django, so the submodule is UNIMPORTABLE here. # # That is harmless and this case is what says so: `__init__.py' does not import # it, so `import semantic_version' is unaffected, and the failure is a plain # `ModuleNotFoundError' for a package that is genuinely absent rather than # anything about this arm. Asserted rather than left implicit because a reader # who found the file would otherwise have to work out whether it mattered. try: import semantic_version.django_fields # noqa: F401 emit('django_fields_imports', 'yes') except Exception as exc: emit('django_fields_imports', type(exc).__name__) emit('probe_done', 'yes') emit('emitted_count', len(emitted) + 1)