# python3-vcversioner probe. ONE interpreter start, every measurement below. # # Emits `key=value' lines on stdout. The suite reads them by key, so a case # added here without a reader in the suite is inert rather than fatal, and a key # the suite reads but the probe never emits reads as the empty string -- which # `probe_done' and `emitted_count' are what catch. # # EXITS 0 WHATEVER HAPPENS. A probe that exited non-zero on an import failure # would turn one red into a probe overrun and cost the transcript, so the import # result is reported as a VALUE (`import_ok') and never as a status. # # EVERY FIXTURE IS WRITTEN BY THIS FILE. The package's whole subject is where a # version number comes from, so each case below builds its own root directory # under $TMPDIR and puts into it exactly the inputs that case is about: a # `version.txt', or a shell script standing in for `git describe', or neither. # Nothing here needs a VCS to be installed on the image, and nothing reads a # path outside the directory it made. # # find_version() PRINTS on its failure paths and vcversioner's module-level # `print' shadow writes to stdout, so EVERY call below is made with stdout # redirected into a buffer. Left unredirected those lines would land in the # middle of the key=value stream. import io import os import shutil import stat import sys import tempfile import traceback import warnings _emitted = [] def emit(key, value): _emitted.append(key) sys.stdout.write("%s=%s\n" % (key, value)) def yn(b): return "yes" if b else "no" def flat(s): """One line, so a value carrying newlines stays one counted result.""" return s.replace("\r", "").replace("\n", "|") def guard(key, fn): """Emit fn()'s value, or `RAISED:' if it raised. One probe case that throws must not take the remaining cases down with it.""" try: emit(key, fn()) except Exception as e: emit(key, "RAISED:%s" % type(e).__name__) # --------------------------------------------------------------- import --- try: import vcversioner emit("import_ok", "yes") except Exception: emit("import_ok", "no") emit("import_error", flat(traceback.format_exc().strip())) emit("probe_done", "yes") emit("emitted_count", len(_emitted) + 1) sys.exit(0) emit("module_file", getattr(vcversioner, "__file__", "")) emit("module_name", vcversioner.__name__) # IT IS A SINGLE MODULE, NOT A PACKAGE. setup.py declares `py_modules=' rather # than `packages=', so there is no `__path__' and no directory payload. A # release that became a package would relocate the whole payload while every # behavioural case below still passed. emit("is_package", yn(hasattr(vcversioner, "__path__"))) # The module's whole non-underscore surface, including the stdlib names it # imports at module level -- `os', `subprocess' and `warnings' are reachable as # `vcversioner.os' and a reader should be able to see that from the suite. emit("public_names", ",".join(sorted( n for n in dir(vcversioner) if not n.startswith("_")))) # NO `__version__' AND NO `__all__'. Both asserted as ABSENCES: a package whose # subject is version numbers is exactly the one a reader assumes carries its own # in the module, and it does not -- the version lives only in the dist-info. emit("has_version_attr", yn(hasattr(vcversioner, "__version__"))) emit("has_all", yn(hasattr(vcversioner, "__all__"))) emit("has_find_version", yn(hasattr(vcversioner, "find_version"))) emit("find_version_callable", yn(callable(getattr(vcversioner, "find_version", None)))) emit("has_setup_hook", yn(callable(getattr(vcversioner, "setup", None)))) emit("has_Version", yn(hasattr(vcversioner, "Version"))) # The named tuple the ticket asks about, described by its own metadata rather # than by an instance -- `_fields' is the declaration, and the field/index # agreement is measured separately below on a real return value. guard("Version_fields", lambda: ",".join(vcversioner.Version._fields)) guard("Version_is_tuple", lambda: yn(issubclass(vcversioner.Version, tuple))) # THE MODULE SHADOWS `print'. Every diagnostic the package emits is prefixed # `vcversioner:' by a module-level function of that name, which is why the # failure-path assertions below look for that prefix. def _print_prefix(): buf = io.StringIO() old = sys.stdout sys.stdout = buf try: vcversioner.print("hello") finally: sys.stdout = old return flat(buf.getvalue().strip()) guard("print_prefix", _print_prefix) # `/' is translated to the platform separator. On this arm os.sep IS `/', so # the assertion is that the translation is a no-op here rather than that it # does nothing anywhere. guard("fix_path", lambda: vcversioner._fix_path("a/b/c")) # ------------------------------------------------------ signature defaults --- # The defaults are the package's documented contract and every behavioural case # below is written against them, so they are asserted rather than assumed. import inspect _sig = inspect.signature(vcversioner.find_version) emit("sig_params", ",".join(_sig.parameters)) guard("default_root", lambda: _sig.parameters["root"].default) guard("default_version_file", lambda: _sig.parameters["version_file"].default) guard("default_strip_prefix", lambda: _sig.parameters["strip_prefix"].default) guard("default_include_dev", lambda: str(_sig.parameters["include_dev_version"].default)) guard("default_vcs_args", lambda: str(_sig.parameters["vcs_args"].default)) # THE AUTO-DETECTION TABLE, asserted as data. The suite cannot exercise the git # branch without a git repository, so the table itself is the evidence for what # `vcs_args=None' would run. guard("vcs_table_paths", lambda: ",".join(p for p, _ in vcversioner._vcs_args_by_path)) guard("vcs_table_git_args", lambda: " ".join(vcversioner._vcs_args_by_path[0][1])) guard("vcs_table_hg_args", lambda: " ".join(vcversioner._vcs_args_by_path[1][1])) # ============================================================================ # Behaviour. Each case gets its own root under $TMPDIR and is run from inside # it, because the default `root' is `%(pwd)s'. # ============================================================================ BASE = tempfile.mkdtemp(prefix="vcv-probe-") _HOME = os.getcwd() def root(name): d = os.path.join(BASE, name) os.mkdir(d) return d def write(path, text): with open(path, "w") as f: f.write(text) def script(path, out, rc=0, err=""): """A shell script standing in for `git describe --tags --long'. It prints on stdout and exits . /bin/sh is the interpreter and it is named explicitly in vcs_args, so nothing here depends on the exec bit, on a shebang being honoured, or on a VCS being installed on the image.""" body = "" if err: body += "printf '%s\\n' " + repr(str(err)) + " >&2\n" if out is not None: body += "printf '%s\\n' " + repr(str(out)) + "\n" body += "exit %d\n" % rc write(path, body) os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR) return ["/bin/sh", path] def call(d, **kw): """find_version() from inside , with its stdout captured. Returns (result, printed, exit_code): exit_code is None unless the call raised SystemExit, which is how this package reports every failure.""" buf = io.StringIO() old_out, old_cwd = sys.stdout, os.getcwd() os.chdir(d) sys.stdout = buf try: try: r = vcversioner.find_version(**kw) return r, buf.getvalue(), None except SystemExit as e: return None, buf.getvalue(), e.code finally: sys.stdout = old_out os.chdir(old_cwd) def read(path): try: with open(path, "r") as f: return f.read() except Exception as e: return "RAISED:%s" % type(e).__name__ # ------------------------------------------- version.txt, and no VCS at all --- # THE FALLBACK THE README IS ABOUT: an sdist unpacked away from its checkout. # No `.git' and no `.hg' in the root, so auto-detection finds nothing, vcs_args # stays None and NO SUBPROCESS IS SPAWNED -- `file_popen_called' is the witness, # through the documented `Popen=' testing hook. class _Sentinel(object): called = False def _sentinel_popen(*a, **kw): _Sentinel.called = True raise OSError("sentinel Popen must not be reached") def _case_file(): d = root("from-file") write(os.path.join(d, "version.txt"), "1.2.3-4-gabcdef") r, _, code = call(d, Popen=_sentinel_popen) emit("file_exit", str(code)) emit("file_version", r.version) emit("file_commits", r.commits) emit("file_sha", r.sha) emit("file_popen_called", yn(_Sentinel.called)) # The file is REWRITTEN with what was read, so its content is unchanged. emit("file_content_after", flat(read(os.path.join(d, "version.txt")))) # THE FIELDS AND THE INDICES AGREE. A named tuple whose field order drifted # from its element order would satisfy every case that reads `.version'. emit("version_index_matches", yn((r[0], r[1], r[2]) == (r.version, r.commits, r.sha))) emit("version_is_Version", yn(isinstance(r, vcversioner.Version))) emit("version_len", str(len(r))) _case_file() def simple(name, content, key, **kw): """One version.txt, one call, emit the version.""" d = root(name) write(os.path.join(d, "version.txt"), content) r, _, code = call(d, **kw) emit(key, r.version if r is not None else "EXIT:%s" % code) return d, r # `commits' of 0 means the tag itself, with no `.post' suffix. This is the # difference between a release build and a build off a later commit, and it is # the branch `file_version' above does not take. simple("zero-commits", "1.2.3-0-gabcdef", "zero_commits_version") # THE DEFAULT `strip_prefix' IS `v'. A `v'-prefixed tag loses it... simple("strip-v", "v1.2.3-0-gabcdef", "strip_v_version") # ...and a custom prefix is honoured instead, which is the git-dch case the # docstring names. `debian/1.5' -> `1.5'. simple("strip-custom", "debian/1.5-0-gabcdef", "strip_custom_version", strip_prefix="debian/") # With the default prefix still `v', `debian/1.5' keeps its whole tag: the # stripping is a prefix test, not a path split. This is what makes the case # above evidence rather than a coincidence. simple("strip-default-keeps", "debian/1.5-0-gabcdef", "strip_default_version") # `include_dev_version=False' suppresses the `.post' suffix even with commits # after the tag -- the same input as `file_version' above, one keyword apart. simple("no-dev", "1.2.3-4-gabcdef", "no_dev_version", include_dev_version=False) # AN hg REVISION DECREMENTS BY DEFAULT. `sha' beginning `hg' sets # decrement_dev_version when it was not specified, because hg needs a commit to # make a tag. Both the adjusted count and the version string are emitted: the # count is where the arithmetic shows. def _case_hg(): d = root("hg-auto") write(os.path.join(d, "version.txt"), "1.0-3-hg123abc") r, _, _ = call(d) emit("hg_commits", r.commits) emit("hg_version", r.version) # ...and the SAME input with the flag forced off keeps the raw count, which # is what says the decrement is the flag's doing and not the parse's. d2 = root("hg-nodec") write(os.path.join(d2, "version.txt"), "1.0-3-hg123abc") r2, _, _ = call(d2, decrement_dev_version=False) emit("hg_nodec_commits", r2.commits) emit("hg_nodec_version", r2.version) _case_hg() # A GIT sha DOES NOT DECREMENT. The trigger is the literal `hg' prefix on the # sha, so the ordinary case must be shown not to take that branch. simple("git-nodec", "1.0-3-gdeadbee", "git_nodec_version") # ------------------------------------------------------ a VCS command runs --- # THE OTHER HALF OF THE CHAIN: no version.txt, but a command that prints what # `git describe --tags --long' would. The version is derived from that output # AND WRITTEN INTO version.txt, which is the behaviour the whole package exists # for -- so the file's contents are asserted literally, not merely its presence. def _case_vcs(): d = root("from-vcs") args = script(os.path.join(d, "fake-vcs"), "2.0-5-gcafe") r, _, code = call(d, vcs_args=args) emit("vcs_exit", str(code)) emit("vcs_version", r.version) emit("vcs_commits", r.commits) emit("vcs_sha", r.sha) vf = os.path.join(d, "version.txt") emit("vcs_file_exists", yn(os.path.exists(vf))) # RAW, and with no trailing newline: the module strips the VCS output before # writing it. A file that kept the newline would still parse, so only a # literal comparison catches it. emit("vcs_file_content", flat(read(vf))) emit("vcs_file_bytes", str(os.path.getsize(vf))) _case_vcs() # `version_file=None' DISABLES THE FILE ENTIRELY. The version still comes from # the command, and nothing is written -- the documented way to use the package # in a tree that must stay clean. def _case_vcs_nofile(): d = root("vcs-nofile") args = script(os.path.join(d, "fake-vcs"), "2.0-5-gcafe") r, _, _ = call(d, vcs_args=args, version_file=None) emit("vcs_nofile_version", r.version) emit("vcs_nofile_no_file", yn(not os.path.exists(os.path.join(d, "version.txt")))) _case_vcs_nofile() # THE VCS WINS OVER AN EXISTING FILE, AND OVERWRITES IT. version.txt is a # fallback, not a cache: a stale file next to a working checkout must not pin # the version. Both the returned version and the file's new contents are # asserted, because either alone is consistent with the other having been left. def _case_vcs_overwrites(): d = root("vcs-overwrites") write(os.path.join(d, "version.txt"), "0.1-0-gstale") args = script(os.path.join(d, "fake-vcs"), "3.0-1-gnew") r, _, _ = call(d, vcs_args=args) emit("vcs_overwrite_version", r.version) emit("vcs_overwrite_file", flat(read(os.path.join(d, "version.txt")))) _case_vcs_overwrites() # `%(root)s' IS SUBSTITUTED INTO EVERY vcs_args ELEMENT. The docstring promises # it and the built-in git invocation depends on it, so it is measured here with # a command that prints the argument it was handed. def _case_root_substitution(): d = root("root-subst") p = os.path.join(d, "echo-arg") write(p, "printf '%s\\n' \"9.0-0-g$(basename \"$1\")\"\nexit 0\n") r, _, _ = call(d, vcs_args=["/bin/sh", p, "%(root)s/marker"]) emit("root_subst_version", r.version) emit("root_subst_sha", r.sha) _case_root_substitution() # AN EXPLICIT `root' MOVES THE VERSION FILE WITH IT, and the call is made from # a DIFFERENT directory, so a version_file resolved against the cwd instead of # against root would read nothing and exit 2. def _case_explicit_root(): d = root("explicit-root") elsewhere = root("explicit-root-cwd") write(os.path.join(d, "version.txt"), "4.4-0-gaaa") r, _, code = call(elsewhere, root=d) emit("explicit_root_version", r.version if r is not None else "EXIT:%s" % code) emit("explicit_root_cwd_clean", yn(not os.path.exists(os.path.join(elsewhere, "version.txt")))) _case_explicit_root() # ---------------------------------------------------------- version modules --- # `version_module_paths' WRITES THE MODULE IT PROMISES. The contents are # compared literally rather than by importing the result, because the file is # generated source a caller's `__init__.py' does `from ._version import ...' # against -- a change in what it defines is a change in that contract. def _case_module_paths(): d = root("module-paths") write(os.path.join(d, "version.txt"), "1.2.3-4-gabcdef") p = os.path.join(d, "_version.py") p2 = os.path.join(d, "_version2.py") r, _, _ = call(d, version_module_paths=[p, p2]) emit("modpath_written", yn(os.path.exists(p))) emit("modpath_second_written", yn(os.path.exists(p2))) emit("modpath_content", flat(read(p))) emit("modpath_bytes", str(os.path.getsize(p))) # The generated module is valid python and defines what it claims to. ns = {} try: exec(compile(read(p), p, "exec"), ns) emit("modpath_exec_version", ns.get("__version__", "")) emit("modpath_exec_sha", ns.get("__sha__", "")) emit("modpath_exec_revision", ns.get("__revision__", "")) except Exception as e: emit("modpath_exec_version", "RAISED:%s" % type(e).__name__) emit("modpath_exec_sha", "RAISED:%s" % type(e).__name__) emit("modpath_exec_revision", "RAISED:%s" % type(e).__name__) # NO MODULE IS WRITTEN when none is asked for -- the default is empty, so a # release that started writing one unbidden would be noticed. d2 = root("module-paths-default") write(os.path.join(d2, "version.txt"), "1.2.3-4-gabcdef") call(d2) emit("modpath_none_by_default", yn(sorted(os.listdir(d2)) == ["version.txt"])) _case_module_paths() # ================================ error paths ================================ # The package's contract on failure is `SystemExit(2)' with a printed # explanation -- NEVER a silent `0.0.0' that would compare wrongly forever # after. Every case below asserts the exit code, and the ones whose message does # not embed a temporary path assert the message too. # NEITHER a VCS nor a version.txt. This is the github-tarball case the module # prints advice for, and the message is checked by its three parts rather than # literally, because it embeds the root and the version-file path. def _case_nothing(): d = root("nothing") r, out, code = call(d) emit("nothing_exit", str(code)) emit("nothing_result", str(r)) emit("nothing_msg_prefix", yn(out.startswith("vcversioner: "))) emit("nothing_msg_novcs", yn("no VCS could be detected in" in out)) emit("nothing_msg_notpresent", yn("isn't present." in out)) emit("nothing_msg_tarball", yn("are you installing from a github tarball?" in out)) emit("nothing_wrote_nothing", yn(os.listdir(d) == [])) _case_nothing() # NEITHER, and version_file DISABLED. A different branch with a different # message -- one line, no tarball advice, because there is no file to be absent. def _case_nothing_nofile(): d = root("nothing-nofile") r, out, code = call(d, version_file=None) emit("nothing_nofile_exit", str(code)) emit("nothing_nofile_msg_novcs", yn("no VCS could be detected in" in out)) emit("nothing_nofile_no_tarball", yn("are you installing from a github tarball?" not in out)) _case_nothing_nofile() # A VCS COMMAND THAT EXITS NON-ZERO BUT PRINTS A USABLE VERSION IS BELIEVED. # The return code is never inspected; emptiness of stdout is the only failure # signal. This is the package's own behaviour and it is asserted so that a # release which started checking the status is NOTICED -- it would change what # every caller with a noisy VCS wrapper gets. def _case_rc_ignored(): d = root("rc-ignored") args = script(os.path.join(d, "fake-vcs"), "9.9-2-gbad", rc=3, err="fake-vcs: complaining on stderr") r, _, code = call(d, vcs_args=args) emit("rc_ignored_exit", str(code)) emit("rc_ignored_version", r.version if r is not None else "") emit("rc_ignored_file", flat(read(os.path.join(d, "version.txt")))) _case_rc_ignored() # A VCS COMMAND THAT FAILS SILENTLY FALLS BACK TO version.txt. Empty stdout is # what "failed" means here, and the file is the documented fallback. def _case_rc_silent_fallback(): d = root("rc-silent-fallback") write(os.path.join(d, "version.txt"), "7.7-0-gccc") args = script(os.path.join(d, "fake-vcs"), None, rc=1, err="fake-vcs: no tags") r, _, _ = call(d, vcs_args=args) emit("rc_fallback_version", r.version if r is not None else "") _case_rc_silent_fallback() # ...and with NO file to fall back on, that same command is a hard exit. The # message names the command that failed and then dumps its stderr under a # `-- VCS output follows --' banner, which is the only place a caller sees why. def _case_rc_silent_noexit(): d = root("rc-silent-nofile") args = script(os.path.join(d, "fake-vcs"), None, rc=1, err="fake-vcs: no tags") r, out, code = call(d, vcs_args=args) emit("rc_nofile_exit", str(code)) emit("rc_nofile_msg_failed", yn("failed" in out)) emit("rc_nofile_msg_banner", yn("-- VCS output follows --" in out)) emit("rc_nofile_msg_stderr", yn("fake-vcs: no tags" in out)) _case_rc_silent_noexit() # A VCS PROGRAM THAT IS NOT INSTALLED. Popen raises OSError, which is caught # and treated exactly as a failure -- so the version.txt fallback still works # on an image with no git. This is what lets a `.git' directory in a source # tarball be harmless. def _case_missing_program(): d = root("missing-program") write(os.path.join(d, "version.txt"), "5.5-0-gddd") r, _, _ = call(d, vcs_args=["/nonexistent/vcversioner-no-such-program"]) emit("missing_prog_version", r.version if r is not None else "") d2 = root("missing-program-nofile") r2, out2, code2 = call(d2, vcs_args=["/nonexistent/vcversioner-no-such-program"]) emit("missing_prog_nofile_exit", str(code2)) emit("missing_prog_nofile_msg", yn("isn't present." in out2)) _case_missing_program() # AN UNPARSABLE STRING. `rsplit('-', 2)' needs three parts; `garbage' has one. # The message is asserted LITERALLY here because `version_source' is the fixed # string `VCS' on this path -- no temporary path appears in it. def _case_unparsable(): d = root("unparsable") args = script(os.path.join(d, "fake-vcs"), "garbage") r, out, code = call(d, vcs_args=args) emit("unparsable_exit", str(code)) emit("unparsable_msg", flat(out.strip())) # NOTHING IS WRITTEN. The file write happens after the parse, so a version # that could not be parsed must not have been cached for the next run. emit("unparsable_no_file", yn(not os.path.exists(os.path.join(d, "version.txt")))) _case_unparsable() # TWO parts is still one too few -- `1.0-3' has no sha. The boundary matters # because it is the shape a hand-written version.txt is most likely to take. def _case_two_parts(): d = root("two-parts") write(os.path.join(d, "version.txt"), "1.0-3") r, _, code = call(d) emit("two_parts_exit", str(code)) _case_two_parts() # FOUR parts parses, because the split is from the RIGHT: `my-tag-1-gaaa' is # the tag `my-tag'. A left split would take `my' as the tag, which is the bug # this case exists to catch. def _case_four_parts(): d = root("four-parts") write(os.path.join(d, "version.txt"), "my-tag-1-gaaa") r, _, _ = call(d) emit("four_parts_version", r.version) emit("four_parts_sha", r.sha) _case_four_parts() # AN EMPTY version.txt IS NOT A VERSION. It exists, so the "isn't present" # branch is not taken; it reads as the empty string, which fails the parse. def _case_empty_file(): d = root("empty-file") write(os.path.join(d, "version.txt"), "") r, out, code = call(d) emit("empty_file_exit", str(code)) emit("empty_file_msg_parse", yn("couldn't be parsed into a version." in out)) _case_empty_file() # A `.git' DIRECTORY WITH NOTHING IN IT selects the git command by # auto-detection, and that command then fails -- whether git is installed on # this image (it errors) or absent (Popen raises OSError). Either way there is # no version.txt to fall back on, so the answer is the same exit 2, which is why # this case does not depend on a VCS being present. def _case_git_dir(): d = root("git-dir") os.mkdir(os.path.join(d, ".git")) r, out, code = call(d) emit("git_dir_exit", str(code)) emit("git_dir_msg_failed", yn("failed" in out)) # ...and WITH a version.txt beside it, the same detection falls back to the # file rather than exiting. This is the sdist-with-stale-.git case. d2 = root("git-dir-file") os.mkdir(os.path.join(d2, ".git")) write(os.path.join(d2, "version.txt"), "6.6-0-geee") r2, _, _ = call(d2) emit("git_dir_fallback_version", r2.version if r2 is not None else "") _case_git_dir() # ------------------------------------------------------- deprecated git_args --- # `git_args' still works and still warns. Asserted because the warning is the # only notice a caller gets, and because a release that removed the alias would # break callers silently if the suite only exercised `vcs_args'. def _case_git_args(): d = root("git-args") args = script(os.path.join(d, "fake-vcs"), "8.8-0-gfff") with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") r, _, _ = call(d, git_args=args) emit("git_args_version", r.version if r is not None else "") emit("git_args_warned", yn(len(caught) >= 1)) emit("git_args_warning_class", caught[0].category.__name__ if caught else "") emit("git_args_warning_text", flat(str(caught[0].message)) if caught else "") _case_git_args() # ------------------------------------------------------------- setup() hook --- # The distutils keyword hook: `setup(dist, attr, value)' calls find_version with # as keyword arguments and assigns the result to dist.metadata.version. # It is what the entry point in the dist-info wires up, so the suite asserts the # metadata is actually written rather than that the function merely exists. def _case_setup_hook(): d = root("setup-hook") write(os.path.join(d, "version.txt"), "6.6-2-gggg") class _Meta(object): version = None class _Dist(object): metadata = _Meta() dist = _Dist() buf = io.StringIO() old_out, old_cwd = sys.stdout, os.getcwd() os.chdir(d) sys.stdout = buf try: vcversioner.setup(dist, "vcversioner", {}) got = str(dist.metadata.version) except SystemExit as e: got = "EXIT:%s" % e.code except Exception as e: got = "RAISED:%s" % type(e).__name__ finally: sys.stdout = old_out os.chdir(old_cwd) # Emitted AFTER stdout is restored: an emit inside the redirect writes into # the capture buffer and the key never reaches the transcript. emit("setup_hook_version", got) # The hook returns the VERSION STRING only -- the commits and sha are # dropped, which is why a caller wanting them calls find_version directly. emit("setup_hook_is_str", yn(isinstance(dist.metadata.version, str))) _case_setup_hook() os.chdir(_HOME) shutil.rmtree(BASE, ignore_errors=True) emit("probe_done", "yes") emit("emitted_count", len(_emitted) + 1) sys.exit(0)