mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
fix(remote): resolve service names against the remote server
Remote mode resolved names with the local alias table and built an empty stub for any unknown tag, so an unentitled service showed a blank help page. Resolve against the server's tags and aliases, and reject unknown names with the list the server does offer.
This commit is contained in:
@@ -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
|
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
|
unshackle cannot fetch the list. Each synthetic command carries the server-side service's options and
|
||||||
documentation, so `unshackle dl --remote <TAG> -h` shows the same help text as it does on the
|
documentation, so `unshackle dl --remote <TAG> -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.
|
full setup.
|
||||||
|
|
||||||
## `serve`
|
## `serve`
|
||||||
|
|||||||
@@ -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
|
||||||
+28
-17
@@ -300,7 +300,7 @@ class Services(click.Group):
|
|||||||
|
|
||||||
remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote"))
|
remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote"))
|
||||||
if remote:
|
if remote:
|
||||||
return Services.make_remote_command(tag, ctx)
|
return Services.make_remote_command(name, ctx)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service = Services.load(tag)
|
service = Services.load(tag)
|
||||||
@@ -355,9 +355,33 @@ class Services(click.Group):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def make_remote_command(tag: str, ctx: click.Context) -> click.Command:
|
def make_remote_command(name: str, ctx: click.Context) -> click.Command:
|
||||||
"""Make a Click command for a remote service with server-provided options."""
|
"""Make a Click command for a remote service, with the options the remote server gives."""
|
||||||
svc_info = Services.fetch_remote_service_info(tag, ctx)
|
# 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
|
short_help = svc_info.get("url") if svc_info else None
|
||||||
help_text = svc_info.get("help") if svc_info else None
|
help_text = svc_info.get("help") if svc_info else None
|
||||||
if help_text:
|
if help_text:
|
||||||
@@ -410,19 +434,6 @@ class Services(click.Group):
|
|||||||
|
|
||||||
return import_cli
|
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
|
@staticmethod
|
||||||
def get_tags() -> list[str]:
|
def get_tags() -> list[str]:
|
||||||
"""Returns a list of service tags from all available Services."""
|
"""Returns a list of service tags from all available Services."""
|
||||||
|
|||||||
Reference in New Issue
Block a user