diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index a30f5aa4..8a70609e 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -101,7 +101,9 @@ In `--remote` mode unshackle turns the server's service list into synthetic CLI commands that operate against it, falling back to the tags in that `services` sub-dict when unshackle cannot fetch the list. Each synthetic command carries the server-side service's options and documentation, so `unshackle dl --remote -h` shows the same help text as it does on the -server. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the +server. Service names resolve against the server's tags and aliases, not your local services. +If the server does not offer a name to your API key, unshackle rejects the name and lists the +tags it does offer. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the full setup. ## `serve` diff --git a/tests/remote/unit/test_remote_command_unknown_tag.py b/tests/remote/unit/test_remote_command_unknown_tag.py new file mode 100644 index 00000000..23ce558a --- /dev/null +++ b/tests/remote/unit/test_remote_command_unknown_tag.py @@ -0,0 +1,51 @@ +"""make_remote_command resolves names against the remote server's list and reports the failures.""" + +from __future__ import annotations + +from unittest.mock import patch + +import click +import pytest + +from unshackle.core.services import Services + +SERVED = [ + {"tag": "AMZN", "url": "https://example.com", "help": "Amazon.", "cli_params": [], "aliases": []}, + {"tag": "HMAX", "url": "https://example.com", "help": "Max.", "cli_params": [], "aliases": ["MAX", "hbomax"]}, +] + + +def _make(name: str) -> click.Command: + return Services.make_remote_command(name, ctx=click.Context(click.Command("dl"))) + + +def test_unknown_name_raises_with_typed_name_and_available_list() -> None: + with patch.object(Services, "fetch_remote_services", return_value=SERVED): + with pytest.raises(click.ClickException, match=r"does not offer a service named 'BBC'.*AMZN, HMAX"): + _make("BBC") + + +def test_served_tag_builds_full_command() -> None: + with patch.object(Services, "fetch_remote_services", return_value=SERVED): + cmd = _make("AMZN") + assert cmd.help == "Amazon." + + +def test_server_alias_resolves_to_server_tag() -> None: + with patch.object(Services, "fetch_remote_services", return_value=SERVED): + cmd = _make("max") + assert cmd.name == "HMAX" + assert cmd.help == "Max." + + +def test_empty_service_list_raises_no_services_message() -> None: + with patch.object(Services, "fetch_remote_services", return_value=[]): + with pytest.raises(click.ClickException, match=r"offers no services to your API key"): + _make("AMZN") + + +def test_fetch_failure_keeps_stub_instead_of_raising() -> None: + with patch.object(Services, "fetch_remote_services", return_value=None): + cmd = _make("AMZN") + assert cmd.name == "AMZN" + assert cmd.help is None diff --git a/unshackle/core/services.py b/unshackle/core/services.py index ed781e6d..db459947 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -300,7 +300,7 @@ class Services(click.Group): remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote")) if remote: - return Services.make_remote_command(tag, ctx) + return Services.make_remote_command(name, ctx) try: service = Services.load(tag) @@ -355,9 +355,33 @@ class Services(click.Group): return None @staticmethod - def make_remote_command(tag: str, ctx: click.Context) -> click.Command: - """Make a Click command for a remote service with server-provided options.""" - svc_info = Services.fetch_remote_service_info(tag, ctx) + def make_remote_command(name: str, ctx: click.Context) -> click.Command: + """Make a Click command for a remote service, with the options the remote server gives.""" + # A failed fetch (None) means the remote server was unreachable, not that the name is + # invalid - fall through to a bare stub so `-h` keeps working offline. + services = Services.fetch_remote_services(ctx) + if services is None: + svc_info = None + tag = Services.get_tag(name) + else: + # Resolve against the remote server's tags and aliases, not the local tables: the + # two drift apart as soon as either side's services change. Tags win over aliases, + # as in local get_tag. + lowered = name.lower() + svc_info = next((svc for svc in services if str(svc.get("tag", "")).lower() == lowered), None) + if svc_info is None: + svc_info = next( + (svc for svc in services if lowered in (str(a).lower() for a in svc.get("aliases") or [])), None + ) + if svc_info is None: + if services: + available = ", ".join(sorted(svc["tag"] for svc in services)) + raise click.ClickException( + f"The remote server does not offer a service named '{name}'. " + f"It offers these services: {available}" + ) + raise click.ClickException("The remote server offers no services to your API key.") + tag = svc_info["tag"] short_help = svc_info.get("url") if svc_info else None help_text = svc_info.get("help") if svc_info else None if help_text: @@ -410,19 +434,6 @@ class Services(click.Group): return import_cli - @staticmethod - def fetch_remote_service_info(tag: str, ctx: click.Context) -> dict | None: - """Fetch service info for a specific service from the remote server.""" - try: - services = Services.fetch_remote_services(ctx) - if services: - for svc in services: - if svc.get("tag") == tag: - return svc - except Exception as e: - log.debug(f"Remote service info lookup failed for {tag}: {e!r}") - return None - @staticmethod def get_tags() -> list[str]: """Returns a list of service tags from all available Services."""