blob: b10cd7dd5c54e2bcd0c5e3c10bb4d5c9b6f6566f [file] [log] [blame]
Gilles Peskine40b3f412019-10-13 21:44:25 +02001#!/usr/bin/env python3
2
Gilles Peskine42f384c2020-03-27 09:23:38 +01003"""Assemble Mbed TLS change log entries into the change log file.
Gilles Peskinea2607962020-01-28 19:58:17 +01004
5Add changelog entries to the first level-2 section.
6Create a new level-2 section for unreleased changes if needed.
7Remove the input files unless --keep-entries is specified.
Gilles Peskine28af9582020-03-26 22:39:18 +01008
9In each level-3 section, entries are sorted in chronological order
10(oldest first). From oldest to newest:
11* Merged entry files are sorted according to their merge date (date of
12 the merge commit that brought the commit that created the file into
13 the target branch).
14* Committed but unmerged entry files are sorted according to the date
15 of the commit that adds them.
16* Uncommitted entry files are sorted according to their modification time.
17
18You must run this program from within a git working directory.
Gilles Peskine40b3f412019-10-13 21:44:25 +020019"""
20
Bence Szépkúti1e148272020-08-07 13:07:28 +020021# Copyright The Mbed TLS Contributors
Dave Rodgman7ff79652023-11-03 12:04:52 +000022# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
Gilles Peskine40b3f412019-10-13 21:44:25 +020023
24import argparse
Gilles Peskine6e97c432020-03-27 19:05:18 +010025from collections import OrderedDict, namedtuple
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010026import datetime
27import functools
Gilles Peskine40b3f412019-10-13 21:44:25 +020028import glob
29import os
30import re
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010031import subprocess
Gilles Peskine40b3f412019-10-13 21:44:25 +020032import sys
33
34class InputFormatError(Exception):
35 def __init__(self, filename, line_number, message, *args, **kwargs):
Gilles Peskine566407d2020-01-22 15:55:36 +010036 message = '{}:{}: {}'.format(filename, line_number,
37 message.format(*args, **kwargs))
38 super().__init__(message)
Gilles Peskine40b3f412019-10-13 21:44:25 +020039
Gilles Peskine4d977a42020-03-27 19:42:50 +010040class CategoryParseError(Exception):
41 def __init__(self, line_offset, error_message):
42 self.line_offset = line_offset
43 self.error_message = error_message
44 super().__init__('{}: {}'.format(line_offset, error_message))
45
Gilles Peskine2b242492020-01-22 15:41:50 +010046class LostContent(Exception):
47 def __init__(self, filename, line):
48 message = ('Lost content from {}: "{}"'.format(filename, line))
49 super().__init__(message)
50
Dave Rodgman68cb9352023-10-02 16:40:57 +010051class FilePathError(Exception):
52 def __init__(self, filenames):
53 message = ('Changelog filenames do not end with .txt: {}'.format(", ".join(filenames)))
54 super().__init__(message)
55
Gilles Peskineb695d5e2020-03-27 20:06:12 +010056# The category names we use in the changelog.
57# If you edit this, update ChangeLog.d/README.md.
Gilles Peskine6e97c432020-03-27 19:05:18 +010058STANDARD_CATEGORIES = (
Gilles Peskine791c40c2021-05-18 14:39:40 +020059 'API changes',
60 'Default behavior changes',
61 'Requirement changes',
62 'New deprecations',
63 'Removals',
64 'Features',
65 'Security',
66 'Bugfix',
67 'Changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020068)
69
Paul Elliottf08648d2021-03-05 12:22:51 +000070# The maximum line length for an entry
71MAX_LINE_LENGTH = 80
72
Gilles Peskine6e97c432020-03-27 19:05:18 +010073CategoryContent = namedtuple('CategoryContent', [
74 'name', 'title_line', # Title text and line number of the title
75 'body', 'body_line', # Body text and starting line number of the body
76])
77
78class ChangelogFormat:
79 """Virtual class documenting how to write a changelog format class."""
80
81 @classmethod
82 def extract_top_version(cls, changelog_file_content):
83 """Split out the top version section.
84
Gilles Peskineeebf24f2020-03-27 19:25:38 +010085 If the top version is already released, create a new top
86 version section for an unreleased version.
Gilles Peskinedba4de02020-03-30 11:37:26 +020087
88 Return ``(header, top_version_title, top_version_body, trailer)``
89 where the "top version" is the existing top version section if it's
90 for unreleased changes, and a newly created section otherwise.
91 To assemble the changelog after modifying top_version_body,
92 concatenate the four pieces.
Gilles Peskine6e97c432020-03-27 19:05:18 +010093 """
94 raise NotImplementedError
95
96 @classmethod
97 def version_title_text(cls, version_title):
98 """Return the text of a formatted version section title."""
99 raise NotImplementedError
100
101 @classmethod
102 def split_categories(cls, version_body):
103 """Split a changelog version section body into categories.
104
105 Return a list of `CategoryContent` the name is category title
106 without any formatting.
107 """
108 raise NotImplementedError
109
110 @classmethod
111 def format_category(cls, title, body):
112 """Construct the text of a category section from its title and body."""
113 raise NotImplementedError
114
115class TextChangelogFormat(ChangelogFormat):
116 """The traditional Mbed TLS changelog format."""
117
Dave Rodgman569d6022022-07-11 11:39:21 +0100118 _unreleased_version_text = '= Mbed TLS x.x.x branch released xxxx-xx-xx'
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100119 @classmethod
120 def is_released_version(cls, title):
121 # Look for an incomplete release date
Gilles Peskine791c40c2021-05-18 14:39:40 +0200122 return not re.search(r'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100123
Gilles Peskine791c40c2021-05-18 14:39:40 +0200124 _top_version_re = re.compile(r'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100125 re.DOTALL)
126 @classmethod
127 def extract_top_version(cls, changelog_file_content):
128 """A version section starts with a line starting with '='."""
129 m = re.search(cls._top_version_re, changelog_file_content)
130 top_version_start = m.start(1)
131 top_version_end = m.end(2)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100132 top_version_title = m.group(1)
133 top_version_body = m.group(2)
134 if cls.is_released_version(top_version_title):
135 top_version_end = top_version_start
Gilles Peskine791c40c2021-05-18 14:39:40 +0200136 top_version_title = cls._unreleased_version_text + '\n\n'
137 top_version_body = ''
Gilles Peskine6e97c432020-03-27 19:05:18 +0100138 return (changelog_file_content[:top_version_start],
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100139 top_version_title, top_version_body,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100140 changelog_file_content[top_version_end:])
141
142 @classmethod
143 def version_title_text(cls, version_title):
Gilles Peskine791c40c2021-05-18 14:39:40 +0200144 return re.sub(r'\n.*', version_title, re.DOTALL)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100145
Gilles Peskine791c40c2021-05-18 14:39:40 +0200146 _category_title_re = re.compile(r'(^\w.*)\n+', re.MULTILINE)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100147 @classmethod
148 def split_categories(cls, version_body):
149 """A category title is a line with the title in column 0."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100150 if not version_body:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100151 return []
Gilles Peskine4d977a42020-03-27 19:42:50 +0100152 title_matches = list(re.finditer(cls._category_title_re, version_body))
153 if not title_matches or title_matches[0].start() != 0:
154 # There is junk before the first category.
155 raise CategoryParseError(0, 'Junk found where category expected')
Gilles Peskine6e97c432020-03-27 19:05:18 +0100156 title_starts = [m.start(1) for m in title_matches]
157 body_starts = [m.end(0) for m in title_matches]
158 body_ends = title_starts[1:] + [len(version_body)]
Gilles Peskine791c40c2021-05-18 14:39:40 +0200159 bodies = [version_body[body_start:body_end].rstrip('\n') + '\n'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100160 for (body_start, body_end) in zip(body_starts, body_ends)]
Gilles Peskine791c40c2021-05-18 14:39:40 +0200161 title_lines = [version_body[:pos].count('\n') for pos in title_starts]
162 body_lines = [version_body[:pos].count('\n') for pos in body_starts]
Gilles Peskine6e97c432020-03-27 19:05:18 +0100163 return [CategoryContent(title_match.group(1), title_line,
164 body, body_line)
165 for title_match, title_line, body, body_line
166 in zip(title_matches, title_lines, bodies, body_lines)]
167
168 @classmethod
169 def format_category(cls, title, body):
170 # `split_categories` ensures that each body ends with a newline.
171 # Make sure that there is additionally a blank line between categories.
Gilles Peskine791c40c2021-05-18 14:39:40 +0200172 if not body.endswith('\n\n'):
173 body += '\n'
174 return title + '\n' + body
Gilles Peskine6e97c432020-03-27 19:05:18 +0100175
Gilles Peskine40b3f412019-10-13 21:44:25 +0200176class ChangeLog:
Gilles Peskine42f384c2020-03-27 09:23:38 +0100177 """An Mbed TLS changelog.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200178
Gilles Peskine6e97c432020-03-27 19:05:18 +0100179 A changelog file consists of some header text followed by one or
180 more version sections. The version sections are in reverse
181 chronological order. Each version section consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200182
Gilles Peskine6e97c432020-03-27 19:05:18 +0100183 The body of a version section consists of zero or more category
184 subsections. Each category subsection consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200185
Gilles Peskine6e97c432020-03-27 19:05:18 +0100186 A changelog entry file has the same format as the body of a version section.
187
188 A `ChangelogFormat` object defines the concrete syntax of the changelog.
189 Entry files must have the same format as the changelog file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200190 """
191
Gilles Peskinea2607962020-01-28 19:58:17 +0100192 # Only accept dotted version numbers (e.g. "3.1", not "3").
Gilles Peskineafc9db82020-01-30 11:38:01 +0100193 # Refuse ".x" in a version number where x is a letter: this indicates
194 # a version that is not yet released. Something like "3.1a" is accepted.
Gilles Peskine791c40c2021-05-18 14:39:40 +0200195 _version_number_re = re.compile(r'[0-9]+\.[0-9A-Za-z.]+')
196 _incomplete_version_number_re = re.compile(r'.*\.[A-Za-z]')
197 _only_url_re = re.compile(r'^\s*\w+://\S+\s*$')
198 _has_url_re = re.compile(r'.*://.*')
Gilles Peskinea2607962020-01-28 19:58:17 +0100199
Gilles Peskine6e97c432020-03-27 19:05:18 +0100200 def add_categories_from_text(self, filename, line_offset,
201 text, allow_unknown_category):
202 """Parse a version section or entry file."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100203 try:
204 categories = self.format.split_categories(text)
205 except CategoryParseError as e:
206 raise InputFormatError(filename, line_offset + e.line_offset,
207 e.error_message)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100208 for category in categories:
209 if not allow_unknown_category and \
210 category.name not in self.categories:
211 raise InputFormatError(filename,
212 line_offset + category.title_line,
213 'Unknown category: "{}"',
Gilles Peskine791c40c2021-05-18 14:39:40 +0200214 category.name)
Paul Elliottf08648d2021-03-05 12:22:51 +0000215
216 body_split = category.body.splitlines()
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200217
Paul Elliottd75773e2021-03-18 18:07:46 +0000218 for line_number, line in enumerate(body_split, 1):
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200219 if not self._only_url_re.match(line) and \
Mateusz Starzyk6e470552021-03-24 12:13:33 +0100220 len(line) > MAX_LINE_LENGTH:
Mateusz Starzyk9b31ad62021-03-31 11:18:28 +0200221 long_url_msg = '. URL exceeding length limit must be alone in its line.' \
222 if self._has_url_re.match(line) else ""
Paul Elliottf08648d2021-03-05 12:22:51 +0000223 raise InputFormatError(filename,
Paul Elliottd75773e2021-03-18 18:07:46 +0000224 category.body_line + line_number,
Mateusz Starzykc8f44892021-03-25 14:06:50 +0100225 'Line is longer than allowed: '
226 'Length {} (Max {}){}',
227 len(line), MAX_LINE_LENGTH,
228 long_url_msg)
Paul Elliottf08648d2021-03-05 12:22:51 +0000229
Gilles Peskine6e97c432020-03-27 19:05:18 +0100230 self.categories[category.name] += category.body
231
232 def __init__(self, input_stream, changelog_format):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200233 """Create a changelog object.
234
Gilles Peskine974232f2020-01-22 12:43:29 +0100235 Populate the changelog object from the content of the file
Gilles Peskine6e97c432020-03-27 19:05:18 +0100236 input_stream.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200237 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100238 self.format = changelog_format
239 whole_file = input_stream.read()
240 (self.header,
241 self.top_version_title, top_version_body,
242 self.trailer) = self.format.extract_top_version(whole_file)
243 # Split the top version section into categories.
244 self.categories = OrderedDict()
245 for category in STANDARD_CATEGORIES:
Gilles Peskine791c40c2021-05-18 14:39:40 +0200246 self.categories[category] = ''
247 offset = (self.header + self.top_version_title).count('\n') + 1
Gilles Peskine6e97c432020-03-27 19:05:18 +0100248 self.add_categories_from_text(input_stream.name, offset,
249 top_version_body, True)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200250
251 def add_file(self, input_stream):
252 """Add changelog entries from a file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200253 """
Gilles Peskinee248e832020-03-27 19:42:38 +0100254 self.add_categories_from_text(input_stream.name, 1,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100255 input_stream.read(), False)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200256
257 def write(self, filename):
258 """Write the changelog to the specified file.
259 """
Gilles Peskine9c6187d2021-05-18 14:49:02 +0200260 with open(filename, 'w', encoding='utf-8') as out:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100261 out.write(self.header)
262 out.write(self.top_version_title)
263 for title, body in self.categories.items():
264 if not body:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200265 continue
Gilles Peskine6e97c432020-03-27 19:05:18 +0100266 out.write(self.format.format_category(title, body))
267 out.write(self.trailer)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200268
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100269
270@functools.total_ordering
Gilles Peskine28af9582020-03-26 22:39:18 +0100271class EntryFileSortKey:
272 """This classes defines an ordering on changelog entry files: older < newer.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100273
Gilles Peskine28af9582020-03-26 22:39:18 +0100274 * Merged entry files are sorted according to their merge date (date of
275 the merge commit that brought the commit that created the file into
276 the target branch).
277 * Committed but unmerged entry files are sorted according to the date
278 of the commit that adds them.
279 * Uncommitted entry files are sorted according to their modification time.
280
281 This class assumes that the file is in a git working directory with
282 the target branch checked out.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100283 """
284
285 # Categories of files. A lower number is considered older.
286 MERGED = 0
287 COMMITTED = 1
288 LOCAL = 2
289
290 @staticmethod
291 def creation_hash(filename):
292 """Return the git commit id at which the given file was created.
293
294 Return None if the file was never checked into git.
295 """
Gilles Peskine98a53aa2020-03-26 22:47:07 +0100296 hashes = subprocess.check_output(['git', 'log', '--format=%H',
297 '--follow',
298 '--', filename])
Gilles Peskine791c40c2021-05-18 14:39:40 +0200299 m = re.search('(.+)$', hashes.decode('ascii'))
Gilles Peskine13dc6342020-03-26 22:46:47 +0100300 if not m:
301 # The git output is empty. This means that the file was
302 # never checked in.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100303 return None
Gilles Peskine13dc6342020-03-26 22:46:47 +0100304 # The last commit in the log is the oldest one, which is when the
305 # file was created.
306 return m.group(0)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100307
308 @staticmethod
309 def list_merges(some_hash, target, *options):
310 """List merge commits from some_hash to target.
311
312 Pass options to git to select which commits are included.
313 """
314 text = subprocess.check_output(['git', 'rev-list',
315 '--merges', *options,
Gilles Peskine791c40c2021-05-18 14:39:40 +0200316 '..'.join([some_hash, target])])
317 return text.decode('ascii').rstrip('\n').split('\n')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100318
319 @classmethod
320 def merge_hash(cls, some_hash):
321 """Return the git commit id at which the given commit was merged.
322
323 Return None if the given commit was never merged.
324 """
Gilles Peskine791c40c2021-05-18 14:39:40 +0200325 target = 'HEAD'
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100326 # List the merges from some_hash to the target in two ways.
327 # The ancestry list is the ones that are both descendants of
328 # some_hash and ancestors of the target.
329 ancestry = frozenset(cls.list_merges(some_hash, target,
330 '--ancestry-path'))
331 # The first_parents list only contains merges that are directly
332 # on the target branch. We want it in reverse order (oldest first).
333 first_parents = cls.list_merges(some_hash, target,
334 '--first-parent', '--reverse')
335 # Look for the oldest merge commit that's both on the direct path
336 # and directly on the target branch. That's the place where some_hash
337 # was merged on the target branch. See
338 # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
339 for commit in first_parents:
340 if commit in ancestry:
341 return commit
342 return None
343
344 @staticmethod
345 def commit_timestamp(commit_id):
Gilles Peskineac0f0862020-03-27 10:56:45 +0100346 """Return the timestamp of the given commit."""
347 text = subprocess.check_output(['git', 'show', '-s',
348 '--format=%ct',
349 commit_id])
350 return datetime.datetime.utcfromtimestamp(int(text))
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100351
352 @staticmethod
353 def file_timestamp(filename):
354 """Return the modification timestamp of the given file."""
355 mtime = os.stat(filename).st_mtime
356 return datetime.datetime.fromtimestamp(mtime)
357
358 def __init__(self, filename):
Gilles Peskine28af9582020-03-26 22:39:18 +0100359 """Determine position of the file in the changelog entry order.
360
361 This constructor returns an object that can be used with comparison
362 operators, with `sort` and `sorted`, etc. Older entries are sorted
363 before newer entries.
364 """
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100365 self.filename = filename
366 creation_hash = self.creation_hash(filename)
367 if not creation_hash:
368 self.category = self.LOCAL
369 self.datetime = self.file_timestamp(filename)
370 return
371 merge_hash = self.merge_hash(creation_hash)
372 if not merge_hash:
373 self.category = self.COMMITTED
374 self.datetime = self.commit_timestamp(creation_hash)
375 return
376 self.category = self.MERGED
377 self.datetime = self.commit_timestamp(merge_hash)
378
379 def sort_key(self):
Gilles Peskine28af9582020-03-26 22:39:18 +0100380 """"Return a concrete sort key for this entry file sort key object.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100381
Gilles Peskine28af9582020-03-26 22:39:18 +0100382 ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100383 """
384 return (self.category, self.datetime, self.filename)
385
386 def __eq__(self, other):
387 return self.sort_key() == other.sort_key()
388
389 def __lt__(self, other):
390 return self.sort_key() < other.sort_key()
391
392
Gilles Peskine2b242492020-01-22 15:41:50 +0100393def check_output(generated_output_file, main_input_file, merged_files):
394 """Make sanity checks on the generated output.
395
396 The intent of these sanity checks is to have reasonable confidence
397 that no content has been lost.
398
399 The sanity check is that every line that is present in an input file
400 is also present in an output file. This is not perfect but good enough
401 for now.
402 """
Gilles Peskineaeb8d662022-03-04 20:02:00 +0100403 with open(generated_output_file, 'r', encoding='utf-8') as out_fd:
404 generated_output = set(out_fd)
405 with open(main_input_file, 'r', encoding='utf-8') as in_fd:
406 for line in in_fd:
407 if line not in generated_output:
408 raise LostContent('original file', line)
409 for merged_file in merged_files:
410 with open(merged_file, 'r', encoding='utf-8') as in_fd:
411 for line in in_fd:
412 if line not in generated_output:
413 raise LostContent(merged_file, line)
Gilles Peskine2b242492020-01-22 15:41:50 +0100414
415def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200416 """Write the changelog to the output file.
417
Gilles Peskine2b242492020-01-22 15:41:50 +0100418 The input file and the list of merged files are used only for sanity
419 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200420 """
421 if os.path.exists(output_file) and not os.path.isfile(output_file):
422 # The output is a non-regular file (e.g. pipe). Write to it directly.
423 output_temp = output_file
424 else:
425 # The output is a regular file. Write to a temporary file,
426 # then move it into place atomically.
427 output_temp = output_file + '.tmp'
428 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100429 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200430 if output_temp != output_file:
431 os.rename(output_temp, output_file)
432
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100433def remove_merged_entries(files_to_remove):
434 for filename in files_to_remove:
435 os.remove(filename)
436
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100437def list_files_to_merge(options):
438 """List the entry files to merge, oldest first.
439
Gilles Peskine28af9582020-03-26 22:39:18 +0100440 "Oldest" is defined by `EntryFileSortKey`.
Dave Rodgman3c6b7c82023-10-02 17:19:51 +0100441
442 Also check for required .txt extension
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100443 """
Dave Rodgman3c6b7c82023-10-02 17:19:51 +0100444 files_to_merge = glob.glob(os.path.join(options.dir, '*'))
445
446 # Ignore 00README.md
447 readme = os.path.join(options.dir, "00README.md")
448 if readme in files_to_merge:
449 files_to_merge.remove(readme)
450
451 # Identify files without the required .txt extension
452 bad_files = [x for x in files_to_merge if not x.endswith(".txt")]
453 if bad_files:
454 raise FilePathError(bad_files)
455
Gilles Peskine7fa3eb72020-03-26 22:41:32 +0100456 files_to_merge.sort(key=EntryFileSortKey)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100457 return files_to_merge
458
Gilles Peskine40b3f412019-10-13 21:44:25 +0200459def merge_entries(options):
460 """Merge changelog entries into the changelog file.
461
462 Read the changelog file from options.input.
Dave Rodgman68cb9352023-10-02 16:40:57 +0100463 Check that all entries have a .txt extension
Gilles Peskine40b3f412019-10-13 21:44:25 +0200464 Read entries to merge from the directory options.dir.
465 Write the new changelog to options.output.
466 Remove the merged entries if options.keep_entries is false.
467 """
Gilles Peskine9c6187d2021-05-18 14:49:02 +0200468 with open(options.input, 'r', encoding='utf-8') as input_file:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100469 changelog = ChangeLog(input_file, TextChangelogFormat)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100470 files_to_merge = list_files_to_merge(options)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200471 if not files_to_merge:
472 sys.stderr.write('There are no pending changelog entries.\n')
473 return
474 for filename in files_to_merge:
Gilles Peskine9c6187d2021-05-18 14:49:02 +0200475 with open(filename, 'r', encoding='utf-8') as input_file:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200476 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100477 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100478 if not options.keep_entries:
479 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200480
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100481def show_file_timestamps(options):
482 """List the files to merge and their timestamp.
483
484 This is only intended for debugging purposes.
485 """
486 files = list_files_to_merge(options)
487 for filename in files:
Gilles Peskine28af9582020-03-26 22:39:18 +0100488 ts = EntryFileSortKey(filename)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100489 print(ts.category, ts.datetime, filename)
490
Gilles Peskine40b3f412019-10-13 21:44:25 +0200491def set_defaults(options):
492 """Add default values for missing options."""
493 output_file = getattr(options, 'output', None)
494 if output_file is None:
495 options.output = options.input
496 if getattr(options, 'keep_entries', None) is None:
497 options.keep_entries = (output_file is not None)
498
499def main():
500 """Command line entry point."""
501 parser = argparse.ArgumentParser(description=__doc__)
502 parser.add_argument('--dir', '-d', metavar='DIR',
503 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100504 help='Directory to read entries from'
505 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200506 parser.add_argument('--input', '-i', metavar='FILE',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100507 default='ChangeLog',
Gilles Peskine6e910092020-01-22 15:58:18 +0100508 help='Existing changelog file to read from and augment'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100509 ' (default: ChangeLog)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200510 parser.add_argument('--keep-entries',
511 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100512 help='Keep the files containing entries'
513 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200514 parser.add_argument('--no-keep-entries',
515 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100516 help='Remove the files containing entries after they are merged'
517 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200518 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100519 help='Output changelog file'
520 ' (default: overwrite the input)')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100521 parser.add_argument('--list-files-only',
522 action='store_true',
Gilles Peskinec68c7c82020-03-27 19:01:35 +0100523 help=('Only list the files that would be processed '
Gilles Peskineac0f0862020-03-27 10:56:45 +0100524 '(with some debugging information)'))
Gilles Peskine40b3f412019-10-13 21:44:25 +0200525 options = parser.parse_args()
526 set_defaults(options)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100527 if options.list_files_only:
528 show_file_timestamps(options)
529 return
Gilles Peskine40b3f412019-10-13 21:44:25 +0200530 merge_entries(options)
531
532if __name__ == '__main__':
533 main()