diff --git a/.gitignore b/.gitignore index 633bd66..4667212 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ doc/__build/* *_rsa.pub locale/ pip-log.txt +/.idea +/.eggs diff --git a/.travis.yml b/.travis.yml index 893e0b0..f5acde5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: python python: - "2.7" - - "3.2" - - "3.3" - "3.4" + - "3.5" + - "3.6" install: - pip install . - pip install nose diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f78e213..2489bcf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,22 @@ httpsig Changes --------------- +1.3.0 (2019-Nov-28) +------------------- + +* Relax pycryptodome requirements (PR#14 by cveilleux) +* Ability to supply another signature header like Signature (PR#15 by rbignon) +* Fixed #2; made Signer.sign() public +* Dropped Python 3.3, added Python 3.7. + +1.2.0 (2018-Mar-28) +------------------- + +* Switched to pycryptodome instead of PyCrypto (PR#11 by iandouglas) +* Updated tests with the test data from Draft 8 and verified it still passes. +* Dropped official Python 3.2 support (pip dropped it so it can't be properly tested) +* Cleaned up the code to be more PEP8-like. + 1.1.2 (2015-Feb-11) ------------------- diff --git a/MANIFEST b/MANIFEST index d969ca8..3dcab25 100644 --- a/MANIFEST +++ b/MANIFEST @@ -5,9 +5,7 @@ README.rst requirements.txt setup.cfg setup.py -versioneer.py httpsig/__init__.py -httpsig/_version.py httpsig/requests_auth.py httpsig/sign.py httpsig/utils.py diff --git a/MANIFEST.in b/MANIFEST.in index 20b80ef..9d6271c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,3 @@ include *.rst include *.txt -include versioneer.py -include httpsig/_version.py include httpsig/tests/*.pem diff --git a/README.rst b/README.rst index ca0674a..bcc9c96 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ httpsig .. image:: https://travis-ci.org/ahknight/httpsig.svg?branch=develop :target: https://travis-ci.org/ahknight/httpsig -Sign HTTP requests with secure signatures according to the IETF HTTP Signatures specification (`Draft 3`_). This is a fork of the original module_ to fully support both RSA and HMAC schemes as well as unit test both schemes to prove they work. It's being used in production and is actively-developed. +Sign HTTP requests with secure signatures according to the IETF HTTP Signatures specification (`Draft 8`_). This is a fork of the original module_ to fully support both RSA and HMAC schemes as well as unit test both schemes to prove they work. It's being used in production and is actively-developed. See the original project_, original Python module_, original spec_, and `current IETF draft`_ for more details on the signing scheme. @@ -15,21 +15,27 @@ See the original project_, original Python module_, original spec_, and `current .. _module: https://github.com/zzsnzmn/py-http-signature .. _spec: https://github.com/joyent/node-http-signature/blob/master/http_signing.md .. _`current IETF draft`: https://datatracker.ietf.org/doc/draft-cavage-http-signatures/ -.. _`Draft 3`: http://tools.ietf.org/html/draft-cavage-http-signatures-03 +.. _`Draft 8`: http://tools.ietf.org/html/draft-cavage-http-signatures-08 Requirements ------------ -* Python 2.7, 3.2, 3.3, 3.4 -* PyCrypto_ +* Python 2.7, 3.4-3.7 +* PyCryptodome_ Optional: * requests_ -.. _PyCrypto: https://pypi.python.org/pypi/pycrypto +.. _PyCryptodome: https://pypi.python.org/pypi/pycryptodome .. _requests: https://pypi.python.org/pypi/requests +For testing: + +* tox +* pyenv (optional, handy way to access multiple versions) + $ for VERS in 2.7.15 3.4.9 3.5.6 3.6.7 3.7.1; do pyenv install -s $VERS; done + Usage ----- @@ -105,6 +111,14 @@ or:: tox +Known Limitations +----------------- + +1. Multiple values for the same header are not supported. New headers with the same name will overwrite the previous header. It might be possible to replace the CaseInsensitiveDict with the collection that the email package uses for headers to overcome this limitation. +2. Keyfiles with passwords are not supported. There has been zero vocal demand for this so if you would like it, a PR would be a good way to get it in. +3. Draft 2 added support for ecdsa-sha256. This is available in PyCryptodome but has not been added to httpsig. PRs welcome. + + License ------- diff --git a/httpsig/__init__.py b/httpsig/__init__.py index b4a758c..01cb860 100644 --- a/httpsig/__init__.py +++ b/httpsig/__init__.py @@ -1,6 +1,12 @@ +from pkg_resources import get_distribution, DistributionNotFound + from .sign import Signer, HeaderSigner from .verify import Verifier, HeaderVerifier -from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions +try: + __version__ = get_distribution(__name__).version +except DistributionNotFound: + # package is not installed + pass + +__all__ = (Signer, HeaderSigner, Verifier, HeaderVerifier) diff --git a/httpsig/_version.py b/httpsig/_version.py deleted file mode 100644 index b1a0acd..0000000 --- a/httpsig/_version.py +++ /dev/null @@ -1,188 +0,0 @@ - -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (build by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. Generated by -# versioneer-0.10 (https://github.com/warner/python-versioneer) - -# these strings will be replaced by git during git-archive -git_refnames = "$Format:%d$" -git_full = "$Format:%H$" - - -import subprocess -import sys -import errno - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): - assert isinstance(commands, list) - p = None - for c in commands: - try: - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % args[0]) - print(e) - return None - else: - if verbose: - print("unable to find command, tried %s" % (commands,)) - return None - stdout = p.communicate()[0].strip() - if sys.version >= '3': - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %s (error)" % args[0]) - return None - return stdout - - -import sys -import re -import os.path - -def get_expanded_variables(versionfile_abs): - # the code embedded in _version.py can just fetch the value of these - # variables. When used from setup.py, we don't want to import - # _version.py, so we do it with a regexp instead. This function is not - # used from _version.py. - variables = {} - try: - f = open(versionfile_abs,"r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["full"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return variables - -def versions_from_expanded_variables(variables, tag_prefix, verbose=False): - refnames = variables["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("variables are unexpanded, not using") - return {} # unexpanded, so not in an unpacked git-archive tarball - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%s', no digits" % ",".join(refs-tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %s" % r) - return { "version": r, - "full": variables["full"].strip() } - # no suitable tags, so we use the full revision id - if verbose: - print("no suitable tags, using full revision id") - return { "version": variables["full"].strip(), - "full": variables["full"].strip() } - -def 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' variables were *not* expanded, and - # _version.py hasn't already been rewritten with a short version string, - # meaning we're inside a checked out source tree. - - if not os.path.exists(os.path.join(root, ".git")): - if verbose: - print("no .git in %s" % root) - return {} - - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], - cwd=root) - if stdout is None: - return {} - if not stdout.startswith(tag_prefix): - if verbose: - print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) - return {} - tag = stdout[len(tag_prefix):] - stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if stdout is None: - return {} - full = stdout.strip() - if tag.endswith("-dirty"): - full += "-dirty" - return {"version": tag, "full": full} - - -def versions_from_parentdir(parentdir_prefix, root, verbose=False): - # Source tarballs conventionally unpack into a directory that includes - # both the project name and a version string. - dirname = os.path.basename(root) - if not dirname.startswith(parentdir_prefix): - if verbose: - print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % - (root, dirname, parentdir_prefix)) - return None - return {"version": dirname[len(parentdir_prefix):], "full": ""} - -tag_prefix = "v" -parentdir_prefix = "httpsig-" -versionfile_source = "httpsig/_version.py" - -def get_versions(default={"version": "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 - # case we can only use expanded variables. - - variables = { "refnames": git_refnames, "full": git_full } - ver = versions_from_expanded_variables(variables, tag_prefix, verbose) - if ver: - return ver - - try: - root = os.path.abspath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for i in range(len(versionfile_source.split("/"))): - root = os.path.dirname(root) - except NameError: - return default - - return (versions_from_vcs(tag_prefix, root, verbose) - or versions_from_parentdir(parentdir_prefix, root, verbose) - or default) - diff --git a/httpsig/requests_auth.py b/httpsig/requests_auth.py index 6a02896..8a00310 100644 --- a/httpsig/requests_auth.py +++ b/httpsig/requests_auth.py @@ -1,4 +1,4 @@ -from requests.auth import AuthBase +import requests.auth try: # Python 3 from urllib.parse import urlparse @@ -9,20 +9,23 @@ from .sign import HeaderSigner -class HTTPSignatureAuth(AuthBase): - ''' +class HTTPSignatureAuth(requests.auth.AuthBase): + """ Sign a request using the http-signature scheme. https://github.com/joyent/node-http-signature/blob/master/http_signing.md - key_id is the mandatory label indicating to the server which secret to use - secret is the filename of a pem file in the case of rsa, a password string in the case of an hmac algorithm - algorithm is one of the six specified algorithms - headers is a list of http headers to be included in the signing string, defaulting to "Date" alone. - ''' + `key_id` is the mandatory label indicating to the server which secret to + use secret is the filename of a pem file in the case of rsa, a password + string in the case of an hmac algorithm + `algorithm` is one of the six specified algorithms + headers is a list of http headers to be included in the signing string, + defaulting to "Date" alone. + """ def __init__(self, key_id='', secret='', algorithm=None, headers=None): headers = headers or [] - self.header_signer = HeaderSigner(key_id=key_id, secret=secret, - algorithm=algorithm, headers=headers) + self.header_signer = HeaderSigner( + key_id=key_id, secret=secret, + algorithm=algorithm, headers=headers) self.uses_host = 'host' in [h.lower() for h in headers] def __call__(self, r): diff --git a/httpsig/sign.py b/httpsig/sign.py index 7125035..94e2180 100644 --- a/httpsig/sign.py +++ b/httpsig/sign.py @@ -15,20 +15,21 @@ class Signer(object): """ When using an RSA algo, the secret is a PEM-encoded private key. When using an HMAC algo, the secret is the HMAC signing secret. - + Password-protected keyfiles are not supported. """ def __init__(self, secret, algorithm=None): if algorithm is None: algorithm = DEFAULT_SIGN_ALGORITHM - + assert algorithm in ALGORITHMS, "Unknown algorithm" - if isinstance(secret, six.string_types): secret = secret.encode("ascii") - + if isinstance(secret, six.string_types): + secret = secret.encode("ascii") + self._rsa = None self._hash = None self.sign_algorithm, self.hash_algorithm = algorithm.split('-') - + if self.sign_algorithm == 'rsa': try: rsa_key = RSA.importKey(secret) @@ -36,28 +37,32 @@ def __init__(self, secret, algorithm=None): self._hash = HASHES[self.hash_algorithm] except ValueError: raise HttpSigException("Invalid key.") - + elif self.sign_algorithm == 'hmac': - self._hash = HMAC.new(secret, digestmod=HASHES[self.hash_algorithm]) + self._hash = HMAC.new(secret, + digestmod=HASHES[self.hash_algorithm]) @property def algorithm(self): return '%s-%s' % (self.sign_algorithm, self.hash_algorithm) def _sign_rsa(self, data): - if isinstance(data, six.string_types): data = data.encode("ascii") + if isinstance(data, six.string_types): + data = data.encode("ascii") h = self._hash.new() h.update(data) return self._rsa.sign(h) def _sign_hmac(self, data): - if isinstance(data, six.string_types): data = data.encode("ascii") + if isinstance(data, six.string_types): + data = data.encode("ascii") hmac = self._hash.copy() hmac.update(data) return hmac.digest() - def _sign(self, data): - if isinstance(data, six.string_types): data = data.encode("ascii") + def sign(self, data): + if isinstance(data, six.string_types): + data = data.encode("ascii") signed = None if self._rsa: signed = self._sign_rsa(data) @@ -69,38 +74,47 @@ def _sign(self, data): class HeaderSigner(Signer): - ''' - Generic object that will sign headers as a dictionary using the http-signature scheme. + """ + Generic object that will sign headers as a dictionary using the + http-signature scheme. https://github.com/joyent/node-http-signature/blob/master/http_signing.md - :arg key_id: the mandatory label indicating to the server which secret to use - :arg secret: a PEM-encoded RSA private key or an HMAC secret (must match the algorithm) + :arg key_id: the mandatory label indicating to the server which secret + to use + :arg secret: a PEM-encoded RSA private key or an HMAC secret (must + match the algorithm) :arg algorithm: one of the six specified algorithms - :arg headers: a list of http headers to be included in the signing string, defaulting to ['date']. - ''' - def __init__(self, key_id, secret, algorithm=None, headers=None): + :arg headers: a list of http headers to be included in the signing + string, defaulting to ['date']. + :arg sign_header: header used to include signature, defaulting to + 'authorization'. + """ + def __init__(self, key_id, secret, algorithm=None, headers=None, sign_header='authorization'): if algorithm is None: algorithm = DEFAULT_SIGN_ALGORITHM - + super(HeaderSigner, self).__init__(secret=secret, algorithm=algorithm) self.headers = headers or ['date'] - self.signature_template = build_signature_template(key_id, algorithm, headers) + self.signature_template = build_signature_template( + key_id, algorithm, headers, sign_header) + self.sign_header = sign_header def sign(self, headers, host=None, method=None, path=None): """ Add Signature Authorization header to case-insensitive header dict. - headers is a case-insensitive dict of mutable headers. - host is a override for the 'host' header (defaults to value in headers). - method is the HTTP method (required when using '(request-target)'). - path is the HTTP path (required when using '(request-target)'). + `headers` is a case-insensitive dict of mutable headers. + `host` is a override for the 'host' header (defaults to value in + headers). + `method` is the HTTP method (required when using '(request-target)'). + `path` is the HTTP path (required when using '(request-target)'). """ headers = CaseInsensitiveDict(headers) required_headers = self.headers or ['date'] - signable = generate_message(required_headers, headers, host, method, path) - - signature = self._sign(signable) - headers['authorization'] = self.signature_template % signature - - return headers + signable = generate_message( + required_headers, headers, host, method, path) + signature = super(HeaderSigner, self).sign(signable) + headers[self.sign_header] = self.signature_template % signature + + return headers diff --git a/httpsig/tests/__init__.py b/httpsig/tests/__init__.py index 72d4383..d9018eb 100644 --- a/httpsig/tests/__init__.py +++ b/httpsig/tests/__init__.py @@ -1,3 +1,3 @@ from .test_signature import * from .test_utils import * -from .test_verify import * \ No newline at end of file +from .test_verify import * diff --git a/httpsig/tests/test_signature.py b/httpsig/tests/test_signature.py index 00ed29d..b8b4c90 100755 --- a/httpsig/tests/test_signature.py +++ b/httpsig/tests/test_signature.py @@ -1,29 +1,37 @@ #!/usr/bin/env python import sys import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -import json import unittest import httpsig.sign as sign from httpsig.utils import parse_authorization_header +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + sign.DEFAULT_SIGN_ALGORITHM = "rsa-sha256" class TestSign(unittest.TestCase): + test_method = 'POST' + test_path = '/foo?param=value&pet=dog' + header_host = 'example.com' + header_date = 'Thu, 05 Jan 2014 21:31:40 GMT' + header_content_type = 'application/json' + header_digest = 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=' + header_content_length = '18' def setUp(self): - self.key_path = os.path.join(os.path.dirname(__file__), 'rsa_private.pem') + self.key_path = os.path.join( + os.path.dirname(__file__), 'rsa_private.pem') with open(self.key_path, 'rb') as f: self.key = f.read() def test_default(self): hs = sign.HeaderSigner(key_id='Test', secret=self.key) unsigned = { - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT' + 'Date': self.header_date } signed = hs.sign(unsigned) self.assertIn('Date', signed) @@ -36,7 +44,34 @@ def test_default(self): self.assertIn('signature', params) self.assertEqual(params['keyId'], 'Test') self.assertEqual(params['algorithm'], 'rsa-sha256') - self.assertEqual(params['signature'], 'ATp0r26dbMIxOopqw0OfABDT7CKMIoENumuruOtarj8n/97Q3htHFYpH8yOSQk3Z5zh8UxUym6FYTb5+A0Nz3NRsXJibnYi7brE/4tx5But9kkFGzG+xpUmimN4c3TMN7OFH//+r8hBf7BT9/GmHDUVZT2JzWGLZES2xDOUuMtA=') + self.assertEqual(params['signature'], 'jKyvPcxB4JbmYY4mByyBY7cZfNl4OW9HpFQlG7N4YcJPteKTu4MWCLyk+gIr0wDgqtLWf9NLpMAMimdfsH7FSWGfbMFSrsVTHNTk0rK3usrfFnti1dxsM4jl0kYJCKTGI/UWkqiaxwNiKqGcdlEDrTcUhhsFsOIo8VhddmZTZ8w=') # noqa: E501 + + def test_basic(self): + hs = sign.HeaderSigner(key_id='Test', secret=self.key, headers=[ + '(request-target)', + 'host', + 'date', + ]) + unsigned = { + 'Host': self.header_host, + 'Date': self.header_date, + } + signed = hs.sign( + unsigned, method=self.test_method, path=self.test_path) + + self.assertIn('Date', signed) + self.assertEqual(unsigned['Date'], signed['Date']) + self.assertIn('Authorization', signed) + auth = parse_authorization_header(signed['authorization']) + params = auth[1] + self.assertIn('keyId', params) + self.assertIn('algorithm', params) + self.assertIn('signature', params) + self.assertEqual(params['keyId'], 'Test') + self.assertEqual(params['algorithm'], 'rsa-sha256') + self.assertEqual( + params['headers'], '(request-target) host date') + self.assertEqual(params['signature'], 'HUxc9BS3P/kPhSmJo+0pQ4IsCo007vkv6bUm4Qehrx+B1Eo4Mq5/6KylET72ZpMUS80XvjlOPjKzxfeTQj4DiKbAzwJAb4HX3qX6obQTa00/qPDXlMepD2JtTw33yNnm/0xV7fQuvILN/ys+378Ysi082+4xBQFwvhNvSoVsGv4=') # noqa: E501 def test_all(self): hs = sign.HeaderSigner(key_id='Test', secret=self.key, headers=[ @@ -44,18 +79,19 @@ def test_all(self): 'host', 'date', 'content-type', - 'content-md5', + 'digest', 'content-length' ]) unsigned = { - 'Host': 'example.com', - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT', - 'Content-Type': 'application/json', - 'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==', - 'Content-Length': '18', + 'Host': self.header_host, + 'Date': self.header_date, + 'Content-Type': self.header_content_type, + 'Digest': self.header_digest, + 'Content-Length': self.header_content_length, } - signed = hs.sign(unsigned, method='POST', path='/foo?param=value&pet=dog') - + signed = hs.sign( + unsigned, method=self.test_method, path=self.test_path) + self.assertIn('Date', signed) self.assertEqual(unsigned['Date'], signed['Date']) self.assertIn('Authorization', signed) @@ -66,5 +102,7 @@ def test_all(self): self.assertIn('signature', params) self.assertEqual(params['keyId'], 'Test') self.assertEqual(params['algorithm'], 'rsa-sha256') - self.assertEqual(params['headers'], '(request-target) host date content-type content-md5 content-length') - self.assertEqual(params['signature'], 'G8/Uh6BBDaqldRi3VfFfklHSFoq8CMt5NUZiepq0q66e+fS3Up3BmXn0NbUnr3L1WgAAZGplifRAJqp2LgeZ5gXNk6UX9zV3hw5BERLWscWXlwX/dvHQES27lGRCvyFv3djHP6Plfd5mhPWRkmjnvqeOOSS0lZJYFYHJz994s6w=') + self.assertEqual( + params['headers'], + '(request-target) host date content-type digest content-length') + self.assertEqual(params['signature'], 'Ef7MlxLXoBovhil3AlyjtBwAL9g4TN3tibLj7uuNB3CROat/9KaeQ4hW2NiJ+pZ6HQEOx9vYZAyi+7cmIkmJszJCut5kQLAwuX+Ms/mUFvpKlSo9StS2bMXDBNjOh4Auj774GFj4gwjS+3NhFeoqyr/MuN6HsEnkvn6zdgfE2i0=') # noqa: E501 diff --git a/httpsig/tests/test_utils.py b/httpsig/tests/test_utils.py index 6d79f69..aa53acd 100755 --- a/httpsig/tests/test_utils.py +++ b/httpsig/tests/test_utils.py @@ -1,17 +1,18 @@ #!/usr/bin/env python import os -import re import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - import unittest - from httpsig.utils import get_fingerprint +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + class TestUtils(unittest.TestCase): def test_get_fingerprint(self): - with open(os.path.join(os.path.dirname(__file__), 'rsa_public.pem'), 'r') as k: + with open(os.path.join( + os.path.dirname(__file__), 'rsa_public.pem'), 'r') as k: key = k.read() fingerprint = get_fingerprint(key) - self.assertEqual(fingerprint, "73:61:a2:21:67:e0:df:be:7e:4b:93:1e:15:98:a5:b7") + self.assertEqual( + fingerprint, "73:61:a2:21:67:e0:df:be:7e:4b:93:1e:15:98:a5:b7") diff --git a/httpsig/tests/test_verify.py b/httpsig/tests/test_verify.py index f49eeb3..6e6d9eb 100755 --- a/httpsig/tests/test_verify.py +++ b/httpsig/tests/test_verify.py @@ -1,15 +1,17 @@ #!/usr/bin/env python import sys import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - -import json import unittest from httpsig.sign import HeaderSigner, Signer from httpsig.verify import HeaderVerifier, Verifier + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + class BaseTestCase(unittest.TestCase): + def _parse_auth(self, auth): """Basic Authorization header parsing.""" # split 'Signature kvpairs' @@ -23,145 +25,196 @@ def _parse_auth(self, auth): param_dict = {k: v.strip('"') for k, v in param_pairs} return param_dict - + class TestVerifyHMACSHA1(BaseTestCase): + test_method = 'POST' + test_path = '/foo?param=value&pet=dog' + header_host = 'example.com' + header_date = 'Thu, 05 Jan 2014 21:31:40 GMT' + header_content_type = 'application/json' + header_digest = 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=' + header_content_length = '18' + sign_header = 'authorization' + def setUp(self): secret = b"something special goes here" - + self.keyId = "Test" self.algorithm = "hmac-sha1" self.sign_secret = secret self.verify_secret = secret - + def test_basic_sign(self): signer = Signer(secret=self.sign_secret, algorithm=self.algorithm) - verifier = Verifier(secret=self.verify_secret, algorithm=self.algorithm) + verifier = Verifier( + secret=self.verify_secret, algorithm=self.algorithm) GOOD = b"this is a test" BAD = b"this is not the signature you were looking for..." - + # generate signed string - signature = signer._sign(GOOD) + signature = signer.sign(GOOD) self.assertTrue(verifier._verify(data=GOOD, signature=signature)) self.assertFalse(verifier._verify(data=BAD, signature=signature)) def test_default(self): unsigned = { - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT' + 'Date': self.header_date } - - hs = HeaderSigner(key_id="Test", secret=self.sign_secret, algorithm=self.algorithm) + + hs = HeaderSigner( + key_id="Test", secret=self.sign_secret, algorithm=self.algorithm, + sign_header=self.sign_header) signed = hs.sign(unsigned) - hv = HeaderVerifier(headers=signed, secret=self.verify_secret) + hv = HeaderVerifier( + headers=signed, secret=self.verify_secret, sign_header=self.sign_header) self.assertTrue(hv.verify()) def test_signed_headers(self): - HOST = "example.com" - METHOD = "POST" - PATH = '/foo?param=value&pet=dog' - hs = HeaderSigner(key_id="Test", secret=self.sign_secret, algorithm=self.algorithm, headers=[ - '(request-target)', - 'host', - 'date', - 'content-type', - 'content-md5', - 'content-length' - ]) + HOST = self.header_host + METHOD = self.test_method + PATH = self.test_path + hs = HeaderSigner( + key_id="Test", + secret=self.sign_secret, + algorithm=self.algorithm, + sign_header=self.sign_header, + headers=[ + '(request-target)', + 'host', + 'date', + 'content-type', + 'digest', + 'content-length' + ]) unsigned = { 'Host': HOST, - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT', - 'Content-Type': 'application/json', - 'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==', - 'Content-Length': '18', + 'Date': self.header_date, + 'Content-Type': self.header_content_type, + 'Digest': self.header_digest, + 'Content-Length': self.header_content_length, } signed = hs.sign(unsigned, method=METHOD, path=PATH) - - hv = HeaderVerifier(headers=signed, secret=self.verify_secret, host=HOST, method=METHOD, path=PATH) + + hv = HeaderVerifier( + headers=signed, secret=self.verify_secret, + host=HOST, method=METHOD, path=PATH, + sign_header=self.sign_header) self.assertTrue(hv.verify()) def test_incorrect_headers(self): - HOST = "example.com" - METHOD = "POST" - PATH = '/foo?param=value&pet=dog' + HOST = self.header_host + METHOD = self.test_method + PATH = self.test_path hs = HeaderSigner(secret=self.sign_secret, key_id="Test", algorithm=self.algorithm, + sign_header=self.sign_header, headers=[ - '(request-target)', - 'host', - 'date', - 'content-type', - 'content-md5', - 'content-length']) + '(request-target)', + 'host', + 'date', + 'content-type', + 'digest', + 'content-length']) unsigned = { 'Host': HOST, - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT', - 'Content-Type': 'application/json', - 'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==', - 'Content-Length': '18', + 'Date': self.header_date, + 'Content-Type': self.header_content_type, + 'Digest': self.header_digest, + 'Content-Length': self.header_content_length, } signed = hs.sign(unsigned, method=METHOD, path=PATH) - hv = HeaderVerifier(headers=signed, secret=self.verify_secret, required_headers=["some-other-header"], host=HOST, method=METHOD, path=PATH) - with self.assertRaises(Exception) as ex: + hv = HeaderVerifier(headers=signed, secret=self.verify_secret, + required_headers=["some-other-header"], + host=HOST, method=METHOD, path=PATH, + sign_header=self.sign_header) + with self.assertRaises(Exception): hv.verify() def test_extra_auth_headers(self): HOST = "example.com" METHOD = "POST" PATH = '/foo?param=value&pet=dog' - hs = HeaderSigner(key_id="Test", secret=self.sign_secret, algorithm=self.algorithm, headers=[ - '(request-target)', - 'host', - 'date', - 'content-type', - 'content-md5', - 'content-length' - ]) + hs = HeaderSigner( + key_id="Test", + secret=self.sign_secret, + sign_header=self.sign_header, + algorithm=self.algorithm, headers=[ + '(request-target)', + 'host', + 'date', + 'content-type', + 'digest', + 'content-length' + ]) unsigned = { 'Host': HOST, - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT', - 'Content-Type': 'application/json', - 'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==', - 'Content-Length': '18', + 'Date': self.header_date, + 'Content-Type': self.header_content_type, + 'Digest': self.header_digest, + 'Content-Length': self.header_content_length, } signed = hs.sign(unsigned, method=METHOD, path=PATH) - hv = HeaderVerifier(headers=signed, secret=self.verify_secret, method=METHOD, path=PATH, required_headers=['date', '(request-target)']) + hv = HeaderVerifier( + headers=signed, + secret=self.verify_secret, + method=METHOD, + path=PATH, + sign_header=self.sign_header, + required_headers=['date', '(request-target)']) self.assertTrue(hv.verify()) class TestVerifyHMACSHA256(TestVerifyHMACSHA1): + def setUp(self): super(TestVerifyHMACSHA256, self).setUp() self.algorithm = "hmac-sha256" + class TestVerifyHMACSHA512(TestVerifyHMACSHA1): + def setUp(self): super(TestVerifyHMACSHA512, self).setUp() self.algorithm = "hmac-sha512" class TestVerifyRSASHA1(TestVerifyHMACSHA1): + def setUp(self): - private_key_path = os.path.join(os.path.dirname(__file__), 'rsa_private.pem') + private_key_path = os.path.join( + os.path.dirname(__file__), + 'rsa_private.pem') with open(private_key_path, 'rb') as f: private_key = f.read() - - public_key_path = os.path.join(os.path.dirname(__file__), 'rsa_public.pem') + + public_key_path = os.path.join( + os.path.dirname(__file__), + 'rsa_public.pem') with open(public_key_path, 'rb') as f: public_key = f.read() - + self.keyId = "Test" self.algorithm = "rsa-sha1" self.sign_secret = private_key self.verify_secret = public_key + class TestVerifyRSASHA256(TestVerifyRSASHA1): + def setUp(self): super(TestVerifyRSASHA256, self).setUp() self.algorithm = "rsa-sha256" + class TestVerifyRSASHA512(TestVerifyRSASHA1): + def setUp(self): super(TestVerifyRSASHA512, self).setUp() self.algorithm = "rsa-sha512" + + +class TestVerifyRSASHA512ChangeHeader(TestVerifyRSASHA1): + sign_header = 'Signature' diff --git a/httpsig/utils.py b/httpsig/utils.py index b34e3fa..5f80ef0 100644 --- a/httpsig/utils.py +++ b/httpsig/utils.py @@ -1,8 +1,8 @@ +import base64 +import six import re import struct import hashlib -import base64 -import six try: # Python 3 @@ -11,10 +11,15 @@ # Python 2 from urllib2 import parse_http_list -from Crypto.PublicKey import RSA from Crypto.Hash import SHA, SHA256, SHA512 -ALGORITHMS = frozenset(['rsa-sha1', 'rsa-sha256', 'rsa-sha512', 'hmac-sha1', 'hmac-sha256', 'hmac-sha512']) +ALGORITHMS = frozenset([ + 'rsa-sha1', + 'rsa-sha256', + 'rsa-sha512', + 'hmac-sha1', + 'hmac-sha256', + 'hmac-sha512']) HASHES = {'sha1': SHA, 'sha256': SHA256, 'sha512': SHA512} @@ -23,11 +28,12 @@ class HttpSigException(Exception): pass -""" -Constant-time string compare. -http://codahale.com/a-lesson-in-timing-attacks/ -""" + def ct_bytes_compare(a, b): + """ + Constant-time string compare. + http://codahale.com/a-lesson-in-timing-attacks/ + """ if not isinstance(a, six.binary_type): a = a.decode('utf8') if not isinstance(b, six.binary_type): @@ -42,23 +48,26 @@ def ct_bytes_compare(a, b): result |= ord(x) ^ ord(y) else: result |= x ^ y - + return (result == 0) -def generate_message(required_headers, headers, host=None, method=None, path=None): + +def generate_message(required_headers, headers, host=None, method=None, + path=None): headers = CaseInsensitiveDict(headers) - + if not required_headers: required_headers = ['date'] - + signable_list = [] for h in required_headers: h = h.lower() if h == '(request-target)': if not method or not path: - raise Exception('method and path arguments required when using "(request-target)"') + raise Exception('method and path arguments required when ' + + 'using "(request-target)"') signable_list.append('%s: %s %s' % (h, method.lower(), path)) - + elif h == 'host': # 'host' special case due to requests lib restrictions # 'host' is not available when adding auth so must use a param @@ -67,11 +76,11 @@ def generate_message(required_headers, headers, host=None, method=None, path=Non if 'host' in headers: host = headers[h] else: - raise Exception('missing required header "%s"' % (h)) + raise Exception('missing required header "%s"' % h) signable_list.append('%s: %s' % (h, host)) else: if h not in headers: - raise Exception('missing required header "%s"' % (h)) + raise Exception('missing required header "%s"' % h) signable_list.append('%s: %s' % (h, headers[h])) @@ -79,40 +88,47 @@ def generate_message(required_headers, headers, host=None, method=None, path=Non return signable +def parse_signature_header(sign_value): + values = {} + if sign_value: + # This is tricky string magic. Let urllib do it. + fields = parse_http_list(sign_value) + + for item in fields: + # Only include keypairs. + if '=' in item: + # Split on the first '=' only. + key, value = item.split('=', 1) + if not (len(key) and len(value)): + continue + + # Unquote values, if quoted. + if value[0] == '"': + value = value[1:-1] + + values[key] = value + return CaseInsensitiveDict(values) + + def parse_authorization_header(header): if not isinstance(header, six.string_types): - header = header.decode("ascii") #HTTP headers cannot be Unicode. - + header = header.decode("ascii") # HTTP headers cannot be Unicode. + auth = header.split(" ", 1) if len(auth) > 2: - raise ValueError('Invalid authorization header. (eg. Method key1=value1,key2="value, \"2\"")') - + raise ValueError('Invalid authorization header. (eg. Method ' + + 'key1=value1,key2="value, \"2\"")') + # Split up any args into a dictionary. values = {} if len(auth) == 2: - auth_value = auth[1] - if auth_value and len(auth_value): - # This is tricky string magic. Let urllib do it. - fields = parse_http_list(auth_value) - - for item in fields: - # Only include keypairs. - if '=' in item: - # Split on the first '=' only. - key, value = item.split('=', 1) - if not (len(key) and len(value)): - continue - - # Unquote values, if quoted. - if value[0] == '"': - value = value[1:-1] - - values[key] = value - + values = parse_signature_header(auth[1]) + # ("Signature", {"headers": "date", "algorithm": "hmac-sha256", ... }) - return (auth[0], CaseInsensitiveDict(values)) + return (auth[0], values) + -def build_signature_template(key_id, algorithm, headers): +def build_signature_template(key_id, algorithm, headers, sign_header='authorization'): """ Build the Signature template for use with the Authorization header. @@ -120,7 +136,8 @@ def build_signature_template(key_id, algorithm, headers): algorithm is one of the six specified algorithms headers is a list of http headers to be included in the signing string. - The signature must be interpolated into the template to get the final Authorization header value. + The signature must be interpolated into the template to get the final + Authorization header value. """ param_map = {'keyId': key_id, 'algorithm': algorithm, @@ -130,27 +147,37 @@ def build_signature_template(key_id, algorithm, headers): param_map['headers'] = ' '.join(headers) kv = map('{0[0]}="{0[1]}"'.format, param_map.items()) kv_string = ','.join(kv) - sig_string = 'Signature {0}'.format(kv_string) - return sig_string + if sign_header.lower() == 'authorization': + return 'Signature {0}'.format(kv_string) + + return kv_string def lkv(d): parts = [] while d: - len = struct.unpack('>I', d[:4])[0] - bits = d[4:len+4] + length = struct.unpack('>I', d[:4])[0] + bits = d[4:length+4] parts.append(bits) - d = d[len+4:] + d = d[length+4:] return parts + def sig(d): return lkv(d)[1] + def is_rsa(keyobj): return lkv(keyobj.blob)[0] == "ssh-rsa" + # based on http://stackoverflow.com/a/2082169/151401 class CaseInsensitiveDict(dict): + """ A case-insensitive dictionary for header storage. + A limitation of this approach is the inability to store + multiple instances of the same header. If that is changed + then we suddenly care about the assembly rules in sec 2.3. + """ def __init__(self, d=None, **kwargs): super(CaseInsensitiveDict, self).__init__(**kwargs) if d: @@ -165,6 +192,7 @@ def __getitem__(self, key): def __contains__(self, key): return super(CaseInsensitiveDict, self).__contains__(key.lower()) + # currently busted... def get_fingerprint(key): """ @@ -182,5 +210,4 @@ def get_fingerprint(key): key = key.strip().encode('ascii') key = base64.b64decode(key) fp_plain = hashlib.md5(key).hexdigest() - return ':'.join(a+b for a,b in zip(fp_plain[::2], fp_plain[1::2])) - + return ':'.join(a+b for a, b in zip(fp_plain[::2], fp_plain[1::2])) diff --git a/httpsig/verify.py b/httpsig/verify.py index a6e1ba3..17e313d 100644 --- a/httpsig/verify.py +++ b/httpsig/verify.py @@ -1,13 +1,9 @@ """ Module to assist in verifying a signed header. """ +import base64 import six -from Crypto.Hash import HMAC -from Crypto.PublicKey import RSA -from Crypto.Signature import PKCS1_v1_5 -from base64 import b64decode - from .sign import Signer from .utils import * @@ -18,26 +14,29 @@ class Verifier(Signer): For HMAC, the secret is the shared secret. For RSA, the secret is the PUBLIC key. """ + def _verify(self, data, signature): """ Verifies the data matches a signed version with the given signature. `data` is the message to verify `signature` is a base64-encoded signature to verify against `data` """ - - if isinstance(data, six.string_types): data = data.encode("ascii") - if isinstance(signature, six.string_types): signature = signature.encode("ascii") - + + if isinstance(data, six.string_types): + data = data.encode("ascii") + if isinstance(signature, six.string_types): + signature = signature.encode("ascii") + if self.sign_algorithm == 'rsa': h = self._hash.new() h.update(data) - return self._rsa.verify(h, b64decode(signature)) - + return self._rsa.verify(h, base64.b64decode(signature)) + elif self.sign_algorithm == 'hmac': h = self._sign_hmac(data) - s = b64decode(signature) + s = base64.b64decode(signature) return ct_bytes_compare(h, s) - + else: raise HttpSigException("Unsupported algorithm.") @@ -46,45 +45,66 @@ class HeaderVerifier(Verifier): """ Verifies an HTTP signature from given headers. """ - def __init__(self, headers, secret, required_headers=None, method=None, path=None, host=None): + + def __init__(self, headers, secret, required_headers=None, method=None, + path=None, host=None, sign_header='authorization'): """ Instantiate a HeaderVerifier object. - - :param headers: A dictionary of headers from the HTTP request. + + :param headers: A dictionary of headers from the HTTP + request. :param secret: The HMAC secret or RSA *public* key. - :param required_headers: Optional. A list of headers required to be present to validate, even if the signature is otherwise valid. Defaults to ['date']. - :param method: Optional. The HTTP method used in the request (eg. "GET"). Required for the '(request-target)' header. - :param path: Optional. The HTTP path requested, exactly as sent (including query arguments and fragments). Required for the '(request-target)' header. - :param host: Optional. The value to use for the Host header, if not supplied in :param:headers. + :param required_headers: Optional. A list of headers required to + be present to validate, even if the signature is otherwise valid. + Defaults to ['date']. + :param method: Optional. The HTTP method used in the + request (eg. "GET"). Required for the '(request-target)' header. + :param path: Optional. The HTTP path requested, + exactly as sent (including query arguments and fragments). + Required for the '(request-target)' header. + :param host: Optional. The value to use for the Host + header, if not supplied in :param:headers. + :param sign_header: Optional. The header where the signature is. + Default is 'authorization'. """ required_headers = required_headers or ['date'] - - auth = parse_authorization_header(headers['authorization']) - if len(auth) == 2: - self.auth_dict = auth[1] - else: - raise HttpSigException("Invalid authorization header.") - self.headers = CaseInsensitiveDict(headers) + + if sign_header.lower() == 'authorization': + auth = parse_authorization_header(self.headers['authorization']) + if len(auth) == 2: + self.auth_dict = auth[1] + else: + raise HttpSigException("Invalid authorization header.") + else: + self.auth_dict = parse_signature_header(self.headers[sign_header]) + self.required_headers = [s.lower() for s in required_headers] self.method = method self.path = path self.host = host - - super(HeaderVerifier, self).__init__(secret, algorithm=self.auth_dict['algorithm']) + + super(HeaderVerifier, self).__init__( + secret, algorithm=self.auth_dict['algorithm']) def verify(self): """ - Verify the headers based on the arguments passed at creation and current properties. - - Raises an Exception if a required header (:param:required_headers) is not found in the signature. + Verify the headers based on the arguments passed at creation and + current properties. + + Raises an Exception if a required header (:param:required_headers) is + not found in the signature. Returns True or False. """ auth_headers = self.auth_dict.get('headers', 'date').split(' ') - + if len(set(self.required_headers) - set(auth_headers)) > 0: - raise Exception('{} is a required header(s)'.format(', '.join(set(self.required_headers)-set(auth_headers)))) - - signing_str = generate_message(auth_headers, self.headers, self.host, self.method, self.path) - + error_headers = ', '.join( + set(self.required_headers) - set(auth_headers)) + raise Exception( + '{} is a required header(s)'.format(error_headers)) + + signing_str = generate_message( + auth_headers, self.headers, self.host, self.method, self.path) + return self._verify(signing_str, self.auth_dict['signature']) diff --git a/requirements.txt b/requirements.txt index 1741886..026278e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -PyCrypto +pycryptodome==3.6.1 six diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..0a8547b --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,2 @@ +setuptools +wheel diff --git a/setup.cfg b/setup.cfg index 3cbd55d..2be6836 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [bdist_wheel] -universal = True \ No newline at end of file +universal = True diff --git a/setup.py b/setup.py index 82cf121..17ba1de 100755 --- a/setup.py +++ b/setup.py @@ -1,13 +1,6 @@ #!/usr/bin/env python from setuptools import setup, find_packages -# versioneer config -import versioneer -versioneer.versionfile_source = 'httpsig/_version.py' -versioneer.versionfile_build = 'httpsig/_version.py' -versioneer.tag_prefix = 'v' # tags are like v1.2.0 -versioneer.parentdir_prefix = 'httpsig-' # dirname like 'myproject-1.2.0' - # create long description with open('README.rst') as file: long_description = file.read() @@ -16,8 +9,6 @@ setup( name='httpsig', - version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), description="Secure HTTP request signing using the HTTP Signature draft specification", long_description=long_description, classifiers=[ @@ -28,9 +19,10 @@ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.2", - "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules", ], @@ -42,6 +34,8 @@ packages=find_packages(), include_package_data=True, zip_safe=True, - install_requires=['pycrypto', 'six'], + use_scm_version=True, + setup_requires=['setuptools_scm'], + install_requires=['pycryptodome>=3,<4', 'six'], test_suite="httpsig.tests", ) diff --git a/tox.ini b/tox.ini index 5add957..7049f1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py32, py33, py34 +envlist = py27, py34, py35, py36, py37 [testenv] commands = python setup.py test diff --git a/versioneer.py b/versioneer.py deleted file mode 100644 index 2cacf9b..0000000 --- a/versioneer.py +++ /dev/null @@ -1,885 +0,0 @@ - -# Version: 0.10 - -""" -The Versioneer -============== - -* like a rocketeer, but for versions! -* https://github.com/warner/python-versioneer -* Brian Warner -* License: Public Domain -* Compatible With: python2.6, 2.7, and 3.2, 3.3 - -[![Build Status](https://travis-ci.org/warner/python-versioneer.png?branch=master)](https://travis-ci.org/warner/python-versioneer) - -This is a tool for managing a recorded version number in distutils-based -python projects. The goal is to remove the tedious and error-prone "update -the embedded version string" step from your release process. Making a new -release should be as easy as recording a new tag in your version-control -system, and maybe making new tarballs. - - -## Quick Install - -* `pip install versioneer` to somewhere to your $PATH -* run `versioneer-installer` in your source tree: this installs `versioneer.py` -* follow the instructions below (also in the `versioneer.py` docstring) - -## Version Identifiers - -Source trees come from a variety of places: - -* a version-control system checkout (mostly used by developers) -* a nightly tarball, produced by build automation -* a snapshot tarball, produced by a web-based VCS browser, like github's - "tarball from tag" feature -* a release tarball, produced by "setup.py sdist", distributed through PyPI - -Within each source tree, the version identifier (either a string or a number, -this tool is format-agnostic) can come from a variety of places: - -* ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows - about recent "tags" and an absolute revision-id -* the name of the directory into which the tarball was unpacked -* an expanded VCS variable ($Id$, etc) -* a `_version.py` created by some earlier build step - -For released software, the version identifier is closely related to a VCS -tag. Some projects use tag names that include more than just the version -string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool -needs to strip the tag prefix to extract the version identifier. For -unreleased software (between tags), the version identifier should provide -enough information to help developers recreate the same tree, while also -giving them an idea of roughly how old the tree is (after version 1.2, before -version 1.3). Many VCS systems can report a description that captures this, -for example 'git describe --tags --dirty --always' reports things like -"0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the -0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has -uncommitted changes. - -The version identifier is used for multiple purposes: - -* to allow the module to self-identify its version: `myproject.__version__` -* to choose a name and prefix for a 'setup.py sdist' tarball - -## Theory of Operation - -Versioneer works by adding a special `_version.py` file into your source -tree, where your `__init__.py` can import it. This `_version.py` knows how to -dynamically ask the VCS tool for version information at import time. However, -when you use "setup.py build" or "setup.py sdist", `_version.py` in the new -copy is replaced by a small static file that contains just the generated -version data. - -`_version.py` also contains `$Revision$` markers, and the installation -process marks `_version.py` to have this marker rewritten with a tag name -during the "git archive" command. As a result, generated tarballs will -contain enough information to get the proper version. - - -## Installation - -First, decide on values for the following configuration variables: - -* `versionfile_source`: - - A project-relative pathname into which the generated version strings should - be written. This is usually a `_version.py` next to your project's main - `__init__.py` file. If your project uses `src/myproject/__init__.py`, this - should be `src/myproject/_version.py`. This file should be checked in to - your VCS as usual: the copy created below by `setup.py versioneer` will - include code that parses expanded VCS keywords in generated tarballs. The - 'build' and 'sdist' commands will replace it with a copy that has just the - calculated version string. - -* `versionfile_build`: - - Like `versionfile_source`, but relative to the build directory instead of - the source directory. These will differ when your setup.py uses - 'package_dir='. If you have `package_dir={'myproject': 'src/myproject'}`, - then you will probably have `versionfile_build='myproject/_version.py'` and - `versionfile_source='src/myproject/_version.py'`. - -* `tag_prefix`: - - a string, like 'PROJECTNAME-', which appears at the start of all VCS tags. - If your tags look like 'myproject-1.2.0', then you should use - tag_prefix='myproject-'. If you use unprefixed tags like '1.2.0', this - should be an empty string. - -* `parentdir_prefix`: - - a string, frequently the same as tag_prefix, which appears at the start of - all unpacked tarball filenames. If your tarball unpacks into - 'myproject-1.2.0', this should be 'myproject-'. - -This tool provides one script, named `versioneer-installer`. That script does -one thing: write a copy of `versioneer.py` into the current directory. - -To versioneer-enable your project: - -* 1: Run `versioneer-installer` to copy `versioneer.py` into the top of your - source tree. - -* 2: add the following lines to the top of your `setup.py`, with the - configuration values you decided earlier: - - import versioneer - versioneer.versionfile_source = 'src/myproject/_version.py' - versioneer.versionfile_build = 'myproject/_version.py' - versioneer.tag_prefix = '' # tags are like 1.2.0 - versioneer.parentdir_prefix = 'myproject-' # dirname like 'myproject-1.2.0' - -* 3: add the following arguments to the setup() call in your setup.py: - - version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), - -* 4: now run `setup.py versioneer`, which will create `_version.py`, and - will modify your `__init__.py` to define `__version__` (by calling a - function from `_version.py`). It will also modify your `MANIFEST.in` to - include both `versioneer.py` and the generated `_version.py` in sdist - tarballs. - -* 5: commit these changes to your VCS. To make sure you won't forget, - `setup.py versioneer` will mark everything it touched for addition. - -## Post-Installation Usage - -Once established, all uses of your tree from a VCS checkout should get the -current version string. All generated tarballs should include an embedded -version string (so users who unpack them will not need a VCS tool installed). - -If you distribute your project through PyPI, then the release process should -boil down to two steps: - -* 1: git tag 1.0 -* 2: python setup.py register sdist upload - -If you distribute it through github (i.e. users use github to generate -tarballs with `git archive`), the process is: - -* 1: git tag 1.0 -* 2: git push; git push --tags - -Currently, all version strings must be based upon a tag. Versioneer will -report "unknown" until your tree has at least one tag in its history. This -restriction will be fixed eventually (see issue #12). - -## Version-String Flavors - -Code which uses Versioneer can learn about its version string at runtime by -importing `_version` from your main `__init__.py` file and running the -`get_versions()` function. From the "outside" (e.g. in `setup.py`), you can -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". - -* `['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". - -Some variants are more useful than others. Including `full` in a bug report -should allow developers to reconstruct the exact code being tested (or -indicate the presence of local changes that should be shared with the -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__`: - - from ._version import get_versions - __version = get_versions()['version'] - del get_versions - -## Updating Versioneer - -To upgrade your project to a new release of Versioneer, do the following: - -* install the new Versioneer (`pip install -U versioneer` or equivalent) -* re-run `versioneer-installer` in your source tree to replace `versioneer.py` -* edit `setup.py`, if necessary, to include any new configuration settings indicated by the release notes -* re-run `setup.py versioneer` to replace `SRC/_version.py` -* commit any changed files - -## Future Directions - -This tool is designed to make it easily extended to other version-control -systems: all VCS-specific components are in separate directories like -src/git/ . The top-level `versioneer.py` script is assembled from these -components by running make-versioneer.py . In the future, make-versioneer.py -will take a VCS name as an argument, and will construct a version of -`versioneer.py` that is specific to the given VCS. It might also take the -configuration arguments that are currently provided manually during -installation by editing setup.py . Alternatively, it might go the other -direction and include code from all supported VCS systems, reducing the -number of intermediate scripts. - - -## License - -To make Versioneer easier to embed, all its code is hereby released into the -public domain. The `_version.py` that it creates is also in the public -domain. - -""" - -import os, sys, re -from distutils.core import Command -from distutils.command.sdist import sdist as _sdist -from distutils.command.build import build as _build - -versionfile_source = None -versionfile_build = None -tag_prefix = None -parentdir_prefix = None - -VCS = "git" - - -LONG_VERSION_PY = ''' -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (build by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. Generated by -# versioneer-0.10 (https://github.com/warner/python-versioneer) - -# these strings will be replaced by git during git-archive -git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" -git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" - - -import subprocess -import sys -import errno - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): - assert isinstance(commands, list) - p = None - for c in commands: - try: - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %%s" %% args[0]) - print(e) - return None - else: - if verbose: - print("unable to find command, tried %%s" %% (commands,)) - return None - stdout = p.communicate()[0].strip() - if sys.version >= '3': - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %%s (error)" %% args[0]) - return None - return stdout - - -import sys -import re -import os.path - -def get_expanded_variables(versionfile_abs): - # the code embedded in _version.py can just fetch the value of these - # variables. When used from setup.py, we don't want to import - # _version.py, so we do it with a regexp instead. This function is not - # used from _version.py. - variables = {} - try: - f = open(versionfile_abs,"r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["full"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return variables - -def versions_from_expanded_variables(variables, tag_prefix, verbose=False): - refnames = variables["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("variables are unexpanded, not using") - return {} # unexpanded, so not in an unpacked git-archive tarball - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %%d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%%s', no digits" %% ",".join(refs-tags)) - if verbose: - print("likely tags: %%s" %% ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %%s" %% r) - return { "version": r, - "full": variables["full"].strip() } - # no suitable tags, so we use the full revision id - if verbose: - print("no suitable tags, using full revision id") - return { "version": variables["full"].strip(), - "full": variables["full"].strip() } - -def 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' variables were *not* expanded, and - # _version.py hasn't already been rewritten with a short version string, - # meaning we're inside a checked out source tree. - - if not os.path.exists(os.path.join(root, ".git")): - if verbose: - print("no .git in %%s" %% root) - return {} - - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], - cwd=root) - if stdout is None: - return {} - if not stdout.startswith(tag_prefix): - if verbose: - print("tag '%%s' doesn't start with prefix '%%s'" %% (stdout, tag_prefix)) - return {} - tag = stdout[len(tag_prefix):] - stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if stdout is None: - return {} - full = stdout.strip() - if tag.endswith("-dirty"): - full += "-dirty" - return {"version": tag, "full": full} - - -def versions_from_parentdir(parentdir_prefix, root, verbose=False): - # Source tarballs conventionally unpack into a directory that includes - # both the project name and a version string. - dirname = os.path.basename(root) - if not dirname.startswith(parentdir_prefix): - if verbose: - print("guessing rootdir is '%%s', but '%%s' doesn't start with prefix '%%s'" %% - (root, dirname, parentdir_prefix)) - return None - return {"version": dirname[len(parentdir_prefix):], "full": ""} - -tag_prefix = "%(TAG_PREFIX)s" -parentdir_prefix = "%(PARENTDIR_PREFIX)s" -versionfile_source = "%(VERSIONFILE_SOURCE)s" - -def get_versions(default={"version": "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 - # case we can only use expanded variables. - - variables = { "refnames": git_refnames, "full": git_full } - ver = versions_from_expanded_variables(variables, tag_prefix, verbose) - if ver: - return ver - - try: - root = os.path.abspath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for i in range(len(versionfile_source.split("/"))): - root = os.path.dirname(root) - except NameError: - return default - - return (versions_from_vcs(tag_prefix, root, verbose) - or versions_from_parentdir(parentdir_prefix, root, verbose) - or default) - -''' - - -import subprocess -import sys -import errno - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): - assert isinstance(commands, list) - p = None - for c in commands: - try: - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % args[0]) - print(e) - return None - else: - if verbose: - print("unable to find command, tried %s" % (commands,)) - return None - stdout = p.communicate()[0].strip() - if sys.version >= '3': - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %s (error)" % args[0]) - return None - return stdout - - -import sys -import re -import os.path - -def get_expanded_variables(versionfile_abs): - # the code embedded in _version.py can just fetch the value of these - # variables. When used from setup.py, we don't want to import - # _version.py, so we do it with a regexp instead. This function is not - # used from _version.py. - variables = {} - try: - f = open(versionfile_abs,"r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["full"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return variables - -def versions_from_expanded_variables(variables, tag_prefix, verbose=False): - refnames = variables["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("variables are unexpanded, not using") - return {} # unexpanded, so not in an unpacked git-archive tarball - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%s', no digits" % ",".join(refs-tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %s" % r) - return { "version": r, - "full": variables["full"].strip() } - # no suitable tags, so we use the full revision id - if verbose: - print("no suitable tags, using full revision id") - return { "version": variables["full"].strip(), - "full": variables["full"].strip() } - -def 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' variables were *not* expanded, and - # _version.py hasn't already been rewritten with a short version string, - # meaning we're inside a checked out source tree. - - if not os.path.exists(os.path.join(root, ".git")): - if verbose: - print("no .git in %s" % root) - return {} - - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], - cwd=root) - if stdout is None: - return {} - if not stdout.startswith(tag_prefix): - if verbose: - print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) - return {} - tag = stdout[len(tag_prefix):] - stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if stdout is None: - return {} - full = stdout.strip() - if tag.endswith("-dirty"): - full += "-dirty" - return {"version": tag, "full": full} - - -def versions_from_parentdir(parentdir_prefix, root, verbose=False): - # Source tarballs conventionally unpack into a directory that includes - # both the project name and a version string. - dirname = os.path.basename(root) - if not dirname.startswith(parentdir_prefix): - if verbose: - print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % - (root, dirname, parentdir_prefix)) - return None - return {"version": dirname[len(parentdir_prefix):], "full": ""} -import os.path -import sys - -# os.path.relpath only appeared in Python-2.6 . Define it here for 2.5. -def os_path_relpath(path, start=os.path.curdir): - """Return a relative version of a path""" - - if not path: - raise ValueError("no path specified") - - start_list = [x for x in os.path.abspath(start).split(os.path.sep) if x] - path_list = [x for x in os.path.abspath(path).split(os.path.sep) if x] - - # Work out how much of the filepath is shared by start and path. - i = len(os.path.commonprefix([start_list, path_list])) - - rel_list = [os.path.pardir] * (len(start_list)-i) + path_list[i:] - if not rel_list: - return os.path.curdir - return os.path.join(*rel_list) - -def do_vcs_install(manifest_in, versionfile_source, ipy): - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - files = [manifest_in, versionfile_source, ipy] - try: - me = __file__ - if me.endswith(".pyc") or me.endswith(".pyo"): - me = os.path.splitext(me)[0] + ".py" - versioneer_file = os_path_relpath(me) - except NameError: - versioneer_file = "versioneer.py" - files.append(versioneer_file) - present = False - try: - f = open(".gitattributes", "r") - for line in f.readlines(): - if line.strip().startswith(versionfile_source): - if "export-subst" in line.strip().split()[1:]: - present = True - f.close() - except EnvironmentError: - pass - if not present: - f = open(".gitattributes", "a+") - f.write("%s export-subst\n" % versionfile_source) - f.close() - files.append(".gitattributes") - run_command(GITS, ["add", "--"] + files) - -SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.10) from -# revision-control system data, or from the parent directory name of an -# unpacked source archive. Distribution tarballs contain a pre-generated copy -# of this file. - -version_version = '%(version)s' -version_full = '%(full)s' -def get_versions(default={}, verbose=False): - return {'version': version_version, 'full': version_full} - -""" - -DEFAULT = {"version": "unknown", "full": "unknown"} - -def versions_from_file(filename): - versions = {} - try: - f = open(filename) - except EnvironmentError: - return versions - for line in f.readlines(): - mo = re.match("version_version = '([^']+)'", line) - if mo: - versions["version"] = mo.group(1) - mo = re.match("version_full = '([^']+)'", line) - if mo: - versions["full"] = mo.group(1) - f.close() - return versions - -def write_to_version_file(filename, versions): - f = open(filename, "w") - f.write(SHORT_VERSION_PY % versions) - f.close() - print("set %s to '%s'" % (filename, versions["version"])) - -def get_root(): - try: - return os.path.dirname(os.path.abspath(__file__)) - except NameError: - return os.path.dirname(os.path.abspath(sys.argv[0])) - -def get_versions(default=DEFAULT, verbose=False): - # returns dict with two keys: 'version' and 'full' - assert versionfile_source is not None, "please set versioneer.versionfile_source" - assert tag_prefix is not None, "please set versioneer.tag_prefix" - assert parentdir_prefix is not None, "please set versioneer.parentdir_prefix" - # I am in versioneer.py, which must live at the top of the source tree, - # which we use to compute the root directory. py2exe/bbfreeze/non-CPython - # don't have __file__, in which case we fall back to sys.argv[0] (which - # ought to be the setup.py script). We prefer __file__ since that's more - # robust in cases where setup.py was invoked in some weird way (e.g. pip) - root = get_root() - versionfile_abs = os.path.join(root, versionfile_source) - - # extract version from first of _version.py, 'git describe', parentdir. - # This is meant to work for developers using a source checkout, for users - # of a tarball created by 'setup.py sdist', and for users of a - # tarball/zipball created by 'git archive' or github's download-from-tag - # feature. - - variables = get_expanded_variables(versionfile_abs) - if variables: - ver = versions_from_expanded_variables(variables, tag_prefix) - if ver: - if verbose: print("got version from expanded variable %s" % ver) - return ver - - ver = versions_from_file(versionfile_abs) - if ver: - if verbose: print("got version from file %s %s" % (versionfile_abs,ver)) - return ver - - ver = versions_from_vcs(tag_prefix, root, verbose) - if ver: - if verbose: print("got version from git %s" % ver) - return ver - - ver = versions_from_parentdir(parentdir_prefix, root, verbose) - if ver: - if verbose: print("got version from parentdir %s" % ver) - return ver - - if verbose: print("got version from default %s" % ver) - return default - -def get_version(verbose=False): - return get_versions(verbose=verbose)["version"] - -class cmd_version(Command): - description = "report generated version string" - user_options = [] - boolean_options = [] - def initialize_options(self): - pass - def finalize_options(self): - pass - def run(self): - ver = get_version(verbose=True) - print("Version is currently: %s" % ver) - - -class cmd_build(_build): - def run(self): - versions = get_versions(verbose=True) - _build.run(self) - # now locate _version.py in the new build/ directory and replace it - # with an updated value - target_versionfile = os.path.join(self.build_lib, versionfile_build) - print("UPDATING %s" % target_versionfile) - os.unlink(target_versionfile) - f = open(target_versionfile, "w") - f.write(SHORT_VERSION_PY % versions) - f.close() - -if 'cx_Freeze' in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe - - class cmd_build_exe(_build_exe): - def run(self): - versions = get_versions(verbose=True) - target_versionfile = versionfile_source - print("UPDATING %s" % target_versionfile) - os.unlink(target_versionfile) - f = open(target_versionfile, "w") - f.write(SHORT_VERSION_PY % versions) - f.close() - _build_exe.run(self) - os.unlink(target_versionfile) - f = open(versionfile_source, "w") - f.write(LONG_VERSION_PY % {"DOLLAR": "$", - "TAG_PREFIX": tag_prefix, - "PARENTDIR_PREFIX": parentdir_prefix, - "VERSIONFILE_SOURCE": versionfile_source, - }) - f.close() - -class cmd_sdist(_sdist): - def run(self): - versions = get_versions(verbose=True) - self._versioneer_generated_versions = versions - # unless we update this, the command will keep using the old version - self.distribution.metadata.version = versions["version"] - return _sdist.run(self) - - def make_release_tree(self, base_dir, files): - _sdist.make_release_tree(self, base_dir, files) - # now locate _version.py in the new base_dir directory (remembering - # that it may be a hardlink) and replace it with an updated value - target_versionfile = os.path.join(base_dir, versionfile_source) - print("UPDATING %s" % target_versionfile) - os.unlink(target_versionfile) - f = open(target_versionfile, "w") - f.write(SHORT_VERSION_PY % self._versioneer_generated_versions) - f.close() - -INIT_PY_SNIPPET = """ -from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions -""" - -class cmd_update_files(Command): - description = "install/upgrade Versioneer files: __init__.py SRC/_version.py" - user_options = [] - boolean_options = [] - def initialize_options(self): - pass - def finalize_options(self): - pass - def run(self): - print(" creating %s" % versionfile_source) - f = open(versionfile_source, "w") - f.write(LONG_VERSION_PY % {"DOLLAR": "$", - "TAG_PREFIX": tag_prefix, - "PARENTDIR_PREFIX": parentdir_prefix, - "VERSIONFILE_SOURCE": versionfile_source, - }) - f.close() - - ipy = os.path.join(os.path.dirname(versionfile_source), "__init__.py") - try: - old = open(ipy, "r").read() - except EnvironmentError: - old = "" - if INIT_PY_SNIPPET not in old: - print(" appending to %s" % ipy) - f = open(ipy, "a") - f.write(INIT_PY_SNIPPET) - f.close() - else: - print(" %s unmodified" % ipy) - - # Make sure both the top-level "versioneer.py" and versionfile_source - # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so - # they'll be copied into source distributions. Pip won't be able to - # install the package without this. - manifest_in = os.path.join(get_root(), "MANIFEST.in") - simple_includes = set() - try: - for line in open(manifest_in, "r").readlines(): - if line.startswith("include "): - for include in line.split()[1:]: - simple_includes.add(include) - except EnvironmentError: - pass - # That doesn't cover everything MANIFEST.in can do - # (http://docs.python.org/2/distutils/sourcedist.html#commands), so - # it might give some false negatives. Appending redundant 'include' - # lines is safe, though. - if "versioneer.py" not in simple_includes: - print(" appending 'versioneer.py' to MANIFEST.in") - f = open(manifest_in, "a") - f.write("include versioneer.py\n") - f.close() - else: - print(" 'versioneer.py' already in MANIFEST.in") - if versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % - versionfile_source) - f = open(manifest_in, "a") - f.write("include %s\n" % versionfile_source) - f.close() - else: - print(" versionfile_source already in MANIFEST.in") - - # Make VCS-specific changes. For git, this means creating/changing - # .gitattributes to mark _version.py for export-time keyword - # substitution. - do_vcs_install(manifest_in, versionfile_source, ipy) - -def get_cmdclass(): - cmds = {'version': cmd_version, - 'versioneer': cmd_update_files, - 'build': cmd_build, - 'sdist': cmd_sdist, - } - if 'cx_Freeze' in sys.modules: # cx_freeze enabled? - cmds['build_exe'] = cmd_build_exe - del cmds['build'] - - return cmds