#!/usr/bin/env python """Generate a new changelog entry. Usage ===== To generate a new changelog entry:: scripts/new-change This will open up a file in your editor (via the ``EDITOR`` env var). You'll see this template:: # Type should be one of: feature, bugfix type: # Category is the high level feature area. # This can be a service identifier (e.g ``s3``), # or something like: Paginator. category: # A brief description of the change. You can # use github style references to issues such as # "fixes #489", "boto/boto3#100", etc. These # will get automatically replaced with the correct # link. description: Fill in the appropriate values, save and exit the editor. Make sure to commit these changes as part of your pull request. If, when your editor is open, you decide don't don't want to add a changelog entry, save an empty file and no entry will be generated. You can then use the ``scripts/gen-changelog`` to generate the CHANGELOG.rst file. """ import argparse import json import os import random import re import string import subprocess import sys import tempfile VALID_CHARS = set(string.ascii_letters + string.digits) CHANGES_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.changes' ) TEMPLATE = """\ # Type should be one of: feature, bugfix, enhancement, api-change # feature: A larger feature or change in behavior, usually resulting in a # minor version bump. # bugfix: Fixing a bug in an existing code path. # enhancement: Small change to an underlying implementation detail. # api-change: Changes to a modeled API. type: {change_type} # Category is the high level feature area. # This can be a service identifier (e.g ``s3``), # or something like: Paginator. category: {category} # A brief description of the change. You can # use github style references to issues such as # "fixes #489", "boto/boto3#100", etc. These # will get automatically replaced with the correct # link. description: {description} """ def new_changelog_entry(args): # Changelog values come from one of two places. # Either all values are provided on the command line, # or we open a text editor and let the user provide # enter their values. if all_values_provided(args): parsed_values = { 'type': args.change_type, 'category': args.category, 'description': args.description, } else: parsed_values = get_values_from_editor(args) if has_empty_values(parsed_values): sys.stderr.write( "Empty changelog values received, skipping entry creation.\n" ) return 1 replace_issue_references(parsed_values, args.repo) write_new_change(parsed_values) return 0 def has_empty_values(parsed_values): return not ( parsed_values.get('type') and parsed_values.get('category') and parsed_values.get('description') ) def all_values_provided(args): return args.change_type and args.category and args.description def get_values_from_editor(args): with tempfile.NamedTemporaryFile('w') as f: contents = TEMPLATE.format( change_type=args.change_type, category=args.category, description=args.description, ) f.write(contents) f.flush() env = os.environ editor = env.get('VISUAL', env.get('EDITOR', 'vim')) p = subprocess.Popen(f'{editor} {f.name}', shell=True) p.communicate() with open(f.name) as f: filled_in_contents = f.read() parsed_values = parse_filled_in_contents(filled_in_contents) return parsed_values def replace_issue_references(parsed, repo_name): description = parsed['description'] def linkify(match): number = match.group()[1:] return '`{} `__'.format( match.group(), repo_name, number ) new_description = re.sub(r'#\d+', linkify, description) parsed['description'] = new_description def write_new_change(parsed_values): if not os.path.isdir(CHANGES_DIR): os.makedirs(CHANGES_DIR) # Assume that new changes go into the next release. dirname = os.path.join(CHANGES_DIR, 'next-release') if not os.path.isdir(dirname): os.makedirs(dirname) # Need to generate a unique filename for this change. # We'll try a couple things until we get a unique match. category = parsed_values['category'] short_summary = ''.join(filter(lambda x: x in VALID_CHARS, category)) filename = '{type_name}-{summary}'.format( type_name=parsed_values['type'], summary=short_summary ) possible_filename = os.path.join( dirname, f'{filename}-{str(random.randint(1, 100000))}.json' ) while os.path.isfile(possible_filename): possible_filename = os.path.join( dirname, f'{filename}-{str(random.randint(1, 100000))}.json' ) with open(possible_filename, 'w') as f: f.write(json.dumps(parsed_values, indent=2) + "\n") def parse_filled_in_contents(contents): """Parse filled in file contents and returns parsed dict. Return value will be:: { "type": "bugfix", "category": "category", "description": "This is a description" } """ if not contents.strip(): return {} parsed = {} lines = iter(contents.splitlines()) for line in lines: line = line.strip() if line.startswith('#'): continue if 'type' not in parsed and line.startswith('type:'): parsed['type'] = line.split(':')[1].strip() elif 'category' not in parsed and line.startswith('category:'): parsed['category'] = line.split(':')[1].strip() elif 'description' not in parsed and line.startswith('description:'): # Assume that everything until the end of the file is part # of the description, so we can break once we pull in the # remaining lines. first_line = line.split(':')[1].strip() full_description = '\n'.join([first_line] + list(lines)) parsed['description'] = full_description.strip() break return parsed def main(): parser = argparse.ArgumentParser() parser.add_argument( '-t', '--type', dest='change_type', default='', choices=('bugfix', 'feature', 'enhancement', 'api-change'), ) parser.add_argument('-c', '--category', dest='category', default='') parser.add_argument('-d', '--description', dest='description', default='') parser.add_argument( '-r', '--repo', default='boto/boto3', help='Optional repo name, e.g: boto/boto3', ) args = parser.parse_args() sys.exit(new_changelog_entry(args)) if __name__ == '__main__': main()