version 16.2 KB
Newer Older
1
2
3
4
5
6
7
8
9
#!/usr/bin/env python3
"""
Get's the current version number or sets a new one by bumping a minor or major
version point, generates debian changelog from commits since last version.
"""
import argparse
import subprocess
import re
import time
10
import collections
11
12
13
import os

try:
14
    from stapled.version import __version__, __app_name__, __debian_version__
15
except ImportError as exc:
16
17
18
    __version__ = None
    __app_name__ = None
    __debian_version__ = None
19
20
21
22
23
24
25
26
27
28
29
30


class NeedInputException(Exception):
    pass


class GitVersion(object):
    """Class to parse versions"""

    VERSION_REGEX = re.compile(r'[v]*(\d+)\.(\d+).*')
    # The git message when a commit is "clean", meaning the files have not been
    # changed
31
    CLEAN_COMMIT = 'nothing to commit, working tree clean'
32
33
    DEFAULT_VERSION = [0, 1]
    CHANGELOG_FILE = 'debian/changelog'
34
    DEFAULT_TARGET_DEB_OS = "stretch"
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

    def __init__(self, **kwargs):
        """
        Initialize with argparser arguments
        Default version would be 0.1

        :param bool major: if true, bump major number
        :param bool minor: if true, bump minornumber
        :param bool version_file: if true, update the version file
        """
        #: If true, prints some debug statements
        self.non_interactive = kwargs['non_interactive']
        self.save = kwargs['save']
        self.force = kwargs['force']
        self.verbose = kwargs['verbose']
        self.file = kwargs['save']

52
        self.guessed_app_name = os.path.basename(os.path.realpath(''))
53

54
55
56
57
        if sum([kwargs['major'], kwargs['minor']]) > 1:
            print("You can increase either the major or minor version")
            exit(1)

58
59
60
61
        (
            self.major,
            self.minor,
            self.app_name,
62
            self.deb_os_version
63
        ) = self.get_version()
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

        for part in ('major', 'minor'):
            if kwargs[part]:
                self.bump(part)
                log = self.getchangelog()
                print(log)
                if kwargs['save']:
                    self.write_changelog(log)
                    self.save_version()
                break  # can only edit one part at a time anyway
        print(self)

    def write_changelog(self, log):
        try:
            with open(self.CHANGELOG_FILE) as changelog_file:
                changelog = changelog_file.read()
        except (FileNotFoundError, IOError, OSError) as exc:
            print("Error reading changelog file: {}".format(str(exc)))
            raise

        log = log + changelog

        with open(self.CHANGELOG_FILE, 'w') as changelog_file:
            changelog_file.write(log)

    def get_version(self):
        """
        Get current version information from version file. Sets self.major,
        and self.minor
        """
94
        if __version__ is None or __app_name__ is None or __debian_version__ is None:
95
96
            (major, minor, app_name, deb_os_version) = self.initial_setup()
        else:
97
98
99
            version = __version__
            app_name = __app_name__
            deb_os_version = __debian_version__
100
101
102
103
104
105
106
107
108
109
            try:
                (major, minor) = \
                    (int(i) for i in self.VERSION_REGEX.findall(version)[0])
            except ValueError:
                print(
                    "Malformed version in version file: '{}'. "
                    "Returning default".format(version)
                )

        return (major, minor, app_name, deb_os_version)
110
111

    def initial_setup(self):
112
113
114
115
116
117
        """
        If there is no version file yet, help setup one. Add the initial
        version, a name for the application and the target OS. You can change
        the version file make it possible to target other OS versions.
        :return list: Major and minor versions in a list.
        """
118
119
120
121
122
123
124
125
126
127

        user_wants_to_create = self.yn_question(
            "A version file named \"%s\" does not yet exist, "
            "do you want to create it?" % self.file,
            default="y"
        )
        if not user_wants_to_create:
            print("Can't continue without initial a version file, sorry.")
            exit(1)

128
        self.app_name = self.question(
129
            "Please specify the name of your application [\"{app}\"]:".format(
130
131
                app=self.guessed_app_name
            ),
132
133
134
135
136
            valid_answers=re.compile(r"[a-z0-9_\-.]+"),
            case_sensitive=False,
            invalid_response=(
                "Please enter a name containing only character in "
                "a-z, 0-9, - _ or _."
137
138
            ),
            default=self.guessed_app_name
139
        )
140
        self.deb_os_version = self.question(
141
            "Please specify the target OS verion name [\"stretch\"]:",
142
            valid_answers=re.compile(r"^([a-z0-9_\-]+|$)"),  # version or ""
143
144
145
146
            case_sensitive=False,
            invalid_response=(
                "Please enter a os version name containing only character in "
                "a-z, 0-9, - _ or _"
147
            ),
148
            default=self.DEFAULT_TARGET_DEB_OS
149
        )
150
        user_wants_default_version = self.yn_question(
151
            "Do you want to set the default version number [{}]".format(
152
153
                "{}.{}".format(*self.DEFAULT_VERSION)
            ),
154
            default="y"
155
        )
156
        if user_wants_default_version:
157
            (self.major, self.minor) = self.DEFAULT_VERSION
158
159
160
161
162
163
164
165
        else:
            user_wants_to_set_version = self.yn_question(
                "Do you want to set a version yourself?",
                default="y"
            )
            if not user_wants_to_set_version:
                print("Can't continue without an initial version, sorry.")
                exit(1)
166
167
168

            num_pat = re.compile('^[0-9]+$')
            print(
169
170
                "You will get two prompts one for each of the version"
                " number components [major.minor]."
171
            )
172
173
174
175
176
177
178
179
            self.major = self.question(
                "Enter the major number (X.x):", [num_pat]
            )
            self.minor = self.question(
                "Enter the minor number (x.X):", [num_pat]
            )

        self.save_version()
180
        return (self.major, self.minor, self.app_name, self.deb_os_version)
181

182
    def yn_question(self, prompt, default=None):
183
184
185
        """
        Ask the user a yes/no question and return only true or false.
        :param str prompt: The question to ask.
186
187
        :param NoneType|str default: Default answer for empty response or None
                                     if an empty response is not allowed.
188
189
        :return bool: True for yes, False for no.
        """
190
        return self.question(
191
192
193
194
195
196
            prompt="{prompt} [y(es), n(o)]{default}".format(
                prompt=prompt,
                default=(" ({})".format(default) if default else "")
            ),
            valid_answers=['y', 'yes', 'n', 'no'],
            default=default
197
198
199
        )[0] == 'y'  # boolean return

    def question(self, prompt, valid_answers, case_sensitive=False,
200
                 invalid_response="Invalid response", default=None):
201
202
203
204
205
206
207
        """
        Ask the user a question and check that the user gives a valid answer.
        :param str prompt: The question to ask.
        :param str|iterable valid_answers: Valid answers as strings or
            regexes in an array or just a string or a regex string.
        :param bool case_sensitive: Handle answer as case sensitive.
        :param str invalid_response: String to output for invalid responses.
208
209
        :param NoneType|str default: Default answer for empty response or None
                                     if an empty response is not allowed.
210
211
        :return str: The answer supplied by the user.
        """
212
213
214
215
        if self.non_interactive:
            raise NeedInputException(
                "User needs to answer a question to continue")
        try:
216
217
            if not isinstance(valid_answers, collections.Iterable):
                valid_answers = (valid_answers,)
218
219
220
            raw_answer = None
            while raw_answer is None:
                raw_answer = input("{} ".format(prompt))
221
222
223
                if default and raw_answer == '':
                    raw_answer = default
                    break
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
                matched = False
                answer = raw_answer if case_sensitive else raw_answer.lower()
                for valid in valid_answers:
                    if isinstance(valid, str):
                        if valid == answer:
                            matched = True
                            break
                    elif isinstance(valid, re._pattern_type):
                        if valid.match(answer) is not None:
                            matched = True
                            break
                if not matched:
                    if invalid_response:
                        print(invalid_response)
                    raw_answer = None
            return raw_answer
        except KeyboardInterrupt:
            print("Can't continue without an answer, sorry.")
            exit(1)

    def bump(self, part):
        """
        Increase either major or minor. Major sets minor to 0

        :param str part: either 'major' or 'minor'
        """
        if self.save and not self.force:
            try:
                # If the version file has unstaged changes, we can't update the
                # patch number
                version_file_diff = str(subprocess.check_output(
                    ['git', 'status', self.file],
                    universal_newlines=True
                ))
                if self.CLEAN_COMMIT not in version_file_diff:
                    print("Version file is already changed, won't update.")
                    exit(2)
            except subprocess.CalledProcessError:
                print(
                    "Something went getting status from version file",
                )
                exit(3)

        if part == 'major':
            self.major += 1
            self.minor = 0
        elif part == 'minor':
            self.minor += 1
        else:
            print("Bump run with invalid argument '%s'" % part)

    def getchangelog(self):
        """
        Gets the commit hash of the last time that the version file was
        touched, and calculates how many commits have been done since.

        Adds 1 to the number of commits, because the current/next commit is
        not counted by git rev-list
        """
        try:

            # The first rule of the output of git log is the commit hash
            # Take index 7: to strip off 'commit '
            commit_hash = str(
                subprocess.check_output(
289
                    ['git', 'log', '--decorate=', '-n', '1', self.file],
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
                    universal_newlines=True
                )
            ).split('\n')[0][len('commit '):]

            if not commit_hash:
                commit_hash = 0
            if self.verbose:
                print("commit hash", commit_hash)

            # Get the commit number of the current revision
            count_head = int(
                str(
                    subprocess.check_output(
                        ['git', 'rev-list', '--count', 'HEAD'],
                        universal_newlines=True
                    )
                ).split('\n')[0]
            )

            if self.verbose:
                print("count head", count_head)

            count_hash = 0
            # Get the commit number of the version change's revision
            if (commit_hash):
                count_hash = int(
                    str(
                        subprocess.check_output(
                            ['git', 'rev-list', '--count', commit_hash],
                            universal_newlines=True
                        )
                    ).split('\n')[0]
                )
                if self.verbose:
                    print("count hash", count_hash)

            diff = count_head - count_hash + 1
            diff = str(diff)

329
            log = []
330
331
332
333
            p = subprocess.Popen(
                ['git', 'log', '-n', diff],
                stdout=subprocess.PIPE
            )
334
            MERGED_MASTER = re.compile(r"\s*Merge branch \'(.*)\' into master")
335
            for line in p.stdout:
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
                match = MERGED_MASTER.match(str(line), re.MULTILINE)
                if match is None:
                    continue
                issue = None
                reformatted_match = match.group(1).split("-")
                if reformatted_match[0].isdigit():
                    issue = reformatted_match[0]
                    reformatted_match = reformatted_match[1:]
                reformatted_match = " ".join(reformatted_match).capitalize()
                if issue:
                    log.append("  * Resolves issue #{issue}: {msg}".format(
                        issue=issue,
                        msg=reformatted_match
                    ))
                else:
                    log.append("  * {}".format(reformatted_match))

            log = "\n".join(log)
354
355
356
357
358
359
360
361
362
363
364
365
366
367

            name = str(
                subprocess.check_output(
                    ['git', 'config', '--global', 'user.name']
                ).decode("utf-8")
            ).rstrip()

            email = str(
                subprocess.check_output(
                    ['git', 'config', '--global', 'user.email']
                ).decode("utf-8")
            ).rstrip()

            # Do we have actually changelog information?
368
            if log == "":
369
370
371
                log = " * Bumped version\n"

            log_str = (
372
                "{app_name} ({version}) {deb_os_version}; urgency=low\n\n"
373
                "{log}\n -- {name} <{email}> {time}\n\n"
374
375
            )

376
377
378
            log = log_str.format(
                app_name=self.app_name,
                version=str(self),
379
                deb_os_version=self.deb_os_version,  # stretch, sid.
380
381
382
383
384
                log=log,
                name=name,
                email=email,
                time=time.strftime('%a, %-d %b %Y %H:%M:%S %z')
            )
385
386
387
388
389
390
391
392
393
394
395
396
397
398

            return log
        except subprocess.CalledProcessError:
            print(
                "Unable to create changelog",
            )
            exit(4)

    def save_version(self):
        """
            Save version number to VERSION_FILE
        """
        version_string = str(self)
        if self.verbose:
399
            print("Saving version '{} v{}'".format(
400
                self.app_name,
401
402
403
404
405
                version_string
            ))
        with open(self.file, 'w') as version_file:
            version_file.write(
                (
406
407
408
                    "__version__ = '{version}'\n"
                    "__app_name__ = '{app_name}'\n"
                    "__debian_version__ = '{__debian_version__}'\n"
409
410
411
                ).format(
                    version=version_string,
                    app_name=self.app_name,
412
                    __debian_version__=self.deb_os_version
413
414
                )
            )
415
416
417
418
419

    def __repr__(self):
        return "%s.%s" % (self.major, self.minor)


420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
def main():
    """
    Parse arguments
    """

    parser = argparse.ArgumentParser(
        description='Display/bump the version number.',
        conflict_handler='resolve',
        epilog="""NOTE: this will only work if are using Git and are using the
                  following version numbering scheme:
                  \"v#.#\",
                  e.g: v1.2
               """
    )

    parser.add_argument(
        '--major',
        action='store_true',
        help='Increase the major number with 1.'
    )

    parser.add_argument(
        '-m',
        '--minor',
        action='store_true',
        help='Increase the minor version number with 1.'
    )

    parser.add_argument(
        '-s',
        '--save',
        type=str,
        nargs='?',
453
454
        default='stapled/version.py',
        const='stapled/version.py',
455
        help=(
456
            'Save the new number to the version file, optionally pass'
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
            'a different filename to the argument.'
        )
    )

    parser.add_argument(
        '-v',
        '--verbose',
        action='store_true',
        help='Print more info.'
    )

    parser.add_argument(
        '--force',
        action='store_true',
        help='Force saving the file even if it was already changed.'
    )

    parser.add_argument(
        '--non-interactive',
        action='store_true',
        help='Don\'t ask the user anything, useful for scripting/cron'
    )

    args = parser.parse_args()
    GitVersion(**args.__dict__)


484
485
if __name__ == '__main__':
    main()