mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-01 15:36:07 +08:00
Merge pull request #321 from hiSandog/fix/click-name-parsing-20260531
Fix skill generator Click name parsing
This commit is contained in:
@@ -30,6 +30,16 @@ def _canonical_skill_name(harness_path: Path, software_name: str) -> str:
|
||||
return f"cli-anything-{software_dir.replace('_', '-')}"
|
||||
|
||||
|
||||
def _click_declared_name(decorator_args: str, fallback: str) -> str:
|
||||
"""Return the Click command/group name declared in a decorator."""
|
||||
match = re.search(r'^\s*["\']([^"\']+)["\']', decorator_args)
|
||||
if not match:
|
||||
match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', decorator_args)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return fallback.replace("_", "-")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandInfo:
|
||||
"""Information about a CLI command."""
|
||||
@@ -209,28 +219,53 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]:
|
||||
# - Docstrings on the same line or following line after function definition
|
||||
# - Various Click decorator patterns like @click.option(), @click.argument()
|
||||
# Uses re.DOTALL to match across newlines between decorator and def
|
||||
optional_decorator_pattern = r'(?:\s*@[\w.]+(?:\([^)]*\))?)*'
|
||||
|
||||
group_pattern = (
|
||||
r'@(\w+)\.group\([^)]*\)' # @xxx.group(...)
|
||||
r'(?:\s*@[\w.]+\([^)]*\))*' # optional additional decorators
|
||||
r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...):
|
||||
r':\s*' # colon with optional whitespace
|
||||
r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''')
|
||||
r'@(\w+)\.group\(([^)]*)\)' # @xxx.group(...)
|
||||
+ optional_decorator_pattern # optional additional decorators
|
||||
+ r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...):
|
||||
+ r':\s*' # colon with optional whitespace
|
||||
+ r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''')
|
||||
)
|
||||
|
||||
for match in re.finditer(group_pattern, content):
|
||||
group_func = match.group(2)
|
||||
# Docstring can be in group 3 (triple-double) or group 4 (triple-single)
|
||||
group_doc = (match.group(3) or match.group(4) or "").strip()
|
||||
group_lookup = {}
|
||||
group_display_paths = {}
|
||||
group_matches = list(re.finditer(group_pattern, content))
|
||||
root_group_funcs = {
|
||||
match.group(3).lower()
|
||||
for match in group_matches
|
||||
if match.group(1).lower() == "click"
|
||||
}
|
||||
|
||||
group_name = group_func.replace("_", " ").title()
|
||||
if not group_name:
|
||||
group_name = group_func.title()
|
||||
for match in group_matches:
|
||||
group_parent = match.group(1)
|
||||
group_args = match.group(2)
|
||||
group_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
group_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
|
||||
groups.append(CommandGroup(
|
||||
local_group_name = _format_display_name(_click_declared_name(group_args, group_func))
|
||||
parent_key = group_parent.lower()
|
||||
if parent_key == "click" or parent_key in root_group_funcs:
|
||||
group_path = [local_group_name]
|
||||
else:
|
||||
parent_path = group_display_paths.get(parent_key)
|
||||
if parent_path:
|
||||
group_path = [*parent_path, local_group_name]
|
||||
else:
|
||||
group_path = [_format_display_name(group_parent), local_group_name]
|
||||
|
||||
group_name = " ".join(group_path)
|
||||
|
||||
group = CommandGroup(
|
||||
name=group_name,
|
||||
description=group_doc or f"Commands for {group_name.lower()} operations.",
|
||||
commands=[]
|
||||
))
|
||||
)
|
||||
groups.append(group)
|
||||
group_lookup[group_func.lower()] = group
|
||||
group_display_paths[group_func.lower()] = group_path
|
||||
|
||||
# Find Click command decorators
|
||||
# Pattern handles:
|
||||
@@ -238,26 +273,28 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]:
|
||||
# - Docstrings on the same line or following line after function definition
|
||||
# - Various Click decorator patterns like @click.option(), @click.argument()
|
||||
command_pattern = (
|
||||
r'@(\w+)\.command\([^)]*\)' # @xxx.command(...)
|
||||
r'(?:\s*@[\w.]+\([^)]*\))*' # optional additional decorators
|
||||
r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...):
|
||||
r':\s*' # colon with optional whitespace
|
||||
r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''')
|
||||
r'@(\w+)\.command\(([^)]*)\)' # @xxx.command(...)
|
||||
+ optional_decorator_pattern # optional additional decorators
|
||||
+ r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...):
|
||||
+ r':\s*' # colon with optional whitespace
|
||||
+ r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''')
|
||||
)
|
||||
|
||||
for match in re.finditer(command_pattern, content):
|
||||
group_name = match.group(1)
|
||||
cmd_name = match.group(2)
|
||||
# Docstring can be in group 3 (triple-double) or group 4 (triple-single)
|
||||
cmd_doc = (match.group(3) or match.group(4) or "").strip()
|
||||
cmd_args = match.group(2)
|
||||
cmd_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
cmd_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
|
||||
# Find the matching group
|
||||
for group in groups:
|
||||
if group.name.lower().replace(" ", "_") == group_name.lower():
|
||||
group.commands.append(CommandInfo(
|
||||
name=cmd_name.replace("_", "-"),
|
||||
description=cmd_doc or f"Execute {cmd_name} operation."
|
||||
))
|
||||
group = group_lookup.get(group_name.lower())
|
||||
if group:
|
||||
cmd_name = _click_declared_name(cmd_args, cmd_func)
|
||||
group.commands.append(CommandInfo(
|
||||
name=cmd_name,
|
||||
description=cmd_doc or f"Execute {cmd_func} operation."
|
||||
))
|
||||
|
||||
# If no groups found, create a default one with all commands
|
||||
if not groups:
|
||||
@@ -268,12 +305,14 @@ def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]:
|
||||
)
|
||||
|
||||
for match in re.finditer(command_pattern, content):
|
||||
cmd_name = match.group(2)
|
||||
# Docstring can be in group 3 (triple-double) or group 4 (triple-single)
|
||||
cmd_doc = (match.group(3) or match.group(4) or "").strip()
|
||||
cmd_args = match.group(2)
|
||||
cmd_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
cmd_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
cmd_name = _click_declared_name(cmd_args, cmd_func)
|
||||
default_group.commands.append(CommandInfo(
|
||||
name=cmd_name.replace("_", "-"),
|
||||
description=cmd_doc or f"Execute {cmd_name} operation."
|
||||
name=cmd_name,
|
||||
description=cmd_doc or f"Execute {cmd_func} operation."
|
||||
))
|
||||
|
||||
if default_group.commands:
|
||||
|
||||
@@ -143,6 +143,145 @@ class TestExtractCliMetadata:
|
||||
assert "export" in cmd_names
|
||||
assert "import-data" in cmd_names
|
||||
|
||||
def test_respects_explicit_click_names(self, tmp_path):
|
||||
software = "named"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(cli_pkg / f"{software}_cli.py").write_text(
|
||||
textwrap.dedent("""\
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.group("remote-access")
|
||||
def remote_access_group():
|
||||
\"\"\"Remote access commands.\"\"\"
|
||||
pass
|
||||
|
||||
@remote_access_group.command("list-active")
|
||||
def list_active_sessions():
|
||||
\"\"\"List active sessions.\"\"\"
|
||||
pass
|
||||
|
||||
@cli.command(name="health-check")
|
||||
def health():
|
||||
\"\"\"Check service health.\"\"\"
|
||||
pass
|
||||
""")
|
||||
)
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
groups = {group.name: group for group in metadata.command_groups}
|
||||
|
||||
assert "Remote Access" in groups
|
||||
assert "Remote Access Group" not in groups
|
||||
assert [cmd.name for cmd in groups["Remote Access"].commands] == ["list-active"]
|
||||
cli_commands = [cmd.name for cmd in groups["Cli"].commands]
|
||||
assert "health-check" in cli_commands
|
||||
assert "health" not in cli_commands
|
||||
|
||||
def test_disambiguates_nested_declared_group_names(self, tmp_path):
|
||||
software = "nested"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(cli_pkg / f"{software}_cli.py").write_text(
|
||||
textwrap.dedent("""\
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.group("configs")
|
||||
def configs_group():
|
||||
\"\"\"Top-level config commands.\"\"\"
|
||||
pass
|
||||
|
||||
@configs_group.command("show")
|
||||
def show_config():
|
||||
\"\"\"Show config.\"\"\"
|
||||
pass
|
||||
|
||||
@cli.group("alerts")
|
||||
def alerts():
|
||||
\"\"\"Alert commands.\"\"\"
|
||||
pass
|
||||
|
||||
@alerts.group("configs")
|
||||
def alerts_configs():
|
||||
\"\"\"Alert config commands.\"\"\"
|
||||
pass
|
||||
|
||||
@alerts_configs.command("enable")
|
||||
def enable_alert_config():
|
||||
\"\"\"Enable alert config.\"\"\"
|
||||
pass
|
||||
""")
|
||||
)
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
groups = {group.name: group for group in metadata.command_groups}
|
||||
group_names = [group.name for group in metadata.command_groups]
|
||||
|
||||
assert group_names.count("Configs") == 1
|
||||
assert "Alerts Configs" in groups
|
||||
assert [cmd.name for cmd in groups["Configs"].commands] == ["show"]
|
||||
assert [cmd.name for cmd in groups["Alerts Configs"].commands] == ["enable"]
|
||||
|
||||
def test_preserves_top_level_group_names_with_decorated_root(self, tmp_path):
|
||||
software = "decorated"
|
||||
cli_pkg = tmp_path / "cli_anything" / software
|
||||
cli_pkg.mkdir(parents=True)
|
||||
(cli_pkg / "__init__.py").write_text("")
|
||||
(cli_pkg / f"{software}_cli.py").write_text(
|
||||
textwrap.dedent("""\
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
def cli(ctx):
|
||||
pass
|
||||
|
||||
@cli.group("devices")
|
||||
def devices_group():
|
||||
\"\"\"Device commands.\"\"\"
|
||||
pass
|
||||
|
||||
@devices_group.command("list")
|
||||
def list_devices():
|
||||
\"\"\"List devices.\"\"\"
|
||||
pass
|
||||
|
||||
@cli.group("alerts")
|
||||
def alerts():
|
||||
\"\"\"Alert commands.\"\"\"
|
||||
pass
|
||||
|
||||
@alerts.group("configs")
|
||||
def alerts_configs():
|
||||
\"\"\"Alert config commands.\"\"\"
|
||||
pass
|
||||
|
||||
@alerts_configs.command("enable")
|
||||
def enable_alert_config():
|
||||
\"\"\"Enable alert config.\"\"\"
|
||||
pass
|
||||
""")
|
||||
)
|
||||
|
||||
metadata = extract_cli_metadata(str(tmp_path))
|
||||
groups = {group.name: group for group in metadata.command_groups}
|
||||
|
||||
assert "Devices" in groups
|
||||
assert "Cli Devices" not in groups
|
||||
assert "Alerts Configs" in groups
|
||||
assert [cmd.name for cmd in groups["Devices"].commands] == ["list"]
|
||||
assert [cmd.name for cmd in groups["Alerts Configs"].commands] == ["enable"]
|
||||
|
||||
def test_generates_examples(self, harness_dir):
|
||||
metadata = extract_cli_metadata(str(harness_dir))
|
||||
assert len(metadata.examples) > 0
|
||||
|
||||
Reference in New Issue
Block a user