Statistics
| Branch: | Tag: | Revision:

root / devflow / versioning.py @ 55775645

History | View | Annotate | Download (14.1 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
        desc = repo.git.describe("--tags")
111
        toplevel = repo.working_dir
112
    except git.InvalidGitRepositoryError:
113
        log.error("Could not retrieve git information. " +
114
                  "Current directory not a git repository?")
115
        return None
116

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

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

    
123

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

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

    
134

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

    
145

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

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

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

159
    with 'master', 'develop', 'release-X', 'hotfix-X' and 'feature-X' branches.
160

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

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

170
    For every combination of branch and mode, releases are numbered as follows:
171

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

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

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

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

192
        http://www.python.org/dev/peps/pep-0386/#setuptools
193

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

197
    So
198
        0.13next < 0.14rcW < 0.14 < 0.14next < 0.14.1
199

200
    and
201

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

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

219
    So
220
        0.14next_* < 0.14
221
        0.14.1_* < 0.14.1
222
        etc
223

224
    and
225

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

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

239
    and
240

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

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

261

262
    """
263

    
264
    branch = vcs_info.branch
265

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

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

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

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

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

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

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

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

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

    
320

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

324
    This helper generates a Debian package version from a Python version,
325
    following devtools conventions.
326

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

330
    Initial tests:
331

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

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

348
    Debian versions are ordered in the same way as Python versions:
349

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

361
    and
362

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

372
    and
373

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

389
    """
390
    return pyver.replace("_", "~").replace("rc", "~rc") + "-1"
391

    
392

    
393
def debian_version(base_version, vcs_info, mode):
394
    p = python_version(base_version, vcs_info, mode)
395
    return debian_version_from_python_version(p)
396

    
397

    
398
def user_info():
399
    import getpass
400
    import socket
401
    return "%s@%s" % (getpass.getuser(), socket.getfqdn())
402

    
403

    
404
def update_version(module, name="version", root="."):
405
    """
406
    Generate or replace version.py as a submodule of `module`.
407

408
    This is a helper to generate/replace a version.py file containing version
409
    information as a submodule of passed `module`.
410

411
    """
412

    
413
    v = vcs_info()
414
    if not v:
415
        # Return early if not in development environment
416
        return
417
    b = base_version(v)
418
    mode = build_mode()
419
    paths = [root] + module.split(".") + ["%s.py" % name]
420
    module_filename = os.path.join(*paths)
421
    version = python_version(b, v, mode)
422
    content = """
423
__version__ = "%(version)s"
424
__version_info__ = %(version_info)s
425
__version_vcs_info__ = %(vcs_info)s
426
__version_user_info__ = "%(user_info)s"
427
    """ % dict(version=version, version_info=version.split("."),
428
               vcs_info=pprint.PrettyPrinter().pformat(dict(v._asdict())),
429
               user_info=user_info())
430

    
431
    module_file = file(module_filename, "w+")
432
    module_file.write(content)
433
    module_file.close()
434

    
435

    
436
if __name__ == "__main__":
437
    v = vcs_info()
438
    b = base_version(v)
439
    mode = build_mode()
440

    
441
    try:
442
        arg = sys.argv[1]
443
        assert arg == "python" or arg == "debian"
444
    except IndexError:
445
        raise ValueError("A single argument, 'python' or 'debian is required")
446

    
447
    if arg == "python":
448
        print python_version(b, v, mode)
449
    elif arg == "debian":
450
        print debian_version(b, v, mode)