1 1.1 mrg #!/usr/bin/env python3 2 1.1 mrg 3 1.1.1.3 mrg # Copyright (C) 2020-2024 Free Software Foundation, Inc. 4 1.1 mrg # 5 1.1 mrg # This file is part of GCC. 6 1.1 mrg # 7 1.1 mrg # GCC is free software; you can redistribute it and/or modify 8 1.1 mrg # it under the terms of the GNU General Public License as published by 9 1.1 mrg # the Free Software Foundation; either version 3, or (at your option) 10 1.1 mrg # any later version. 11 1.1 mrg # 12 1.1 mrg # GCC is distributed in the hope that it will be useful, 13 1.1 mrg # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 1.1 mrg # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 1.1 mrg # GNU General Public License for more details. 16 1.1 mrg # 17 1.1 mrg # You should have received a copy of the GNU General Public License 18 1.1 mrg # along with GCC; see the file COPYING. If not, write to 19 1.1 mrg # the Free Software Foundation, 51 Franklin Street, Fifth Floor, 20 1.1 mrg # Boston, MA 02110-1301, USA. 21 1.1 mrg 22 1.1 mrg # This script parses a .diff file generated with 'diff -up' or 'diff -cp' 23 1.1 mrg # and adds a skeleton ChangeLog file to the file. It does not try to be 24 1.1 mrg # too smart when parsing function names, but it produces a reasonable 25 1.1 mrg # approximation. 26 1.1 mrg # 27 1.1 mrg # Author: Martin Liska <mliska (at] suse.cz> 28 1.1 mrg 29 1.1 mrg import argparse 30 1.1 mrg import datetime 31 1.1.1.3 mrg import json 32 1.1 mrg import os 33 1.1 mrg import re 34 1.1 mrg import subprocess 35 1.1 mrg import sys 36 1.1 mrg from itertools import takewhile 37 1.1 mrg 38 1.1 mrg import requests 39 1.1 mrg 40 1.1 mrg from unidiff import PatchSet 41 1.1 mrg 42 1.1.1.2 mrg LINE_LIMIT = 100 43 1.1.1.2 mrg TAB_WIDTH = 8 44 1.1.1.3 mrg 45 1.1.1.3 mrg # Initial commit: 46 1.1.1.3 mrg # +--------------------------------------------------+ 47 1.1.1.3 mrg # | gccrs: Some title | 48 1.1.1.3 mrg # | | This is the "start" 49 1.1.1.3 mrg # | This is some text explaining the commit. | 50 1.1.1.3 mrg # | There can be several lines. | 51 1.1.1.3 mrg # | |<-------------------> 52 1.1.1.3 mrg # | Signed-off-by: My Name <my (at] mail.com> | This is the "end" 53 1.1.1.3 mrg # +--------------------------------------------------+ 54 1.1.1.3 mrg # 55 1.1.1.3 mrg # Results in: 56 1.1.1.3 mrg # +--------------------------------------------------+ 57 1.1.1.3 mrg # | gccrs: Some title | 58 1.1.1.3 mrg # | | 59 1.1.1.3 mrg # | This is some text explaining the commit. | This is the "start" 60 1.1.1.3 mrg # | There can be several lines. | 61 1.1.1.3 mrg # | |<-------------------> 62 1.1.1.3 mrg # | gcc/rust/ChangeLog: | 63 1.1.1.3 mrg # | | This is the generated 64 1.1.1.3 mrg # | * some_file (bla): | ChangeLog part 65 1.1.1.3 mrg # | (foo): | 66 1.1.1.3 mrg # | |<-------------------> 67 1.1.1.3 mrg # | Signed-off-by: My Name <my (at] mail.com> | This is the "end" 68 1.1.1.3 mrg # +--------------------------------------------------+ 69 1.1.1.3 mrg 70 1.1.1.3 mrg # this regex matches the first line of the "end" in the initial commit message 71 1.1.1.3 mrg FIRST_LINE_OF_END_RE = re.compile('(?i)^(signed-off-by:|co-authored-by:|#)') 72 1.1.1.2 mrg 73 1.1 mrg pr_regex = re.compile(r'(\/(\/|\*)|[Cc*!])\s+(?P<pr>PR [a-z+-]+\/[0-9]+)') 74 1.1 mrg prnum_regex = re.compile(r'PR (?P<comp>[a-z+-]+)/(?P<num>[0-9]+)') 75 1.1 mrg dr_regex = re.compile(r'(\/(\/|\*)|[Cc*!])\s+(?P<dr>DR [0-9]+)') 76 1.1 mrg dg_regex = re.compile(r'{\s+dg-(error|warning)') 77 1.1 mrg pr_filename_regex = re.compile(r'(^|[\W_])[Pp][Rr](?P<pr>\d{4,})') 78 1.1 mrg identifier_regex = re.compile(r'^([a-zA-Z0-9_#].*)') 79 1.1 mrg comment_regex = re.compile(r'^\/\*') 80 1.1 mrg struct_regex = re.compile(r'^(class|struct|union|enum)\s+' 81 1.1 mrg r'(GTY\(.*\)\s+)?([a-zA-Z0-9_]+)') 82 1.1 mrg macro_regex = re.compile(r'#\s*(define|undef)\s+([a-zA-Z0-9_]+)') 83 1.1 mrg super_macro_regex = re.compile(r'^DEF[A-Z0-9_]+\s*\(([a-zA-Z0-9_]+)') 84 1.1 mrg fn_regex = re.compile(r'([a-zA-Z_][^()\s]*)\s*\([^*]') 85 1.1 mrg template_and_param_regex = re.compile(r'<[^<>]*>') 86 1.1 mrg md_def_regex = re.compile(r'\(define.*\s+"(.*)"') 87 1.1 mrg bugzilla_url = 'https://gcc.gnu.org/bugzilla/rest.cgi/bug?id=%s&' \ 88 1.1 mrg 'include_fields=summary,component' 89 1.1 mrg 90 1.1 mrg function_extensions = {'.c', '.cpp', '.C', '.cc', '.h', '.inc', '.def', '.md'} 91 1.1 mrg 92 1.1 mrg # NB: Makefile.in isn't listed as it's not always generated. 93 1.1 mrg generated_files = {'aclocal.m4', 'config.h.in', 'configure'} 94 1.1 mrg 95 1.1 mrg help_message = """\ 96 1.1 mrg Generate ChangeLog template for PATCH. 97 1.1 mrg PATCH must be generated using diff(1)'s -up or -cp options 98 1.1 mrg (or their equivalent in git). 99 1.1 mrg """ 100 1.1 mrg 101 1.1 mrg script_folder = os.path.realpath(__file__) 102 1.1 mrg root = os.path.dirname(os.path.dirname(script_folder)) 103 1.1 mrg 104 1.1 mrg 105 1.1 mrg def find_changelog(path): 106 1.1 mrg folder = os.path.split(path)[0] 107 1.1 mrg while True: 108 1.1 mrg if os.path.exists(os.path.join(root, folder, 'ChangeLog')): 109 1.1 mrg return folder 110 1.1 mrg folder = os.path.dirname(folder) 111 1.1 mrg if folder == '': 112 1.1 mrg return folder 113 1.1 mrg raise AssertionError() 114 1.1 mrg 115 1.1 mrg 116 1.1 mrg def extract_function_name(line): 117 1.1 mrg if comment_regex.match(line): 118 1.1 mrg return None 119 1.1 mrg m = struct_regex.search(line) 120 1.1 mrg if m: 121 1.1 mrg # Struct declaration 122 1.1 mrg return m.group(1) + ' ' + m.group(3) 123 1.1 mrg m = macro_regex.search(line) 124 1.1 mrg if m: 125 1.1 mrg # Macro definition 126 1.1 mrg return m.group(2) 127 1.1 mrg m = super_macro_regex.search(line) 128 1.1 mrg if m: 129 1.1 mrg # Supermacro 130 1.1 mrg return m.group(1) 131 1.1 mrg m = fn_regex.search(line) 132 1.1 mrg if m: 133 1.1 mrg # Discard template and function parameters. 134 1.1 mrg fn = m.group(1) 135 1.1 mrg fn = re.sub(template_and_param_regex, '', fn) 136 1.1 mrg return fn.rstrip() 137 1.1 mrg return None 138 1.1 mrg 139 1.1 mrg 140 1.1 mrg def try_add_function(functions, line): 141 1.1 mrg fn = extract_function_name(line) 142 1.1 mrg if fn and fn not in functions: 143 1.1 mrg functions.append(fn) 144 1.1 mrg return bool(fn) 145 1.1 mrg 146 1.1 mrg 147 1.1 mrg def sort_changelog_files(changed_file): 148 1.1 mrg return (changed_file.is_added_file, changed_file.is_removed_file) 149 1.1 mrg 150 1.1 mrg 151 1.1 mrg def get_pr_titles(prs): 152 1.1 mrg output = [] 153 1.1 mrg for idx, pr in enumerate(prs): 154 1.1 mrg pr_id = pr.split('/')[-1] 155 1.1 mrg r = requests.get(bugzilla_url % pr_id) 156 1.1 mrg bugs = r.json()['bugs'] 157 1.1 mrg if len(bugs) == 1: 158 1.1 mrg prs[idx] = 'PR %s/%s' % (bugs[0]['component'], pr_id) 159 1.1 mrg out = '%s - %s\n' % (prs[idx], bugs[0]['summary']) 160 1.1 mrg if out not in output: 161 1.1 mrg output.append(out) 162 1.1 mrg if output: 163 1.1 mrg output.append('') 164 1.1 mrg return '\n'.join(output) 165 1.1 mrg 166 1.1 mrg 167 1.1.1.2 mrg def append_changelog_line(out, relative_path, text): 168 1.1.1.2 mrg line = f'\t* {relative_path}:' 169 1.1.1.2 mrg if len(line.replace('\t', ' ' * TAB_WIDTH) + ' ' + text) <= LINE_LIMIT: 170 1.1.1.2 mrg out += f'{line} {text}\n' 171 1.1.1.2 mrg else: 172 1.1.1.2 mrg out += f'{line}\n' 173 1.1.1.2 mrg out += f'\t{text}\n' 174 1.1.1.2 mrg return out 175 1.1.1.2 mrg 176 1.1.1.2 mrg 177 1.1.1.2 mrg def get_rel_path_if_prefixed(path, folder): 178 1.1.1.2 mrg if path.startswith(folder): 179 1.1.1.2 mrg return path[len(folder):].lstrip('/') 180 1.1.1.2 mrg else: 181 1.1.1.2 mrg return path 182 1.1.1.2 mrg 183 1.1.1.2 mrg 184 1.1 mrg def generate_changelog(data, no_functions=False, fill_pr_titles=False, 185 1.1 mrg additional_prs=None): 186 1.1.1.3 mrg global prs 187 1.1.1.3 mrg prs = [] 188 1.1.1.3 mrg 189 1.1 mrg changelogs = {} 190 1.1 mrg changelog_list = [] 191 1.1 mrg out = '' 192 1.1 mrg diff = PatchSet(data) 193 1.1 mrg 194 1.1 mrg if additional_prs: 195 1.1.1.2 mrg for apr in additional_prs: 196 1.1.1.2 mrg if not apr.startswith('PR ') and '/' in apr: 197 1.1.1.2 mrg apr = 'PR ' + apr 198 1.1.1.2 mrg if apr not in prs: 199 1.1.1.2 mrg prs.append(apr) 200 1.1 mrg for file in diff: 201 1.1 mrg # skip files that can't be parsed 202 1.1 mrg if file.path == '/dev/null': 203 1.1 mrg continue 204 1.1 mrg changelog = find_changelog(file.path) 205 1.1 mrg if changelog not in changelogs: 206 1.1 mrg changelogs[changelog] = [] 207 1.1 mrg changelog_list.append(changelog) 208 1.1 mrg changelogs[changelog].append(file) 209 1.1 mrg 210 1.1 mrg # Extract PR entries from newly added tests 211 1.1 mrg if 'testsuite' in file.path and file.is_added_file: 212 1.1 mrg # Only search first ten lines as later lines may 213 1.1 mrg # contains commented code which a note that it 214 1.1 mrg # has not been tested due to a certain PR or DR. 215 1.1 mrg this_file_prs = [] 216 1.1.1.3 mrg hunks = list(file) 217 1.1.1.3 mrg if hunks: 218 1.1.1.3 mrg for line in hunks[0][0:10]: 219 1.1.1.3 mrg m = pr_regex.search(line.value) 220 1.1 mrg if m: 221 1.1.1.3 mrg pr = m.group('pr') 222 1.1.1.3 mrg if pr not in prs: 223 1.1.1.3 mrg prs.append(pr) 224 1.1.1.3 mrg this_file_prs.append(pr.split('/')[-1]) 225 1.1.1.3 mrg else: 226 1.1.1.3 mrg m = dr_regex.search(line.value) 227 1.1.1.3 mrg if m: 228 1.1.1.3 mrg dr = m.group('dr') 229 1.1.1.3 mrg if dr not in prs: 230 1.1.1.3 mrg prs.append(dr) 231 1.1.1.3 mrg this_file_prs.append(dr.split('/')[-1]) 232 1.1.1.3 mrg elif dg_regex.search(line.value): 233 1.1.1.3 mrg # Found dg-warning/dg-error line 234 1.1.1.3 mrg break 235 1.1.1.3 mrg 236 1.1 mrg # PR number in the file name 237 1.1 mrg fname = os.path.basename(file.path) 238 1.1 mrg m = pr_filename_regex.search(fname) 239 1.1 mrg if m: 240 1.1 mrg pr = m.group('pr') 241 1.1 mrg pr2 = 'PR ' + pr 242 1.1 mrg if pr not in this_file_prs and pr2 not in prs: 243 1.1 mrg prs.append(pr2) 244 1.1 mrg 245 1.1 mrg if fill_pr_titles: 246 1.1 mrg out += get_pr_titles(prs) 247 1.1 mrg 248 1.1 mrg # print list of PR entries before ChangeLog entries 249 1.1 mrg if prs: 250 1.1 mrg if not out: 251 1.1 mrg out += '\n' 252 1.1 mrg for pr in prs: 253 1.1 mrg out += '\t%s\n' % pr 254 1.1 mrg out += '\n' 255 1.1 mrg 256 1.1 mrg # sort ChangeLog so that 'testsuite' is at the end 257 1.1 mrg for changelog in sorted(changelog_list, key=lambda x: 'testsuite' in x): 258 1.1 mrg files = changelogs[changelog] 259 1.1 mrg out += '%s:\n' % os.path.join(changelog, 'ChangeLog') 260 1.1 mrg out += '\n' 261 1.1 mrg # new and deleted files should be at the end 262 1.1 mrg for file in sorted(files, key=sort_changelog_files): 263 1.1 mrg assert file.path.startswith(changelog) 264 1.1 mrg in_tests = 'testsuite' in changelog or 'testsuite' in file.path 265 1.1.1.2 mrg relative_path = get_rel_path_if_prefixed(file.path, changelog) 266 1.1 mrg functions = [] 267 1.1 mrg if file.is_added_file: 268 1.1.1.2 mrg msg = 'New test.' if in_tests else 'New file.' 269 1.1.1.2 mrg out = append_changelog_line(out, relative_path, msg) 270 1.1 mrg elif file.is_removed_file: 271 1.1.1.2 mrg out = append_changelog_line(out, relative_path, 'Removed.') 272 1.1 mrg elif hasattr(file, 'is_rename') and file.is_rename: 273 1.1 mrg # A file can be theoretically moved to a location that 274 1.1 mrg # belongs to a different ChangeLog. Let user fix it. 275 1.1.1.2 mrg # 276 1.1.1.2 mrg # Since unidiff 0.7.0, path.file == path.target_file[2:], 277 1.1.1.2 mrg # it used to be path.source_file[2:] 278 1.1.1.2 mrg relative_path = get_rel_path_if_prefixed(file.source_file[2:], 279 1.1.1.2 mrg changelog) 280 1.1.1.3 mrg out = append_changelog_line(out, relative_path, 'Move to...') 281 1.1.1.2 mrg new_path = get_rel_path_if_prefixed(file.target_file[2:], 282 1.1.1.2 mrg changelog) 283 1.1.1.2 mrg out += f'\t* {new_path}: ...here.\n' 284 1.1 mrg elif os.path.basename(file.path) in generated_files: 285 1.1 mrg out += '\t* %s: Regenerate.\n' % (relative_path) 286 1.1.1.2 mrg append_changelog_line(out, relative_path, 'Regenerate.') 287 1.1 mrg else: 288 1.1 mrg if not no_functions: 289 1.1 mrg for hunk in file: 290 1.1 mrg # Do not add function names for testsuite files 291 1.1 mrg extension = os.path.splitext(relative_path)[1] 292 1.1 mrg if not in_tests and extension in function_extensions: 293 1.1 mrg last_fn = None 294 1.1 mrg modified_visited = False 295 1.1 mrg success = False 296 1.1 mrg for line in hunk: 297 1.1 mrg m = identifier_regex.match(line.value) 298 1.1 mrg if line.is_added or line.is_removed: 299 1.1 mrg # special-case definition in .md files 300 1.1 mrg m2 = md_def_regex.match(line.value) 301 1.1 mrg if extension == '.md' and m2: 302 1.1 mrg fn = m2.group(1) 303 1.1 mrg if fn not in functions: 304 1.1 mrg functions.append(fn) 305 1.1 mrg last_fn = None 306 1.1 mrg success = True 307 1.1 mrg 308 1.1 mrg if not line.value.strip(): 309 1.1 mrg continue 310 1.1 mrg modified_visited = True 311 1.1 mrg if m and try_add_function(functions, 312 1.1 mrg m.group(1)): 313 1.1 mrg last_fn = None 314 1.1 mrg success = True 315 1.1 mrg elif line.is_context: 316 1.1 mrg if last_fn and modified_visited: 317 1.1 mrg try_add_function(functions, last_fn) 318 1.1 mrg last_fn = None 319 1.1 mrg modified_visited = False 320 1.1 mrg success = True 321 1.1 mrg elif m: 322 1.1 mrg last_fn = m.group(1) 323 1.1 mrg modified_visited = False 324 1.1 mrg if not success: 325 1.1 mrg try_add_function(functions, 326 1.1 mrg hunk.section_header) 327 1.1 mrg if functions: 328 1.1 mrg out += '\t* %s (%s):\n' % (relative_path, functions[0]) 329 1.1 mrg for fn in functions[1:]: 330 1.1 mrg out += '\t(%s):\n' % fn 331 1.1 mrg else: 332 1.1 mrg out += '\t* %s:\n' % relative_path 333 1.1 mrg out += '\n' 334 1.1 mrg return out 335 1.1 mrg 336 1.1 mrg 337 1.1 mrg def update_copyright(data): 338 1.1 mrg current_timestamp = datetime.datetime.now().strftime('%Y-%m-%d') 339 1.1 mrg username = subprocess.check_output('git config user.name', shell=True, 340 1.1 mrg encoding='utf8').strip() 341 1.1 mrg email = subprocess.check_output('git config user.email', shell=True, 342 1.1 mrg encoding='utf8').strip() 343 1.1 mrg 344 1.1 mrg changelogs = set() 345 1.1 mrg diff = PatchSet(data) 346 1.1 mrg 347 1.1 mrg for file in diff: 348 1.1 mrg changelog = os.path.join(find_changelog(file.path), 'ChangeLog') 349 1.1 mrg if changelog not in changelogs: 350 1.1 mrg changelogs.add(changelog) 351 1.1 mrg with open(changelog) as f: 352 1.1 mrg content = f.read() 353 1.1 mrg with open(changelog, 'w+') as f: 354 1.1 mrg f.write(f'{current_timestamp} {username} <{email}>\n\n') 355 1.1 mrg f.write('\tUpdate copyright years.\n\n') 356 1.1 mrg f.write(content) 357 1.1 mrg 358 1.1 mrg 359 1.1.1.2 mrg def skip_line_in_changelog(line): 360 1.1.1.3 mrg return FIRST_LINE_OF_END_RE.match(line) is None 361 1.1.1.2 mrg 362 1.1.1.2 mrg 363 1.1 mrg if __name__ == '__main__': 364 1.1.1.3 mrg extra_args = os.getenv('GCC_MKLOG_ARGS') 365 1.1.1.3 mrg if extra_args: 366 1.1.1.3 mrg sys.argv += json.loads(extra_args) 367 1.1.1.3 mrg 368 1.1 mrg parser = argparse.ArgumentParser(description=help_message) 369 1.1 mrg parser.add_argument('input', nargs='?', 370 1.1 mrg help='Patch file (or missing, read standard input)') 371 1.1 mrg parser.add_argument('-b', '--pr-numbers', action='store', 372 1.1 mrg type=lambda arg: arg.split(','), nargs='?', 373 1.1 mrg help='Add the specified PRs (comma separated)') 374 1.1 mrg parser.add_argument('-s', '--no-functions', action='store_true', 375 1.1 mrg help='Do not generate function names in ChangeLogs') 376 1.1 mrg parser.add_argument('-p', '--fill-up-bug-titles', action='store_true', 377 1.1 mrg help='Download title of mentioned PRs') 378 1.1 mrg parser.add_argument('-d', '--directory', 379 1.1 mrg help='Root directory where to search for ChangeLog ' 380 1.1 mrg 'files') 381 1.1 mrg parser.add_argument('-c', '--changelog', 382 1.1 mrg help='Append the ChangeLog to a git commit message ' 383 1.1 mrg 'file') 384 1.1 mrg parser.add_argument('--update-copyright', action='store_true', 385 1.1 mrg help='Update copyright in ChangeLog files') 386 1.1.1.3 mrg parser.add_argument('-a', '--append', action='store_true', 387 1.1.1.3 mrg help='Append the generate ChangeLog to the patch file') 388 1.1 mrg args = parser.parse_args() 389 1.1 mrg if args.input == '-': 390 1.1 mrg args.input = None 391 1.1 mrg if args.directory: 392 1.1 mrg root = args.directory 393 1.1 mrg 394 1.1.1.3 mrg data = open(args.input, newline='\n') if args.input else sys.stdin 395 1.1 mrg if args.update_copyright: 396 1.1 mrg update_copyright(data) 397 1.1 mrg else: 398 1.1 mrg output = generate_changelog(data, args.no_functions, 399 1.1 mrg args.fill_up_bug_titles, args.pr_numbers) 400 1.1.1.3 mrg if args.append: 401 1.1.1.3 mrg if (not args.input): 402 1.1.1.3 mrg raise Exception("`-a or --append` option not support standard " 403 1.1.1.3 mrg "input") 404 1.1.1.3 mrg lines = [] 405 1.1.1.3 mrg with open(args.input, 'r', newline='\n') as f: 406 1.1.1.3 mrg # 1 -> not find the possible start of diff log 407 1.1.1.3 mrg # 2 -> find the possible start of diff log 408 1.1.1.3 mrg # 3 -> finish add ChangeLog to the patch file 409 1.1.1.3 mrg maybe_diff_log = 1 410 1.1.1.3 mrg for line in f: 411 1.1.1.3 mrg if maybe_diff_log == 1 and line == "---\n": 412 1.1.1.3 mrg maybe_diff_log = 2 413 1.1.1.3 mrg elif (maybe_diff_log == 2 and 414 1.1.1.3 mrg re.match(r"\s[^\s]+\s+\|\s+\d+\s[+\-]+\n", line)): 415 1.1.1.3 mrg lines += [output, "---\n", line] 416 1.1.1.3 mrg maybe_diff_log = 3 417 1.1.1.3 mrg else: 418 1.1.1.3 mrg # the possible start is not the true start. 419 1.1.1.3 mrg if maybe_diff_log == 2: 420 1.1.1.3 mrg lines.append("---\n") 421 1.1.1.3 mrg maybe_diff_log = 1 422 1.1.1.3 mrg lines.append(line) 423 1.1.1.3 mrg with open(args.input, "w") as f: 424 1.1.1.3 mrg f.writelines(lines) 425 1.1.1.3 mrg elif args.changelog: 426 1.1 mrg lines = open(args.changelog).read().split('\n') 427 1.1.1.2 mrg start = list(takewhile(skip_line_in_changelog, lines)) 428 1.1 mrg end = lines[len(start):] 429 1.1 mrg with open(args.changelog, 'w') as f: 430 1.1 mrg if not start or not start[0]: 431 1.1.1.3 mrg if len(prs) == 1: 432 1.1.1.3 mrg # initial commit subject line 'component: [PRnnnnn]' 433 1.1.1.3 mrg m = prnum_regex.match(prs[0]) 434 1.1.1.3 mrg if m: 435 1.1.1.3 mrg title = f'{m.group("comp")}: [PR{m.group("num")}]' 436 1.1.1.3 mrg start.insert(0, title) 437 1.1 mrg if start: 438 1.1 mrg # append empty line 439 1.1 mrg if start[-1] != '': 440 1.1 mrg start.append('') 441 1.1 mrg else: 442 1.1 mrg # append 2 empty lines 443 1.1 mrg start = 2 * [''] 444 1.1 mrg f.write('\n'.join(start)) 445 1.1 mrg f.write('\n') 446 1.1 mrg f.write(output) 447 1.1 mrg f.write('\n'.join(end)) 448 1.1 mrg else: 449 1.1 mrg print(output, end='') 450