assemble_changelog.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. #!/usr/bin/env python3
  2. """Assemble Mbed TLS change log entries into the change log file.
  3. Add changelog entries to the first level-2 section.
  4. Create a new level-2 section for unreleased changes if needed.
  5. Remove the input files unless --keep-entries is specified.
  6. In each level-3 section, entries are sorted in chronological order
  7. (oldest first). From oldest to newest:
  8. * Merged entry files are sorted according to their merge date (date of
  9. the merge commit that brought the commit that created the file into
  10. the target branch).
  11. * Committed but unmerged entry files are sorted according to the date
  12. of the commit that adds them.
  13. * Uncommitted entry files are sorted according to their modification time.
  14. You must run this program from within a git working directory.
  15. """
  16. # Copyright The Mbed TLS Contributors
  17. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  18. #
  19. # This file is provided under the Apache License 2.0, or the
  20. # GNU General Public License v2.0 or later.
  21. #
  22. # **********
  23. # Apache License 2.0:
  24. #
  25. # Licensed under the Apache License, Version 2.0 (the "License"); you may
  26. # not use this file except in compliance with the License.
  27. # You may obtain a copy of the License at
  28. #
  29. # http://www.apache.org/licenses/LICENSE-2.0
  30. #
  31. # Unless required by applicable law or agreed to in writing, software
  32. # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  33. # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  34. # See the License for the specific language governing permissions and
  35. # limitations under the License.
  36. #
  37. # **********
  38. #
  39. # **********
  40. # GNU General Public License v2.0 or later:
  41. #
  42. # This program is free software; you can redistribute it and/or modify
  43. # it under the terms of the GNU General Public License as published by
  44. # the Free Software Foundation; either version 2 of the License, or
  45. # (at your option) any later version.
  46. #
  47. # This program is distributed in the hope that it will be useful,
  48. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  49. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  50. # GNU General Public License for more details.
  51. #
  52. # You should have received a copy of the GNU General Public License along
  53. # with this program; if not, write to the Free Software Foundation, Inc.,
  54. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  55. #
  56. # **********
  57. import argparse
  58. from collections import OrderedDict, namedtuple
  59. import datetime
  60. import functools
  61. import glob
  62. import os
  63. import re
  64. import subprocess
  65. import sys
  66. class InputFormatError(Exception):
  67. def __init__(self, filename, line_number, message, *args, **kwargs):
  68. message = '{}:{}: {}'.format(filename, line_number,
  69. message.format(*args, **kwargs))
  70. super().__init__(message)
  71. class CategoryParseError(Exception):
  72. def __init__(self, line_offset, error_message):
  73. self.line_offset = line_offset
  74. self.error_message = error_message
  75. super().__init__('{}: {}'.format(line_offset, error_message))
  76. class LostContent(Exception):
  77. def __init__(self, filename, line):
  78. message = ('Lost content from {}: "{}"'.format(filename, line))
  79. super().__init__(message)
  80. # The category names we use in the changelog.
  81. # If you edit this, update ChangeLog.d/README.md.
  82. STANDARD_CATEGORIES = (
  83. b'API changes',
  84. b'Default behavior changes',
  85. b'Requirement changes',
  86. b'New deprecations',
  87. b'Removals',
  88. b'Features',
  89. b'Security',
  90. b'Bugfix',
  91. b'Changes',
  92. )
  93. # The maximum line length for an entry
  94. MAX_LINE_LENGTH = 80
  95. CategoryContent = namedtuple('CategoryContent', [
  96. 'name', 'title_line', # Title text and line number of the title
  97. 'body', 'body_line', # Body text and starting line number of the body
  98. ])
  99. class ChangelogFormat:
  100. """Virtual class documenting how to write a changelog format class."""
  101. @classmethod
  102. def extract_top_version(cls, changelog_file_content):
  103. """Split out the top version section.
  104. If the top version is already released, create a new top
  105. version section for an unreleased version.
  106. Return ``(header, top_version_title, top_version_body, trailer)``
  107. where the "top version" is the existing top version section if it's
  108. for unreleased changes, and a newly created section otherwise.
  109. To assemble the changelog after modifying top_version_body,
  110. concatenate the four pieces.
  111. """
  112. raise NotImplementedError
  113. @classmethod
  114. def version_title_text(cls, version_title):
  115. """Return the text of a formatted version section title."""
  116. raise NotImplementedError
  117. @classmethod
  118. def split_categories(cls, version_body):
  119. """Split a changelog version section body into categories.
  120. Return a list of `CategoryContent` the name is category title
  121. without any formatting.
  122. """
  123. raise NotImplementedError
  124. @classmethod
  125. def format_category(cls, title, body):
  126. """Construct the text of a category section from its title and body."""
  127. raise NotImplementedError
  128. class TextChangelogFormat(ChangelogFormat):
  129. """The traditional Mbed TLS changelog format."""
  130. _unreleased_version_text = b'= mbed TLS x.x.x branch released xxxx-xx-xx'
  131. @classmethod
  132. def is_released_version(cls, title):
  133. # Look for an incomplete release date
  134. return not re.search(br'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
  135. _top_version_re = re.compile(br'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
  136. re.DOTALL)
  137. @classmethod
  138. def extract_top_version(cls, changelog_file_content):
  139. """A version section starts with a line starting with '='."""
  140. m = re.search(cls._top_version_re, changelog_file_content)
  141. top_version_start = m.start(1)
  142. top_version_end = m.end(2)
  143. top_version_title = m.group(1)
  144. top_version_body = m.group(2)
  145. if cls.is_released_version(top_version_title):
  146. top_version_end = top_version_start
  147. top_version_title = cls._unreleased_version_text + b'\n\n'
  148. top_version_body = b''
  149. return (changelog_file_content[:top_version_start],
  150. top_version_title, top_version_body,
  151. changelog_file_content[top_version_end:])
  152. @classmethod
  153. def version_title_text(cls, version_title):
  154. return re.sub(br'\n.*', version_title, re.DOTALL)
  155. _category_title_re = re.compile(br'(^\w.*)\n+', re.MULTILINE)
  156. @classmethod
  157. def split_categories(cls, version_body):
  158. """A category title is a line with the title in column 0."""
  159. if not version_body:
  160. return []
  161. title_matches = list(re.finditer(cls._category_title_re, version_body))
  162. if not title_matches or title_matches[0].start() != 0:
  163. # There is junk before the first category.
  164. raise CategoryParseError(0, 'Junk found where category expected')
  165. title_starts = [m.start(1) for m in title_matches]
  166. body_starts = [m.end(0) for m in title_matches]
  167. body_ends = title_starts[1:] + [len(version_body)]
  168. bodies = [version_body[body_start:body_end].rstrip(b'\n') + b'\n'
  169. for (body_start, body_end) in zip(body_starts, body_ends)]
  170. title_lines = [version_body[:pos].count(b'\n') for pos in title_starts]
  171. body_lines = [version_body[:pos].count(b'\n') for pos in body_starts]
  172. return [CategoryContent(title_match.group(1), title_line,
  173. body, body_line)
  174. for title_match, title_line, body, body_line
  175. in zip(title_matches, title_lines, bodies, body_lines)]
  176. @classmethod
  177. def format_category(cls, title, body):
  178. # `split_categories` ensures that each body ends with a newline.
  179. # Make sure that there is additionally a blank line between categories.
  180. if not body.endswith(b'\n\n'):
  181. body += b'\n'
  182. return title + b'\n' + body
  183. class ChangeLog:
  184. """An Mbed TLS changelog.
  185. A changelog file consists of some header text followed by one or
  186. more version sections. The version sections are in reverse
  187. chronological order. Each version section consists of a title and a body.
  188. The body of a version section consists of zero or more category
  189. subsections. Each category subsection consists of a title and a body.
  190. A changelog entry file has the same format as the body of a version section.
  191. A `ChangelogFormat` object defines the concrete syntax of the changelog.
  192. Entry files must have the same format as the changelog file.
  193. """
  194. # Only accept dotted version numbers (e.g. "3.1", not "3").
  195. # Refuse ".x" in a version number where x is a letter: this indicates
  196. # a version that is not yet released. Something like "3.1a" is accepted.
  197. _version_number_re = re.compile(br'[0-9]+\.[0-9A-Za-z.]+')
  198. _incomplete_version_number_re = re.compile(br'.*\.[A-Za-z]')
  199. _only_url_re = re.compile(br'^\s*\w+://\S+\s*$')
  200. _has_url_re = re.compile(br'.*://.*')
  201. def add_categories_from_text(self, filename, line_offset,
  202. text, allow_unknown_category):
  203. """Parse a version section or entry file."""
  204. try:
  205. categories = self.format.split_categories(text)
  206. except CategoryParseError as e:
  207. raise InputFormatError(filename, line_offset + e.line_offset,
  208. e.error_message)
  209. for category in categories:
  210. if not allow_unknown_category and \
  211. category.name not in self.categories:
  212. raise InputFormatError(filename,
  213. line_offset + category.title_line,
  214. 'Unknown category: "{}"',
  215. category.name.decode('utf8'))
  216. body_split = category.body.splitlines()
  217. for line_number, line in enumerate(body_split, 1):
  218. if not self._only_url_re.match(line) and \
  219. len(line) > MAX_LINE_LENGTH:
  220. long_url_msg = '. URL exceeding length limit must be alone in its line.' \
  221. if self._has_url_re.match(line) else ""
  222. raise InputFormatError(filename,
  223. category.body_line + line_number,
  224. 'Line is longer than allowed: '
  225. 'Length {} (Max {}){}',
  226. len(line), MAX_LINE_LENGTH,
  227. long_url_msg)
  228. self.categories[category.name] += category.body
  229. def __init__(self, input_stream, changelog_format):
  230. """Create a changelog object.
  231. Populate the changelog object from the content of the file
  232. input_stream.
  233. """
  234. self.format = changelog_format
  235. whole_file = input_stream.read()
  236. (self.header,
  237. self.top_version_title, top_version_body,
  238. self.trailer) = self.format.extract_top_version(whole_file)
  239. # Split the top version section into categories.
  240. self.categories = OrderedDict()
  241. for category in STANDARD_CATEGORIES:
  242. self.categories[category] = b''
  243. offset = (self.header + self.top_version_title).count(b'\n') + 1
  244. self.add_categories_from_text(input_stream.name, offset,
  245. top_version_body, True)
  246. def add_file(self, input_stream):
  247. """Add changelog entries from a file.
  248. """
  249. self.add_categories_from_text(input_stream.name, 1,
  250. input_stream.read(), False)
  251. def write(self, filename):
  252. """Write the changelog to the specified file.
  253. """
  254. with open(filename, 'wb') as out:
  255. out.write(self.header)
  256. out.write(self.top_version_title)
  257. for title, body in self.categories.items():
  258. if not body:
  259. continue
  260. out.write(self.format.format_category(title, body))
  261. out.write(self.trailer)
  262. @functools.total_ordering
  263. class EntryFileSortKey:
  264. """This classes defines an ordering on changelog entry files: older < newer.
  265. * Merged entry files are sorted according to their merge date (date of
  266. the merge commit that brought the commit that created the file into
  267. the target branch).
  268. * Committed but unmerged entry files are sorted according to the date
  269. of the commit that adds them.
  270. * Uncommitted entry files are sorted according to their modification time.
  271. This class assumes that the file is in a git working directory with
  272. the target branch checked out.
  273. """
  274. # Categories of files. A lower number is considered older.
  275. MERGED = 0
  276. COMMITTED = 1
  277. LOCAL = 2
  278. @staticmethod
  279. def creation_hash(filename):
  280. """Return the git commit id at which the given file was created.
  281. Return None if the file was never checked into git.
  282. """
  283. hashes = subprocess.check_output(['git', 'log', '--format=%H',
  284. '--follow',
  285. '--', filename])
  286. m = re.search(b'(.+)$', hashes)
  287. if not m:
  288. # The git output is empty. This means that the file was
  289. # never checked in.
  290. return None
  291. # The last commit in the log is the oldest one, which is when the
  292. # file was created.
  293. return m.group(0)
  294. @staticmethod
  295. def list_merges(some_hash, target, *options):
  296. """List merge commits from some_hash to target.
  297. Pass options to git to select which commits are included.
  298. """
  299. text = subprocess.check_output(['git', 'rev-list',
  300. '--merges', *options,
  301. b'..'.join([some_hash, target])])
  302. return text.rstrip(b'\n').split(b'\n')
  303. @classmethod
  304. def merge_hash(cls, some_hash):
  305. """Return the git commit id at which the given commit was merged.
  306. Return None if the given commit was never merged.
  307. """
  308. target = b'HEAD'
  309. # List the merges from some_hash to the target in two ways.
  310. # The ancestry list is the ones that are both descendants of
  311. # some_hash and ancestors of the target.
  312. ancestry = frozenset(cls.list_merges(some_hash, target,
  313. '--ancestry-path'))
  314. # The first_parents list only contains merges that are directly
  315. # on the target branch. We want it in reverse order (oldest first).
  316. first_parents = cls.list_merges(some_hash, target,
  317. '--first-parent', '--reverse')
  318. # Look for the oldest merge commit that's both on the direct path
  319. # and directly on the target branch. That's the place where some_hash
  320. # was merged on the target branch. See
  321. # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
  322. for commit in first_parents:
  323. if commit in ancestry:
  324. return commit
  325. return None
  326. @staticmethod
  327. def commit_timestamp(commit_id):
  328. """Return the timestamp of the given commit."""
  329. text = subprocess.check_output(['git', 'show', '-s',
  330. '--format=%ct',
  331. commit_id])
  332. return datetime.datetime.utcfromtimestamp(int(text))
  333. @staticmethod
  334. def file_timestamp(filename):
  335. """Return the modification timestamp of the given file."""
  336. mtime = os.stat(filename).st_mtime
  337. return datetime.datetime.fromtimestamp(mtime)
  338. def __init__(self, filename):
  339. """Determine position of the file in the changelog entry order.
  340. This constructor returns an object that can be used with comparison
  341. operators, with `sort` and `sorted`, etc. Older entries are sorted
  342. before newer entries.
  343. """
  344. self.filename = filename
  345. creation_hash = self.creation_hash(filename)
  346. if not creation_hash:
  347. self.category = self.LOCAL
  348. self.datetime = self.file_timestamp(filename)
  349. return
  350. merge_hash = self.merge_hash(creation_hash)
  351. if not merge_hash:
  352. self.category = self.COMMITTED
  353. self.datetime = self.commit_timestamp(creation_hash)
  354. return
  355. self.category = self.MERGED
  356. self.datetime = self.commit_timestamp(merge_hash)
  357. def sort_key(self):
  358. """"Return a concrete sort key for this entry file sort key object.
  359. ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
  360. """
  361. return (self.category, self.datetime, self.filename)
  362. def __eq__(self, other):
  363. return self.sort_key() == other.sort_key()
  364. def __lt__(self, other):
  365. return self.sort_key() < other.sort_key()
  366. def check_output(generated_output_file, main_input_file, merged_files):
  367. """Make sanity checks on the generated output.
  368. The intent of these sanity checks is to have reasonable confidence
  369. that no content has been lost.
  370. The sanity check is that every line that is present in an input file
  371. is also present in an output file. This is not perfect but good enough
  372. for now.
  373. """
  374. generated_output = set(open(generated_output_file, 'rb'))
  375. for line in open(main_input_file, 'rb'):
  376. if line not in generated_output:
  377. raise LostContent('original file', line)
  378. for merged_file in merged_files:
  379. for line in open(merged_file, 'rb'):
  380. if line not in generated_output:
  381. raise LostContent(merged_file, line)
  382. def finish_output(changelog, output_file, input_file, merged_files):
  383. """Write the changelog to the output file.
  384. The input file and the list of merged files are used only for sanity
  385. checks on the output.
  386. """
  387. if os.path.exists(output_file) and not os.path.isfile(output_file):
  388. # The output is a non-regular file (e.g. pipe). Write to it directly.
  389. output_temp = output_file
  390. else:
  391. # The output is a regular file. Write to a temporary file,
  392. # then move it into place atomically.
  393. output_temp = output_file + '.tmp'
  394. changelog.write(output_temp)
  395. check_output(output_temp, input_file, merged_files)
  396. if output_temp != output_file:
  397. os.rename(output_temp, output_file)
  398. def remove_merged_entries(files_to_remove):
  399. for filename in files_to_remove:
  400. os.remove(filename)
  401. def list_files_to_merge(options):
  402. """List the entry files to merge, oldest first.
  403. "Oldest" is defined by `EntryFileSortKey`.
  404. """
  405. files_to_merge = glob.glob(os.path.join(options.dir, '*.txt'))
  406. files_to_merge.sort(key=EntryFileSortKey)
  407. return files_to_merge
  408. def merge_entries(options):
  409. """Merge changelog entries into the changelog file.
  410. Read the changelog file from options.input.
  411. Read entries to merge from the directory options.dir.
  412. Write the new changelog to options.output.
  413. Remove the merged entries if options.keep_entries is false.
  414. """
  415. with open(options.input, 'rb') as input_file:
  416. changelog = ChangeLog(input_file, TextChangelogFormat)
  417. files_to_merge = list_files_to_merge(options)
  418. if not files_to_merge:
  419. sys.stderr.write('There are no pending changelog entries.\n')
  420. return
  421. for filename in files_to_merge:
  422. with open(filename, 'rb') as input_file:
  423. changelog.add_file(input_file)
  424. finish_output(changelog, options.output, options.input, files_to_merge)
  425. if not options.keep_entries:
  426. remove_merged_entries(files_to_merge)
  427. def show_file_timestamps(options):
  428. """List the files to merge and their timestamp.
  429. This is only intended for debugging purposes.
  430. """
  431. files = list_files_to_merge(options)
  432. for filename in files:
  433. ts = EntryFileSortKey(filename)
  434. print(ts.category, ts.datetime, filename)
  435. def set_defaults(options):
  436. """Add default values for missing options."""
  437. output_file = getattr(options, 'output', None)
  438. if output_file is None:
  439. options.output = options.input
  440. if getattr(options, 'keep_entries', None) is None:
  441. options.keep_entries = (output_file is not None)
  442. def main():
  443. """Command line entry point."""
  444. parser = argparse.ArgumentParser(description=__doc__)
  445. parser.add_argument('--dir', '-d', metavar='DIR',
  446. default='ChangeLog.d',
  447. help='Directory to read entries from'
  448. ' (default: ChangeLog.d)')
  449. parser.add_argument('--input', '-i', metavar='FILE',
  450. default='ChangeLog',
  451. help='Existing changelog file to read from and augment'
  452. ' (default: ChangeLog)')
  453. parser.add_argument('--keep-entries',
  454. action='store_true', dest='keep_entries', default=None,
  455. help='Keep the files containing entries'
  456. ' (default: remove them if --output/-o is not specified)')
  457. parser.add_argument('--no-keep-entries',
  458. action='store_false', dest='keep_entries',
  459. help='Remove the files containing entries after they are merged'
  460. ' (default: remove them if --output/-o is not specified)')
  461. parser.add_argument('--output', '-o', metavar='FILE',
  462. help='Output changelog file'
  463. ' (default: overwrite the input)')
  464. parser.add_argument('--list-files-only',
  465. action='store_true',
  466. help=('Only list the files that would be processed '
  467. '(with some debugging information)'))
  468. options = parser.parse_args()
  469. set_defaults(options)
  470. if options.list_files_only:
  471. show_file_timestamps(options)
  472. return
  473. merge_entries(options)
  474. if __name__ == '__main__':
  475. main()