Skip to content

Commit 2106ae6

Browse files
gpsheadpicnixz
andcommitted
Add user-friendly interface improvements to blurb add command
Integrate the user-friendly features from PR python#16 by @picnixz into the automation support from PR python#45, making the CLI more intuitive: - Change --gh-issue to --issue, accepting multiple formats: * Plain numbers: --issue 12345 * With gh- prefix: --issue gh-12345 * GitHub URLs: --issue python/cpython#12345 - Add smart section matching with: * Case-insensitive matching: --section lib matches "Library" * Partial matching: --section doc matches "Documentation" * Common aliases: --section api matches "C API" * Separator normalization: --section core-and-builtins - Improve error messages for invalid sections This combines the automation features from PR python#45 with the interface improvements suggested by @picnixz in PR python#16, as reviewed by @hugovk and @larryhastings. Co-authored-by: picnixz <[email protected]>
1 parent e79dd63 commit 2106ae6

File tree

7 files changed

+536
-160
lines changed

7 files changed

+536
-160
lines changed

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
## 2.1.0 (unreleased)
44

55
* Add automation support to `blurb add` command:
6-
* New `--gh-issue` option to specify GitHub issue number
7-
* New `--section` option to specify NEWS section
6+
* New `--issue` option to specify GitHub issue number (supports URLs and various formats)
7+
* New `--section` option to specify NEWS section (with smart case-insensitive matching)
88
* New `--rst-on-stdin` option to read entry content from stdin
99
* Useful for CI systems and automated tools
1010
* Uses `cyclopts` for command line parsing instead of rolling our own to reduce our code size, this changes the help format and brings in a dependency.

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,22 @@ For automated tools and CI systems, `blurb add` supports non-interactive operati
8484
```bash
8585
# Add a blurb entry from stdin
8686
echo 'Added beans to the :mod:`spam` module.' | blurb add \
87-
--gh-issue 123456 \
87+
--issue 123456 \
8888
--section Library \
8989
--rst-on-stdin
9090
```
9191

92-
When using `--rst_on_stdin`, both `--gh_issue` and `--section` are required.
92+
When using `--rst-on-stdin`, both `--issue` and `--section` are required.
93+
94+
The `--issue` parameter accepts various formats:
95+
- Issue number: `--issue 12345`
96+
- With gh- prefix: `--issue gh-12345`
97+
- GitHub URL: `--issue https://github.com/python/cpython/issues/12345`
98+
99+
The `--section` parameter supports smart matching:
100+
- Case insensitive: `--section library` or `--section LIBRARY`
101+
- Partial matching: `--section lib` (matches "Library")
102+
- Common aliases: `--section api` (matches "C API"), `--section builtin` (matches "Core and Builtins")
93103

94104
The template for the `blurb add` message looks like this:
95105

src/blurb/blurb.py

100755100644
Lines changed: 133 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -747,31 +747,26 @@ def find_editor():
747747
error('Could not find an editor! Set the EDITOR environment variable.')
748748

749749

750-
def validate_add_parameters(section, gh_issue, rst_on_stdin):
751-
"""Validate parameters for the add command."""
752-
if section and section not in SECTIONS:
753-
error(f"--section must be one of {SECTIONS} not {section!r}")
754750

755-
if gh_issue < 0:
756-
error(f"--gh-issue must be a positive integer not {gh_issue!r}")
757751

758-
if rst_on_stdin and (gh_issue <= 0 or not section):
759-
error("--gh-issue and --section required with --rst-on-stdin")
760-
761-
762-
def prepare_template(tmp_path, gh_issue, section, rst_content):
752+
def prepare_template(tmp_path, issue_number, section_name, rst_content):
763753
"""Write the template file with substitutions."""
764754
text = template
765755

766-
# Ensure gh-issue line ends with space
756+
# Ensure gh-issue line ends with space (or fill in issue number)
767757
issue_line = ".. gh-issue:"
768-
text = text.replace(f"\n{issue_line}\n", f"\n{issue_line} \n")
758+
pattern = f"\n{issue_line}\n"
759+
if issue_number:
760+
replacement = f"\n{issue_line} {issue_number}\n"
761+
else:
762+
replacement = f"\n{issue_line} \n"
763+
text = text.replace(pattern, replacement)
764+
765+
# Apply section substitution
766+
if section_name:
767+
text = text.replace(f"#.. section: {section_name}\n", f".. section: {section_name}\n")
769768

770-
# Apply substitutions
771-
if gh_issue:
772-
text = text.replace(".. gh-issue: \n", f".. gh-issue: {gh_issue}\n")
773-
if section:
774-
text = text.replace(f"#.. section: {section}\n", f".. section: {section}\n")
769+
# Apply content substitution
775770
if rst_content:
776771
marker = "#################\n\n"
777772
text = text.replace(marker, f"{marker}{rst_content}\n")
@@ -815,25 +810,138 @@ def edit_until_valid(editor, tmp_path):
815810
print()
816811

817812

813+
def _extract_issue_number(issue):
814+
"""Extract issue number from various formats like '12345', 'gh-12345', or GitHub URLs."""
815+
if issue is None:
816+
return None
817+
818+
issue = raw_issue = str(issue).strip()
819+
if issue.startswith('gh-'):
820+
issue = issue[3:]
821+
if issue.isdigit():
822+
return issue
823+
824+
match = re.match(r'^(?:https://)?github\.com/python/cpython/issues/(\d+)$', issue)
825+
if match is None:
826+
error(f"Invalid GitHub issue: {raw_issue}")
827+
return match.group(1)
828+
829+
830+
def _extract_section_name(section):
831+
"""Extract section name with smart matching."""
832+
if section is None:
833+
return None
834+
835+
section = raw_section = section.strip()
836+
if not section:
837+
error("Empty section name!")
838+
839+
matches = []
840+
# Try simple case-insensitive substring matching
841+
section_lower = section.lower()
842+
for section_name in SECTIONS:
843+
if section_lower in section_name.lower():
844+
matches.append(section_name)
845+
846+
# If no matches, try more complex matching
847+
if not matches:
848+
matches = _find_smart_matches(section)
849+
850+
if not matches:
851+
sections_list = '\n'.join(f' - {s}' for s in SECTIONS)
852+
error(f"Invalid section name: {raw_section!r}\n\nValid sections are:\n{sections_list}")
853+
854+
if len(matches) > 1:
855+
multiple_matches = ', '.join(map(repr, sorted(matches)))
856+
error(f"More than one match for: {raw_section!r}\nMatches: {multiple_matches}")
857+
858+
return matches[0]
859+
860+
861+
def _find_smart_matches(section):
862+
"""Find matches using advanced pattern matching."""
863+
# Normalize separators
864+
sanitized = re.sub(r'[_\- /]', ' ', section).strip()
865+
if not sanitized:
866+
return []
867+
868+
matches = []
869+
section_words = re.split(r'\s+', sanitized)
870+
871+
# Build pattern to match against known sections
872+
section_pattern = r'[\s/]*'.join(map(re.escape, section_words))
873+
section_pattern = re.compile(section_pattern, re.I)
874+
875+
for section_name in SECTIONS:
876+
if section_pattern.search(section_name):
877+
matches.append(section_name)
878+
879+
# Special cases and aliases
880+
normalized = ''.join(section_words).lower()
881+
882+
# Check special aliases
883+
aliases = {
884+
'api': 'C API',
885+
'capi': 'C API',
886+
'builtin': 'Core and Builtins',
887+
'builtins': 'Core and Builtins',
888+
'core': 'Core and Builtins',
889+
'demo': 'Tools/Demos',
890+
'demos': 'Tools/Demos',
891+
'tool': 'Tools/Demos',
892+
'tools': 'Tools/Demos',
893+
}
894+
895+
for alias, section_name in aliases.items():
896+
if normalized.startswith(alias):
897+
if section_name not in matches:
898+
matches.append(section_name)
899+
900+
# Try matching by removing spaces/separators
901+
if not matches:
902+
for section_name in SECTIONS:
903+
section_normalized = re.sub(r'[^a-zA-Z0-9]', '', section_name).lower()
904+
if section_normalized.startswith(normalized):
905+
matches.append(section_name)
906+
907+
return matches
908+
909+
818910
@app.command(name="add")
819-
def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False):
911+
def add(*, issue: Annotated[Optional[str], Parameter(alias=["-i"])] = None,
912+
section: Annotated[Optional[str], Parameter(alias=["-s"])] = None,
913+
rst_on_stdin: bool = False):
820914
# This docstring template is formatted after the function definition.
821915
"""Add a new Misc/NEWS entry.
822916
823917
Opens an editor to create a new entry for Misc/NEWS unless all
824918
automation parameters are provided.
825919
920+
Use -i/--issue to specify a GitHub issue number or link.
921+
Use -s/--section to specify the NEWS section (case insensitive with partial matching).
922+
826923
Parameters
827924
----------
828-
gh_issue : int, optional
829-
GitHub issue number (optional, must be >= {lowest_possible_gh_issue_number}).
925+
issue : str, optional
926+
GitHub issue number or URL (e.g. '12345', 'gh-12345', or 'https://github.com/python/cpython/issues/12345').
830927
section : str, optional
831-
NEWS section. One of {sections_csv}.
928+
NEWS section. Can use partial matching (e.g. 'lib' for 'Library'). One of {sections_csv}.
832929
rst_on_stdin : bool
833-
Read restructured text entry from stdin (requires gh issue and section).
930+
Read restructured text entry from stdin (requires issue and section).
834931
"""
835932

836-
validate_add_parameters(section, gh_issue, rst_on_stdin)
933+
# Extract and validate issue number
934+
issue_number = _extract_issue_number(issue) if issue else None
935+
if issue_number and int(issue_number) < LOWEST_POSSIBLE_GH_ISSUE_NUMBER:
936+
error(f"Invalid issue number: {issue_number} (must be >= {LOWEST_POSSIBLE_GH_ISSUE_NUMBER})")
937+
938+
# Extract and validate section
939+
section_name = _extract_section_name(section) if section else None
940+
941+
# Validate parameters for stdin mode
942+
if rst_on_stdin and (not issue_number or not section_name):
943+
error("--issue and --section required with --rst-on-stdin")
944+
837945
chdir_to_repo_root()
838946

839947
# Prepare content source
@@ -852,7 +960,7 @@ def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False):
852960
atexit.register(lambda: os.path.exists(tmp_path) and os.unlink(tmp_path))
853961

854962
# Prepare template
855-
prepare_template(tmp_path, gh_issue, section, rst_content)
963+
prepare_template(tmp_path, issue_number, section_name, rst_content)
856964

857965
# Get blurb content
858966
if editor:
@@ -874,7 +982,6 @@ def add(*, gh_issue: int = 0, section: str = "", rst_on_stdin: bool = False):
874982

875983

876984
add.__doc__ = add.__doc__.format(
877-
lowest_possible_gh_issue_number=LOWEST_POSSIBLE_GH_ISSUE_NUMBER,
878985
sections_csv=", ".join(repr(s) for s in SECTIONS)
879986
)
880987

tests/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""pytest configuration and fixtures."""
2+
3+
import pytest
4+
from pyfakefs.fake_filesystem_unittest import Patcher
5+
6+
7+
@pytest.fixture
8+
def fs():
9+
"""Pyfakefs fixture compatible with pytest."""
10+
with Patcher() as patcher:
11+
yield patcher.fs

tests/test_add_command.py

Lines changed: 0 additions & 125 deletions
This file was deleted.

0 commit comments

Comments
 (0)