Statistics
| Branch: | Tag: | Revision:

root / devtools / version.py @ c9b113ac

History | View | Annotate | Download (13.6 kB)

1
#!/usr/bin/env python
2
#
3
# Copyright (C) 2010, 2011, 2012 GRNET S.A. All rights reserved.
4
#
5
# Redistribution and use in source and binary forms, with or
6
# without modification, are permitted provided that the following
7
# conditions are met:
8
#
9
#   1. Redistributions of source code must retain the above
10
#      copyright notice, this list of conditions and the following
11
#      disclaimer.
12
#
13
#   2. Redistributions in binary form must reproduce the above
14
#      copyright notice, this list of conditions and the following
15
#      disclaimer in the documentation and/or other materials
16
#      provided with the distribution.
17
#
18
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
19
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
21
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
22
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
25
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
26
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
# POSSIBILITY OF SUCH DAMAGE.
30
#
31
# The views and conclusions contained in the software and
32
# documentation are those of the authors and should not be
33
# interpreted as representing official policies, either expressed
34
# or implied, of GRNET S.A.
35

    
36

    
37
import os
38
import re
39
import sys
40
import pprint
41
import subprocess
42
from distutils import log
43
from collections import namedtuple
44

    
45

    
46
# Branch types:
47
# builds_snapshot: Whether the branch can produce snapshot builds
48
# builds_release: Whether the branch can produce release builds
49
# versioned: Whether the name of the branch defines a specific version
50
# allowed_version_re: A regular expression describing allowed values for
51
#                     base_version in this branch
52
branch_type = namedtuple("branch_type", ["builds_snapshot", "builds_release",
53
                                         "versioned", "allowed_version_re"])
54
VERSION_RE = "[0-9]+\.[0-9]+"
55
BRANCH_TYPES = {
56
    "feature": branch_type(True, False, False, "^%snext$" % VERSION_RE),
57
    "develop": branch_type(True, False, False, "^%snext$" % VERSION_RE),
58
    "release": branch_type(True, True, True,
59
                           "^(?P<bverstr>%s)rc[1-9][0-9]*$" % VERSION_RE),
60
    "master": branch_type(False, True, False,
61
                          "^%s$" % VERSION_RE),
62
    "hotfix": branch_type(True, True, True,
63
                          "^(?P<bverstr>^%s\.[1-9][0-9]*)$" % VERSION_RE)}
64
BASE_VERSION_FILE = "version"
65

    
66

    
67
def callgit(cmd):
68
    p = subprocess.Popen(["/bin/sh", "-c", cmd],
69
                          stdout=subprocess.PIPE,
70
                          stderr=subprocess.PIPE)
71
    output = p.communicate()[0].strip()
72
    if p.returncode != 0:
73
        log.error("Command '%s' failed with output:\n%s" % (cmd, output))
74
        raise subprocess.CalledProcessError(p.returncode, cmd, output)
75
    return output
76

    
77

    
78
def vcs_info():
79
    """
80
    Return current git HEAD commit information.
81

82
    Returns a tuple containing
83
        - branch name
84
        - commit id
85
        - commit count
86
        - git describe output
87
        - path of git toplevel directory
88

89
    """
90
    try:
91
        branch = callgit("git rev-parse --abbrev-ref HEAD")
92
        revid = callgit("git rev-parse --short HEAD")
93
        revno = int(callgit("git rev-list HEAD|wc -l"))
94
        desc = callgit("git describe --tags")
95
        toplevel = callgit("git rev-parse --show-toplevel")
96
    except subprocess.CalledProcessError:
97
        log.error("Could not retrieve git information. " +
98
                  "Current directory not a git repository?")
99
        raise
100

    
101
    info = namedtuple("vcs_info", ["branch", "revid", "revno",
102
                                   "desc", "toplevel"])
103

    
104
    return info(branch=branch, revid=revid, revno=revno, desc=desc,
105
                toplevel=toplevel)
106

    
107

    
108
def base_version(vcs_info):
109
    """Determine the base version from a file in the repository"""
110

    
111
    f = open(os.path.join(vcs_info.toplevel, BASE_VERSION_FILE))
112
    lines = [l.strip() for l in f.readlines()]
113
    l = [l for l in lines if not l.startswith("#")]
114
    if len(l) != 1:
115
        raise ValueError("File '%s' should contain a single non-comment line.")
116
    return l[0]
117

    
118

    
119
def build_mode():
120
    """Determine the build mode from the value of $GITFLOW_BUILD_MODE"""
121
    try:
122
        mode = os.environ["GITFLOW_BUILD_MODE"]
123
        assert mode == "release" or mode == "snapshot"
124
    except (KeyError, AssertionError):
125
        raise ValueError("GITFLOW_BUILD_MODE environment variable must be "
126
                         "'release' or 'snapshot'")
127
    return mode
128

    
129

    
130
def python_version(base_version, vcs_info, mode):
131
    """Generate a Python distribution version following devtools conventions.
132

133
    This helper generates a Python distribution version from a repository
134
    commit, following devtools conventions. The input data are:
135
        * base_version: a base version number, presumably stored in text file
136
          inside the repository, e.g., /version
137
        * vcs_info: vcs information: current branch name and revision no
138
        * mode: "snapshot", or "release"
139

140
    This helper assumes a git branching model following:
141
    http://nvie.com/posts/a-successful-git-branching-model/
142

143
    with 'master', 'develop', 'release-X', 'hotfix-X' and 'feature-X' branches.
144

145
    General rules:
146
    a) any repository commit can get as a Python version
147
    b) a version is generated either in 'release' or in 'snapshot' mode
148
    c) the choice of mode depends on the branch, see following table.
149

150
    A python version is of the form A_NNN,
151
    where A: X.Y.Z{,next,rcW} and NNN: a revision number for the commit,
152
    as returned by vcs_info().
153

154
    For every combination of branch and mode, releases are numbered as follows:
155

156
    BRANCH:  /  MODE: snapshot        release
157
    --------          ------------------------------
158
    feature           0.14next_150    N/A
159
    develop           0.14next_151    N/A
160
    release           0.14rc2_249     0.14rc2
161
    master            N/A             0.14
162
    hotfix            0.14.1rc6_121   0.14.1rc6
163
                      N/A             0.14.1
164

165
    The suffix 'next' in a version name is used to denote the upcoming version,
166
    the one being under development in the develop and release branches.
167
    Version '0.14next' is the version following 0.14, and only lives on the
168
    develop and feature branches.
169

170
    The suffix 'rc' is used to denote release candidates. 'rc' versions live
171
    only release and hotfix branches.
172

173
    Suffixes 'next' and 'rc' have been chosen to ensure proper ordering
174
    according to setuptools rules:
175

176
        http://www.python.org/dev/peps/pep-0386/#setuptools
177

178
    Every branch uses a value for A so that all releases are ordered based
179
    on the branch they came from, so:
180

181
    So
182
        0.13next < 0.14rcW < 0.14 < 0.14next < 0.14.1
183

184
    and
185

186
    >>> V("0.14next") > V("0.14")
187
    True
188
    >>> V("0.14next") > V("0.14rc7")
189
    True
190
    >>> V("0.14next") > V("0.14.1")
191
    False
192
    >>> V("0.14rc6") > V("0.14")
193
    False
194
    >>> V("0.14.2rc6") > V("0.14.1")
195
    True
196

197
    The value for _NNN is chosen based of the revision number of the specific
198
    commit. It is used to ensure ascending ordering of consecutive releases
199
    from the same branch. Every version of the form A_NNN comes *before*
200
    than A: All snapshots are ordered so they come before the corresponding
201
    release.
202

203
    So
204
        0.14next_* < 0.14
205
        0.14.1_* < 0.14.1
206
        etc
207

208
    and
209

210
    >>> V("0.14next_150") < V("0.14next")
211
    True
212
    >>> V("0.14.1next_150") < V("0.14.1next")
213
    True
214
    >>> V("0.14.1_149") < V("0.14.1")
215
    True
216
    >>> V("0.14.1_149") < V("0.14.1_150")
217
    True
218

219
    Combining both of the above, we get
220
       0.13next_* < 0.13next < 0.14rcW_* < 0.14rcW < 0.14_* < 0.14
221
       < 0.14next_* < 0.14next < 0.14.1_* < 0.14.1
222

223
    and
224

225
    >>> V("0.13next_102") < V("0.13next")
226
    True
227
    >>> V("0.13next") < V("0.14rc5_120")
228
    True
229
    >>> V("0.14rc3_120") < V("0.14rc3")
230
    True
231
    >>> V("0.14rc3") < V("0.14_1")
232
    True
233
    >>> V("0.14_120") < V("0.14")
234
    True
235
    >>> V("0.14") < V("0.14next_20")
236
    True
237
    >>> V("0.14next_20") < V("0.14next")
238
    True
239

240
    Note: one of the tests above fails because of constraints in the way
241
    setuptools parses version numbers. It does not affect us because the
242
    specific version format that triggers the problem is not contained in the
243
    table showing allowed branch / mode combinations, above.
244

245

246
    """
247

    
248
    branch = vcs_info.branch
249

    
250
    # If it's a debian branch, ignore starting "debian-"
251
    brnorm = branch
252
    if brnorm == "debian":
253
        brnorm = "debian-master"
254
    if brnorm.startswith("debian-"):
255
        brnorm = brnorm.split("debian-")[1]
256

    
257
    # Sanity checks
258
    if "-" in brnorm:
259
        btypestr = brnorm.split("-")[0]
260
        bverstr = brnorm.split("-")[1]
261
        if bverstr == "":
262
            raise ValueError("Malformed branch name '%s'" % branch)
263
        versioned = True
264
    else:
265
        btypestr = branch
266
        versioned = False
267
    try:
268
        btype = BRANCH_TYPES[btypestr]
269
    except KeyError:
270
        allowed_branches = ", ".join(x for x in BRANCH_TYPES.keys())
271
        raise ValueError("Malformed branch name '%s', cannot classify as one "
272
                         "of %s" % (btypestr, allowed_branches))
273

    
274
    if versioned != btype.versioned:
275
        raise ValueError(("Branch name '%s' should %s contain version" %
276
                          (branch, "not" if versioned else "")))
277
    if btype.versioned and not re.match(VERSION_RE, bverstr):
278
        raise ValueError(("Malformed version '%s' in branch name '%s'" %
279
                          (bverstr, branch)))
280

    
281
    m = re.match(btype.allowed_version_re, base_version)
282
    if not m or (btype.versioned and m.groupdict()["bverstr"] != bverstr):
283
        raise ValueError(("Base version '%s' unsuitable for branch name '%s'" %
284
                         (base_version, branch)))
285

    
286
    if mode not in ["snapshot", "release"]:
287
        raise ValueError(("Specified mode '%s' should be one of 'snapshot' or "
288
                          "'release'" % mode))
289
    snap = (mode == "snapshot")
290

    
291
    if ((snap and not btype.builds_snapshot) or
292
        (not snap and not btype.builds_release)):
293
        raise ValueError(("Invalid mode '%s' in branch type '%s'" %
294
                          (mode, btypestr)))
295

    
296
    if snap:
297
        v = "%s_%d_%s" % (base_version, vcs_info.revno, vcs_info.revid)
298
    else:
299
        v = base_version
300
    return v
301

    
302

    
303
def debian_version_from_python_version(pyver):
304
    """Generate a debian package version from a Python version.
305

306
    This helper generates a Debian package version from a Python version,
307
    following devtools conventions.
308

309
    Debian sorts version strings differently compared to setuptools:
310
    http://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version
311

312
    Initial tests:
313

314
    >>> debian_version("3") < debian_version("6")
315
    True
316
    >>> debian_version("3") < debian_version("2")
317
    False
318
    >>> debian_version("1") == debian_version("1")
319
    True
320
    >>> debian_version("1") != debian_version("1")
321
    False
322
    >>> debian_version("1") >= debian_version("1")
323
    True
324
    >>> debian_version("1") <= debian_version("1")
325
    True
326

327
    This helper defines a 1-1 mapping between Python and Debian versions,
328
    with the same ordering.
329

330
    Debian versions are ordered in the same way as Python versions:
331

332
    >>> D("0.14next") > D("0.14")
333
    True
334
    >>> D("0.14next") > D("0.14rc7")
335
    True
336
    >>> D("0.14next") > D("0.14.1")
337
    False
338
    >>> D("0.14rc6") > D("0.14")
339
    False
340
    >>> D("0.14.2rc6") > D("0.14.1")
341
    True
342

343
    and
344

345
    >>> D("0.14next_150") < D("0.14next")
346
    True
347
    >>> D("0.14.1next_150") < D("0.14.1next")
348
    True
349
    >>> D("0.14.1_149") < D("0.14.1")
350
    True
351
    >>> D("0.14.1_149") < D("0.14.1_150")
352
    True
353

354
    and
355

356
    >>> D("0.13next_102") < D("0.13next")
357
    True
358
    >>> D("0.13next") < D("0.14rc5_120")
359
    True
360
    >>> D("0.14rc3_120") < D("0.14rc3")
361
    True
362
    >>> D("0.14rc3") < D("0.14_1")
363
    True
364
    >>> D("0.14_120") < D("0.14")
365
    True
366
    >>> D("0.14") < D("0.14next_20")
367
    True
368
    >>> D("0.14next_20") < D("0.14next")
369
    True
370

371
    """
372
    return pyver.replace("_", "~").replace("rc", "~rc")
373

    
374

    
375
def debian_version(base_version, vcs_info, mode):
376
    p = python_version(base_version, vcs_info, mode)
377
    return debian_version_from_python_version(p)
378

    
379

    
380
def update_version(module, name="version", root="."):
381
    """
382
    Generate or replace version.py as a submodule of `module`.
383

384
    This is a helper to generate/replace a version.py file containing version
385
    information as a submodule of passed `module`.
386

387
    """
388

    
389
    # FIXME: exit or fail if not in development environment?
390
    v = vcs_info()
391
    b = base_version(v)
392
    mode = build_mode()
393
    paths = [root] + module.split(".") + ["%s.py" % name]
394
    module_filename = os.path.join(*paths)
395
    content = """
396
__version__ = "%(version)s"
397
__version_info__ = __version__.split(".")
398
__version_vcs_info__ = %(vcs_info)s
399
    """ % dict(version=python_version(b, v, mode),
400
            vcs_info=pprint.PrettyPrinter().pformat(dict(v._asdict())))
401

    
402
    module_file = file(module_filename, "w+")
403
    module_file.write(content)
404
    module_file.close()
405

    
406

    
407
if __name__ == "__main__":
408
    v = vcs_info()
409
    b = base_version(v)
410
    mode = build_mode()
411

    
412
    try:
413
        arg = sys.argv[1]
414
        assert arg == "python" or arg == "debian"
415
    except IndexError:
416
        raise ValueError("A single argument, 'python' or 'debian is required")
417

    
418
    if arg == "python":
419
        print python_version(b, v, mode)
420
    elif arg == "debian":
421
        print debian_version(b, v, mode)