Tuesday, November 1, 2016

robotic releases

Basic Auto-Versioning from Git

If you're using the winning workflow and the recommended Python project layout then you've set up a CI server to build releases when you tag them in Git, and you set your version in the __init__.py file of your package. But, "Oh, No!" you did it again. You created the Git tag, but forgot to update your code's __version__ string.

Okay, there is a Python package called Versioneer that handles this for you, and it's pretty awesome. But it turns out it's also pretty easy to roll your own, especially if you're just using Git, because Python has a Git implementation called Dulwich that can do this in just a few lines. Maybe it will get integrated into a future version of Dulwich - I've submitted a PR (#462) which was merged into v0.16.3 and an update (#489) which was also merged into v0.17 to also list tags that are not objects. Anyway, for now, the easiest way to use this is to copy this file into your package at the top level, Install the latest version of dulwich (>=0.17.1), import it and then add something like this to your package dunder init module so it works both in your repo during dev and then later when deployed to users.

"""
Example package dunder init module implementing
``dulwich.contrib.release_robot`` to get current version.
"""

import os
import importlib

# try to import Dulwich or create dummies
try:
    from dulwich.contrib.release_robot import get_current_version
    from dulwich.repo import NotGitRepository
except ImportError:
    NotGitRepository = NotImplementedError

    def get_current_version():
        raise NotGitRepository

BASEDIR = os.path.dirname(__file__)  # this directory
VER_FILE = 'version'  # name of file to store version
# use release robot to try to get current Git tag
try:
    GIT_TAG = get_current_version()
except NotGitRepository:
    GIT_TAG = None
# check version file
try:
    version = importlib.import_module('%s.%s' % (__name__, VER_FILE))
except ImportError:
    VERSION = None
else:
    VERSION = version.VERSION
# update version file if it differs from Git tag
if GIT_TAG is not None and VERSION != GIT_TAG:
    with open(os.path.join(BASEDIR, VER_FILE + '.py'), 'w') as vf:
        vf.write('VERSION = "%s"\n' % GIT_TAG)
else:
    GIT_TAG = VERSION  # if Git tag is none use version file
VERSION = GIT_TAG  # version

__author__ = u'your name'
__email__ = u'your.email@your.company.com'
__url__ = u'https://github.com/your-org/your-project'
__version__ = VERSION
__release__ = u'your release name'

Or you can also use it to get all recent tags.

get_recent_tags()[0][0]

assuming your tags all use semantic versions like "v0.3". Enjoy!

"""Determine last version string from tags.
Alternate to `Versioneer <https://pypi.python.org/pypi/versioneer/>`_ using
`Dulwich <https://pypi.python.org/pypi/dulwich>`_ to sort tags by time from
newest to oldest.
Copy the following into the package ``__init__.py`` module::
from dulwich.contrib.release_robot import get_current_version
__version__ = get_current_version('..')
This example assumes the tags have a leading "v" like "v0.3", and that the
``.git`` folder is in a project folder that containts the package folder.
EG::
* project
|
* .git
|
+-* package
|
* __init__.py <-- put __version__ here
"""
import datetime
import re
import sys
import time
from dulwich.repo import Repo
# CONSTANTS
PROJDIR = '.'
PATTERN = r'[ a-zA-Z_\-]*([\d\.]+[\-\w\.]*)'
def get_recent_tags(projdir=PROJDIR):
"""Get list of tags in order from newest to oldest and their datetimes.
:param projdir: path to ``.git``
:returns: list of tags sorted by commit time from newest to oldest
Each tag in the list contains the tag name, commit time, commit id, author
and any tag meta. If a tag isn't annotated, then its tag meta is ``None``.
Otherwise the tag meta is a tuple containing the tag time, tag id and tag
name. Time is in UTC.
"""
with Repo(projdir) as project: # dulwich repository object
refs = project.get_refs() # dictionary of refs and their SHA-1 values
tags = {} # empty dictionary to hold tags, commits and datetimes
# iterate over refs in repository
for key, value in refs.items():
key = key.decode('utf-8') # compatible with Python-3
obj = project.get_object(value) # dulwich object from SHA-1
# don't just check if object is "tag" b/c it could be a "commit"
# instead check if "tags" is in the ref-name
if u'tags' not in key:
# skip ref if not a tag
continue
# strip the leading text from refs to get "tag name"
_, tag = key.rsplit(u'/', 1)
# check if tag object is "commit" or "tag" pointing to a "commit"
try:
commit = obj.object # a tuple (commit class, commit id)
except AttributeError:
commit = obj
tag_meta = None
else:
tag_meta = (
datetime.datetime(*time.gmtime(obj.tag_time)[:6]),
obj.id.decode('utf-8'),
obj.name.decode('utf-8')
) # compatible with Python-3
commit = project.get_object(commit[1]) # commit object
# get tag commit datetime, but dulwich returns seconds since
# beginning of epoch, so use Python time module to convert it to
# timetuple then convert to datetime
tags[tag] = [
datetime.datetime(*time.gmtime(commit.commit_time)[:6]),
commit.id.decode('utf-8'),
commit.author.decode('utf-8'),
tag_meta
] # compatible with Python-3
# return list of tags sorted by their datetimes from newest to oldest
return sorted(tags.items(), key=lambda tag: tag[1][0], reverse=True)
def get_current_version(projdir=PROJDIR, pattern=PATTERN, logger=None):
"""Return the most recent tag, using an options regular expression pattern.
The default pattern will strip any characters preceding the first semantic
version. *EG*: "Release-0.2.1-rc.1" will be come "0.2.1-rc.1". If no match
is found, then the most recent tag is return without modification.
:param projdir: path to ``.git``
:param pattern: regular expression pattern with group that matches version
:param logger: a Python logging instance to capture exception
:returns: tag matching first group in regular expression pattern
"""
tags = get_recent_tags(projdir)
try:
tag = tags[0][0]
except IndexError:
return
matches = re.match(pattern, tag)
try:
current_version = matches.group(1)
except (IndexError, AttributeError) as err:
if logger:
logger.exception(err)
return tag
return current_version
if __name__ == '__main__':
if len(sys.argv) > 1:
_PROJDIR = sys.argv[1]
else:
_PROJDIR = PROJDIR
print(get_current_version(projdir=_PROJDIR))
# release_robot.py
#
# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
# General Public License as public by the Free Software Foundation; version 2.0
# or (at your option) any later version. You can redistribute it and/or
# modify it under the terms of either of these two licenses.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# You should have received a copy of the licenses; if not, see
# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
# License, Version 2.0.
#
"""Tests for release_robot."""
import datetime
import os
import re
import shutil
import tempfile
import time
import unittest
from dulwich.contrib import release_robot
from dulwich.repo import Repo
from dulwich.tests.utils import make_commit, make_tag
BASEDIR = os.path.abspath(os.path.dirname(__file__)) # this directory
def gmtime_to_datetime(gmt):
return datetime.datetime(*time.gmtime(gmt)[:6])
class TagPatternTests(unittest.TestCase):
"""test tag patterns"""
def test_tag_pattern(self):
"""test tag patterns"""
test_cases = {
'0.3': '0.3', 'v0.3': '0.3', 'release0.3': '0.3',
'Release-0.3': '0.3', 'v0.3rc1': '0.3rc1', 'v0.3-rc1': '0.3-rc1',
'v0.3-rc.1': '0.3-rc.1', 'version 0.3': '0.3',
'version_0.3_rc_1': '0.3_rc_1', 'v1': '1', '0.3rc1': '0.3rc1'
}
for testcase, version in test_cases.items():
matches = re.match(release_robot.PATTERN, testcase)
self.assertEqual(matches.group(1), version)
class GetRecentTagsTest(unittest.TestCase):
"""test get recent tags"""
# Git repo for dulwich project
test_repo = os.path.join(BASEDIR, 'dulwich_test_repo.zip')
committer = b"Mark Mikofski <mark.mikofski@sunpowercorp.com>"
test_tags = [b'v0.1a', b'v0.1']
tag_test_data = {
test_tags[0]: [1484788003, b'0' * 40, None],
test_tags[1]: [1484788314, b'1' * 40, (1484788401, b'2' * 40)]
}
@classmethod
def setUpClass(cls):
cls.projdir = tempfile.mkdtemp() # temporary project directory
cls.repo = Repo.init(cls.projdir) # test repo
obj_store = cls.repo.object_store # test repo object store
# commit 1 ('2017-01-19T01:06:43')
cls.c1 = make_commit(
id=cls.tag_test_data[cls.test_tags[0]][1],
commit_time=cls.tag_test_data[cls.test_tags[0]][0],
message=b'unannotated tag',
author=cls.committer
)
obj_store.add_object(cls.c1)
# tag 1: unannotated
cls.t1 = cls.test_tags[0]
cls.repo[b'refs/tags/' + cls.t1] = cls.c1.id # add unannotated tag
# commit 2 ('2017-01-19T01:11:54')
cls.c2 = make_commit(
id=cls.tag_test_data[cls.test_tags[1]][1],
commit_time=cls.tag_test_data[cls.test_tags[1]][0],
message=b'annotated tag',
parents=[cls.c1.id],
author=cls.committer
)
obj_store.add_object(cls.c2)
# tag 2: annotated ('2017-01-19T01:13:21')
cls.t2 = make_tag(
cls.c2,
id=cls.tag_test_data[cls.test_tags[1]][2][1],
name=cls.test_tags[1],
tag_time=cls.tag_test_data[cls.test_tags[1]][2][0]
)
obj_store.add_object(cls.t2)
cls.repo[b'refs/heads/master'] = cls.c2.id
cls.repo[b'refs/tags/' + cls.t2.name] = cls.t2.id # add annotated tag
@classmethod
def tearDownClass(cls):
cls.repo.close()
shutil.rmtree(cls.projdir)
def test_get_recent_tags(self):
"""test get recent tags"""
tags = release_robot.get_recent_tags(self.projdir) # get test tags
for tag, metadata in tags:
tag = tag.encode('utf-8')
test_data = self.tag_test_data[tag] # test data tag
# test commit date, id and author name
self.assertEqual(metadata[0], gmtime_to_datetime(test_data[0]))
self.assertEqual(metadata[1].encode('utf-8'), test_data[1])
self.assertEqual(metadata[2].encode('utf-8'), self.committer)
# skip unannotated tags
tag_obj = test_data[2]
if not tag_obj:
continue
# tag date, id and name
self.assertEqual(metadata[3][0], gmtime_to_datetime(tag_obj[0]))
self.assertEqual(metadata[3][1].encode('utf-8'), tag_obj[1])
self.assertEqual(metadata[3][2].encode('utf-8'), tag)
"""
Example package dunder init module implementing
``dulwich.contrib.release_robot`` to get current version.
"""
import os
import importlib
# try to import Dulwich or create dummies
try:
from dulwich.contrib.release_robot import get_current_version
from dulwich.repo import NotGitRepository
except ImportError:
NotGitRepository = NotImplementedError
def get_current_version():
raise NotGitRepository
BASEDIR = os.path.dirname(__file__) # this directory
PROJDIR = os.path.dirname(BASEDIR)
VER_FILE = 'version' # name of file to store version
# use release robot to try to get current Git tag
try:
GIT_TAG = get_current_version(PROJDIR)
except NotGitRepository:
GIT_TAG = None
# check version file
try:
version = importlib.import_module('%s.%s' % (__name__, VER_FILE))
except ImportError:
VERSION = None
else:
VERSION = version.VERSION
# update version file if it differs from Git tag
if GIT_TAG is not None and VERSION != GIT_TAG:
with open(os.path.join(BASEDIR, VER_FILE + '.py'), 'w') as vf:
vf.write('VERSION = "%s"\n' % GIT_TAG)
else:
GIT_TAG = VERSION # if Git tag is none use version file
VERSION = GIT_TAG # version
__author__ = u'your name'
__email__ = u'your.email@your.company.com'
__url__ = u'https://github.com/your-org/your-project'
__version__ = VERSION
__release__ = u'your release name'
view raw xyz__init__.py hosted with ❤ by GitHub

No comments:

Post a Comment

Fork me on GitHub