-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathchangelog.py
288 lines (198 loc) · 9 KB
/
changelog.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""
Author: Michael Rapp ([email protected])
Provides utility functions for maintaining the project's changelog.
"""
from typing import List, Optional
from enum import Enum, auto
from dataclasses import dataclass, field
from update_version import Version, get_current_version
from datetime import date
PREFIX_HEADER = '# '
PREFIX_SUB_HEADER = '## '
PREFIX_SUB_SUB_HEADER = '### '
PREFIX_DASH = '- '
PREFIX_ASTERISK = '* '
CHANGELOG_FILE_MAIN = '.changelog-main.md'
CHANGELOG_FILE_FEATURE = '.changelog-feature.md'
CHANGELOG_FILE_BUGFIX = '.changelog-bugfix.md'
CHANGELOG_FILE = 'CHANGELOG.md'
CHANGELOG_ENCODING = 'utf-8'
class LineType(Enum):
BLANK = auto()
HEADER = auto()
ENUMERATION = auto()
@staticmethod
def parse(line: str) -> Optional['LineType']:
if not line or line.isspace():
return LineType.BLANK
if line.startswith(PREFIX_HEADER):
return LineType.HEADER
if line.startswith(PREFIX_DASH) or line.startswith(PREFIX_ASTERISK):
return LineType.ENUMERATION
return None
@dataclass
class Line:
line_number: int
line_type: LineType
line: str
content: str
@dataclass
class Changeset:
header: str
contents: List[str] = field(default_factory=list)
def __str__(self) -> str:
changeset = PREFIX_SUB_SUB_HEADER + self.header + '\n\n'
for content in self.contents:
changeset += PREFIX_DASH + content + '\n'
return changeset
class ReleaseType(Enum):
MAJOR = 'major'
MINOR = 'feature'
PATCH = 'bugfix'
@dataclass
class Release:
version: Version
release_date: date
release_type: ReleaseType
changesets: List[Changeset] = field(default_factory=list)
@staticmethod
def __format_release_month(month: int) -> str:
return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][month - 1]
@staticmethod
def __format_release_day(day: int) -> str:
if 11 <= (day % 100) <= 13:
suffix = 'th'
else:
suffix = ['th', 'st', 'nd', 'rd', 'th'][min(day % 10, 4)]
return str(day) + suffix
def __format_release_date(self) -> str:
return self.__format_release_month(self.release_date.month) + '. ' + self.__format_release_day(
self.release_date.day) + ', ' + str(self.release_date.year)
def __format_disclaimer(self) -> str:
if [changeset for changeset in self.changesets if changeset.header.lower() == 'api changes']:
return ('```{warning}\nThis release comes with API changes. For an updated overview of the available '
+ 'parameters and command line arguments, please refer to the '
+ '[documentation](https://documentation/en/' + str(self.version) + '/).\n```\n\n')
return ''
def __str__(self) -> str:
release = PREFIX_SUB_HEADER + 'Version ' + str(self.version) + ' (' + self.__format_release_date() + ')\n\n'
release += 'A ' + self.release_type.value + ' release that comes with the following changes.\n\n'
release += self.__format_disclaimer()
for i, changeset in enumerate(self.changesets):
release += str(changeset) + ('\n' if i < len(self.changesets) else '\n\n')
return release
def __parse_line(changelog_file: str, line_number: int, line: str) -> Line:
line = line.strip('\n')
line_type = LineType.parse(line)
if not line_type:
print(
'Line ' + str(line_number) + ' of file "' + changelog_file
+ '" is invalid: Must be blank, a top-level header (starting with "' + PREFIX_HEADER
+ '"), or an enumeration (starting with "' + PREFIX_DASH + '" or "' + PREFIX_ASTERISK
+ '"), but is "' + line + '"')
exit(-1)
content = line
if line_type != LineType.BLANK:
content = line.lstrip(PREFIX_HEADER).lstrip(PREFIX_DASH).lstrip(PREFIX_ASTERISK)
if not content or content.isspace():
print(
'Line ' + str(line_number) + ' of file "' + changelog_file
+ '" is is invalid: Content must not be blank, but is "' + line + '"')
exit(-1)
return Line(line_number=line_number, line_type=line_type, line=line, content=content)
def __validate_line(changelog_file: str, line: Optional[Line], previous_line: Optional[Line]):
if line and line.line_type == LineType.ENUMERATION and not previous_line:
print('File "' + changelog_file + '" must start with a top-level header (starting with "'
+ PREFIX_HEADER + '")')
exit(-1)
if (line and line.line_type == LineType.HEADER and previous_line and previous_line.line_type == LineType.HEADER) \
or (not line and previous_line and previous_line.line_type == LineType.HEADER):
print('Header "' + previous_line.line + '" at line ' + str(previous_line.line_number) + ' of file "' +
changelog_file + '" is not followed by any content')
exit(-1)
def __parse_lines(changelog_file: str, lines: List[str]) -> List[Line]:
previous_line = None
parsed_lines = []
for i, line in enumerate(lines):
current_line = __parse_line(changelog_file=changelog_file, line_number=(i + 1), line=line)
if current_line.line_type != LineType.BLANK:
__validate_line(changelog_file=changelog_file, line=current_line, previous_line=previous_line)
previous_line = current_line
parsed_lines.append(current_line)
__validate_line(changelog_file=changelog_file, line=None, previous_line=previous_line)
return parsed_lines
def __read_lines(changelog_file: str) -> List[str]:
with open(changelog_file, mode='r', encoding=CHANGELOG_ENCODING) as file:
return file.readlines()
def __write_lines(changelog_file: str, lines: List[str]):
with open(changelog_file, mode='w', encoding=CHANGELOG_ENCODING) as file:
file.writelines(lines)
def __parse_changesets(changelog_file: str) -> List[Changeset]:
changesets = []
lines = __parse_lines(changelog_file, __read_lines(changelog_file))
for line in lines:
if line.line_type == LineType.HEADER:
changesets.append(Changeset(header=line.content))
elif line.line_type == LineType.ENUMERATION:
current_changeset = changesets[-1]
current_changeset.contents.append(line.content)
return changesets
def __merge_changesets(*changelog_files) -> List[Changeset]:
changesets_by_header = {}
for changelog_file in changelog_files:
for changeset in __parse_changesets(changelog_file):
merged_changeset = changesets_by_header.setdefault(changeset.header.lower(), changeset)
if merged_changeset != changeset:
merged_changeset.contents.extend(changeset.contents)
return list(changesets_by_header.values())
def __create_release(release_type: ReleaseType, *changelog_files) -> Release:
return Release(version=get_current_version(), release_date=date.today(), release_type=release_type,
changesets=__merge_changesets(*changelog_files))
def __add_release_to_changelog(changelog_file: str, new_release: Release):
original_lines = __read_lines(changelog_file)
modified_lines = []
offset = 0
for offset, line in enumerate(original_lines):
if line.startswith(PREFIX_SUB_HEADER):
break
modified_lines.append(line)
modified_lines.append(str(new_release))
modified_lines.extend(original_lines[offset:])
__write_lines(changelog_file, modified_lines)
def __update_changelog(release_type: ReleaseType, *changelog_files):
new_release = __create_release(release_type, *changelog_files)
__add_release_to_changelog(CHANGELOG_FILE, new_release)
for changelog_file in changelog_files:
__write_lines(changelog_file, [''])
def __get_latest_changelog() -> str:
changelog = ''
lines = __read_lines(CHANGELOG_FILE)
offset = 0
for offset, line in enumerate(lines):
if line.startswith(PREFIX_SUB_HEADER):
break
for line in lines[offset + 2:]:
if line.startswith(PREFIX_SUB_HEADER):
break
if line.startswith('```{'):
changelog += '***'
elif line.startswith('```'):
changelog = changelog.rstrip('\n')
changelog += '***\n'
else:
changelog += line
return changelog.rstrip('\n')
def validate_changelog_main():
__parse_changesets(CHANGELOG_FILE_MAIN)
def validate_changelog_feature():
__parse_changesets(CHANGELOG_FILE_FEATURE)
def validate_changelog_bugfix():
__parse_changesets(CHANGELOG_FILE_BUGFIX)
def update_changelog_main():
__update_changelog(ReleaseType.MAJOR, CHANGELOG_FILE_MAIN, CHANGELOG_FILE_FEATURE, CHANGELOG_FILE_BUGFIX)
def update_changelog_feature():
__update_changelog(ReleaseType.MINOR, CHANGELOG_FILE_FEATURE, CHANGELOG_FILE_BUGFIX)
def update_changelog_bugfix():
__update_changelog(ReleaseType.PATCH, CHANGELOG_FILE_BUGFIX)
def print_latest_changelog():
print(__get_latest_changelog())