From 02222df7541b3408059a55bd0087a6e4931838f2 Mon Sep 17 00:00:00 2001 From: Brian Warner Date: Thu, 12 Feb 2015 02:05:02 -0800 Subject: [PATCH 1/2] mostly there --- src/git/from_keywords.py | 6 ++-- src/git/from_vcs.py | 64 +++++++++++++++++++++++++++++++++------- test/test_git.py | 49 ++++++++++++++++++++++-------- 3 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/git/from_keywords.py b/src/git/from_keywords.py index f75f6ff7..bb777cc6 100644 --- a/src/git/from_keywords.py +++ b/src/git/from_keywords.py @@ -58,9 +58,9 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose=False): print("picking %s" % r) return {"version": r, "full": keywords["full"].strip()} - # no suitable tags, so we use the full revision id + # no suitable tags, so version is "unknown", but full hex is still there if verbose: - print("no suitable tags, using full revision id") - return {"version": keywords["full"].strip(), + print("no suitable tags, using unknown + full revision id") + return {"version": "unknown", "full": keywords["full"].strip()} diff --git a/src/git/from_vcs.py b/src/git/from_vcs.py index eceb51ab..be743033 100644 --- a/src/git/from_vcs.py +++ b/src/git/from_vcs.py @@ -1,3 +1,44 @@ +def git_parse_vcs_describe(git_describe, tag_prefix, verbose=False): + if not git_describe.startswith(tag_prefix): + if verbose: + fmt = "tag '%s' doesn't start with prefix '%s'" + print(fmt % (git_describe, tag_prefix)) + dirty = bool(git_describe.endswith("-dirty")) # still there + return None, dirty + git_version = git_describe[len(tag_prefix):] + + # parse TAG-NUM-gHEX[-dirty], given that TAG might contain "-" + + # dirty + dirty = git_version.endswith("-dirty") + if dirty: + git_version = git_version[:git_version.rindex("-dirty")] + + # commit: short hex revision ID + idx = git_version.rindex("-g") + commit = git_version[idx+2:] + git_version = git_version[:idx] + + # distance: number of commits since tag + idx = git_version.rindex("-") + if re.match("^\d+$", git_version[idx+1:]): + distance = int(git_version[idx+1:]) + git_version = git_version[:idx] + + # tag: stripped of tag_prefix + tag = git_version + + # now build up version string, with post-release "local version + # identifier". Our goal: TAG[+NUM.gHEX[-dirty]] . Note that if you get a + # tagged build and then dirty it, you'll get TAG+0.gHEX-dirty . So you + # can always test version.endswith("-dirty"). + version = tag + if distance or dirty: + version += "+%d.g%s" % (distance, commit) + if dirty: + version += "-dirty" + + return version, dirty def git_versions_from_vcs(tag_prefix, root, verbose=False): # this runs 'git' from the root of the source tree. This only gets called @@ -8,26 +49,27 @@ def git_versions_from_vcs(tag_prefix, root, verbose=False): if not os.path.exists(os.path.join(root, ".git")): if verbose: print("no .git in %s" % root) - return {} + return {} # get_versions() will try next method GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], + # this yields TAG-NUM-gHEX[-dirty] + stdout = run_command(GITS, ["describe", "--tags", "--dirty", + "--always", "--long"], cwd=root) + # --long was added in git-1.5.5 if stdout is None: - return {} - if not stdout.startswith(tag_prefix): - if verbose: - fmt = "tag '%s' doesn't start with prefix '%s'" - print(fmt % (stdout, tag_prefix)) - return {} - tag = stdout[len(tag_prefix):] + return {} # try next method + version, dirty = git_parse_vcs_describe(stdout, tag_prefix, verbose) + + # build "full", which is FULLHEX[-dirty] stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) if stdout is None: return {} full = stdout.strip() - if tag.endswith("-dirty"): + if dirty: full += "-dirty" - return {"version": tag, "full": full} + + return {"version": version, "full": full} diff --git a/test/test_git.py b/test/test_git.py index 4fdc9157..20daa900 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -5,6 +5,7 @@ import tarfile import unittest import tempfile +from pkg_resources import parse_version, SetuptoolsLegacyVersion sys.path.insert(0, "src") from git.from_keywords import git_versions_from_keywords @@ -40,12 +41,12 @@ def test_unexpanded(self): def test_no_tags(self): v = self.parse("(HEAD, master)", "full") - self.assertEqual(v["version"], "full") + self.assertEqual(v["version"], "unknown") self.assertEqual(v["full"], "full") def test_no_prefix(self): v = self.parse("(HEAD, master, 1.23)", "full", "missingprefix-") - self.assertEqual(v["version"], "full") + self.assertEqual(v["version"], "unknown") self.assertEqual(v["full"], "full") VERBOSE = False @@ -72,6 +73,7 @@ def subpath(self, path): # SA: sitting on the 1.0 tag # SB: dirtying the tree after 1.0 # SC: making a new commit after 1.0, clean tree + # SD: dirtying the tree after the post-1.0 commit # # Then we're interested in 5 kinds of trees: # TA: source tree (with .git) @@ -195,15 +197,24 @@ def run_test(self, demoapp_dir, script_only): f = open(self.subpath("demoapp/setup.py"),"a") f.write("# dirty\n") f.close() - self.do_checks("1.0-dirty", full+"-dirty", dirty=True, state="SB") + short = "1.0+0.g%s-dirty" % full[:7] + self.do_checks(short, full+"-dirty", dirty=True, state="SB") # SC: now we make one commit past the tag self.git("add", "setup.py") self.git("commit", "-m", "dirty") full = self.git("rev-parse", "HEAD") - short = "1.0-1-g%s" % full[:7] + short = "1.0+1.g%s" % full[:7] self.do_checks(short, full, dirty=False, state="SC") + # SD: dirty the post-tag tree + f = open(self.subpath("demoapp/setup.py"),"a") + f.write("# more dirty\n") + f.close() + full = self.git("rev-parse", "HEAD") + short = "1.0+1.g%s-dirty" % full[:7] + self.do_checks(short, full+"-dirty", dirty=True, state="SD") + def do_checks(self, exp_short, exp_long, dirty, state): if os.path.exists(self.subpath("out")): @@ -239,8 +250,8 @@ def do_checks(self, exp_short, exp_long, dirty, state): # expanded keywords only tell us about tags and full revisionids, # not how many patches we are beyond a tag. So we can't expect # the short version to be like 1.0-1-gHEXID. The code falls back - # to short=long - exp_short_TD = exp_long + # to short="unknown" + exp_short_TD = "unknown" self.check_version(target, exp_short_TD, exp_long, False, state, tree="TD") # TE: unpacked setup.py sdist tarball @@ -260,10 +271,20 @@ def do_checks(self, exp_short, exp_long, dirty, state): self.assertTrue(os.path.isdir(target)) self.check_version(target, exp_short, exp_long, False, state, tree="TE") - def compare(self, got, expected, state, tree, runtime): + def compare(self, got, expected, state, tree, runtime, pep440): where = "/".join([state, tree, runtime]) self.assertEqual(got, expected, "%s: got '%s' != expected '%s'" % (where, got, expected)) + if pep440: + if got == "unknown": + return # not required to be compatible + pv = parse_version(got) + if isinstance(pv, SetuptoolsLegacyVersion): + print "not PEP440:", where, got + return + self.assertFalse(isinstance(pv, SetuptoolsLegacyVersion), + "%s: '%s' was not pep440-compatible" + % (where, got)) if VERBOSE: print(" good %s" % where) def check_version(self, workdir, exp_short, exp_long, dirty, state, tree): @@ -275,11 +296,12 @@ def check_version(self, workdir, exp_short, exp_long, dirty, state, tree): print(self.python("setup.py", "version", workdir=workdir)) # setup.py --version gives us get_version() with verbose=False. v = self.python("setup.py", "--version", workdir=workdir) - self.compare(v, exp_short, state, tree, "RA1") + self.compare(v, exp_short, state, tree, "RA1", pep440=True) + # and test again from outside the tree v = self.python(os.path.join(workdir, "setup.py"), "--version", workdir=self.testdir) - self.compare(v, exp_short, state, tree, "RA2") + self.compare(v, exp_short, state, tree, "RA2", pep440=True) if dirty: return # cannot detect dirty files in a build # XXX really? @@ -292,9 +314,12 @@ def check_version(self, workdir, exp_short, exp_long, dirty, state, tree): build_lib = os.path.join(workdir, "build", "lib") out = self.python("rundemo", "--version", workdir=build_lib) data = dict([line.split(":",1) for line in out.splitlines()]) - self.compare(data["__version__"], exp_short, state, tree, "RB") - self.compare(data["shortversion"], exp_short, state, tree, "RB") - self.compare(data["longversion"], exp_long, state, tree, "RB") + self.compare(data["__version__"], exp_short, state, tree, "RB", + pep440=True) + self.compare(data["shortversion"], exp_short, state, tree, "RB", + pep440=True) + self.compare(data["longversion"], exp_long, state, tree, "RB", + pep440=False) if __name__ == '__main__': From 1f2e68c12eb1ced1a3fa49662a9cd037ae8ffa9d Mon Sep 17 00:00:00 2001 From: Brian Warner Date: Thu, 12 Feb 2015 14:13:01 -0800 Subject: [PATCH 2/2] PEP440-compatibility: rewrite parser, change version format The short version should now always be PEP440-compliant, in one of the following forms: * TAG * TAG+DISTANCE.gHASH[.dirty] * 0+unknown * 0+untagged.gHASH[.dirty] * 0+unparseable[.dirty] This uses PEP440's "local version" section to represent the non-tag portions, retaining all the information from before but not triggering "LegacyVersion" warnings. ".dirty" is used instead of "-dirty" because "-dirty" would trigger a warning and be normalized into ".dirty" anyways. Thanks to @muggenhor, @sebastianneubauer, and @glennmatthews for the many attempts to solve this which have been incorporated into this patch. --- README.md | 27 ++++++-------- src/from_file.py | 2 +- src/git/from_keywords.py | 4 +- src/git/from_vcs.py | 71 ++++++++++++++++++++---------------- src/git/long_get_versions.py | 2 +- test/test_git.py | 51 ++++++++++++++++++-------- 6 files changed, 90 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 88ad2800..7ebb90f3 100644 --- a/README.md +++ b/README.md @@ -197,17 +197,19 @@ import the top-level `versioneer.py` and run `get_versions()`. Both functions return a dictionary with different keys for different flavors of the version string: -* `['version']`: condensed tag+distance+shortid+dirty identifier. For git, - this uses the output of `git describe --tags --dirty --always` but strips - the tag_prefix. For example "0.11-2-g1076c97-dirty" indicates that the tree - is like the "1076c97" commit but has uncommitted changes ("-dirty"), and - that this commit is two revisions ("-2-") beyond the "0.11" tag. For - released software (exactly equal to a known tag), the identifier will only - contain the stripped tag, e.g. "0.11". +* `['version']`: A condensed PEP440-compliant string, equal to the + un-prefixed tag name for actual releases, and containing an additional + "local version" section with more detail for in-between builds. For Git, + this is TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe + --tags --dirty --always`. For example "0.11+2.g1076c97.dirty" indicates + that the tree is like the "1076c97" commit but has uncommitted changes + (".dirty"), and that this commit is two revisions ("+2") beyond the "0.11" + tag. For released software (exactly equal to a known tag), the identifier + will only contain the stripped tag, e.g. "0.11". * `['full']`: detailed revision identifier. For Git, this is the full SHA1 - commit id, followed by "-dirty" if the tree contains uncommitted changes, - e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac-dirty". + commit id, followed by ".dirty" if the tree contains uncommitted changes, + e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac.dirty". Some variants are more useful than others. Including `full` in a bug report should allow developers to reconstruct the exact code being tested (or @@ -216,13 +218,6 @@ developers). `version` is suitable for display in an "about" box or a CLI `--version` output: it can be easily compared against release notes and lists of bugs fixed in various releases. -In the future, this will also include a -[PEP-0440](http://legacy.python.org/dev/peps/pep-0440/) -compatible flavor -(e.g. `1.2.post0.dev123`). This loses a lot of information (and has no room -for a hash-based revision id), but is safe to use in a `setup.py` -"`version=`" argument. It also enables tools like *pip* to compare version -strings and evaluate compatibility constraint declarations. - The `setup.py versioneer` command adds the following text to your `__init__.py` to place a basic version in `YOURPROJECT.__version__`: diff --git a/src/from_file.py b/src/from_file.py index 372c9d6a..832d6fd3 100644 --- a/src/from_file.py +++ b/src/from_file.py @@ -12,7 +12,7 @@ def get_versions(default={}, verbose=False): """ -DEFAULT = {"version": "unknown", "full": "unknown"} +DEFAULT = {"version": "0+unknown", "full": "unknown"} def versions_from_file(filename): diff --git a/src/git/from_keywords.py b/src/git/from_keywords.py index bb777cc6..a289b306 100644 --- a/src/git/from_keywords.py +++ b/src/git/from_keywords.py @@ -58,9 +58,9 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose=False): print("picking %s" % r) return {"version": r, "full": keywords["full"].strip()} - # no suitable tags, so version is "unknown", but full hex is still there + # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return {"version": "unknown", + return {"version": "0+unknown", "full": keywords["full"].strip()} diff --git a/src/git/from_vcs.py b/src/git/from_vcs.py index be743033..90a2033a 100644 --- a/src/git/from_vcs.py +++ b/src/git/from_vcs.py @@ -1,45 +1,51 @@ -def git_parse_vcs_describe(git_describe, tag_prefix, verbose=False): - if not git_describe.startswith(tag_prefix): - if verbose: - fmt = "tag '%s' doesn't start with prefix '%s'" - print(fmt % (git_describe, tag_prefix)) - dirty = bool(git_describe.endswith("-dirty")) # still there - return None, dirty - git_version = git_describe[len(tag_prefix):] +import re # --STRIP DURING BUILD - # parse TAG-NUM-gHEX[-dirty], given that TAG might contain "-" +def git_parse_vcs_describe(git_describe, tag_prefix, verbose=False): + # TAG-NUM-gHEX[-dirty] or HEX[-dirty] . TAG might have hyphens. # dirty - dirty = git_version.endswith("-dirty") + dirty = git_describe.endswith("-dirty") if dirty: - git_version = git_version[:git_version.rindex("-dirty")] + git_describe = git_describe[:git_describe.rindex("-dirty")] + dirty_suffix = ".dirty" if dirty else "" - # commit: short hex revision ID - idx = git_version.rindex("-g") - commit = git_version[idx+2:] - git_version = git_version[:idx] + # now we have TAG-NUM-gHEX or HEX + + if "-" not in git_describe: # just HEX + return "0+untagged.g"+git_describe+dirty_suffix, dirty + + # just TAG-NUM-gHEX + mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) + if not mo: + # unparseable. Maybe git-describe is misbehaving? + return "0+unparseable"+dirty_suffix, dirty + + # tag + full_tag = mo.group(1) + if not full_tag.startswith(tag_prefix): + if verbose: + fmt = "tag '%s' doesn't start with prefix '%s'" + print(fmt % (full_tag, tag_prefix)) + return None, dirty + tag = full_tag[len(tag_prefix):] # distance: number of commits since tag - idx = git_version.rindex("-") - if re.match("^\d+$", git_version[idx+1:]): - distance = int(git_version[idx+1:]) - git_version = git_version[:idx] + distance = int(mo.group(2)) - # tag: stripped of tag_prefix - tag = git_version + # commit: short hex revision ID + commit = mo.group(3) # now build up version string, with post-release "local version - # identifier". Our goal: TAG[+NUM.gHEX[-dirty]] . Note that if you get a - # tagged build and then dirty it, you'll get TAG+0.gHEX-dirty . So you - # can always test version.endswith("-dirty"). + # identifier". Our goal: TAG[+NUM.gHEX[.dirty]] . Note that if you get a + # tagged build and then dirty it, you'll get TAG+0.gHEX.dirty . So you + # can always test version.endswith(".dirty"). version = tag if distance or dirty: - version += "+%d.g%s" % (distance, commit) - if dirty: - version += "-dirty" + version += "+%d.g%s" % (distance, commit) + dirty_suffix return version, dirty + def git_versions_from_vcs(tag_prefix, root, verbose=False): # this runs 'git' from the root of the source tree. This only gets called # if the git-archive 'subst' keywords were *not* expanded, and @@ -49,27 +55,28 @@ def git_versions_from_vcs(tag_prefix, root, verbose=False): if not os.path.exists(os.path.join(root, ".git")): if verbose: print("no .git in %s" % root) - return {} # get_versions() will try next method + return {} # get_versions() will try next method GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - # this yields TAG-NUM-gHEX[-dirty] + # if there is a tag, this yields TAG-NUM-gHEX[-dirty] + # if there are no tags, this yields HEX[-dirty] (no NUM) stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always", "--long"], cwd=root) # --long was added in git-1.5.5 if stdout is None: - return {} # try next method + return {} # try next method version, dirty = git_parse_vcs_describe(stdout, tag_prefix, verbose) - # build "full", which is FULLHEX[-dirty] + # build "full", which is FULLHEX[.dirty] stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) if stdout is None: return {} full = stdout.strip() if dirty: - full += "-dirty" + full += ".dirty" return {"version": version, "full": full} diff --git a/src/git/long_get_versions.py b/src/git/long_get_versions.py index 97c8fe16..49e16f93 100644 --- a/src/git/long_get_versions.py +++ b/src/git/long_get_versions.py @@ -1,5 +1,5 @@ -def get_versions(default={"version": "unknown", "full": ""}, verbose=False): +def get_versions(default={"version": "0+unknown", "full": ""}, verbose=False): # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which diff --git a/test/test_git.py b/test/test_git.py index 20daa900..ee902c45 100644 --- a/test/test_git.py +++ b/test/test_git.py @@ -8,6 +8,7 @@ from pkg_resources import parse_version, SetuptoolsLegacyVersion sys.path.insert(0, "src") +from git.from_vcs import git_parse_vcs_describe from git.from_keywords import git_versions_from_keywords from subprocess_helper import run_command @@ -15,6 +16,27 @@ if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] +class ParseGitDescribe(unittest.TestCase): + def test_parse(self): + def pv(git_describe): + return git_parse_vcs_describe(git_describe, "v") + self.assertEqual(pv("1f"), ("0+untagged.g1f", False)) + self.assertEqual(pv("1f-dirty"), ("0+untagged.g1f.dirty", True)) + self.assertEqual(pv("v1.0-0-g1f"), ("1.0", False)) + self.assertEqual(pv("v1.0-0-g1f-dirty"), ("1.0+0.g1f.dirty", True)) + self.assertEqual(pv("v1.0-1-g1f"), ("1.0+1.g1f", False)) + self.assertEqual(pv("v1.0-1-g1f-dirty"), ("1.0+1.g1f.dirty", True)) + + def p(git_describe): + return git_parse_vcs_describe(git_describe, "") + self.assertEqual(p("1f"), ("0+untagged.g1f", False)) + self.assertEqual(p("1f-dirty"), ("0+untagged.g1f.dirty", True)) + self.assertEqual(p("1.0-0-g1f"), ("1.0", False)) + self.assertEqual(p("1.0-0-g1f-dirty"), ("1.0+0.g1f.dirty", True)) + self.assertEqual(p("1.0-1-g1f"), ("1.0+1.g1f", False)) + self.assertEqual(p("1.0-1-g1f-dirty"), ("1.0+1.g1f.dirty", True)) + + class Keywords(unittest.TestCase): def parse(self, refnames, full, prefix=""): return git_versions_from_keywords({"refnames": refnames, "full": full}, @@ -41,12 +63,12 @@ def test_unexpanded(self): def test_no_tags(self): v = self.parse("(HEAD, master)", "full") - self.assertEqual(v["version"], "unknown") + self.assertEqual(v["version"], "0+unknown") self.assertEqual(v["full"], "full") def test_no_prefix(self): v = self.parse("(HEAD, master, 1.23)", "full", "missingprefix-") - self.assertEqual(v["version"], "unknown") + self.assertEqual(v["version"], "0+unknown") self.assertEqual(v["full"], "full") VERBOSE = False @@ -136,11 +158,12 @@ def run_test(self, demoapp_dir, script_only): self.git("add", "--all") self.git("commit", "-m", "comment") + full = self.git("rev-parse", "HEAD") v = self.python("setup.py", "--version") - self.assertEqual(v, "unknown") + self.assertEqual(v, "0+untagged.g%s" % full[:7]) v = self.python(os.path.join(self.subpath("demoapp"), "setup.py"), "--version", workdir=self.testdir) - self.assertEqual(v, "unknown") + self.assertEqual(v, "0+untagged.g%s" % full[:7]) out = self.python("setup.py", "versioneer").splitlines() self.assertEqual(out[0], "running versioneer") @@ -197,8 +220,8 @@ def run_test(self, demoapp_dir, script_only): f = open(self.subpath("demoapp/setup.py"),"a") f.write("# dirty\n") f.close() - short = "1.0+0.g%s-dirty" % full[:7] - self.do_checks(short, full+"-dirty", dirty=True, state="SB") + short = "1.0+0.g%s.dirty" % full[:7] + self.do_checks(short, full+".dirty", dirty=True, state="SB") # SC: now we make one commit past the tag self.git("add", "setup.py") @@ -212,8 +235,8 @@ def run_test(self, demoapp_dir, script_only): f.write("# more dirty\n") f.close() full = self.git("rev-parse", "HEAD") - short = "1.0+1.g%s-dirty" % full[:7] - self.do_checks(short, full+"-dirty", dirty=True, state="SD") + short = "1.0+1.g%s.dirty" % full[:7] + self.do_checks(short, full+".dirty", dirty=True, state="SD") def do_checks(self, exp_short, exp_long, dirty, state): @@ -229,7 +252,7 @@ def do_checks(self, exp_short, exp_long, dirty, state): target = self.subpath("out/demoapp-TB") shutil.copytree(self.subpath("demoapp"), target) shutil.rmtree(os.path.join(target, ".git")) - self.check_version(target, "unknown", "unknown", False, state, tree="TB") + self.check_version(target, "0+unknown", "unknown", False, state, tree="TB") # TC: source tree in versionprefix-named parentdir target = self.subpath("out/demo-1.1") @@ -251,7 +274,7 @@ def do_checks(self, exp_short, exp_long, dirty, state): # not how many patches we are beyond a tag. So we can't expect # the short version to be like 1.0-1-gHEXID. The code falls back # to short="unknown" - exp_short_TD = "unknown" + exp_short_TD = "0+unknown" self.check_version(target, exp_short_TD, exp_long, False, state, tree="TD") # TE: unpacked setup.py sdist tarball @@ -276,15 +299,13 @@ def compare(self, got, expected, state, tree, runtime, pep440): self.assertEqual(got, expected, "%s: got '%s' != expected '%s'" % (where, got, expected)) if pep440: - if got == "unknown": - return # not required to be compatible pv = parse_version(got) - if isinstance(pv, SetuptoolsLegacyVersion): - print "not PEP440:", where, got - return self.assertFalse(isinstance(pv, SetuptoolsLegacyVersion), "%s: '%s' was not pep440-compatible" % (where, got)) + self.assertEqual(str(pv), got, + "%s: '%s' pep440-normalized to '%s'" + % (where, got, str(pv))) if VERBOSE: print(" good %s" % where) def check_version(self, workdir, exp_short, exp_long, dirty, state, tree):