diff --git a/CHANGELOG.md b/CHANGELOG.md index 3628254..1409fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.3.1 + +- Add the `-i/--issue` and `-s/--section` options to the `add` command. + This lets you pre-fill the `gh-issue` and `section` fields in the template. + Added by @picnixz in https://github.com/python/blurb/pull/16. + ## 1.3.0 * Add support for Python 3.13 by @hugovk in https://github.com/python/blurb/pull/26 diff --git a/README.md b/README.md index c5e6abc..eea1f46 100644 --- a/README.md +++ b/README.md @@ -107,12 +107,29 @@ The template for the `blurb add` message looks like this: Here's how you interact with the file: * Add the GitHub issue number for this commit to the - end of the `.. gh-issue:` line. + end of the `.. gh-issue:` line. The issue can also + be specified via the ``-i/--issue`` option: + + ```shell + $ blurb add -i 109198 + # or equivalently + $ blurb add -i https://github.com/python/cpython/issues/109198 + ``` * Uncomment the line with the relevant `Misc/NEWS` section for this entry. For example, if this should go in the `Library` section, uncomment the line reading `#.. section: Library`. To uncomment, just delete - the `#` at the front of the line. + the `#` at the front of the line. Alternatively, the section can + be specified via the ``-s/--section`` option: + + ```shell + $ blurb add -s 'Library' + # or equivalently + $ blurb add -s lib + ``` + + The match is performed case insensitively and partial matching is + supported as long as the match is unique. * Finally, go to the end of the file, and enter your `NEWS` entry. This should be a single paragraph of English text using diff --git a/src/blurb/blurb.py b/src/blurb/blurb.py index 0af3ea9..285311a 100755 --- a/src/blurb/blurb.py +++ b/src/blurb/blurb.py @@ -66,6 +66,9 @@ # This template is the canonical list of acceptable section names! # It's parsed internally into the "sections" set. # +# Do not forget to update the example for the 'add' command if +# the section names change as well as the corresponding tests. +# template = """ @@ -806,7 +809,14 @@ def help(subcommand=None): for name, p in inspect.signature(fn).parameters.items(): if p.kind == inspect.Parameter.KEYWORD_ONLY: short_option = name[0] - options.append(f" [-{short_option}|--{name}]") + if isinstance(p.default, bool): + options.append(f" [-{short_option}|--{name}]") + else: + if p.default is None: + metavar = f'{name.upper()}' + else: + metavar = f'{name.upper()}[={p.default}]' + options.append(f" [-{short_option}|--{name} {metavar}]") elif p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: positionals.append(" ") has_default = (p.default != inspect._empty) @@ -887,10 +897,175 @@ def find_editor(): error('Could not find an editor! Set the EDITOR environment variable.') +def _extract_issue_number(issue): + if issue is None: + return None + + issue = raw_issue = str(issue).strip() + if issue.startswith('gh-'): + issue = issue[3:] + if issue.isdigit(): + return issue + + match = re.match(r'^(?:https://)?github\.com/python/cpython/issues/(\d+)$', issue) + if match is None: + sys.exit(f"Invalid GitHub issue: {raw_issue}") + return match.group(1) + + +# Mapping from section names to additional allowed patterns +# which ignore whitespaces for composed section names. +# +# For instance, 'Core and Builtins' is represented by the +# pattern 'Core?and?Builtins' where are the +# allowed user separators '_', '-', ' ' and '/'. +_section_special_patterns = {__: set() for __ in sections} + +# Mapping from section names to sanitized names (no separators, lowercase). +# +# For instance, 'Core and Builtins' is mapped to 'coreandbuiltins', and +# passing a prefix of that would match to 'Core and Builtins'. Note that +# this is only used as a last resort. +_section_names_lower_nosep = {} + +for _section in sections: + # ' ' and '/' are the separators used by known sections + _sanitized = re.sub(r'[ /]', ' ', _section) + _section_words = re.split(r'\s+', _sanitized) + _section_names_lower_nosep[_section] = ''.join(_section_words).lower() + del _sanitized + # '_', '-', ' ' and '/' are the allowed (user) separators + _section_pattern = r'[_\- /]?'.join(map(re.escape, _section_words)) + # add '$' to avoid matching after the pattern + _section_pattern = f'{_section_pattern}$' + del _section_words + _section_pattern = re.compile(_section_pattern, re.I) + _section_special_patterns[_section].add(_section_pattern) + del _section_pattern, _section + +# the following statements will raise KeyError if the names are invalid +_section_special_patterns['C API'].add(re.compile(r'^((?<=c)[_\- /])?api$', re.I)) +_section_special_patterns['Core and Builtins'].add(re.compile('^builtins?$', re.I)) +_section_special_patterns['Tools/Demos'].add(re.compile('^dem(?:o|os)?$', re.I)) + + +def _find_smart_matches(section): + # '_', '-' and ' ' are the allowed (user) whitespace separators + sanitized = re.sub(r'[_\- ]', ' ', section).strip() + if not sanitized: + return [] + + matches = [] + section_words = re.split(r'\s+', sanitized) + # ' ' and '/' are the separators used by known sections + section_pattern = r'[ /]'.join(map(re.escape, section_words)) + section_pattern = re.compile(section_pattern, re.I) + + for section_name in sections: + # try to use the input as the pattern to match against known names + if section_pattern.match(section_name): + matches.append(section_name) + + if not matches: + for section_name, special_patterns in _section_special_patterns.items(): + for special_pattern in special_patterns: + if special_pattern.match(sanitized): + matches.append(section_name) + break + + if not matches: + # try to use the input as the prefix of a flattened section name + normalized_prefix = ''.join(section_words).lower() + for section_name, normalized in _section_names_lower_nosep.items(): + if normalized.startswith(normalized_prefix): + matches.append(section_name) + + return matches + + +def _extract_section_name(section): + if section is None: + return None + + section = raw_section = section.strip() + if not section: + sys.exit("Empty section name!") + + matches = [] + # try a simple exact match + for section_name in sections: + if section_name.lower().startswith(section.lower()): + matches.append(section_name) + # try a more complex algorithm if we are unlucky + matches = matches or _find_smart_matches(section) + + if not matches: + sys.exit(f"Invalid section name: {raw_section!r}\n\n" + f"Choose from the following table:\n\n" + f'{sections_table}') + + if len(matches) > 1: + multiple_matches = ', '.join(map(repr, sorted(matches))) + sys.exit(f"More than one match for: {raw_section!r}\n" + f"Matches: {multiple_matches}") + + return matches[0] + + +def _update_blurb_template(issue, section): + issue_number = _extract_issue_number(issue) + section_name = _extract_section_name(section) + + # Ensure that a whitespace is given after 'gh-issue:' + # to help filling up the template, unless an issue + # number was manually specified via the CLI. + text = template + + issue_line = ".. gh-issue:" + pattern = "\n" + issue_line + "\n" + if issue_number is None: + if pattern not in text: + sys.exit("Can't find gh-issue line to ensure there's a space on the end!") + replacement = "\n" + issue_line + " \n" + else: + if pattern not in text: + sys.exit("Can't find gh-issue line to fill!") + replacement = "\n" + issue_line + " " + str(issue_number) + "\n" + + text = text.replace(pattern, replacement) + + # Uncomment the section if needed. + if section_name is not None: + pattern = f'#.. section: {section_name}' + text = text.replace(pattern, pattern.lstrip('#')) + + return text + + @subcommand -def add(): +def add(*, issue=None, section=None): """ Add a blurb (a Misc/NEWS.d/next entry) to the current CPython repo. + +Use -i/--issue to specify a GitHub issue number or link, e.g.: + + blurb add -i 109198 + + # or equivalently + blurb add -i https://github.com/python/cpython/issues/109198 + +The blurb's section can be specified via -s/--section +with its name (case insenstitive), e.g.: + + blurb add -s 'Library' + + # or using a partial matching + blurb add -s lib + +The known sections names are defined as follows and +spaces in names can be substituted for underscores: + +{sections} """ editor = find_editor() @@ -901,20 +1076,8 @@ def add(): def init_tmp_with_template(): with open(tmp_path, "wt", encoding="utf-8") as file: - # hack: - # my editor likes to strip trailing whitespace from lines. - # normally this is a good idea. but in the case of the template - # it's unhelpful. - # so, manually ensure there's a space at the end of the gh-issue line. - text = template - - issue_line = ".. gh-issue:" - without_space = "\n" + issue_line + "\n" - with_space = "\n" + issue_line + " \n" - if without_space not in text: - sys.exit("Can't find gh-issue line to ensure there's a space on the end!") - text = text.replace(without_space, with_space) - file.write(text) + updated = _update_blurb_template(issue, section) + file.write(updated) init_tmp_with_template() @@ -965,6 +1128,17 @@ def init_tmp_with_template(): print("Ready for commit.") +assert sections, 'sections is empty' +_sec_name_width = 2 + max(map(len, sections)) +_sec_row_rule = '+'.join(['', '-' * _sec_name_width, '']) +_format_row = (f'| {{:{_sec_name_width - 2:d}}} |').format +del _sec_name_width +sections_table = '\n'.join(map(_format_row, sections)) +del _format_row +sections_table = '\n'.join((_sec_row_rule, sections_table, _sec_row_rule)) +del _sec_row_rule +add.__doc__ = add.__doc__.format(sections=sections_table) + @subcommand def release(version): @@ -1239,25 +1413,39 @@ def main(): kwargs = {} for name, p in inspect.signature(fn).parameters.items(): if p.kind == inspect.Parameter.KEYWORD_ONLY: - assert isinstance(p.default, bool), "blurb command-line processing only handles boolean options" + assert p.default is None or isinstance(p.default, (bool, str)), \ + "blurb command-line processing only handles boolean options" kwargs[name] = p.default short_options[name[0]] = name long_options[name] = name filtered_args = [] done_with_options = False + consume_after = None def handle_option(s, dict): + nonlocal consume_after name = dict.get(s, None) if not name: sys.exit(f'blurb: Unknown option for {subcommand}: "{s}"') - kwargs[name] = not kwargs[name] + + value = kwargs[name] + if isinstance(value, bool): + kwargs[name] = not value + else: + consume_after = name # print(f"short_options {short_options} long_options {long_options}") for a in args: + if consume_after: + kwargs[consume_after] = a + consume_after = None + continue + if done_with_options: filtered_args.append(a) continue + if a.startswith('-'): if a == "--": done_with_options = True @@ -1267,8 +1455,12 @@ def handle_option(s, dict): for s in a[1:]: handle_option(s, short_options) continue + filtered_args.append(a) + if consume_after: + sys.exit(f"Error: blurb: {subcommand} {consume_after} " + f"must be followed by an option argument") sys.exit(fn(*filtered_args, **kwargs)) except TypeError as e: diff --git a/tests/test_blurb.py b/tests/test_blurb.py index 9ff3a8d..7a2c499 100644 --- a/tests/test_blurb.py +++ b/tests/test_blurb.py @@ -1,9 +1,16 @@ import pytest -from pyfakefs.fake_filesystem import FakeFilesystem from blurb import blurb +def test_section_names(): + assert tuple(blurb.sections) == ( + 'Security', 'Core and Builtins', 'Library', 'Documentation', + 'Tests', 'Build', 'Windows', 'macOS', 'IDLE', 'Tools/Demos', + 'C API', + ) + + UNCHANGED_SECTIONS = ( "Library", ) diff --git a/tests/test_blurb_add.py b/tests/test_blurb_add.py new file mode 100644 index 0000000..061dd48 --- /dev/null +++ b/tests/test_blurb_add.py @@ -0,0 +1,269 @@ +import re + +import pytest + +from blurb import blurb + + +def test_valid_no_issue_number(): + assert blurb._extract_issue_number(None) is None + res = blurb._update_blurb_template(issue=None, section=None) + lines = res.splitlines() + assert '.. gh-issue:' not in lines + assert '.. gh-issue: ' in lines + for line in lines: + assert not line.startswith('.. section: ') + + +@pytest.mark.parametrize('issue', [ + # issue given by their number + '12345', + '12345 ', + ' 12345', + ' 12345 ', + # issue given by their number and a 'gh-' prefix + 'gh-12345', + 'gh-12345 ', + ' gh-12345', + ' gh-12345 ', + # issue given by their URL (no protocol) + 'github.com/python/cpython/issues/12345', + 'github.com/python/cpython/issues/12345 ', + ' github.com/python/cpython/issues/12345', + ' github.com/python/cpython/issues/12345 ', + # issue given by their URL (with protocol) + 'https://github.com/python/cpython/issues/12345', + 'https://github.com/python/cpython/issues/12345 ', + ' https://github.com/python/cpython/issues/12345', + ' https://github.com/python/cpython/issues/12345 ', +]) +def test_valid_issue_number_12345(issue): + actual = blurb._extract_issue_number(issue) + assert actual == '12345' + + res = blurb._update_blurb_template(issue=issue, section=None) + + lines = res.splitlines() + assert '.. gh-issue:' not in lines + assert '.. gh-issue: ' not in lines + assert '.. gh-issue: 12345' in lines + + for line in lines: + assert not line.startswith('.. section: ') + + +@pytest.mark.parametrize('issue', [ + '', + 'abc', + 'gh-abc', + 'gh-', + 'bpo-', + 'bpo-12345', + 'github.com/python/cpython/issues', + 'github.com/python/cpython/issues/', + 'github.com/python/cpython/issues/abc', + 'github.com/python/cpython/issues/gh-abc', + 'github.com/python/cpython/issues/gh-123', + 'github.com/python/cpython/issues/1234?param=1', + 'https://github.com/python/cpython/issues', + 'https://github.com/python/cpython/issues/', + 'https://github.com/python/cpython/issues/abc', + 'https://github.com/python/cpython/issues/gh-abc', + 'https://github.com/python/cpython/issues/gh-123', + 'https://github.com/python/cpython/issues/1234?param=1', +]) +def test_invalid_issue_number(issue): + error_message = re.escape(f'Invalid GitHub issue: {issue}') + with pytest.raises(SystemExit, match=error_message): + blurb._extract_issue_number(issue) + + with pytest.raises(SystemExit, match=error_message): + blurb._update_blurb_template(issue=issue, section=None) + + +class TestValidSectionNames: + @staticmethod + def check(section, expect): + actual = blurb._extract_section_name(section) + assert actual == expect + + res = blurb._update_blurb_template(issue=None, section=section) + res = res.splitlines() + for section_name in blurb.sections: + if section_name == expect: + assert f'.. section: {section_name}' in res + else: + assert f'#.. section: {section_name}' in res + assert f'.. section: {section_name}' not in res + + @pytest.mark.parametrize( + ('section', 'expect'), + tuple(zip(blurb.sections, blurb.sections)) + ) + def test_exact_names(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('Sec', 'Security'), + ('sec', 'Security'), + ('security', 'Security'), + ('Core And', 'Core and Builtins'), + ('Core And Built', 'Core and Builtins'), + ('Core And Builtins', 'Core and Builtins'), + ('Lib', 'Library'), + ('doc', 'Documentation'), + ('document', 'Documentation'), + ('Tes', 'Tests'), + ('tes', 'Tests'), + ('Test', 'Tests'), + ('Tests', 'Tests'), + ('Buil', 'Build'), + ('buil', 'Build'), + ('build', 'Build'), + ('Tool', 'Tools/Demos'), + ('Tools', 'Tools/Demos'), + ('Tools/', 'Tools/Demos'), + ('core', 'Core and Builtins'), + ] + ) + def test_partial_words(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('builtin', 'Core and Builtins'), + ('builtins', 'Core and Builtins'), + ('api', 'C API'), + ('c-api', 'C API'), + ('c/api', 'C API'), + ('c api', 'C API'), + ('dem', 'Tools/Demos'), + ('demo', 'Tools/Demos'), + ('demos', 'Tools/Demos'), + ] + ) + def test_partial_special_names(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), [ + ('Core-and-Builtins', 'Core and Builtins'), + ('Core_and_Builtins', 'Core and Builtins'), + ('Core_and-Builtins', 'Core and Builtins'), + ('Core and', 'Core and Builtins'), + ('Core_and', 'Core and Builtins'), + ('core_and', 'Core and Builtins'), + ('core-and', 'Core and Builtins'), + ('Core and Builtins', 'Core and Builtins'), + ('cOre _ and - bUILtins', 'Core and Builtins'), + ('Tools/demo', 'Tools/Demos'), + ('Tools-demo', 'Tools/Demos'), + ('Tools demo', 'Tools/Demos'), + ] + ) + def test_partial_separators(self, section, expect): + # normalize the separtors '_', '-', ' ' and '/' + self.check(section, expect) + + @pytest.mark.parametrize( + ('prefix', 'expect'), [ + ('corean', 'Core and Builtins'), + ('coreand', 'Core and Builtins'), + ('coreandbuilt', 'Core and Builtins'), + ('coreand Builtins', 'Core and Builtins'), + ('coreand Builtins', 'Core and Builtins'), + ('coreAnd Builtins', 'Core and Builtins'), + ('CoreAnd Builtins', 'Core and Builtins'), + ('Coreand', 'Core and Builtins'), + ('Coreand Builtins', 'Core and Builtins'), + ('Coreand builtin', 'Core and Builtins'), + ('Coreand buil', 'Core and Builtins'), + ] + ) + def test_partial_prefix_words(self, prefix, expect): + # try to find a match using prefixes (without separators and lowercase) + self.check(prefix, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), + [(name.lower(), name) for name in blurb.sections], + ) + def test_exact_names_lowercase(self, section, expect): + self.check(section, expect) + + @pytest.mark.parametrize( + ('section', 'expect'), + [(name.upper(), name) for name in blurb.sections], + ) + def test_exact_names_uppercase(self, section, expect): + self.check(section, expect) + + +@pytest.mark.parametrize('section', ['', ' ', ' ']) +def test_empty_section_name(section): + error_message = re.escape('Empty section name!') + with pytest.raises(SystemExit, match=error_message): + blurb._extract_section_name(section) + + with pytest.raises(SystemExit, match=error_message): + blurb._update_blurb_template(issue=None, section=section) + + +@pytest.mark.parametrize('section', [ + # invalid + '_', + '-', + '/', + 'invalid', + 'Not a section', + # non-special names + 'c?api', + 'cXapi', + 'C+API', + # super-strings + 'Library and more', + 'library3', + 'librari', +]) +def test_invalid_section_name(section): + error_message = re.escape(f'Invalid section name: {section!r}') + error_message = re.compile(rf'{error_message}\n\n.+', re.MULTILINE) + with pytest.raises(SystemExit, match=error_message): + blurb._extract_section_name(section) + + with pytest.raises(SystemExit, match=error_message): + blurb._update_blurb_template(issue=None, section=section) + + +@pytest.mark.parametrize(('section', 'matches'), [ + # 'matches' must be a sorted sequence of matching section names + ('c', ['C API', 'Core and Builtins']), + ('C', ['C API', 'Core and Builtins']), + ('t', ['Tests', 'Tools/Demos']), + ('T', ['Tests', 'Tools/Demos']), +]) +def test_ambiguous_section_name(section, matches): + matching_list = ', '.join(map(repr, matches)) + error_message = re.escape(f'More than one match for: {section!r}\n' + f'Matches: {matching_list}') + error_message = re.compile(rf'{error_message}', re.MULTILINE) + with pytest.raises(SystemExit, match=error_message): + blurb._extract_section_name(section) + + with pytest.raises(SystemExit, match=error_message): + blurb._update_blurb_template(issue=None, section=section) + + +@pytest.mark.parametrize('invalid', [ + 'gh-issue: ', + 'gh-issue: 1', + 'gh-issue', +]) +def test_illformed_gh_issue_line(invalid, monkeypatch): + template = blurb.template.replace('.. gh-issue:', invalid) + error_message = re.escape("Can't find gh-issue line to fill!") + with monkeypatch.context() as cm: + cm.setattr(blurb, 'template', template) + with pytest.raises(SystemExit, match=error_message): + blurb._update_blurb_template(issue='1234', section=None)