diff --git a/kicad_lib_manager/commands/add_3d/__init__.py b/kicad_lib_manager/commands/add_3d/__init__.py index d60f0dd..8676021 100644 --- a/kicad_lib_manager/commands/add_3d/__init__.py +++ b/kicad_lib_manager/commands/add_3d/__init__.py @@ -1,13 +1,3 @@ -import typer - from .command import add_3d -add_3d_app = typer.Typer( - name="add-3d", - help="Add cloud-based 3D model libraries to KiCad configuration", - rich_markup_mode="rich", - callback=add_3d, - invoke_without_command=True, -) - -__all__ = ["add_3d", "add_3d_app"] +__all__ = ["add_3d"] diff --git a/kicad_lib_manager/commands/add_hook/__init__.py b/kicad_lib_manager/commands/add_hook/__init__.py index d7eeab3..e802f9f 100644 --- a/kicad_lib_manager/commands/add_hook/__init__.py +++ b/kicad_lib_manager/commands/add_hook/__init__.py @@ -1,14 +1,3 @@ -import typer - from .command import add_hook -# Create Typer app and add the add_hook command as callback -add_hook_app = typer.Typer( - name="add-hook", - help="Add a Git post-merge hook to automatically sync KiCad libraries", - rich_markup_mode="rich", - callback=add_hook, - invoke_without_command=True, -) - -__all__ = ["add_hook", "add_hook_app"] +__all__ = ["add_hook"] diff --git a/kicad_lib_manager/commands/import_zip/__init__.py b/kicad_lib_manager/commands/import_zip/__init__.py index 9e09d4f..77e81a9 100644 --- a/kicad_lib_manager/commands/import_zip/__init__.py +++ b/kicad_lib_manager/commands/import_zip/__init__.py @@ -1,13 +1,3 @@ -import typer - from .command import import_zip -import_zip_app = typer.Typer( - name="import", - help="Import SamacSys/Mouser/UltraLibrarian/SnapMagic KiCad ZIP(s) into the configured library", - rich_markup_mode="rich", - callback=import_zip, - invoke_without_command=True, -) - -__all__ = ["import_zip", "import_zip_app"] +__all__ = ["import_zip"] diff --git a/kicad_lib_manager/commands/init/__init__.py b/kicad_lib_manager/commands/init/__init__.py index 30d926c..3ea0b37 100644 --- a/kicad_lib_manager/commands/init/__init__.py +++ b/kicad_lib_manager/commands/init/__init__.py @@ -1,14 +1,3 @@ -import typer - from .command import init -# Create Typer app and add the init command as callback -init_app = typer.Typer( - name="init", - help="Initialize library configuration", - rich_markup_mode="rich", - callback=init, - invoke_without_command=True, -) - -__all__ = ["init", "init_app"] +__all__ = ["init"] diff --git a/kicad_lib_manager/commands/list_libraries/__init__.py b/kicad_lib_manager/commands/list_libraries/__init__.py index a05f940..df7abab 100644 --- a/kicad_lib_manager/commands/list_libraries/__init__.py +++ b/kicad_lib_manager/commands/list_libraries/__init__.py @@ -1,14 +1,3 @@ -import typer - from .command import list_cmd -# Create Typer app and add the list command as callback -list_app = typer.Typer( - name="list", - help="List available KiCad libraries", - rich_markup_mode="rich", - callback=list_cmd, - invoke_without_command=True, -) - -__all__ = ["list_cmd", "list_app"] +__all__ = ["list_cmd"] diff --git a/kicad_lib_manager/commands/pin/__init__.py b/kicad_lib_manager/commands/pin/__init__.py index 24e32dc..21044cf 100644 --- a/kicad_lib_manager/commands/pin/__init__.py +++ b/kicad_lib_manager/commands/pin/__init__.py @@ -1,14 +1,3 @@ -import typer - from .command import pin -# Create Typer app and add the pin command as callback -pin_app = typer.Typer( - name="pin", - help="Pin favorite libraries", - rich_markup_mode="rich", - callback=pin, - invoke_without_command=True, -) - -__all__ = ["pin", "pin_app"] +__all__ = ["pin"] diff --git a/kicad_lib_manager/commands/setup/__init__.py b/kicad_lib_manager/commands/setup/__init__.py index f409e05..9142379 100644 --- a/kicad_lib_manager/commands/setup/__init__.py +++ b/kicad_lib_manager/commands/setup/__init__.py @@ -1,14 +1,3 @@ -import typer - from .command import setup -# Create Typer app and add the setup command as callback -setup_app = typer.Typer( - name="setup", - help="Configure KiCad to use libraries", - rich_markup_mode="rich", - callback=setup, - invoke_without_command=True, -) - -__all__ = ["setup", "setup_app"] +__all__ = ["setup"] diff --git a/kicad_lib_manager/commands/status/__init__.py b/kicad_lib_manager/commands/status/__init__.py index 520d19c..239e435 100644 --- a/kicad_lib_manager/commands/status/__init__.py +++ b/kicad_lib_manager/commands/status/__init__.py @@ -1,14 +1,3 @@ -import typer - from .command import status -# Create Typer app and add the status command as callback -status_app = typer.Typer( - name="status", - help="Show current library configuration status", - rich_markup_mode="rich", - callback=status, - invoke_without_command=True, -) - -__all__ = ["status", "status_app"] +__all__ = ["status"] diff --git a/kicad_lib_manager/commands/sync/__init__.py b/kicad_lib_manager/commands/sync/__init__.py index b6f578f..d56ff97 100644 --- a/kicad_lib_manager/commands/sync/__init__.py +++ b/kicad_lib_manager/commands/sync/__init__.py @@ -1,14 +1,3 @@ -import typer - from .command import sync -# Create Typer app and add the sync command as callback -sync_app = typer.Typer( - name="sync", - help="Update/sync library content from git repositories", - rich_markup_mode="rich", - callback=sync, - invoke_without_command=True, -) - -__all__ = ["sync", "sync_app"] +__all__ = ["sync"] diff --git a/kicad_lib_manager/commands/unpin/__init__.py b/kicad_lib_manager/commands/unpin/__init__.py index 788b895..90604f2 100644 --- a/kicad_lib_manager/commands/unpin/__init__.py +++ b/kicad_lib_manager/commands/unpin/__init__.py @@ -1,14 +1,3 @@ -import typer - from .command import unpin -# Create Typer app and add the unpin command as callback -unpin_app = typer.Typer( - name="unpin", - help="Unpin libraries in KiCad for quick access", - rich_markup_mode="rich", - callback=unpin, - invoke_without_command=True, -) - -__all__ = ["unpin", "unpin_app"] +__all__ = ["unpin"] diff --git a/kicad_lib_manager/main.py b/kicad_lib_manager/main.py index f711179..465877d 100644 --- a/kicad_lib_manager/main.py +++ b/kicad_lib_manager/main.py @@ -13,18 +13,18 @@ from rich.traceback import install from typer.core import TyperGroup -from .commands.add_3d import add_3d_app -from .commands.add_hook import add_hook_app +from .commands.add_3d import add_3d +from .commands.add_hook import add_hook from .commands.config import config_app -from .commands.import_zip import import_zip_app -from .commands.init import init_app -from .commands.list_libraries import list_app -from .commands.pin import pin_app -from .commands.setup import setup_app -from .commands.status import status_app -from .commands.sync import sync_app +from .commands.import_zip import import_zip +from .commands.init import init +from .commands.list_libraries import list_cmd +from .commands.pin import pin +from .commands.setup import setup +from .commands.status import status +from .commands.sync import sync from .commands.template import template_app -from .commands.unpin import unpin_app +from .commands.unpin import unpin from .commands.update import update_app from .services.config_service import ConfigService from .services.update_service import UpdateService @@ -147,24 +147,25 @@ def main( _check_for_updates_if_needed() -# Register command apps (migrated to Typer) -app.add_typer(status_app, name="status", help="Show current library configuration") -app.add_typer(list_app, name="list", help="List available KiCad libraries") -app.add_typer(init_app, name="init", help="Initialize library configuration") -app.add_typer(pin_app, name="pin", help="Pin favorite libraries") -app.add_typer(unpin_app, name="unpin", help="Unpin favorite libraries") -app.add_typer(setup_app, name="setup", help="Configure KiCad to use libraries") +# Leaf commands are registered as plain commands: a Typer sub-app with only a +# callback renders as a group, and its usage line gets a spurious COMMAND [ARGS]... +app.command("status", help="Show current library configuration")(status) +app.command("list", help="List available KiCad libraries")(list_cmd) +app.command("init", help="Initialize library configuration")(init) +app.command("pin", help="Pin favorite libraries")(pin) +app.command("unpin", help="Unpin favorite libraries")(unpin) +app.command("setup", help="Configure KiCad to use libraries")(setup) +app.command("sync", help="Update/sync library content")(sync) +app.command("add-3d", help="Add 3D model libraries")(add_3d) +app.command("add-hook", help="Add project hooks")(add_hook) +app.command( + "import", + help="Import SamacSys/Mouser/UltraLibrarian/SnapMagic KiCad ZIP(s) into the library", +)(import_zip) +# Groups keep their subcommands. app.add_typer(config_app, name="config", help="Manage configuration settings") app.add_typer(template_app, name="template", help="Manage project templates") -app.add_typer(add_3d_app, name="add-3d", help="Add 3D model libraries") -app.add_typer(sync_app, name="sync", help="Update/sync library content") app.add_typer(update_app, name="update", help="Update KiLM itself") -app.add_typer(add_hook_app, name="add-hook", help="Add project hooks") -app.add_typer( - import_zip_app, - name="import", - help="Import SamacSys/Mouser/UltraLibrarian/SnapMagic KiCad ZIP(s) into the library", -) if __name__ == "__main__": diff --git a/tests/test_leaf_command_usage.py b/tests/test_leaf_command_usage.py new file mode 100644 index 0000000..cafdaa7 --- /dev/null +++ b/tests/test_leaf_command_usage.py @@ -0,0 +1,51 @@ +"""Usage-line contract for the registered top-level commands (issue #13). + +A Typer sub-app registered with ``add_typer`` always becomes a click group, so a +leaf command registered that way renders ``COMMAND [ARGS]...`` in its usage line +although it has no subcommands. Leaf commands must use ``app.command``; only real +groups use ``add_typer``. The cases are derived from the app, so a new command +registered the wrong way fails here without editing this file. +""" + +from collections.abc import Mapping + +import click +import pytest +import typer +from typer.testing import CliRunner + +from kicad_lib_manager.main import app + +SUBCOMMAND_PLACEHOLDER = "COMMAND [ARGS]..." + + +def _registered_commands() -> Mapping[str, click.Command]: + root = typer.main.get_command(app) + assert isinstance(root, click.Group) + return root.commands + + +COMMANDS = _registered_commands() +GROUPS = sorted(name for name, cmd in COMMANDS.items() if isinstance(cmd, click.Group)) +LEAVES = sorted(name for name in COMMANDS if name not in GROUPS) + + +def _usage_line(command: str) -> str: + result = CliRunner().invoke(app, [command, "--help"]) + assert result.exit_code == 0, result.output + return next(line for line in result.output.splitlines() if "Usage:" in line) + + +@pytest.mark.parametrize("command", LEAVES) +def test_leaf_command_usage_has_no_subcommand_placeholder(command: str) -> None: + usage = _usage_line(command) + assert "COMMAND" not in usage + assert "[ARGS]" not in usage + + +@pytest.mark.parametrize("command", GROUPS) +def test_group_has_subcommands_and_keeps_placeholder(command: str) -> None: + group = COMMANDS[command] + assert isinstance(group, click.Group) + assert group.commands, f"{command} is a group without subcommands; use app.command" + assert SUBCOMMAND_PLACEHOLDER in _usage_line(command)