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.
- changes_semver/__init__.py +23 -0
- changes_semver/__main__.py +13 -0
- changes_semver/changes.py +886 -0
- changes_semver/cli.py +80 -0
- changes_semver/templates/multiple.markdown +9 -0
- changes_semver/templates/single.markdown +9 -0
- changes_semver-6.0.3.dist-info/METADATA +56 -0
- changes_semver-6.0.3.dist-info/RECORD +12 -0
- changes_semver-6.0.3.dist-info/WHEEL +5 -0
- changes_semver-6.0.3.dist-info/entry_points.txt +2 -0
- changes_semver-6.0.3.dist-info/licenses/LICENSE +21 -0
- changes_semver-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
[](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,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
|