changes-semver 6.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright (c) 2021-2024 Jason Morley
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ from . import *
@@ -0,0 +1,13 @@
1
+ import os
2
+ import sys
3
+
4
+ if not __package__:
5
+ # Make CLI runnable from source tree with
6
+ # python src/package
7
+ package_source_path = os.path.dirname(os.path.dirname(__file__))
8
+ sys.path.insert(0, package_source_path)
9
+
10
+
11
+ if __name__ == "__main__":
12
+ from changes_semver.changes import main
13
+ main()
@@ -0,0 +1,886 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright (c) 2021 InSeven Limited
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ import argparse
24
+ import collections
25
+ import copy
26
+ import enum
27
+ import logging
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+
34
+ import jinja2
35
+ import yaml
36
+
37
+ from . import cli
38
+
39
+
40
+ CHANGES_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
41
+ TEMPLATES_DIRECTORY = os.path.join(CHANGES_DIRECTORY, "templates")
42
+
43
+ MULTIPLE_RELEASE_TEMPLATE = "multiple.markdown"
44
+ SINGLE_RELEASE_TEMPLATE = "single.markdown"
45
+
46
+
47
+ class Type(enum.Enum):
48
+ CI = "ci"
49
+ DOCUMENTATION = "docs"
50
+ FEATURE = "feat"
51
+ FIX = "fix"
52
+ UNKNOWN = "UNKNOWN"
53
+
54
+
55
+ class Sections(enum.Enum):
56
+ IGNORE = "IGNORE"
57
+ CHANGES = "CHANGES"
58
+ FIXES = "FIXES"
59
+
60
+
61
+ OPERATIONS = {
62
+ Type.CI: None,
63
+ Type.DOCUMENTATION: None,
64
+ Type.FEATURE: lambda commit, version: version.bump_minor(),
65
+ Type.FIX: lambda commit, version: version.bump_patch(),
66
+ Type.UNKNOWN: None,
67
+ }
68
+
69
+
70
+ TYPE_TO_SECTION = {
71
+ Type.CI: Sections.IGNORE,
72
+ Type.DOCUMENTATION: Sections.IGNORE,
73
+ Type.FEATURE: Sections.CHANGES,
74
+ Type.FIX: Sections.FIXES,
75
+ Type.UNKNOWN: Sections.IGNORE,
76
+ }
77
+
78
+
79
+ SECTION_TITLES = {
80
+ Sections.CHANGES: "Changes",
81
+ Sections.FIXES: "Fixes",
82
+ }
83
+
84
+
85
+ class Chdir(object):
86
+
87
+ def __init__(self, path):
88
+ self.path = os.path.abspath(path)
89
+
90
+ def __enter__(self):
91
+ self.pwd = os.getcwd()
92
+ os.chdir(self.path)
93
+ return self.path
94
+
95
+ def __exit__(self, exc_type, exc_value, traceback):
96
+ os.chdir(self.pwd)
97
+
98
+
99
+ class PreRelease(object):
100
+
101
+ def __init__(self, prefix, version=0):
102
+ self.prefix = prefix
103
+ self.version = version
104
+ self._did_update = False
105
+
106
+ def bump(self):
107
+ if self._did_update:
108
+ return
109
+ self._did_update = True
110
+ self.version = self.version + 1
111
+
112
+ def __str__(self):
113
+ if self.version:
114
+ return f"{self.prefix}.{self.version}"
115
+ return self.prefix
116
+
117
+ def __eq__(self, other):
118
+ if not isinstance(other, PreRelease):
119
+ return False
120
+ if self.prefix != other.prefix:
121
+ return False
122
+ if self.version != other.version:
123
+ return False
124
+ return True
125
+
126
+ def __lt__(self, other):
127
+ if self == other:
128
+ return False
129
+ if self.prefix > other.prefix:
130
+ return False
131
+ if self.prefix < other.prefix:
132
+ return True
133
+ if self.version > other.version:
134
+ return False
135
+ if self.version < other.version:
136
+ return True
137
+ return True
138
+
139
+
140
+ class Version(object):
141
+
142
+ def __init__(self, major=0, minor=0, patch=0, pre_release=None, prefix=None):
143
+ self.major = major
144
+ self.minor = minor
145
+ self.patch = patch
146
+ self.prefix = prefix
147
+ self.pre_release = pre_release
148
+ self._did_update_major = False
149
+ self._did_update_minor = False
150
+ self._did_update_patch = False
151
+
152
+ def bump_major(self):
153
+ assert self.pre_release is None, "Version bumps are not supported for pre-release versions."
154
+ if self._did_update_major:
155
+ return
156
+ self.major = self.major + 1
157
+ self.minor = 0
158
+ self.patch = 0
159
+ self._did_update_major = True
160
+
161
+ def bump_minor(self):
162
+ assert self.pre_release is None, "Version bumps are not supported for pre-release versions."
163
+ if self._did_update_minor or self._did_update_major:
164
+ return
165
+ self.minor = self.minor + 1
166
+ self.patch = 0
167
+ self._did_update_minor = True
168
+
169
+ def bump_patch(self):
170
+ assert self.pre_release is None, "Version bumps are not supported for pre-release versions."
171
+ if self._did_update_patch or self._did_update_minor or self._did_update_major:
172
+ return
173
+ self.patch = self.patch + 1
174
+ self._did_update_patch = True
175
+
176
+ @property
177
+ def is_initial_development(self):
178
+ if self.major == 0:
179
+ return True
180
+ return False
181
+
182
+ @property
183
+ def is_pre_release(self):
184
+ return self.pre_release is not None
185
+
186
+ def __str__(self):
187
+ version = f"{self.major}.{self.minor}.{self.patch}"
188
+ if self.is_pre_release:
189
+ version = version + f"-{str(self.pre_release)}"
190
+ return version
191
+
192
+ def qualifiedString(self):
193
+ version = str(self)
194
+ if self.prefix:
195
+ version = f"{self.prefix}_" + version
196
+ return version
197
+
198
+ def __eq__(self, other):
199
+ if not isinstance(other, Version):
200
+ return False
201
+ if self.major != other.major:
202
+ return False
203
+ if self.minor != other.minor:
204
+ return False
205
+ if self.patch != other.patch:
206
+ return False
207
+ if self.pre_release != other.pre_release:
208
+ return False
209
+ if self.prefix != other.prefix:
210
+ return False
211
+ return True
212
+
213
+ def __lt__(self, other):
214
+ if self == other:
215
+ return False
216
+ if ("" if self.prefix is None else self.prefix) > ("" if other.prefix is None else other.prefix):
217
+ return False
218
+ if ("" if self.prefix is None else self.prefix) < ("" if other.prefix is None else other.prefix):
219
+ return True
220
+ if self.major > other.major:
221
+ return False
222
+ if self.major < other.major:
223
+ return True
224
+ if self.minor > other.minor:
225
+ return False
226
+ if self.minor < other.minor:
227
+ return True
228
+ if self.patch > other.patch:
229
+ return False
230
+ if self.patch < other.patch:
231
+ return True
232
+ if self.pre_release is None and other.pre_release is not None:
233
+ return False
234
+ if self.pre_release is not None and other.pre_release is None:
235
+ return True
236
+ if self.pre_release is not None and other.pre_release is not None:
237
+ return self.pre_release < other.pre_release
238
+ return True
239
+
240
+ def __hash__(self):
241
+ return str(self).__hash__()
242
+
243
+ def __repr__(self):
244
+ return "Version(major=%r, minor=%r, patch=%r, pre_release=%r, prefix=%r)" % (self.major,
245
+ self.minor,
246
+ self.patch,
247
+ self.pre_release,
248
+ self.prefix)
249
+
250
+ @classmethod
251
+ def from_string(self, string, strip_scope=None):
252
+ sv_parser = re.compile(r"^((.+?)_)?(\d+).(\d+).(\d+)(-([A-Za-z]+)(\.(\d+))?)?$")
253
+ match = sv_parser.match(string)
254
+ if match:
255
+ prefix = match.group(2)
256
+ pre_release_prefix = match.group(7)
257
+ pre_release = None
258
+ if pre_release_prefix is not None:
259
+ pre_release_version = int(match.group(9)) if match.group(9) is not None else 0
260
+ pre_release = PreRelease(pre_release_prefix, pre_release_version) # TODO: This might be cleaner as a 'sub-version' or similar?
261
+ return Version(major=int(match.group(3)),
262
+ minor=int(match.group(4)),
263
+ patch=int(match.group(5)),
264
+ pre_release=pre_release,
265
+ prefix=prefix)
266
+ raise ValueError("'%s' is not a valid version." % string)
267
+
268
+
269
+ class Change(object):
270
+
271
+ def __init__(self, message):
272
+ self.message = message
273
+
274
+ def __eq__(self, other):
275
+ if type(self) != type(other):
276
+ return False
277
+ return self.message == other.message
278
+
279
+ def __str__(self):
280
+ return str(self.message)
281
+
282
+
283
+ class Commit(Change):
284
+
285
+ def __init__(self, sha, message, tags, versions):
286
+ super().__init__(message)
287
+ self.sha = sha
288
+ self.tags = tags
289
+ self.versions = versions
290
+
291
+
292
+ class Message(object):
293
+
294
+ def __init__(self, type, scope, breaking_change, description):
295
+ self.type = type
296
+ self.scope = scope
297
+ self.breaking_change = breaking_change
298
+ self.description = description
299
+
300
+ def __eq__(self, other):
301
+ if type(self) != type(other):
302
+ return False
303
+ if self.type != other.type:
304
+ return False
305
+ if self.scope != other.scope:
306
+ return False
307
+ if self.breaking_change != other.breaking_change:
308
+ return False
309
+ if self.description != other.description:
310
+ return False
311
+ return True
312
+
313
+ def __str__(self):
314
+ return self.description
315
+
316
+
317
+ class Group(object):
318
+
319
+ def __init__(self, identifier, items):
320
+ self.identifier = identifier
321
+ self.items = items
322
+
323
+ def __repr__(self):
324
+ return "Group(identiifer=%r, items=%r)" % (self.identifier, self.items)
325
+
326
+
327
+ # TODO: Consider reusing this?
328
+ def group(items, identifier):
329
+ results = [Group(None, [])]
330
+ for item in items:
331
+ item_identifier = identifier(item)
332
+ if item_identifier is not None and results[-1].identifier != item_identifier:
333
+ results.append(Group(item_identifier, []))
334
+ results[-1].items.append(item)
335
+ if not results[0].items:
336
+ results.pop(0)
337
+ return results
338
+
339
+
340
+ class Release(object):
341
+
342
+ def __init__(self, version, changes, is_released=False):
343
+ self.version = version
344
+ self.changes = changes
345
+ self.is_released = is_released
346
+
347
+ def calculate_version(self, previous_released_version, pre_release_prefix=None):
348
+ """Recomputes the current version based on the previous version by applying the changes in order."""
349
+
350
+ # Copy the previous version so we can update it, accounting for the changes in this release.
351
+ if previous_released_version.is_pre_release:
352
+ raise AssertionError("Incorrectly created a relese with a pre-release verison (%s)." % (previous_released_version, ))
353
+
354
+ self.version = copy.deepcopy(previous_released_version)
355
+
356
+ # Iterate over all the changes that are in this release and determine the version number.
357
+ for commit in reversed(self.changes):
358
+ if commit.message.type in OPERATIONS and OPERATIONS[commit.message.type] is not None:
359
+ if commit.message.breaking_change:
360
+ self.version.bump_major()
361
+ else:
362
+ OPERATIONS[commit.message.type](commit, self.version)
363
+ else:
364
+ logging.warning("Ignoring commit: '%s'", commit.message.description)
365
+
366
+ # If we're not being asked to generate a pre-release version we're finished.
367
+ if pre_release_prefix is None:
368
+ return
369
+
370
+ def relevant_pre_release_version(commit):
371
+ pre_release_versions = sorted([version for version in commit.versions
372
+ if (version.is_pre_release and
373
+ version.pre_release.prefix == pre_release_prefix and
374
+ self.version.major == version.major and
375
+ self.version.minor == version.minor and
376
+ self.version.patch == version.patch)])
377
+ if pre_release_versions:
378
+ return pre_release_versions[-1]
379
+ return None
380
+
381
+ # Group the commits by version, filtered to match just our current version and requested pre-release prefix.
382
+ commits_by_pre_release = group(reversed(self.changes), relevant_pre_release_version)
383
+ if commits_by_pre_release and commits_by_pre_release[-1].identifier is not None:
384
+ pre_release = copy.deepcopy(commits_by_pre_release[-1].identifier.pre_release)
385
+ pre_release.bump()
386
+ self.version.pre_release = pre_release
387
+ else:
388
+ self.version.pre_release = PreRelease(prefix=pre_release_prefix)
389
+
390
+ @property
391
+ def is_empty(self):
392
+ for change in self.changes:
393
+ if OPERATIONS[change.message.type] is not None:
394
+ return False
395
+ return True
396
+
397
+ @property
398
+ def is_pre_release(self):
399
+ return self.version.is_pre_release
400
+
401
+ @property
402
+ def is_initial_development(self):
403
+ return self.version.is_initial_development
404
+
405
+ def merge(self, release):
406
+ self.changes.extend(release.changes)
407
+
408
+ @property
409
+ def sections(self):
410
+ return group_changes(self.changes)
411
+
412
+
413
+ class Section(object):
414
+
415
+ def __init__(self, type, changes):
416
+ self.type = type
417
+ self.changes = changes
418
+
419
+ @property
420
+ def title(self):
421
+ return SECTION_TITLES[self.type]
422
+
423
+
424
+ class History(object):
425
+
426
+ def __init__(self,
427
+ path,
428
+ scope=None,
429
+ history=None,
430
+ skip_unreleased=False,
431
+ pre_release=False,
432
+ pre_release_prefix="rc"):
433
+ self.path = os.path.abspath(path)
434
+ self.scope = scope
435
+ self.skip_unreleased = skip_unreleased
436
+ self.history = os.path.abspath(history) if history is not None else None
437
+ self.pre_release = pre_release
438
+ self.pre_release_prefix = pre_release_prefix
439
+ self._load()
440
+
441
+ def _load(self):
442
+ with Chdir(self.path):
443
+
444
+ if is_shallow():
445
+ logging.error("Unable to determine change history for shallow clones.")
446
+ exit(1)
447
+
448
+ # Get all the changes on the current branch.
449
+ all_changes = get_commits(scope=self.scope)
450
+
451
+ # Group the changes by release.
452
+ # We create an empty head release to absorb all the changes that don't yet have versions.
453
+ releases = []
454
+ releases.append(Release(None, []))
455
+
456
+ # Releases currently being processed by the loop.
457
+ current_releases = [releases[-1]]
458
+
459
+ # Iterate over the changes, most recent first.
460
+ for change in all_changes:
461
+
462
+ # Iterate over all the relevant version tags (highest version first) on this change and update the
463
+ # current set of releases accordingly.
464
+ change_versions = [version for version in reversed(sorted(change.versions))
465
+ if not version.is_pre_release or version.pre_release.prefix == self.pre_release_prefix]
466
+ for change_version in change_versions:
467
+
468
+ # This method is pretty magical as it's the way by which we determine how the changes we see affect
469
+ # the current set of releases we're dropping changes into as we go through the changes in reverse
470
+ # chronological order.
471
+ # In essence, pre-release versions should never affect the active set as pre-release versions are
472
+ # intentionally overlapping (they collect all changes since the last release version), while
473
+ # release versions will always displace releases that came after them (including pre-releases).
474
+ # The only place where this differs is that pre-release versions are allowed to replace _empty_
475
+ # unreleased versions. This is, in many ways a side effect of a poorly designed loop; we probably
476
+ # shouldn't insert an empty release until we need one, then we wouldn't need this magic.
477
+ def version_replaces_release(version, release):
478
+ if version.is_pre_release:
479
+ return release.version is None and release.is_empty and self.pre_release
480
+ return release.version is None or release.version > version
481
+
482
+ # Update the active set of releases.
483
+ current_releases = [release for release in current_releases
484
+ if not version_replaces_release(change_version, release)]
485
+
486
+ # Create a new release for the current change.
487
+ release = Release(change_version, [], is_released=True)
488
+ releases.append(release)
489
+ current_releases.append(release)
490
+
491
+ # Append the change to the latest release (which we might have just created).
492
+ for release in current_releases:
493
+ release.changes.append(change)
494
+
495
+ # Fix-up the version number for any un-released current release.
496
+ # `calculate_version` does all the work to determine the version for the release by applying the releases'
497
+ # changes to the previous version.
498
+ if releases[0].version is None:
499
+ # Pass in the previous _released_ version.
500
+ released_versions = [release.version for release in releases[1:] if not release.is_pre_release]
501
+ previous_released_version = released_versions[0] if len(released_versions) > 0 else Version(0, 0, 0)
502
+ releases[0].calculate_version(previous_released_version=previous_released_version,
503
+ pre_release_prefix=self.pre_release_prefix if self.pre_release else None)
504
+
505
+ # Remove the empty head release if there's already an active release.
506
+ if len(releases) > 1 and releases[0].is_empty:
507
+ releases.pop(0)
508
+
509
+ releases_by_version = {release.version: release for release in releases}
510
+
511
+ if self.history is not None:
512
+ for version, release in load_history(path=self.history, prefix=self.scope).items():
513
+ try:
514
+ releases_by_version[version].merge(release)
515
+ except KeyError:
516
+ releases_by_version[version] = release
517
+
518
+ releases = list(sorted(releases_by_version.values(),
519
+ key=lambda release: release.version, reverse=True))
520
+
521
+ # Filter the releases to match our requested state.
522
+
523
+ # Filter unreleased versions
524
+ releases = [release for release in releases
525
+ if release.is_released or not self.skip_unreleased]
526
+
527
+ # Filter pre-releases
528
+ releases = [release for release in releases
529
+ if not release.is_pre_release or self.pre_release]
530
+
531
+ self.releases = releases
532
+
533
+
534
+ def load_history(path, prefix=None):
535
+ history = {}
536
+ with open(path) as fh:
537
+ contents = yaml.load(fh, Loader=yaml.SafeLoader)
538
+ # Check the format.
539
+ if not isinstance(contents, dict):
540
+ raise ValueError("Invalid configuration")
541
+ for version_string, changes in contents.items():
542
+ version = Version.from_string(version_string)
543
+ if version.prefix != prefix:
544
+ logging.warning("Ignoring version '%s'...", version_string)
545
+ continue
546
+ if not isinstance(version_string, str) or not isinstance(changes, list):
547
+ raise ValueError("Invalid configuration")
548
+ messages = [parse_message(change) for change in changes]
549
+ commits = [Change(message=message) for message in messages]
550
+ commits.reverse()
551
+ release = Release(version, commits, is_released=True)
552
+ history[version] = release
553
+ return history
554
+
555
+
556
+ def run(command, dry_run=False):
557
+ if dry_run:
558
+ logging.info(command)
559
+ return []
560
+ result = subprocess.run(command, capture_output=True)
561
+ result.check_returncode()
562
+ lines = result.stdout.decode("utf-8").strip().split("\n")
563
+ return lines
564
+
565
+
566
+ def is_shallow():
567
+ return run(["git", "rev-parse", "--is-shallow-repository"])[0] == "true"
568
+
569
+
570
+ def get_tags():
571
+ tags = collections.defaultdict(list)
572
+ for tag in [tag for tag in run(["git", "tag"]) if tag]:
573
+ sha = run(["git", "rev-list", "-n", "1", "tags/%s" % (tag, )])[0]
574
+ tags[sha].append(tag)
575
+ return tags
576
+
577
+
578
+ class UnknownScope(ValueError):
579
+ pass
580
+
581
+
582
+ def versions_from_tags(tags, prefix):
583
+ versions = []
584
+ for tag in tags:
585
+ try:
586
+ version = Version.from_string(tag)
587
+ if version.prefix == prefix:
588
+ versions.append(version)
589
+ except ValueError:
590
+ pass
591
+ return versions
592
+
593
+
594
+ def get_commits(scope=None):
595
+
596
+ # Guard against empty repositories.
597
+ count = int(run(["git", "rev-list", "--all", "--count"])[0])
598
+ if count < 1:
599
+ return []
600
+
601
+ # Load the tags and versions.
602
+ tags = get_tags()
603
+ versions = collections.defaultdict(list)
604
+ for sha, sha_tags in tags.items():
605
+ versions[sha] = versions_from_tags(sha_tags, prefix=scope)
606
+
607
+ results = []
608
+ command = ["git", "log", "--pretty=format:%H:%s"]
609
+ try:
610
+ commits = run(command)
611
+ except subprocess.CalledProcessError as e:
612
+ logging.error(e.stderr.decode("utf-8"))
613
+ exit(1)
614
+ for c in commits:
615
+ sha, message = c.split(":", 1)
616
+ commit = Commit(sha, parse_message(message), tags[sha], versions[sha])
617
+ results.append(commit)
618
+ return results
619
+
620
+
621
+ def parse_message(message):
622
+ cc_parser = re.compile(r"^(.+?)(\((.+?)\))?(\!)?:(.+)$")
623
+ match = cc_parser.match(message)
624
+ if match is not None:
625
+ (cc_type, cc_scope, cc_break, cc_description) = (match.group(1), match.group(3), match.group(4), match.group(5))
626
+ try:
627
+ return Message(type=Type(cc_type),
628
+ scope=cc_scope,
629
+ breaking_change=(cc_break == "!"),
630
+ description=cc_description.strip())
631
+ except ValueError:
632
+ pass
633
+ return Message(type=Type.UNKNOWN,
634
+ scope=None,
635
+ breaking_change=False,
636
+ description=message.strip())
637
+
638
+
639
+ def group_changes(changes):
640
+ sections = {}
641
+ for commit in changes:
642
+ section_type = TYPE_TO_SECTION[commit.message.type]
643
+ if section_type not in sections:
644
+ sections[section_type] = Section(type=section_type, changes=[])
645
+ section = sections[section_type]
646
+ section.changes.append(commit.message)
647
+ results = []
648
+ if Sections.CHANGES in sections:
649
+ results.append(sections[Sections.CHANGES])
650
+ if Sections.FIXES in sections:
651
+ results.append(sections[Sections.FIXES])
652
+ return results
653
+
654
+
655
+ def regex_replace(s, find, replace):
656
+ return re.sub(find, replace, s)
657
+
658
+
659
+ def format_notes(releases, template):
660
+ loader = jinja2.ChoiceLoader([
661
+ AbsolutePathLoader(),
662
+ jinja2.FileSystemLoader(TEMPLATES_DIRECTORY),
663
+ ])
664
+ environment = jinja2.Environment(loader=loader)
665
+ environment.filters['regex_replace'] = regex_replace
666
+ return environment.get_template(template).render(releases=releases, Sections=Sections).rstrip() + "\n"
667
+
668
+
669
+ def resolve_scope(options):
670
+ if options.scope is not None:
671
+ return options.scope
672
+ try:
673
+ return options.legacy_scope
674
+ except AttributeError:
675
+ return None
676
+
677
+
678
+ @cli.command("version", help="output the current version as determined by taking the the most recent version tag and applying any subsequent changes; if there have been no changes since the most recent version tag, this will output the version of the most recent tag", arguments=[
679
+ cli.Argument("--scope", help="scope to be used in tags and commit messages"),
680
+ cli.Argument("--released", action="store_true", default=False, help="scope to be used in tags and commit messages"),
681
+ cli.Argument("--pre-release", action="store_true", default=False, help="generate a pre-release version"),
682
+ cli.Argument("--pre-release-prefix", type=str, default="rc", help="prefix to be used when generating a pre-release version (defaults to 'rc')"),
683
+ ])
684
+ def command_version(options):
685
+ history = History(path=os.getcwd(),
686
+ scope=resolve_scope(options),
687
+ skip_unreleased=options.released,
688
+ pre_release=options.pre_release,
689
+ pre_release_prefix=options.pre_release_prefix)
690
+ print(history.releases[0].version)
691
+
692
+
693
+ @cli.command("release", help="tag the commit as a new release", formatter_class=argparse.RawDescriptionHelpFormatter, arguments=[
694
+ cli.Argument("--scope", help="scope to be used in tags and commit messages"),
695
+ cli.Argument("--skip-if-empty", action="store_true", default=False, help="exit cleanly if there are no changes to release"),
696
+ cli.Argument("--command", help="additional command to run during the release; if the command fails, the release will be rolled back (cannot be used alongside --exec)"),
697
+ cli.Argument("--exec", help="executable to run to during the release; if the executable fails, the release will be rolled back (cannot be used alongside --command)"),
698
+ cli.Argument("--push", action="store_true", default=False, help="push the newly created tag"),
699
+ cli.Argument("--dry-run", action="store_true", default=False, help="perform a dry run, only logging the operations that would be performed"),
700
+ cli.Argument("--template", help="custom Jinja2 template"),
701
+ cli.Argument("--pre-release", action="store_true", default=False, help="generate a pre-release version"),
702
+ cli.Argument("--pre-release-prefix", type=str, default="rc", help="prefix to be used when generating a pre-release version (defaults to 'rc')"),
703
+ cli.Argument("arguments", nargs="*", help="arguments to pass to the release command"),
704
+ ], epilog="""
705
+ When calling a script specified by `--command` or `--exec`, Changes defines a number of environment variables:
706
+
707
+ CHANGES_TITLE a proposed title for the release
708
+ CHANGES_QUALIFIED_TITLE a proposed title including pre-release version details (if applicable)
709
+ CHANGES_VERSION version number
710
+ CHANGES_QUALIFIED_VERSION full version number including pre-release version details (if applicable)
711
+ CHANGES_INITIAL_DEVELOPMENT true if the major version number is less than 0; false otherwise
712
+ CHANGES_PRE_RELEASE true if the release is a pre-release; false otherwise
713
+ CHANGES_TAG the Git tag used for the release
714
+ CHANGES_NOTES the release notes
715
+ CHANGES_NOTES_FILE path to a file containing the release notes
716
+ """)
717
+ def command_release(options):
718
+
719
+ if options.command is not None and options.exec is not None:
720
+ logging.error("--command and --exec cannot be used together.")
721
+ exit(1)
722
+
723
+ scope = resolve_scope(options)
724
+ history = History(path=os.getcwd(),
725
+ scope=scope,
726
+ pre_release=options.pre_release,
727
+ pre_release_prefix=options.pre_release_prefix)
728
+ releases = history.releases
729
+ if releases[0].is_released or releases[0].is_empty:
730
+ # There aren't any unreleased versions.
731
+ if options.skip_if_empty:
732
+ exit()
733
+ logging.error("No versions to release.")
734
+ exit(1)
735
+ version = releases[0].version
736
+ logging.info("Releasing %s...", version)
737
+ tag = str(version)
738
+ if scope is not None:
739
+ tag = f"{scope}_{tag}"
740
+ logging.info("Creating tag '%s'...", tag)
741
+ run(["git", "tag", tag], dry_run=options.dry_run)
742
+
743
+ title = f"{version.major}.{version.minor}.{version.patch}"
744
+ if scope is not None:
745
+ title = f"{scope} {title}"
746
+ qualified_title = title
747
+ if version.is_pre_release:
748
+ qualified_title = f"{qualified_title} {version.pre_release}"
749
+
750
+ if options.push:
751
+ logging.info("Pushing tag '%s'...", tag)
752
+ run(["git", "push", "origin", tag], dry_run=options.dry_run)
753
+
754
+ if options.command is not None or options.exec is not None:
755
+ logging.info("Running command...")
756
+ success = True
757
+
758
+ if options.template is not None:
759
+ template = os.path.abspath(options.template)
760
+ else:
761
+ template = SINGLE_RELEASE_TEMPLATE
762
+ notes = format_notes(releases=[releases[0]], template=template)
763
+
764
+ with tempfile.NamedTemporaryFile() as notes_file, tempfile.TemporaryDirectory() as temporary_directory:
765
+
766
+ # Create a temporary directory containing the notes.
767
+ with open(notes_file.name, "w") as fh:
768
+ fh.write(notes)
769
+
770
+ # Create a temporary executable script to make it easy to forward arguments to the command.
771
+ if options.command is not None:
772
+ command = os.path.join(temporary_directory, "script.sh")
773
+ with open(command, "w") as fh:
774
+ fh.write("#!/bin/sh\n")
775
+ fh.write(options.command)
776
+ os.chmod(command, 0o744)
777
+ elif options.exec is not None:
778
+ command = os.path.abspath(options.exec)
779
+
780
+ # Set up the environment.
781
+ env = copy.deepcopy(os.environ)
782
+ env['CHANGES_TITLE'] = title
783
+ env['CHANGES_QUALIFIED_TITLE'] = qualified_title
784
+ env['CHANGES_VERSION'] = f"{version.major}.{version.minor}.{version.patch}"
785
+ env['CHANGES_QUALIFIED_VERSION'] = str(version)
786
+ env['CHANGES_PRE_RELEASE_VERSION'] = str(version.pre_release) if version.pre_release is not None else ""
787
+ env['CHANGES_INITIAL_DEVELOPMENT'] = "true" if version.is_initial_development else "false"
788
+ env['CHANGES_PRE_RELEASE'] = "true" if version.is_pre_release else "false"
789
+ env['CHANGES_TAG'] = tag
790
+ env['CHANGES_NOTES'] = notes
791
+ env['CHANGES_NOTES_FILE'] = notes_file.name
792
+
793
+ # Run the command.
794
+ command_args = [command] + options.arguments
795
+ if options.dry_run:
796
+ logging.info("Running command '%s'...", command_args)
797
+ else:
798
+ logging.debug("Running command '%s' in directory '%s' with files '%s'...", command_args, os.getcwd(), os.listdir())
799
+ result = subprocess.run(command_args, capture_output=True, env=env)
800
+ try:
801
+ result.check_returncode()
802
+ logging.info(result.stdout.decode("utf-8").strip())
803
+ except subprocess.CalledProcessError as e:
804
+ logging.info(result.stdout.decode("utf-8").strip())
805
+ logging.error("Release command failed with error '%s'; reverting release.", e.stderr.decode("utf-8").strip())
806
+ run(["git", "tag", "-d", tag])
807
+ if options.push:
808
+ run(["git", "push", "origin", f":{tag}"])
809
+ success = False
810
+
811
+ if not success:
812
+ exit(1)
813
+
814
+ logging.info("Done.")
815
+
816
+
817
+ class AbsolutePathLoader(jinja2.BaseLoader):
818
+
819
+ def get_source(self, environment, template):
820
+ path = os.path.abspath(template)
821
+ if not os.path.exists(path):
822
+ raise jinja2.TemplateNotFound(path)
823
+ mtime = os.path.getmtime(path)
824
+ with open(path) as f:
825
+ source = f.read()
826
+ return source, path, lambda: mtime == os.path.getmtime(path)
827
+
828
+
829
+ @cli.command("notes", help="output the release notes", arguments=[
830
+ cli.Argument("--scope", help="filter the release notes to the given scope"),
831
+ cli.Argument("--skip-unreleased", action="store_true", help="skip unreleased versions"),
832
+ cli.Argument("--history", help="file containing changes for versions not adhereing to Conventional Commits"),
833
+ cli.Argument("--released", action="store_true", default=False, help="show only released versions; display the most recent released version, or all versions if the '--all' flag is specified"),
834
+ cli.Argument("--pre-release", action="store_true", default=False, help="include pre-release versions"),
835
+ cli.Argument("--pre-release-prefix", type=str, default="rc", help="prefix to be used when generating a pre-release version (defaults to 'rc')"),
836
+ cli.Argument("--all", action="store_true", default=False, help="output release notes for all versions"),
837
+ cli.Argument("--template", help="custom Jinja2 template")
838
+ ])
839
+ def command_notes(options):
840
+ history = History(path=os.getcwd(),
841
+ history=options.history,
842
+ scope=resolve_scope(options),
843
+ skip_unreleased=options.released,
844
+ pre_release=options.pre_release,
845
+ pre_release_prefix=options.pre_release_prefix)
846
+
847
+ if options.template is not None:
848
+ template = os.path.abspath(options.template)
849
+ else:
850
+ template = MULTIPLE_RELEASE_TEMPLATE if options.all else SINGLE_RELEASE_TEMPLATE
851
+
852
+ if options.all:
853
+ print(format_notes(releases=history.releases, template=template), end="")
854
+ else:
855
+ print(format_notes(releases=[history.releases[0]], template=template), end="")
856
+
857
+
858
+ @cli.command("scopes", help="show all the unique scopes used within the repository")
859
+ def command_scopes(options) -> None:
860
+ scopes = set([commit.message.scope for commit in get_commits() if commit.message.scope is not None])
861
+ for scope in sorted(scopes):
862
+ print(scope)
863
+
864
+
865
+ DESCRIPTION = """
866
+
867
+ Lightweight and (hopefully) unopinionated tool for managing Semantic Versioning using Conventional Commits.
868
+
869
+ Changes currently a number of commands that can be assembled in whatever way fits your workflow.
870
+ """
871
+
872
+ EPILOG = """
873
+ You can find out more about Conventional Commits and Semantic Versioning at the following links:
874
+
875
+ - Conventional Commits: https://www.conventionalcommits.org
876
+ - Semantic Versioning: https://semver.org
877
+ """
878
+
879
+ def main():
880
+ verbose = '--verbose' in sys.argv[1:] or '-v' in sys.argv[1:]
881
+ logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, format="[%(levelname)s] %(message)s")
882
+ parser = cli.CommandParser(description=DESCRIPTION, epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
883
+ parser.add_argument('--verbose', '-v', action='store_true', default=False, help="show verbose output")
884
+ if "--scope" in sys.argv:
885
+ parser.add_argument("--scope", dest="legacy_scope", help="scope to be used in tags and commit messages")
886
+ parser.run()
changes_semver/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright (c) 2021 InSeven Limited
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ import argparse
24
+ import functools
25
+ import logging
26
+
27
+
28
+ COMMANDS = {}
29
+
30
+
31
+ class Command(object):
32
+
33
+ def __init__(self, name, help, arguments, epilog, formatter_class, callback):
34
+ self.name = name
35
+ self.help = help
36
+ self.arguments = arguments
37
+ self.epilog = epilog
38
+ self.formatter_class = formatter_class
39
+ self.callback = callback
40
+
41
+ class Argument(object):
42
+
43
+ def __init__(self, *args, **kwargs):
44
+ self.args = args
45
+ self.kwargs = kwargs
46
+
47
+
48
+ def command(name, help="", arguments=[], epilog=None, formatter_class=argparse.HelpFormatter):
49
+ def wrapper(fn):
50
+ @functools.wraps(fn)
51
+ def inner(*args, **kwargs):
52
+ return fn(*args, **kwargs)
53
+ COMMANDS[name] = Command(name, help, arguments, epilog, formatter_class, inner)
54
+ return inner
55
+ return wrapper
56
+
57
+
58
+ class CommandParser(object):
59
+
60
+ def __init__(self, *args, **kwargs):
61
+ self.parser = argparse.ArgumentParser(*args, **kwargs)
62
+ subparsers = self.parser.add_subparsers(help="command")
63
+ for name, command in COMMANDS.items():
64
+ subparser = subparsers.add_parser(command.name,
65
+ help=command.help,
66
+ epilog=command.epilog,
67
+ formatter_class=command.formatter_class)
68
+ for argument in command.arguments:
69
+ subparser.add_argument(*(argument.args), **(argument.kwargs))
70
+ subparser.set_defaults(fn=command.callback)
71
+
72
+ def add_argument(self, *args, **kwargs):
73
+ self.parser.add_argument(*args, **kwargs)
74
+
75
+ def run(self):
76
+ options = self.parser.parse_args()
77
+ if 'fn' not in options:
78
+ logging.error("No command specified.")
79
+ exit(1)
80
+ options.fn(options)
@@ -0,0 +1,9 @@
1
+ {%- for release in releases -%}
2
+ # {{ release.version }}{% if not release.is_released %} (Unreleased){% endif %}
3
+ {% for section in release.sections %}
4
+ **{{ section.title }}**
5
+
6
+ {% for change in section.changes | reverse -%}
7
+ - {{ change.description }}{% if change.scope %}{{ change.scope }}{% endif %}
8
+ {% endfor %}{% endfor %}
9
+ {% endfor %}
@@ -0,0 +1,9 @@
1
+ {%- for release in releases -%}
2
+ {% for section in release.sections -%}
3
+ **{{ section.title }}**
4
+
5
+ {% for change in section.changes | reverse -%}
6
+ - {{ change.description }}{% if change.scope %}{{ change.scope }}{% endif %}
7
+ {% endfor %}
8
+ {% endfor %}
9
+ {% endfor %}
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: changes-semver
3
+ Version: 6.0.3
4
+ Author-email: Jason Morley <hello@jbmorley.co.uk>
5
+ License-Expression: MIT
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: jinja2
9
+ Requires-Dist: pyyaml
10
+ Dynamic: license-file
11
+
12
+ # Changes
13
+
14
+ [![Build](https://github.com/jbmorley/changes/actions/workflows/test.yaml/badge.svg)](https://github.com/jbmorley/changes/actions/workflows/test.yaml)
15
+
16
+ Lightweight and (hopefully) unopinionated tool for working with [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org).
17
+
18
+ ## Overview
19
+
20
+ Many of the SemVer tools out there force very specific workflows that I found hard to adopt in my own projects. Changes attempts to provide a collection of tools that fit into your own project lifecycle.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ git clone git@github.com:jbmorley/changes.git
26
+ cd changes
27
+ pipenv install
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ changes --help
34
+ ```
35
+
36
+ You can also find out details of specific sub-commands by passing the `--help` flag directly to those commands. For example,
37
+
38
+ ```bash
39
+ changes release --help
40
+ ```
41
+
42
+ ## Development
43
+
44
+ ### Tests
45
+
46
+ Run tests locally using the `test.sh` script:
47
+
48
+ ```bash
49
+ ./scripts/test.sh
50
+ ```
51
+
52
+ You can run a specific test by specifying the test class on the command line:
53
+
54
+ ```bash
55
+ ./scripts/test.sh test_cli.CLITestCase.test_version_multiple_changes_yield_single_increment
56
+ ```
@@ -0,0 +1,12 @@
1
+ changes_semver/__init__.py,sha256=PCtu9bdE-QQbgHAjrDBNrXsbDuWccifDWSI-2OWn0nY,1137
2
+ changes_semver/__main__.py,sha256=qPOR7HdO3LDFhFEZpoKEcqNoBe2gFcUnbB_x50XnAcA,313
3
+ changes_semver/changes.py,sha256=Xw7LFxAJkh3gs5asxlozQ1r59MP4ZsOnKMFH8wTnok8,34837
4
+ changes_semver/cli.py,sha256=7EgHNptnxrEUCRaKUE2l5qP9OCu5FlU_Xe5F9V6DlVw,2955
5
+ changes_semver/templates/multiple.markdown,sha256=KRquCvTQWvZOkd5P0Ux0Goap0SKr4TTIpTog27lv9MU,337
6
+ changes_semver/templates/single.markdown,sha256=PHKtS2d6Nf7CFxDDUBAubxUW4fCjxAOgVZwO82IGJGQ,259
7
+ changes_semver-6.0.3.dist-info/licenses/LICENSE,sha256=OUyPkXK4K4FKav-ETsazpTmK9KQRIVpnjJLNA5TAmNs,1074
8
+ changes_semver-6.0.3.dist-info/METADATA,sha256=3kyvC7l6w6lfChR5n4BeurBzB9br9Jkgrieub7iUwLE,1405
9
+ changes_semver-6.0.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ changes_semver-6.0.3.dist-info/entry_points.txt,sha256=BuDy81jXPGPJTB2_frMccv412SdEN_sl_kVj-AWnb_c,56
11
+ changes_semver-6.0.3.dist-info/top_level.txt,sha256=3dsiU77t4GXvz1kLo3xqzX3OdOpN3S2ulL6PLOoare4,15
12
+ changes_semver-6.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ changes = changes_semver.changes:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-2024 Jason Morley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ changes_semver