121 lines
3.7 KiB
Python
121 lines
3.7 KiB
Python
|
import datetime
|
||
|
import re
|
||
|
|
||
|
CHANGELOG_START_RE = re.compile(r'^\#\# \[Unreleased\]')
|
||
|
CHANGELOG_END_RE = re.compile(r'^\#\# \[.*\] - \d{4}-\d{2}-\d{2}')
|
||
|
# if we come across a section header between two release section headers
|
||
|
# then we probably have an improperly formatted changelog
|
||
|
CHANGELOG_ERROR_RE = re.compile(r'^\#\# ')
|
||
|
SECTION_RE = re.compile(r'^\#\#\# (.*)$')
|
||
|
EMPTY_RE = re.compile(r'^\w*\*\w*$')
|
||
|
ENTRY_RE = re.compile(r'\* (.*)')
|
||
|
VALID_SECTIONS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
|
||
|
|
||
|
# allocate some entries to cut-down on merge conflicts
|
||
|
TEMPLATE = """### Added
|
||
|
*
|
||
|
*
|
||
|
|
||
|
### Changed
|
||
|
*
|
||
|
*
|
||
|
|
||
|
### Fixed
|
||
|
*
|
||
|
*
|
||
|
|
||
|
"""
|
||
|
|
||
|
|
||
|
class Changelog(object):
|
||
|
def __init__(self, path):
|
||
|
self.path = path
|
||
|
self.start = []
|
||
|
self.unreleased = []
|
||
|
self.rest = []
|
||
|
self._parse()
|
||
|
|
||
|
def _parse(self):
|
||
|
with open(self.path) as fp:
|
||
|
lines = fp.readlines()
|
||
|
|
||
|
unreleased_start_found = False
|
||
|
unreleased_end_found = False
|
||
|
|
||
|
for line in lines:
|
||
|
if not unreleased_start_found:
|
||
|
self.start.append(line)
|
||
|
if CHANGELOG_START_RE.search(line):
|
||
|
unreleased_start_found = True
|
||
|
continue
|
||
|
if unreleased_end_found:
|
||
|
self.rest.append(line)
|
||
|
continue
|
||
|
if CHANGELOG_END_RE.search(line):
|
||
|
self.rest.append(line)
|
||
|
unreleased_end_found = True
|
||
|
continue
|
||
|
if CHANGELOG_ERROR_RE.search(line):
|
||
|
raise Exception(
|
||
|
'Failed to parse {}: {}'.format(self.path, 'unexpected section header found'))
|
||
|
self.unreleased.append(line)
|
||
|
|
||
|
self.unreleased = self._normalize_section(self.unreleased)
|
||
|
|
||
|
@staticmethod
|
||
|
def _normalize_section(lines):
|
||
|
"""Parse a changelog entry and output a normalized form"""
|
||
|
sections = {}
|
||
|
current_section_name = None
|
||
|
current_section_contents = []
|
||
|
for line in lines:
|
||
|
line = line.strip()
|
||
|
if not line or EMPTY_RE.match(line):
|
||
|
continue
|
||
|
match = SECTION_RE.match(line)
|
||
|
if match:
|
||
|
if current_section_contents:
|
||
|
sections[current_section_name] = current_section_contents
|
||
|
current_section_contents = []
|
||
|
current_section_name = match.group(1)
|
||
|
if current_section_name not in VALID_SECTIONS:
|
||
|
raise ValueError("Section '{}' is not valid".format(current_section_name))
|
||
|
continue
|
||
|
match = ENTRY_RE.match(line)
|
||
|
if match:
|
||
|
current_section_contents.append(match.group(1))
|
||
|
continue
|
||
|
raise Exception('Something is wrong with line: {}'.format(line))
|
||
|
if current_section_contents:
|
||
|
sections[current_section_name] = current_section_contents
|
||
|
|
||
|
output = []
|
||
|
for section in VALID_SECTIONS:
|
||
|
if section not in sections:
|
||
|
continue
|
||
|
output.append('### {}'.format(section))
|
||
|
for entry in sections[section]:
|
||
|
output.append(' * {}'.format(entry))
|
||
|
return output
|
||
|
|
||
|
def get_unreleased(self):
|
||
|
return '\n'.join(self.unreleased) if self.unreleased else None
|
||
|
|
||
|
def bump(self, version):
|
||
|
if not self.unreleased:
|
||
|
return
|
||
|
|
||
|
today = datetime.datetime.today()
|
||
|
header = '## [{}] - {}\n'.format(version, today.strftime('%Y-%m-%d'))
|
||
|
|
||
|
changelog_data = (
|
||
|
''.join(self.start) +
|
||
|
TEMPLATE +
|
||
|
header +
|
||
|
'\n'.join(self.unreleased) + '\n\n'
|
||
|
+ ''.join(self.rest)
|
||
|
)
|
||
|
|
||
|
with open(self.path, 'w') as fp:
|
||
|
fp.write(changelog_data)
|