-
Notifications
You must be signed in to change notification settings - Fork 6
/
document.py
511 lines (437 loc) · 21.5 KB
/
document.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
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
import datetime
import re
import semver
import os
import git
from .element import KACLElement
from .version import KACLVersion
from .parser import KACLParser
from .config import KACLConfig
from .link_provider import LinkProvider
from .validation import KACLValidation
from .exception import KACLException
WINDOWS_LINE_ENDING = r'\r\n'
UNIX_LINE_ENDING = r'\n'
class KACLDocument:
def __init__(self, data="", headers=[], versions=[], link_references=None, config=KACLConfig()):
self.__data = data
self.__headers = headers
self.__versions = versions
self.__link_references = link_references
if not self.__link_references:
self.__link_references = dict()
self.config = config
def validate(self):
"""Validates the current changelog and returns KACLValidation object containing all information
Returns:
[KACLValidation] -- object holding all error information
"""
validation = KACLValidation()
# 1. assert only one header and starts on first line
if len(self.__headers) == 0:
validation.add_error(
line=None,
line_number=None,
error_message="No 'Changelog' header found.")
# we can stop here already
return validation
else:
if self.header().raw() != self.header().raw().lstrip():
validation.add_error(
line=None,
line_number=None,
error_message="Changelog header not placed on first line.")
if len(self.__headers) > 1:
for header in self.__headers[1:]:
validation.add_error(
line=header.raw(),
line_number=header.line_number(),
error_message="Unexpected additional top-level heading found.",
start_character_pos=0,
end_character_pos=len(header.raw())
)
# 1.1 assert header title is in allowed list of header titles
if self.header().title() not in self.config.allowed_header_titles:
header = self.header()
start_pos = header.raw().find(header.title())
end_pos = start_pos+len(header.title())
validation.add_error(
line=header.raw(),
line_number=header.line_number(),
error_message=f"Header title not valid. Options are [{','.join(self.config.allowed_header_titles)}]",
start_character_pos=start_pos,
end_character_pos=end_pos
)
# 1.2 assert default content is in the header section
for default_line in self.config.default_content:
if default_line not in self.header().body().replace('\n', ' '):
header = self.header()
start_pos = header.raw().find(header.title())
end_pos = start_pos+len(header.title())
validation.add_error(
line=header.raw(),
line_number=header.line_number(),
error_message=f"Missing default content '{default_line}'",
start_character_pos=start_pos,
end_character_pos=end_pos
)
# 2. assert 'unreleased' version is available
# if self.get('Unreleased') == None:
# validation.add_error(
# line=None,
# line_number=None,
# error_message="'Unreleased' section is missing from the Changelog"
# )
# 3. assert versions in valid format
versions = self.versions()
for v in versions:
if "Unreleased" != v.version():
raw = v.raw()
regex = KACLParser.semver_regex
regex_error = r'#\s+(.*)\s+'
if v.link():
regex = f'#\\s+\\[{KACLParser.semver_regex}\\]'
regex_error = r'#\s+\[(.*)\]'
if not KACLParser.parse_sem_ver(raw, regex):
start_pos = 0
end_pos = 0
m = re.match(regex_error, raw)
if m:
start_pos = raw.find(m.group(1))
end_pos = start_pos+len(m.group(1))
validation.add_error(
line=raw,
line_number=v.line_number(),
error_message=f"Version is not a valid semantic version.",
start_character_pos=start_pos,
end_character_pos=end_pos
)
# 3.1 assert versions in descending order
for i in range(len(versions)-1):
try:
v0 = versions[i]
v1 = versions[i+1]
if semver.VersionInfo.compare(v0.version(), v1.version()) < 1:
validation.add_error(
line=v1.raw(),
line_number=v1.line_number(),
error_message="Versions are not in descending order.",
start_character_pos=0,
end_character_pos=len(v1.raw())
)
except:
pass
# 3.2 assert versions have a valid date
for v in versions:
if "Unreleased" != v.version():
if not v.date() or len(v.date()) < 1:
validation.add_error(
line=v.raw(),
line_number=v.line_number(),
error_message="Versions need to be decorated with a release date in the following format 'YYYY-MM-DD'",
start_character_pos=0,
end_character_pos=len(v.raw())
)
if v.date() and not re.match(r'\d\d\d\d-[0-1][0-9]-[0-3][0-9]', v.date()):
start_pos = v.raw().find(v.date())
end_pos = start_pos+len(v.date())
validation.add_error(
line=v.raw(),
line_number=v.line_number(),
error_message="Date does not match format 'YYYY-MM-DD'",
start_character_pos=start_pos,
end_character_pos=end_pos
)
# 3.3 check that only allowed sections are in the version
sections = v.sections()
for title, element in sections.items():
if title not in self.config.allowed_version_sections:
start_pos = element.raw().find(title)
end_pos = start_pos+len(title)
validation.add_error(
line=element.raw(),
line_number=element.line_number(),
error_message=f'"{title}" is not a valid section for a version. Options are [{",".join( self.config.allowed_version_sections)}]',
start_character_pos=start_pos,
end_character_pos=end_pos
)
# 3.4 check that only list elements are in the sections
# 3.4.1 bring everything into a single line
body = element.body()
body_clean = re.sub(r'\n\s+', '', body)
lines = body_clean.split('\n\n')
non_list_lines = [x for x in lines if not x.strip(
).startswith('-') and len(x.strip()) > 0]
if len(non_list_lines) > 0:
validation.add_error(
line=body.strip(),
line_number=element.line_number(),
error_message='Section does contain more than only listings.'
)
# 3.5 make sure that every version that has content has it's content in a section
if len(v.sections()) == 0 and len(v.body().strip()) != 0:
validation.add_error(
line=v.raw(),
line_number=v.line_number(),
error_message=f'Version "{v.version()}" has change elements outside of a change section.'
)
# 3.6 Check that a link exists for linked versions
if '[' in v.raw() and ']' in v.raw() and not v.has_link_reference():
validation.add_error(
line=v.raw(),
line_number=v.line_number(),
error_message=f'Version "{v.version()}" is linked, but no link reference found in changelog file.',
start_character_pos=v.raw().find('['),
end_character_pos=v.raw().find(']')
)
# 4 link references
# 4.1 check that there are only linked references
version_strings = [v.version() for v in versions]
for v, link in self.__link_references.items():
if v not in version_strings:
validation.add_error(
line=link.raw(),
line_number=link.line_number(),
error_message=f"Link not referenced anywhere in the document",
start_character_pos=0,
end_character_pos=len(link.raw())
)
return validation
def is_valid(self):
"""Checks if the current changelog is valid
Returns:
[bool] -- true if valid false if not
"""
validation_results = self.validate()
return validation_results.is_valid()
def has_changes(self):
unreleased_version = self.get('Unreleased')
if not unreleased_version:
return False
sections = unreleased_version.sections()
if not sections or len(sections) == 0:
return False
for pair in sections.items():
if pair[1] and len(pair[1].items()) > 0:
return True
return False
def add(self, section, data):
"""adds a new change to a given section in the 'unreleased' version
Arguments:
section {[str]} -- section to add data to
data {[str]} -- change information
"""
unreleased_version = self.get('Unreleased')
if unreleased_version == None:
unreleased_version = KACLVersion(version="Unreleased")
self.__versions.insert(0, unreleased_version)
unreleased_version.add(section.capitalize(), data)
def release(self, version=None, link=None, auto_link=False, increment=None):
"""Creates a new release version by copying the 'unreleased' changes into the
new version
Keyword Arguments:
link {[str]} -- url the version will be linked with (default: {None})
version {[str]} -- semantic versioning string
increment {[str]} -- use either 'patch', 'minor', or 'major' to automatically increment the last version
"""
if increment:
v = self.current_version()
if v:
sv = semver.VersionInfo.parse(v)
if 'post' == increment:
sv = sv.bump_prerelease(token='post')
elif 'patch' == increment:
sv = sv.bump_patch()
elif 'minor' == increment:
sv = sv.bump_minor()
elif 'major' == increment:
sv = sv.bump_major()
version = str(sv)
else:
raise KACLException("No previously released version found. Incrementing not possible")
# check that version is a valid semantic version
future_version = semver.VersionInfo.parse(version) # --> will throw a ValueError if version is not a valid semver
# check if there are changes to release
if self.has_changes() is False:
raise KACLException("The current changelog has no changes. You can only release if changes are available.")
# check if the version already exists
if self.get(version) != None:
raise KACLException(f"The version '{version}' already exists in the changelog. You cannot release the same version twice.")
# check if new version is greater than the last one
# 1. there has to be an 'unreleased' section
# 2. All other versions are in descending order
version_list = self.versions()
if len(version_list) > 1: # versions[0] --> unreleased
last_version = semver.VersionInfo.parse(version_list[1].version())
last_version_base = semver.VersionInfo(major=last_version.major, minor=last_version.minor, patch=last_version.patch)
future_version_base = semver.VersionInfo(major=future_version.major, minor=future_version.minor, patch=future_version.patch)
# check if 'post_release_version_prefix' is set
post_release_version_prefix = self.config.post_release_version_prefix
comp_result = 0
if post_release_version_prefix:
# 2.1 Check if 'future version' is a 'post version'
if future_version.prerelease and post_release_version_prefix in future_version.prerelease:
# 2.2 if 'last version' was a 'post version' continue
if last_version.prerelease and post_release_version_prefix in last_version.prerelease:
comp_result = future_version.compare(last_version)
# 2.3 if 'last version' was NOT a 'post version' ensure 'post version' has same 'base version'
elif future_version_base != last_version_base:
comp_result = -1 # indicate error
else:
comp_result = 1
else:
comp_result = future_version.compare(last_version)
else:
comp_result = future_version.compare(last_version)
if comp_result < 1:
raise KACLException(f"The version '{version}' cannot be released since it is smaller than the preceding version '{last_version}'.")
# get current unreleased changes
unreleased_version = self.get('Unreleased')
# remove current unrelease version from list
self.__versions.pop(0)
self.__versions.insert(0, KACLVersion(version="Unreleased", link=unreleased_version.link()))
# convert unreleased version to version
self.__versions.insert(1, KACLVersion(version=version,
link=KACLElement(
title=version, body=link),
date=datetime.datetime.now().strftime("%Y-%m-%d"),
sections=unreleased_version.sections()))
if auto_link:
link_provider = self.__get_link_provider()
for i in range(2):
fargs = {
"version": self.__versions[i].version(),
"previous_version": None,
"latest_version": version
}
if len(self.__versions) > i+1:
fargs["previous_version"] = self.__versions[i+1].version()
if 'unreleased' in self.__versions[i].version().lower():
self.__versions[i].set_link( link_provider.unreleased_changes(**fargs) )
else:
if fargs["previous_version"]:
self.__versions[i].set_link( link_provider.compare_versions(**fargs) )
else:
self.__versions[i].set_link( link_provider.initial_version(**fargs) )
def get(self, version):
"""Returns the selected version
Arguments:
version {[str]} -- semantic versioning string
Returns:
[KACLVersion] -- version object with all information
"""
res = [x for x in self.__versions if x.version()
and version.capitalize() == x.version()]
if res and len(res):
return res[0]
def current_version(self):
"""returns the current version (last released)
Returns:
[str] -- latest released version, None if none is available
"""
version_list = self.versions()
for v in version_list:
if v.version().lower() != 'unreleased':
return v.version()
def generate_links(self, host_url=None, compare_versions_template=None, unreleased_changes_template=None, initial_version_template=None):
"""automatically generates links for all versions
Returns: None
"""
link_provider = self.__get_link_provider(host_url=host_url,
compare_versions_template=compare_versions_template,
unreleased_changes_template=unreleased_changes_template,
initial_version_template=initial_version_template)
versions = self.versions()
if len(versions) > 1:
for i in range(len(versions)-1):
fargs = {
"version": versions[i].version(),
"previous_version": versions[i+1].version(),
"latest_version": self.current_version()
}
if 'unreleased' in versions[i].version().lower():
versions[i].set_link( link_provider.unreleased_changes(**fargs) )
else:
versions[i].set_link( link_provider.compare_versions(**fargs) )
versions[-1].set_link( link_provider.initial_version(**fargs) )
elif len(versions) == 1:
fargs = {
"version": versions[0].version(),
"latest_version": self.current_version()
}
if 'unreleased' in versions[0].version().lower():
versions[0].set_link( link_provider.initial_version(version="master") )
else:
versions[0].set_link( link_provider.initial_version(**fargs) )
def header(self):
"""Gives access to the top level heading element
Returns:
[KACLElement] -- object holding all information of the top level heading
"""
if self.__headers and len(self.__headers) > 0:
return self.__headers[0]
def title(self):
"""Returns the title of the changelog
Returns:
[str] -- title of the changelog
"""
if self.__headers and len(self.__headers) > 0:
return self.__headers[0].title()
return None
def versions(self):
"""Returns a list of all available versions
Returns:
[list] -- list of KACLVersions
"""
return self.__versions
@staticmethod
def init():
return KACLDocument.parse("""# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
""")
@staticmethod
def parse(data):
"""Parses a given text object and returns the KACLDocument
Arguments:
data {[str]} -- markdown text holding the changelog
Returns:
[KACLDocument] -- object holding all information
"""
data_lf = data.replace(WINDOWS_LINE_ENDING, UNIX_LINE_ENDING)
# First check if there are link references and split the document where they begin
link_reference_begin, link_references = KACLParser.parse_link_references(data_lf)
changelog_body = data_lf
if link_reference_begin:
changelog_body = data_lf[:link_reference_begin]
# read header
headers = KACLParser.parse_header(changelog_body, 1, 2)
# read versions
versions = KACLParser.parse_header(changelog_body, 2, 2)
versions = [KACLVersion(element=x) for x in versions]
# set link references into versions if available
for v in versions:
v.set_link(link_references.get(v.version(), None))
return KACLDocument(data=data, headers=headers, versions=versions, link_references=link_references)
def __get_link_provider(self, host_url=None, compare_versions_template=None, unreleased_changes_template=None, initial_version_template=None):
host_url = host_url if host_url else self.config.link_host_url
compare_versions_template = compare_versions_template if compare_versions_template else self.config.links_compare_versions_template
unreleased_changes_template = unreleased_changes_template if unreleased_changes_template else self.config.links_unreleased_changes_template
initial_version_template = initial_version_template if initial_version_template else self.config.links_initial_version_template
if host_url is None:
if 'CI_PROJECT_URL' in os.environ:
host_url = os.environ['CI_PROJECT_URL']
else:
try:
repo = git.Repo(os.getcwd())
remote = repo.remote()
for url in remote.urls:
host_url = url
break
except:
raise KACLException("ERROR: Could not determine project url. Update your config or run within a valid git repository")
return LinkProvider(host_url=host_url,
compare_versions_template=compare_versions_template,
unreleased_changes_template=unreleased_changes_template,
initial_version_template=initial_version_template)