Statistics
| Branch: | Tag: | Revision:

root / devflow / versioning.py @ 866bb9c1

History | View | Annotate | Download (14.7 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
import git
43

    
44
from distutils import log
45
from collections import namedtuple
46

    
47

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

    
68

    
69
def get_commit_id(commit, current_branch):
70
    """Return the commit ID
71

72
    If the commit is a 'merge' commit, and one of the parents is a
73
    debian branch we return a compination of the parents commits.
74

75
    """
76
    def short_id(commit):
77
        return commit.hexsha[0:7]
78

    
79
    parents = commit.parents
80
    cur_br_name = current_branch.name
81
    if len(parents) == 1:
82
        return short_id(commit)
83
    elif len(parents) == 2:
84
        if cur_br_name.startswith("debian-") or cur_br_name == "debian":
85
            pr1, pr2 = parents
86
            return short_id(pr1) + "-" + short_id(pr2)
87
        else:
88
            return short_id(commit)
89
    else:
90
        raise RuntimeError("Commit %s has more than 2 parents!" % commit)
91

    
92

    
93
def vcs_info():
94
    """
95
    Return current git HEAD commit information.
96

97
    Returns a tuple containing
98
        - branch name
99
        - commit id
100
        - commit count
101
        - git describe output
102
        - path of git toplevel directory
103

104
    """
105
    try:
106
        repo = git.Repo(".")
107
        branch = repo.head.reference
108
        revid = get_commit_id(branch.commit, branch)
109
        revno = len(list(repo.iter_commits()))
110
        toplevel = repo.working_dir
111
    except git.InvalidGitRepositoryError:
112
        log.error("Could not retrieve git information. " +
113
                  "Current directory not a git repository?")
114
        return None
115

    
116
    info = namedtuple("vcs_info", ["branch", "revid", "revno",
117
                                   "toplevel"])
118

    
119
    return info(branch=branch.name, revid=revid, revno=revno,
120
                toplevel=toplevel)
121

    
122

    
123
def base_version(vcs_info):
124
    """Determine the base version from a file in the repository"""
125

    
126
    f = open(os.path.join(vcs_info.toplevel, BASE_VERSION_FILE))
127
    lines = [l.strip() for l in f.readlines()]
128
    l = [l for l in lines if not l.startswith("#")]
129
    if len(l) != 1:
130
        raise ValueError("File '%s' should contain a single non-comment line.")
131
    return l[0]
132

    
133

    
134
def build_mode():
135
    """Determine the build mode from the value of $GITFLOW_BUILD_MODE"""
136
    try:
137
        mode = os.environ["GITFLOW_BUILD_MODE"]
138
        assert mode == "release" or mode == "snapshot"
139
    except KeyError:
140
        raise ValueError("GITFLOW_BUILD_MODE environment variable is not set."
141
                         " Set this variable to 'release' or 'snapshot'")
142
    except AssertionError:
143
        raise ValueError("GITFLOW_BUILD_MODE environment variable must be"
144
                         " 'release' or 'snapshot'")
145
    return mode
146

    
147

    
148
def python_version(base_version, vcs_info, mode):
149
    """Generate a Python distribution version following devtools conventions.
150

151
    This helper generates a Python distribution version from a repository
152
    commit, following devtools conventions. The input data are:
153
        * base_version: a base version number, presumably stored in text file
154
          inside the repository, e.g., /version
155
        * vcs_info: vcs information: current branch name and revision no
156
        * mode: "snapshot", or "release"
157

158
    This helper assumes a git branching model following:
159
    http://nvie.com/posts/a-successful-git-branching-model/
160

161
    with 'master', 'develop', 'release-X', 'hotfix-X' and 'feature-X' branches.
162

163
    General rules:
164
    a) any repository commit can get as a Python version
165
    b) a version is generated either in 'release' or in 'snapshot' mode
166
    c) the choice of mode depends on the branch, see following table.
167

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

172
    For every combination of branch and mode, releases are numbered as follows:
173

174
    BRANCH:  /  MODE: snapshot        release
175
    --------          ------------------------------
176
    feature           0.14next_150    N/A
177
    develop           0.14next_151    N/A
178
    release           0.14rc2_249     0.14rc2
179
    master            N/A             0.14
180
    hotfix            0.14.1rc6_121   0.14.1rc6
181
                      N/A             0.14.1
182

183
    The suffix 'next' in a version name is used to denote the upcoming version,
184
    the one being under development in the develop and release branches.
185
    Version '0.14next' is the version following 0.14, and only lives on the
186
    develop and feature branches.
187

188
    The suffix 'rc' is used to denote release candidates. 'rc' versions live
189
    only in release and hotfix branches.
190

191
    Suffixes 'next' and 'rc' have been chosen to ensure proper ordering
192
    according to setuptools rules:
193

194
        http://www.python.org/dev/peps/pep-0386/#setuptools
195

196
    Every branch uses a value for A so that all releases are ordered based
197
    on the branch they came from, so:
198

199
    So
200
        0.13next < 0.14rcW < 0.14 < 0.14next < 0.14.1
201

202
    and
203

204
    >>> V("0.14next") > V("0.14")
205
    True
206
    >>> V("0.14next") > V("0.14rc7")
207
    True
208
    >>> V("0.14next") > V("0.14.1")
209
    False
210
    >>> V("0.14rc6") > V("0.14")
211
    False
212
    >>> V("0.14.2rc6") > V("0.14.1")
213
    True
214

215
    The value for _NNN is chosen based of the revision number of the specific
216
    commit. It is used to ensure ascending ordering of consecutive releases
217
    from the same branch. Every version of the form A_NNN comes *before*
218
    than A: All snapshots are ordered so they come before the corresponding
219
    release.
220

221
    So
222
        0.14next_* < 0.14
223
        0.14.1_* < 0.14.1
224
        etc
225

226
    and
227

228
    >>> V("0.14next_150") < V("0.14next")
229
    True
230
    >>> V("0.14.1next_150") < V("0.14.1next")
231
    True
232
    >>> V("0.14.1_149") < V("0.14.1")
233
    True
234
    >>> V("0.14.1_149") < V("0.14.1_150")
235
    True
236

237
    Combining both of the above, we get
238
       0.13next_* < 0.13next < 0.14rcW_* < 0.14rcW < 0.14_* < 0.14
239
       < 0.14next_* < 0.14next < 0.14.1_* < 0.14.1
240

241
    and
242

243
    >>> V("0.13next_102") < V("0.13next")
244
    True
245
    >>> V("0.13next") < V("0.14rc5_120")
246
    True
247
    >>> V("0.14rc3_120") < V("0.14rc3")
248
    True
249
    >>> V("0.14rc3") < V("0.14_1")
250
    True
251
    >>> V("0.14_120") < V("0.14")
252
    True
253
    >>> V("0.14") < V("0.14next_20")
254
    True
255
    >>> V("0.14next_20") < V("0.14next")
256
    True
257

258
    Note: one of the tests above fails because of constraints in the way
259
    setuptools parses version numbers. It does not affect us because the
260
    specific version format that triggers the problem is not contained in the
261
    table showing allowed branch / mode combinations, above.
262

263

264
    """
265

    
266
    branch = vcs_info.branch
267

    
268
    # If it's a debian branch, ignore starting "debian-"
269
    brnorm = branch
270
    if brnorm == "debian":
271
        brnorm = "debian-master"
272
    if brnorm.startswith("debian-"):
273
        brnorm = brnorm.replace("debian-", "", 1)
274

    
275
    # Sanity checks
276
    if "-" in brnorm:
277
        btypestr = brnorm.split("-")[0]
278
    else:
279
        btypestr = brnorm
280

    
281
    try:
282
        btype = BRANCH_TYPES[btypestr]
283
    except KeyError:
284
        allowed_branches = ", ".join(x for x in BRANCH_TYPES.keys())
285
        raise ValueError("Malformed branch name '%s', cannot classify as one "
286
                         "of %s" % (btypestr, allowed_branches))
287

    
288
    if btype.versioned:
289
        try:
290
            bverstr = brnorm.split("-")[1]
291
        except IndexError:
292
            # No version
293
            raise ValueError("Branch name '%s' should contain version" %
294
                             branch)
295

    
296
        # Check that version is well-formed
297
        if not re.match(VERSION_RE, bverstr):
298
            raise ValueError("Malformed version '%s' in branch name '%s'" %
299
                             (bverstr, branch))
300

    
301
    m = re.match(btype.allowed_version_re, base_version)
302
    if not m or (btype.versioned and m.groupdict()["bverstr"] != bverstr):
303
        raise ValueError("Base version '%s' unsuitable for branch name '%s'" %
304
                         (base_version, branch))
305

    
306
    if mode not in ["snapshot", "release"]:
307
        raise ValueError("Specified mode '%s' should be one of 'snapshot' or "
308
                         "'release'" % mode)
309
    snap = (mode == "snapshot")
310

    
311
    if ((snap and not btype.builds_snapshot) or
312
        (not snap and not btype.builds_release)):
313
        raise ValueError("Invalid mode '%s' in branch type '%s'" %
314
                         (mode, btypestr))
315

    
316
    if snap:
317
        v = "%s_%d_%s" % (base_version, vcs_info.revno, vcs_info.revid)
318
    else:
319
        v = base_version
320
    return v
321

    
322

    
323
def debian_version_from_python_version(pyver):
324
    """Generate a debian package version from a Python version.
325

326
    This helper generates a Debian package version from a Python version,
327
    following devtools conventions.
328

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

332
    Initial tests:
333

334
    >>> debian_version("3") < debian_version("6")
335
    True
336
    >>> debian_version("3") < debian_version("2")
337
    False
338
    >>> debian_version("1") == debian_version("1")
339
    True
340
    >>> debian_version("1") != debian_version("1")
341
    False
342
    >>> debian_version("1") >= debian_version("1")
343
    True
344
    >>> debian_version("1") <= debian_version("1")
345
    True
346

347
    This helper defines a 1-1 mapping between Python and Debian versions,
348
    with the same ordering.
349

350
    Debian versions are ordered in the same way as Python versions:
351

352
    >>> D("0.14next") > D("0.14")
353
    True
354
    >>> D("0.14next") > D("0.14rc7")
355
    True
356
    >>> D("0.14next") > D("0.14.1")
357
    False
358
    >>> D("0.14rc6") > D("0.14")
359
    False
360
    >>> D("0.14.2rc6") > D("0.14.1")
361
    True
362

363
    and
364

365
    >>> D("0.14next_150") < D("0.14next")
366
    True
367
    >>> D("0.14.1next_150") < D("0.14.1next")
368
    True
369
    >>> D("0.14.1_149") < D("0.14.1")
370
    True
371
    >>> D("0.14.1_149") < D("0.14.1_150")
372
    True
373

374
    and
375

376
    >>> D("0.13next_102") < D("0.13next")
377
    True
378
    >>> D("0.13next") < D("0.14rc5_120")
379
    True
380
    >>> D("0.14rc3_120") < D("0.14rc3")
381
    True
382
    >>> D("0.14rc3") < D("0.14_1")
383
    True
384
    >>> D("0.14_120") < D("0.14")
385
    True
386
    >>> D("0.14") < D("0.14next_20")
387
    True
388
    >>> D("0.14next_20") < D("0.14next")
389
    True
390

391
    """
392
    return pyver.replace("_", "~").replace("rc", "~rc") + "-1"
393

    
394

    
395
def get_python_version():
396
    v = vcs_info()
397
    b = base_version(v)
398
    mode = build_mode()
399
    return python_version(b, v, mode)
400

    
401

    
402
def debian_version(base_version, vcs_info, mode):
403
    p = python_version(base_version, vcs_info, mode)
404
    return debian_version_from_python_version(p)
405

    
406

    
407
def get_debian_version():
408
    v = vcs_info()
409
    b = base_version(v)
410
    mode = build_mode()
411
    return debian_version(b, v, mode)
412

    
413

    
414
def user_info():
415
    import getpass
416
    import socket
417
    return "%s@%s" % (getpass.getuser(), socket.getfqdn())
418

    
419

    
420
def update_version(module, name="version", root="."):
421
    """
422
    Generate or replace version.py as a submodule of `module`.
423

424
    This is a helper to generate/replace a version.py file containing version
425
    information as a submodule of passed `module`.
426

427
    """
428

    
429
    paths = [root] + module.split(".") + ["%s.py" % name]
430
    module_filename = os.path.join(*paths)
431

    
432
    v = vcs_info()
433
    if not v:
434
        # Return early if not in development environment
435
        log.error("Can not compute version outside of a git repository."
436
                  " Will not update %s version file" % module_filename)
437
        return
438
    b = base_version(v)
439
    mode = build_mode()
440
    version = python_version(b, v, mode)
441
    content = """
442
__version__ = "%(version)s"
443
__version_info__ = %(version_info)s
444
__version_vcs_info__ = %(vcs_info)s
445
__version_user_info__ = "%(user_info)s"
446
""" % dict(version=version, version_info=version.split("."),
447
               vcs_info=pprint.PrettyPrinter().pformat(dict(v._asdict())),
448
               user_info=user_info())
449

    
450
    module_file = file(module_filename, "w+")
451
    module_file.write(content)
452
    module_file.close()
453
    return module_filename
454

    
455

    
456
def main():
457
    v = vcs_info()
458
    b = base_version(v)
459
    mode = build_mode()
460

    
461
    try:
462
        arg = sys.argv[1]
463
        assert arg == "python" or arg == "debian"
464
    except IndexError:
465
        raise ValueError("A single argument, 'python' or 'debian is required")
466

    
467
    if arg == "python":
468
        print python_version(b, v, mode)
469
    elif arg == "debian":
470
        print debian_version(b, v, mode)
471

    
472
if __name__ == "__main__":
473
    sys.exit(main())