blob: e8081012a8ea3f5080247f5e6f2494a0e6889329 [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
Gilles Peskine40b3f412019-10-13 21:44:25 +020022# SPDX-License-Identifier: Apache-2.0
23#
24# Licensed under the Apache License, Version 2.0 (the "License"); you may
25# not use this file except in compliance with the License.
26# You may obtain a copy of the License at
27#
28# http://www.apache.org/licenses/LICENSE-2.0
29#
30# Unless required by applicable law or agreed to in writing, software
31# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
32# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
33# See the License for the specific language governing permissions and
34# limitations under the License.
Gilles Peskine40b3f412019-10-13 21:44:25 +020035
36import argparse
Gilles Peskine6e97c432020-03-27 19:05:18 +010037from collections import OrderedDict, namedtuple
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010038import datetime
39import functools
Gilles Peskine40b3f412019-10-13 21:44:25 +020040import glob
41import os
42import re
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010043import subprocess
Gilles Peskine40b3f412019-10-13 21:44:25 +020044import sys
45
46class InputFormatError(Exception):
47 def __init__(self, filename, line_number, message, *args, **kwargs):
Gilles Peskine566407d2020-01-22 15:55:36 +010048 message = '{}:{}: {}'.format(filename, line_number,
49 message.format(*args, **kwargs))
50 super().__init__(message)
Gilles Peskine40b3f412019-10-13 21:44:25 +020051
Gilles Peskine4d977a42020-03-27 19:42:50 +010052class CategoryParseError(Exception):
53 def __init__(self, line_offset, error_message):
54 self.line_offset = line_offset
55 self.error_message = error_message
56 super().__init__('{}: {}'.format(line_offset, error_message))
57
Gilles Peskine2b242492020-01-22 15:41:50 +010058class LostContent(Exception):
59 def __init__(self, filename, line):
60 message = ('Lost content from {}: "{}"'.format(filename, line))
61 super().__init__(message)
62
Dave Rodgman3901e2e2023-10-02 16:40:57 +010063class FilePathError(Exception):
64 def __init__(self, filenames):
65 message = ('Changelog filenames do not end with .txt: {}'.format(", ".join(filenames)))
66 super().__init__(message)
67
Gilles Peskineb695d5e2020-03-27 20:06:12 +010068# The category names we use in the changelog.
69# If you edit this, update ChangeLog.d/README.md.
Gilles Peskine6e97c432020-03-27 19:05:18 +010070STANDARD_CATEGORIES = (
Gilles Peskine7261fff2021-05-18 14:39:40 +020071 'API changes',
72 'Default behavior changes',
73 'Requirement changes',
74 'New deprecations',
75 'Removals',
76 'Features',
77 'Security',
78 'Bugfix',
79 'Changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020080)
81
Paul Elliottf08648d2021-03-05 12:22:51 +000082# The maximum line length for an entry
83MAX_LINE_LENGTH = 80
84
Gilles Peskine6e97c432020-03-27 19:05:18 +010085CategoryContent = namedtuple('CategoryContent', [
86 'name', 'title_line', # Title text and line number of the title
87 'body', 'body_line', # Body text and starting line number of the body
88])
89
90class ChangelogFormat:
91 """Virtual class documenting how to write a changelog format class."""
92
93 @classmethod
94 def extract_top_version(cls, changelog_file_content):
95 """Split out the top version section.
96
Gilles Peskineeebf24f2020-03-27 19:25:38 +010097 If the top version is already released, create a new top
98 version section for an unreleased version.
Gilles Peskinedba4de02020-03-30 11:37:26 +020099
100 Return ``(header, top_version_title, top_version_body, trailer)``
101 where the "top version" is the existing top version section if it's
102 for unreleased changes, and a newly created section otherwise.
103 To assemble the changelog after modifying top_version_body,
104 concatenate the four pieces.
Gilles Peskine6e97c432020-03-27 19:05:18 +0100105 """
106 raise NotImplementedError
107
108 @classmethod
109 def version_title_text(cls, version_title):
110 """Return the text of a formatted version section title."""
111 raise NotImplementedError
112
113 @classmethod
114 def split_categories(cls, version_body):
115 """Split a changelog version section body into categories.
116
117 Return a list of `CategoryContent` the name is category title
118 without any formatting.
119 """
120 raise NotImplementedError
121
122 @classmethod
123 def format_category(cls, title, body):
124 """Construct the text of a category section from its title and body."""
125 raise NotImplementedError
126
127class TextChangelogFormat(ChangelogFormat):
128 """The traditional Mbed TLS changelog format."""
129
Dave Rodgmanca9556b2022-07-11 11:39:21 +0100130 _unreleased_version_text = '= Mbed TLS x.x.x branch released xxxx-xx-xx'
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100131 @classmethod
132 def is_released_version(cls, title):
133 # Look for an incomplete release date
Gilles Peskine7261fff2021-05-18 14:39:40 +0200134 return not re.search(r'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100135
Gilles Peskine7261fff2021-05-18 14:39:40 +0200136 _top_version_re = re.compile(r'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100137 re.DOTALL)
138 @classmethod
139 def extract_top_version(cls, changelog_file_content):
140 """A version section starts with a line starting with '='."""
141 m = re.search(cls._top_version_re, changelog_file_content)
142 top_version_start = m.start(1)
143 top_version_end = m.end(2)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100144 top_version_title = m.group(1)
145 top_version_body = m.group(2)
146 if cls.is_released_version(top_version_title):
147 top_version_end = top_version_start
Gilles Peskine7261fff2021-05-18 14:39:40 +0200148 top_version_title = cls._unreleased_version_text + '\n\n'
149 top_version_body = ''
Gilles Peskine6e97c432020-03-27 19:05:18 +0100150 return (changelog_file_content[:top_version_start],
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100151 top_version_title, top_version_body,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100152 changelog_file_content[top_version_end:])
153
154 @classmethod
155 def version_title_text(cls, version_title):
Gilles Peskine7261fff2021-05-18 14:39:40 +0200156 return re.sub(r'\n.*', version_title, re.DOTALL)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100157
Gilles Peskine7261fff2021-05-18 14:39:40 +0200158 _category_title_re = re.compile(r'(^\w.*)\n+', re.MULTILINE)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100159 @classmethod
160 def split_categories(cls, version_body):
161 """A category title is a line with the title in column 0."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100162 if not version_body:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100163 return []
Gilles Peskine4d977a42020-03-27 19:42:50 +0100164 title_matches = list(re.finditer(cls._category_title_re, version_body))
165 if not title_matches or title_matches[0].start() != 0:
166 # There is junk before the first category.
167 raise CategoryParseError(0, 'Junk found where category expected')
Gilles Peskine6e97c432020-03-27 19:05:18 +0100168 title_starts = [m.start(1) for m in title_matches]
169 body_starts = [m.end(0) for m in title_matches]
170 body_ends = title_starts[1:] + [len(version_body)]
Gilles Peskine7261fff2021-05-18 14:39:40 +0200171 bodies = [version_body[body_start:body_end].rstrip('\n') + '\n'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100172 for (body_start, body_end) in zip(body_starts, body_ends)]
Gilles Peskine7261fff2021-05-18 14:39:40 +0200173 title_lines = [version_body[:pos].count('\n') for pos in title_starts]
174 body_lines = [version_body[:pos].count('\n') for pos in body_starts]
Gilles Peskine6e97c432020-03-27 19:05:18 +0100175 return [CategoryContent(title_match.group(1), title_line,
176 body, body_line)
177 for title_match, title_line, body, body_line
178 in zip(title_matches, title_lines, bodies, body_lines)]
179
180 @classmethod
181 def format_category(cls, title, body):
182 # `split_categories` ensures that each body ends with a newline.
183 # Make sure that there is additionally a blank line between categories.
Gilles Peskine7261fff2021-05-18 14:39:40 +0200184 if not body.endswith('\n\n'):
185 body += '\n'
186 return title + '\n' + body
Gilles Peskine6e97c432020-03-27 19:05:18 +0100187
Gilles Peskine40b3f412019-10-13 21:44:25 +0200188class ChangeLog:
Gilles Peskine42f384c2020-03-27 09:23:38 +0100189 """An Mbed TLS changelog.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200190
Gilles Peskine6e97c432020-03-27 19:05:18 +0100191 A changelog file consists of some header text followed by one or
192 more version sections. The version sections are in reverse
193 chronological order. Each version section consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200194
Gilles Peskine6e97c432020-03-27 19:05:18 +0100195 The body of a version section consists of zero or more category
196 subsections. Each category subsection consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200197
Gilles Peskine6e97c432020-03-27 19:05:18 +0100198 A changelog entry file has the same format as the body of a version section.
199
200 A `ChangelogFormat` object defines the concrete syntax of the changelog.
201 Entry files must have the same format as the changelog file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200202 """
203
Gilles Peskinea2607962020-01-28 19:58:17 +0100204 # Only accept dotted version numbers (e.g. "3.1", not "3").
Gilles Peskineafc9db82020-01-30 11:38:01 +0100205 # Refuse ".x" in a version number where x is a letter: this indicates
206 # a version that is not yet released. Something like "3.1a" is accepted.
Gilles Peskine7261fff2021-05-18 14:39:40 +0200207 _version_number_re = re.compile(r'[0-9]+\.[0-9A-Za-z.]+')
208 _incomplete_version_number_re = re.compile(r'.*\.[A-Za-z]')
209 _only_url_re = re.compile(r'^\s*\w+://\S+\s*$')
210 _has_url_re = re.compile(r'.*://.*')
Gilles Peskinea2607962020-01-28 19:58:17 +0100211
Gilles Peskine6e97c432020-03-27 19:05:18 +0100212 def add_categories_from_text(self, filename, line_offset,
213 text, allow_unknown_category):
214 """Parse a version section or entry file."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100215 try:
216 categories = self.format.split_categories(text)
217 except CategoryParseError as e:
218 raise InputFormatError(filename, line_offset + e.line_offset,
219 e.error_message)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100220 for category in categories:
221 if not allow_unknown_category and \
222 category.name not in self.categories:
223 raise InputFormatError(filename,
224 line_offset + category.title_line,
225 'Unknown category: "{}"',
Gilles Peskine7261fff2021-05-18 14:39:40 +0200226 category.name)
Paul Elliottf08648d2021-03-05 12:22:51 +0000227
228 body_split = category.body.splitlines()
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200229
Paul Elliottd75773e2021-03-18 18:07:46 +0000230 for line_number, line in enumerate(body_split, 1):
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200231 if not self._only_url_re.match(line) and \
Mateusz Starzyk6e470552021-03-24 12:13:33 +0100232 len(line) > MAX_LINE_LENGTH:
Mateusz Starzyk9b31ad62021-03-31 11:18:28 +0200233 long_url_msg = '. URL exceeding length limit must be alone in its line.' \
234 if self._has_url_re.match(line) else ""
Paul Elliottf08648d2021-03-05 12:22:51 +0000235 raise InputFormatError(filename,
Paul Elliottd75773e2021-03-18 18:07:46 +0000236 category.body_line + line_number,
Mateusz Starzykc8f44892021-03-25 14:06:50 +0100237 'Line is longer than allowed: '
238 'Length {} (Max {}){}',
239 len(line), MAX_LINE_LENGTH,
240 long_url_msg)
Paul Elliottf08648d2021-03-05 12:22:51 +0000241
Gilles Peskine6e97c432020-03-27 19:05:18 +0100242 self.categories[category.name] += category.body
243
244 def __init__(self, input_stream, changelog_format):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200245 """Create a changelog object.
246
Gilles Peskine974232f2020-01-22 12:43:29 +0100247 Populate the changelog object from the content of the file
Gilles Peskine6e97c432020-03-27 19:05:18 +0100248 input_stream.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200249 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100250 self.format = changelog_format
251 whole_file = input_stream.read()
252 (self.header,
253 self.top_version_title, top_version_body,
254 self.trailer) = self.format.extract_top_version(whole_file)
255 # Split the top version section into categories.
256 self.categories = OrderedDict()
257 for category in STANDARD_CATEGORIES:
Gilles Peskine7261fff2021-05-18 14:39:40 +0200258 self.categories[category] = ''
259 offset = (self.header + self.top_version_title).count('\n') + 1
Gilles Peskine6e97c432020-03-27 19:05:18 +0100260 self.add_categories_from_text(input_stream.name, offset,
261 top_version_body, True)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200262
263 def add_file(self, input_stream):
264 """Add changelog entries from a file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200265 """
Gilles Peskinee248e832020-03-27 19:42:38 +0100266 self.add_categories_from_text(input_stream.name, 1,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100267 input_stream.read(), False)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200268
269 def write(self, filename):
270 """Write the changelog to the specified file.
271 """
Gilles Peskinee151e212021-05-18 14:49:02 +0200272 with open(filename, 'w', encoding='utf-8') as out:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100273 out.write(self.header)
274 out.write(self.top_version_title)
275 for title, body in self.categories.items():
276 if not body:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200277 continue
Gilles Peskine6e97c432020-03-27 19:05:18 +0100278 out.write(self.format.format_category(title, body))
279 out.write(self.trailer)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200280
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100281
282@functools.total_ordering
Gilles Peskine28af9582020-03-26 22:39:18 +0100283class EntryFileSortKey:
284 """This classes defines an ordering on changelog entry files: older < newer.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100285
Gilles Peskine28af9582020-03-26 22:39:18 +0100286 * Merged entry files are sorted according to their merge date (date of
287 the merge commit that brought the commit that created the file into
288 the target branch).
289 * Committed but unmerged entry files are sorted according to the date
290 of the commit that adds them.
291 * Uncommitted entry files are sorted according to their modification time.
292
293 This class assumes that the file is in a git working directory with
294 the target branch checked out.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100295 """
296
297 # Categories of files. A lower number is considered older.
298 MERGED = 0
299 COMMITTED = 1
300 LOCAL = 2
301
302 @staticmethod
303 def creation_hash(filename):
304 """Return the git commit id at which the given file was created.
305
306 Return None if the file was never checked into git.
307 """
Gilles Peskine98a53aa2020-03-26 22:47:07 +0100308 hashes = subprocess.check_output(['git', 'log', '--format=%H',
309 '--follow',
310 '--', filename])
Gilles Peskine7261fff2021-05-18 14:39:40 +0200311 m = re.search('(.+)$', hashes.decode('ascii'))
Gilles Peskine13dc6342020-03-26 22:46:47 +0100312 if not m:
313 # The git output is empty. This means that the file was
314 # never checked in.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100315 return None
Gilles Peskine13dc6342020-03-26 22:46:47 +0100316 # The last commit in the log is the oldest one, which is when the
317 # file was created.
318 return m.group(0)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100319
320 @staticmethod
321 def list_merges(some_hash, target, *options):
322 """List merge commits from some_hash to target.
323
324 Pass options to git to select which commits are included.
325 """
326 text = subprocess.check_output(['git', 'rev-list',
327 '--merges', *options,
Gilles Peskine7261fff2021-05-18 14:39:40 +0200328 '..'.join([some_hash, target])])
329 return text.decode('ascii').rstrip('\n').split('\n')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100330
331 @classmethod
332 def merge_hash(cls, some_hash):
333 """Return the git commit id at which the given commit was merged.
334
335 Return None if the given commit was never merged.
336 """
Gilles Peskine7261fff2021-05-18 14:39:40 +0200337 target = 'HEAD'
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100338 # List the merges from some_hash to the target in two ways.
339 # The ancestry list is the ones that are both descendants of
340 # some_hash and ancestors of the target.
341 ancestry = frozenset(cls.list_merges(some_hash, target,
342 '--ancestry-path'))
343 # The first_parents list only contains merges that are directly
344 # on the target branch. We want it in reverse order (oldest first).
345 first_parents = cls.list_merges(some_hash, target,
346 '--first-parent', '--reverse')
347 # Look for the oldest merge commit that's both on the direct path
348 # and directly on the target branch. That's the place where some_hash
349 # was merged on the target branch. See
350 # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
351 for commit in first_parents:
352 if commit in ancestry:
353 return commit
354 return None
355
356 @staticmethod
357 def commit_timestamp(commit_id):
Gilles Peskineac0f0862020-03-27 10:56:45 +0100358 """Return the timestamp of the given commit."""
359 text = subprocess.check_output(['git', 'show', '-s',
360 '--format=%ct',
361 commit_id])
362 return datetime.datetime.utcfromtimestamp(int(text))
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100363
364 @staticmethod
365 def file_timestamp(filename):
366 """Return the modification timestamp of the given file."""
367 mtime = os.stat(filename).st_mtime
368 return datetime.datetime.fromtimestamp(mtime)
369
370 def __init__(self, filename):
Gilles Peskine28af9582020-03-26 22:39:18 +0100371 """Determine position of the file in the changelog entry order.
372
373 This constructor returns an object that can be used with comparison
374 operators, with `sort` and `sorted`, etc. Older entries are sorted
375 before newer entries.
376 """
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100377 self.filename = filename
378 creation_hash = self.creation_hash(filename)
379 if not creation_hash:
380 self.category = self.LOCAL
381 self.datetime = self.file_timestamp(filename)
382 return
383 merge_hash = self.merge_hash(creation_hash)
384 if not merge_hash:
385 self.category = self.COMMITTED
386 self.datetime = self.commit_timestamp(creation_hash)
387 return
388 self.category = self.MERGED
389 self.datetime = self.commit_timestamp(merge_hash)
390
391 def sort_key(self):
Gilles Peskine28af9582020-03-26 22:39:18 +0100392 """"Return a concrete sort key for this entry file sort key object.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100393
Gilles Peskine28af9582020-03-26 22:39:18 +0100394 ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100395 """
396 return (self.category, self.datetime, self.filename)
397
398 def __eq__(self, other):
399 return self.sort_key() == other.sort_key()
400
401 def __lt__(self, other):
402 return self.sort_key() < other.sort_key()
403
404
Gilles Peskine2b242492020-01-22 15:41:50 +0100405def check_output(generated_output_file, main_input_file, merged_files):
406 """Make sanity checks on the generated output.
407
408 The intent of these sanity checks is to have reasonable confidence
409 that no content has been lost.
410
411 The sanity check is that every line that is present in an input file
412 is also present in an output file. This is not perfect but good enough
413 for now.
414 """
Gilles Peskinedcf2ff52022-03-04 20:02:00 +0100415 with open(generated_output_file, 'r', encoding='utf-8') as fd:
416 generated_output = set(fd)
417 for line in open(main_input_file, 'r', encoding='utf-8'):
Gilles Peskine2b242492020-01-22 15:41:50 +0100418 if line not in generated_output:
Gilles Peskinedcf2ff52022-03-04 20:02:00 +0100419 raise LostContent('original file', line)
420 for merged_file in merged_files:
421 for line in open(merged_file, 'r', encoding='utf-8'):
422 if line not in generated_output:
423 raise LostContent(merged_file, line)
Gilles Peskine2b242492020-01-22 15:41:50 +0100424
425def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200426 """Write the changelog to the output file.
427
Gilles Peskine2b242492020-01-22 15:41:50 +0100428 The input file and the list of merged files are used only for sanity
429 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200430 """
431 if os.path.exists(output_file) and not os.path.isfile(output_file):
432 # The output is a non-regular file (e.g. pipe). Write to it directly.
433 output_temp = output_file
434 else:
435 # The output is a regular file. Write to a temporary file,
436 # then move it into place atomically.
437 output_temp = output_file + '.tmp'
438 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100439 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200440 if output_temp != output_file:
441 os.rename(output_temp, output_file)
442
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100443def remove_merged_entries(files_to_remove):
444 for filename in files_to_remove:
445 os.remove(filename)
446
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100447def list_files_to_merge(options):
448 """List the entry files to merge, oldest first.
449
Gilles Peskine28af9582020-03-26 22:39:18 +0100450 "Oldest" is defined by `EntryFileSortKey`.
Dave Rodgman65d8ec12023-10-02 17:19:51 +0100451
452 Also check for required .txt extension
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100453 """
Dave Rodgman65d8ec12023-10-02 17:19:51 +0100454 files_to_merge = glob.glob(os.path.join(options.dir, '*'))
455
456 # Ignore 00README.md
457 readme = os.path.join(options.dir, "00README.md")
458 if readme in files_to_merge:
459 files_to_merge.remove(readme)
460
461 # Identify files without the required .txt extension
462 bad_files = [x for x in files_to_merge if not x.endswith(".txt")]
463 if bad_files:
464 raise FilePathError(bad_files)
465
Gilles Peskine7fa3eb72020-03-26 22:41:32 +0100466 files_to_merge.sort(key=EntryFileSortKey)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100467 return files_to_merge
468
Gilles Peskine40b3f412019-10-13 21:44:25 +0200469def merge_entries(options):
470 """Merge changelog entries into the changelog file.
471
472 Read the changelog file from options.input.
Dave Rodgman3901e2e2023-10-02 16:40:57 +0100473 Check that all entries have a .txt extension
Gilles Peskine40b3f412019-10-13 21:44:25 +0200474 Read entries to merge from the directory options.dir.
475 Write the new changelog to options.output.
476 Remove the merged entries if options.keep_entries is false.
477 """
Gilles Peskinee151e212021-05-18 14:49:02 +0200478 with open(options.input, 'r', encoding='utf-8') as input_file:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100479 changelog = ChangeLog(input_file, TextChangelogFormat)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100480 files_to_merge = list_files_to_merge(options)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200481 if not files_to_merge:
482 sys.stderr.write('There are no pending changelog entries.\n')
483 return
484 for filename in files_to_merge:
Gilles Peskinee151e212021-05-18 14:49:02 +0200485 with open(filename, 'r', encoding='utf-8') as input_file:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200486 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100487 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100488 if not options.keep_entries:
489 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200490
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100491def show_file_timestamps(options):
492 """List the files to merge and their timestamp.
493
494 This is only intended for debugging purposes.
495 """
496 files = list_files_to_merge(options)
497 for filename in files:
Gilles Peskine28af9582020-03-26 22:39:18 +0100498 ts = EntryFileSortKey(filename)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100499 print(ts.category, ts.datetime, filename)
500
Gilles Peskine40b3f412019-10-13 21:44:25 +0200501def set_defaults(options):
502 """Add default values for missing options."""
503 output_file = getattr(options, 'output', None)
504 if output_file is None:
505 options.output = options.input
506 if getattr(options, 'keep_entries', None) is None:
507 options.keep_entries = (output_file is not None)
508
509def main():
510 """Command line entry point."""
511 parser = argparse.ArgumentParser(description=__doc__)
512 parser.add_argument('--dir', '-d', metavar='DIR',
513 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100514 help='Directory to read entries from'
515 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200516 parser.add_argument('--input', '-i', metavar='FILE',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100517 default='ChangeLog',
Gilles Peskine6e910092020-01-22 15:58:18 +0100518 help='Existing changelog file to read from and augment'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100519 ' (default: ChangeLog)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200520 parser.add_argument('--keep-entries',
521 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100522 help='Keep the files containing entries'
523 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200524 parser.add_argument('--no-keep-entries',
525 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100526 help='Remove the files containing entries after they are merged'
527 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200528 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100529 help='Output changelog file'
530 ' (default: overwrite the input)')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100531 parser.add_argument('--list-files-only',
532 action='store_true',
Gilles Peskinec68c7c82020-03-27 19:01:35 +0100533 help=('Only list the files that would be processed '
Gilles Peskineac0f0862020-03-27 10:56:45 +0100534 '(with some debugging information)'))
Gilles Peskine40b3f412019-10-13 21:44:25 +0200535 options = parser.parse_args()
536 set_defaults(options)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100537 if options.list_files_only:
538 show_file_timestamps(options)
539 return
Gilles Peskine40b3f412019-10-13 21:44:25 +0200540 merge_entries(options)
541
542if __name__ == '__main__':
543 main()