blob: a7477aa8eb6ab24de4bbd1aab46acbbf190a9da6 [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
Gilles Peskineb695d5e2020-03-27 20:06:12 +010063# The category names we use in the changelog.
64# If you edit this, update ChangeLog.d/README.md.
Gilles Peskine6e97c432020-03-27 19:05:18 +010065STANDARD_CATEGORIES = (
66 b'API changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020067 b'Default behavior changes',
68 b'Requirement changes',
69 b'New deprecations',
70 b'Removals',
Gilles Peskine6e97c432020-03-27 19:05:18 +010071 b'Features',
Gilles Peskine40b3f412019-10-13 21:44:25 +020072 b'Security',
Gilles Peskine6e97c432020-03-27 19:05:18 +010073 b'Bugfix',
74 b'Changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020075)
76
Paul Elliottf08648d2021-03-05 12:22:51 +000077# The maximum line length for an entry
78MAX_LINE_LENGTH = 80
79
Gilles Peskine6e97c432020-03-27 19:05:18 +010080CategoryContent = namedtuple('CategoryContent', [
81 'name', 'title_line', # Title text and line number of the title
82 'body', 'body_line', # Body text and starting line number of the body
83])
84
85class ChangelogFormat:
86 """Virtual class documenting how to write a changelog format class."""
87
88 @classmethod
89 def extract_top_version(cls, changelog_file_content):
90 """Split out the top version section.
91
Gilles Peskineeebf24f2020-03-27 19:25:38 +010092 If the top version is already released, create a new top
93 version section for an unreleased version.
Gilles Peskinedba4de02020-03-30 11:37:26 +020094
95 Return ``(header, top_version_title, top_version_body, trailer)``
96 where the "top version" is the existing top version section if it's
97 for unreleased changes, and a newly created section otherwise.
98 To assemble the changelog after modifying top_version_body,
99 concatenate the four pieces.
Gilles Peskine6e97c432020-03-27 19:05:18 +0100100 """
101 raise NotImplementedError
102
103 @classmethod
104 def version_title_text(cls, version_title):
105 """Return the text of a formatted version section title."""
106 raise NotImplementedError
107
108 @classmethod
109 def split_categories(cls, version_body):
110 """Split a changelog version section body into categories.
111
112 Return a list of `CategoryContent` the name is category title
113 without any formatting.
114 """
115 raise NotImplementedError
116
117 @classmethod
118 def format_category(cls, title, body):
119 """Construct the text of a category section from its title and body."""
120 raise NotImplementedError
121
122class TextChangelogFormat(ChangelogFormat):
123 """The traditional Mbed TLS changelog format."""
124
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100125 _unreleased_version_text = b'= mbed TLS x.x.x branch released xxxx-xx-xx'
126 @classmethod
127 def is_released_version(cls, title):
128 # Look for an incomplete release date
129 return not re.search(br'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
130
Gilles Peskine6e97c432020-03-27 19:05:18 +0100131 _top_version_re = re.compile(br'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
132 re.DOTALL)
133 @classmethod
134 def extract_top_version(cls, changelog_file_content):
135 """A version section starts with a line starting with '='."""
136 m = re.search(cls._top_version_re, changelog_file_content)
137 top_version_start = m.start(1)
138 top_version_end = m.end(2)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100139 top_version_title = m.group(1)
140 top_version_body = m.group(2)
141 if cls.is_released_version(top_version_title):
142 top_version_end = top_version_start
143 top_version_title = cls._unreleased_version_text + b'\n\n'
144 top_version_body = b''
Gilles Peskine6e97c432020-03-27 19:05:18 +0100145 return (changelog_file_content[:top_version_start],
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100146 top_version_title, top_version_body,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100147 changelog_file_content[top_version_end:])
148
149 @classmethod
150 def version_title_text(cls, version_title):
151 return re.sub(br'\n.*', version_title, re.DOTALL)
152
153 _category_title_re = re.compile(br'(^\w.*)\n+', re.MULTILINE)
154 @classmethod
155 def split_categories(cls, version_body):
156 """A category title is a line with the title in column 0."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100157 if not version_body:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100158 return []
Gilles Peskine4d977a42020-03-27 19:42:50 +0100159 title_matches = list(re.finditer(cls._category_title_re, version_body))
160 if not title_matches or title_matches[0].start() != 0:
161 # There is junk before the first category.
162 raise CategoryParseError(0, 'Junk found where category expected')
Gilles Peskine6e97c432020-03-27 19:05:18 +0100163 title_starts = [m.start(1) for m in title_matches]
164 body_starts = [m.end(0) for m in title_matches]
165 body_ends = title_starts[1:] + [len(version_body)]
166 bodies = [version_body[body_start:body_end].rstrip(b'\n') + b'\n'
167 for (body_start, body_end) in zip(body_starts, body_ends)]
168 title_lines = [version_body[:pos].count(b'\n') for pos in title_starts]
169 body_lines = [version_body[:pos].count(b'\n') for pos in body_starts]
170 return [CategoryContent(title_match.group(1), title_line,
171 body, body_line)
172 for title_match, title_line, body, body_line
173 in zip(title_matches, title_lines, bodies, body_lines)]
174
175 @classmethod
176 def format_category(cls, title, body):
177 # `split_categories` ensures that each body ends with a newline.
178 # Make sure that there is additionally a blank line between categories.
179 if not body.endswith(b'\n\n'):
180 body += b'\n'
181 return title + b'\n' + body
182
Gilles Peskine40b3f412019-10-13 21:44:25 +0200183class ChangeLog:
Gilles Peskine42f384c2020-03-27 09:23:38 +0100184 """An Mbed TLS changelog.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200185
Gilles Peskine6e97c432020-03-27 19:05:18 +0100186 A changelog file consists of some header text followed by one or
187 more version sections. The version sections are in reverse
188 chronological order. Each version section consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200189
Gilles Peskine6e97c432020-03-27 19:05:18 +0100190 The body of a version section consists of zero or more category
191 subsections. Each category subsection consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200192
Gilles Peskine6e97c432020-03-27 19:05:18 +0100193 A changelog entry file has the same format as the body of a version section.
194
195 A `ChangelogFormat` object defines the concrete syntax of the changelog.
196 Entry files must have the same format as the changelog file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200197 """
198
Gilles Peskinea2607962020-01-28 19:58:17 +0100199 # Only accept dotted version numbers (e.g. "3.1", not "3").
Gilles Peskineafc9db82020-01-30 11:38:01 +0100200 # Refuse ".x" in a version number where x is a letter: this indicates
201 # a version that is not yet released. Something like "3.1a" is accepted.
202 _version_number_re = re.compile(br'[0-9]+\.[0-9A-Za-z.]+')
203 _incomplete_version_number_re = re.compile(br'.*\.[A-Za-z]')
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200204 _only_url_re = re.compile(br'^\s*\w+://\S+\s*$')
205 _has_url_re = re.compile(br'.*://.*')
Gilles Peskinea2607962020-01-28 19:58:17 +0100206
Gilles Peskine6e97c432020-03-27 19:05:18 +0100207 def add_categories_from_text(self, filename, line_offset,
208 text, allow_unknown_category):
209 """Parse a version section or entry file."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100210 try:
211 categories = self.format.split_categories(text)
212 except CategoryParseError as e:
213 raise InputFormatError(filename, line_offset + e.line_offset,
214 e.error_message)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100215 for category in categories:
216 if not allow_unknown_category and \
217 category.name not in self.categories:
218 raise InputFormatError(filename,
219 line_offset + category.title_line,
220 'Unknown category: "{}"',
221 category.name.decode('utf8'))
Paul Elliottf08648d2021-03-05 12:22:51 +0000222
223 body_split = category.body.splitlines()
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200224
Paul Elliottd75773e2021-03-18 18:07:46 +0000225 for line_number, line in enumerate(body_split, 1):
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200226 if not self._only_url_re.match(line) and \
Mateusz Starzyk6e470552021-03-24 12:13:33 +0100227 len(line) > MAX_LINE_LENGTH:
Mateusz Starzykc8f44892021-03-25 14:06:50 +0100228 long_url_msg = '. URL exceeding length limit must be ' \
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200229 'alone in it\'s line.' if self._has_url_re.match(line) \
Mateusz Starzyk51726052021-03-25 14:49:57 +0100230 else ""
Paul Elliottf08648d2021-03-05 12:22:51 +0000231 raise InputFormatError(filename,
Paul Elliottd75773e2021-03-18 18:07:46 +0000232 category.body_line + line_number,
Mateusz Starzykc8f44892021-03-25 14:06:50 +0100233 'Line is longer than allowed: '
234 'Length {} (Max {}){}',
235 len(line), MAX_LINE_LENGTH,
236 long_url_msg)
Paul Elliottf08648d2021-03-05 12:22:51 +0000237
Gilles Peskine6e97c432020-03-27 19:05:18 +0100238 self.categories[category.name] += category.body
239
240 def __init__(self, input_stream, changelog_format):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200241 """Create a changelog object.
242
Gilles Peskine974232f2020-01-22 12:43:29 +0100243 Populate the changelog object from the content of the file
Gilles Peskine6e97c432020-03-27 19:05:18 +0100244 input_stream.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200245 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100246 self.format = changelog_format
247 whole_file = input_stream.read()
248 (self.header,
249 self.top_version_title, top_version_body,
250 self.trailer) = self.format.extract_top_version(whole_file)
251 # Split the top version section into categories.
252 self.categories = OrderedDict()
253 for category in STANDARD_CATEGORIES:
254 self.categories[category] = b''
Gilles Peskinee248e832020-03-27 19:42:38 +0100255 offset = (self.header + self.top_version_title).count(b'\n') + 1
Gilles Peskine6e97c432020-03-27 19:05:18 +0100256 self.add_categories_from_text(input_stream.name, offset,
257 top_version_body, True)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200258
259 def add_file(self, input_stream):
260 """Add changelog entries from a file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200261 """
Gilles Peskinee248e832020-03-27 19:42:38 +0100262 self.add_categories_from_text(input_stream.name, 1,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100263 input_stream.read(), False)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200264
265 def write(self, filename):
266 """Write the changelog to the specified file.
267 """
268 with open(filename, 'wb') as out:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100269 out.write(self.header)
270 out.write(self.top_version_title)
271 for title, body in self.categories.items():
272 if not body:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200273 continue
Gilles Peskine6e97c432020-03-27 19:05:18 +0100274 out.write(self.format.format_category(title, body))
275 out.write(self.trailer)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200276
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100277
278@functools.total_ordering
Gilles Peskine28af9582020-03-26 22:39:18 +0100279class EntryFileSortKey:
280 """This classes defines an ordering on changelog entry files: older < newer.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100281
Gilles Peskine28af9582020-03-26 22:39:18 +0100282 * Merged entry files are sorted according to their merge date (date of
283 the merge commit that brought the commit that created the file into
284 the target branch).
285 * Committed but unmerged entry files are sorted according to the date
286 of the commit that adds them.
287 * Uncommitted entry files are sorted according to their modification time.
288
289 This class assumes that the file is in a git working directory with
290 the target branch checked out.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100291 """
292
293 # Categories of files. A lower number is considered older.
294 MERGED = 0
295 COMMITTED = 1
296 LOCAL = 2
297
298 @staticmethod
299 def creation_hash(filename):
300 """Return the git commit id at which the given file was created.
301
302 Return None if the file was never checked into git.
303 """
Gilles Peskine98a53aa2020-03-26 22:47:07 +0100304 hashes = subprocess.check_output(['git', 'log', '--format=%H',
305 '--follow',
306 '--', filename])
Gilles Peskine13dc6342020-03-26 22:46:47 +0100307 m = re.search(b'(.+)$', hashes)
308 if not m:
309 # The git output is empty. This means that the file was
310 # never checked in.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100311 return None
Gilles Peskine13dc6342020-03-26 22:46:47 +0100312 # The last commit in the log is the oldest one, which is when the
313 # file was created.
314 return m.group(0)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100315
316 @staticmethod
317 def list_merges(some_hash, target, *options):
318 """List merge commits from some_hash to target.
319
320 Pass options to git to select which commits are included.
321 """
322 text = subprocess.check_output(['git', 'rev-list',
323 '--merges', *options,
324 b'..'.join([some_hash, target])])
325 return text.rstrip(b'\n').split(b'\n')
326
327 @classmethod
328 def merge_hash(cls, some_hash):
329 """Return the git commit id at which the given commit was merged.
330
331 Return None if the given commit was never merged.
332 """
333 target = b'HEAD'
334 # List the merges from some_hash to the target in two ways.
335 # The ancestry list is the ones that are both descendants of
336 # some_hash and ancestors of the target.
337 ancestry = frozenset(cls.list_merges(some_hash, target,
338 '--ancestry-path'))
339 # The first_parents list only contains merges that are directly
340 # on the target branch. We want it in reverse order (oldest first).
341 first_parents = cls.list_merges(some_hash, target,
342 '--first-parent', '--reverse')
343 # Look for the oldest merge commit that's both on the direct path
344 # and directly on the target branch. That's the place where some_hash
345 # was merged on the target branch. See
346 # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
347 for commit in first_parents:
348 if commit in ancestry:
349 return commit
350 return None
351
352 @staticmethod
353 def commit_timestamp(commit_id):
Gilles Peskineac0f0862020-03-27 10:56:45 +0100354 """Return the timestamp of the given commit."""
355 text = subprocess.check_output(['git', 'show', '-s',
356 '--format=%ct',
357 commit_id])
358 return datetime.datetime.utcfromtimestamp(int(text))
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100359
360 @staticmethod
361 def file_timestamp(filename):
362 """Return the modification timestamp of the given file."""
363 mtime = os.stat(filename).st_mtime
364 return datetime.datetime.fromtimestamp(mtime)
365
366 def __init__(self, filename):
Gilles Peskine28af9582020-03-26 22:39:18 +0100367 """Determine position of the file in the changelog entry order.
368
369 This constructor returns an object that can be used with comparison
370 operators, with `sort` and `sorted`, etc. Older entries are sorted
371 before newer entries.
372 """
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100373 self.filename = filename
374 creation_hash = self.creation_hash(filename)
375 if not creation_hash:
376 self.category = self.LOCAL
377 self.datetime = self.file_timestamp(filename)
378 return
379 merge_hash = self.merge_hash(creation_hash)
380 if not merge_hash:
381 self.category = self.COMMITTED
382 self.datetime = self.commit_timestamp(creation_hash)
383 return
384 self.category = self.MERGED
385 self.datetime = self.commit_timestamp(merge_hash)
386
387 def sort_key(self):
Gilles Peskine28af9582020-03-26 22:39:18 +0100388 """"Return a concrete sort key for this entry file sort key object.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100389
Gilles Peskine28af9582020-03-26 22:39:18 +0100390 ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100391 """
392 return (self.category, self.datetime, self.filename)
393
394 def __eq__(self, other):
395 return self.sort_key() == other.sort_key()
396
397 def __lt__(self, other):
398 return self.sort_key() < other.sort_key()
399
400
Gilles Peskine2b242492020-01-22 15:41:50 +0100401def check_output(generated_output_file, main_input_file, merged_files):
402 """Make sanity checks on the generated output.
403
404 The intent of these sanity checks is to have reasonable confidence
405 that no content has been lost.
406
407 The sanity check is that every line that is present in an input file
408 is also present in an output file. This is not perfect but good enough
409 for now.
410 """
411 generated_output = set(open(generated_output_file, 'rb'))
412 for line in open(main_input_file, 'rb'):
413 if line not in generated_output:
414 raise LostContent('original file', line)
415 for merged_file in merged_files:
416 for line in open(merged_file, 'rb'):
417 if line not in generated_output:
418 raise LostContent(merged_file, line)
419
420def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200421 """Write the changelog to the output file.
422
Gilles Peskine2b242492020-01-22 15:41:50 +0100423 The input file and the list of merged files are used only for sanity
424 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200425 """
426 if os.path.exists(output_file) and not os.path.isfile(output_file):
427 # The output is a non-regular file (e.g. pipe). Write to it directly.
428 output_temp = output_file
429 else:
430 # The output is a regular file. Write to a temporary file,
431 # then move it into place atomically.
432 output_temp = output_file + '.tmp'
433 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100434 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200435 if output_temp != output_file:
436 os.rename(output_temp, output_file)
437
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100438def remove_merged_entries(files_to_remove):
439 for filename in files_to_remove:
440 os.remove(filename)
441
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100442def list_files_to_merge(options):
443 """List the entry files to merge, oldest first.
444
Gilles Peskine28af9582020-03-26 22:39:18 +0100445 "Oldest" is defined by `EntryFileSortKey`.
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100446 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100447 files_to_merge = glob.glob(os.path.join(options.dir, '*.txt'))
Gilles Peskine7fa3eb72020-03-26 22:41:32 +0100448 files_to_merge.sort(key=EntryFileSortKey)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100449 return files_to_merge
450
Gilles Peskine40b3f412019-10-13 21:44:25 +0200451def merge_entries(options):
452 """Merge changelog entries into the changelog file.
453
454 Read the changelog file from options.input.
455 Read entries to merge from the directory options.dir.
456 Write the new changelog to options.output.
457 Remove the merged entries if options.keep_entries is false.
458 """
459 with open(options.input, 'rb') as input_file:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100460 changelog = ChangeLog(input_file, TextChangelogFormat)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100461 files_to_merge = list_files_to_merge(options)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200462 if not files_to_merge:
463 sys.stderr.write('There are no pending changelog entries.\n')
464 return
465 for filename in files_to_merge:
466 with open(filename, 'rb') as input_file:
467 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100468 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100469 if not options.keep_entries:
470 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200471
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100472def show_file_timestamps(options):
473 """List the files to merge and their timestamp.
474
475 This is only intended for debugging purposes.
476 """
477 files = list_files_to_merge(options)
478 for filename in files:
Gilles Peskine28af9582020-03-26 22:39:18 +0100479 ts = EntryFileSortKey(filename)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100480 print(ts.category, ts.datetime, filename)
481
Gilles Peskine40b3f412019-10-13 21:44:25 +0200482def set_defaults(options):
483 """Add default values for missing options."""
484 output_file = getattr(options, 'output', None)
485 if output_file is None:
486 options.output = options.input
487 if getattr(options, 'keep_entries', None) is None:
488 options.keep_entries = (output_file is not None)
489
490def main():
491 """Command line entry point."""
492 parser = argparse.ArgumentParser(description=__doc__)
493 parser.add_argument('--dir', '-d', metavar='DIR',
494 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100495 help='Directory to read entries from'
496 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200497 parser.add_argument('--input', '-i', metavar='FILE',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100498 default='ChangeLog',
Gilles Peskine6e910092020-01-22 15:58:18 +0100499 help='Existing changelog file to read from and augment'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100500 ' (default: ChangeLog)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200501 parser.add_argument('--keep-entries',
502 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100503 help='Keep the files containing entries'
504 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200505 parser.add_argument('--no-keep-entries',
506 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100507 help='Remove the files containing entries after they are merged'
508 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200509 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100510 help='Output changelog file'
511 ' (default: overwrite the input)')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100512 parser.add_argument('--list-files-only',
513 action='store_true',
Gilles Peskinec68c7c82020-03-27 19:01:35 +0100514 help=('Only list the files that would be processed '
Gilles Peskineac0f0862020-03-27 10:56:45 +0100515 '(with some debugging information)'))
Gilles Peskine40b3f412019-10-13 21:44:25 +0200516 options = parser.parse_args()
517 set_defaults(options)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100518 if options.list_files_only:
519 show_file_timestamps(options)
520 return
Gilles Peskine40b3f412019-10-13 21:44:25 +0200521 merge_entries(options)
522
523if __name__ == '__main__':
524 main()