From 4276acc5b3f55e728283fcd35bdfce031652ddf4 Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:18:49 +0200 Subject: [PATCH 01/14] feat: Add new commands to manage HUB API keys --- README.md | 18 ++++- src/skore_cli/__init__.py | 9 ++- src/skore_cli/hub/__init__.py | 5 ++ src/skore_cli/hub/_commands.py | 78 ++++++++++++++++++++++ tests/conftest.py | 30 +++++++++ tests/test_cli.py | 3 +- tests/test_hub.py | 116 +++++++++++++++++++++++++++++++++ 7 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 src/skore_cli/hub/__init__.py create mode 100644 src/skore_cli/hub/_commands.py create mode 100644 tests/test_hub.py diff --git a/README.md b/README.md index 8b241b2..4a07ffc 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ Command-line interface for [skore](https://github.com/probabl-ai/skore). -`skore-cli` installs a single `skore` command with three areas: +`skore-cli` installs a single `skore` command with four areas: - **skills** — discover, install and manage [Agent Skills](https://agentskills.io) from the [probabl-ai/skills](https://github.com/probabl-ai/skills) catalog - **agent** — connect a project to the Skore Hub agent, write harness config and launch a local coding agent +- **hub** — manage locally stored Skore Hub API keys - **sync** — synchronize report projects across local storage, Skore Hub, and MLflow ## Installation @@ -76,6 +77,21 @@ skore agent --harness claude # non-interactive harness choice skore agent --workspace ./myapp # configure another project directory ``` +### Hub + +Store and inspect Hub API keys locally via skore's credential registry +(`~/.skore.hub/credentials.json`). `--host` selects a non-default hub (omit it +to use `SKORE_HUB_URI` or the public hub); `--workspace` is required when +adding or deleting a key. + +```bash +skore hub api-key add --workspace= +skore hub api-key add --host= --workspace= +skore hub api-key delete --workspace= +skore hub api-key delete --host= --workspace= +skore hub api-key list +``` + ### Sync Synchronize a source project to a destination mode. The source defaults to local when diff --git a/src/skore_cli/__init__.py b/src/skore_cli/__init__.py index 5aa5a9a..8353723 100644 --- a/src/skore_cli/__init__.py +++ b/src/skore_cli/__init__.py @@ -18,6 +18,7 @@ # importing them (to build the CLI / show `--help`) stays instant. Each merges its # own `rich_click.COMMAND_GROUPS` entry, so import order does not matter. from skore_cli.agent import agent +from skore_cli.hub import hub from skore_cli.skills import skills from skore_cli.sync import sync @@ -25,6 +26,7 @@ **getattr(click.rich_click, "COMMAND_GROUPS", {}), "cli": [ {"name": "Agent", "commands": ["agent"]}, + {"name": "Hub", "commands": ["hub"]}, {"name": "Projects", "commands": ["sync"]}, {"name": "Skills", "commands": ["skills"]}, ], @@ -32,6 +34,7 @@ _COMMANDS = [ ("agent", "Authenticate, configure and launch a Skore Hub agent harness."), + ("hub", "Manage Skore Hub credentials."), ("skills", "Install and manage Agent Skills from the probabl-ai/skills release."), ("sync", "Synchronize report projects across local storage, Skore Hub and MLflow."), ] @@ -86,8 +89,9 @@ def _render_help(detected: Agent | None) -> str: def cli(ctx) -> None: """Skore command-line interface. - Use ``skore agent`` to connect a project to the Skore Hub agent, ``skore sync`` - to synchronize report projects, and ``skore skills`` to install probabl-skills. + Use ``skore agent`` to connect a project to the Skore Hub agent, ``skore hub`` + to manage Hub credentials, ``skore sync`` to synchronize report projects, and + ``skore skills`` to install probabl-skills. """ if ctx.invoked_subcommand is None: if is_non_interactive(): @@ -99,6 +103,7 @@ def cli(ctx) -> None: cli.add_command(skills) cli.add_command(agent) +cli.add_command(hub) cli.add_command(sync) # Commands contributed by other packages via the `skore_cli.plugins` entry-point diff --git a/src/skore_cli/hub/__init__.py b/src/skore_cli/hub/__init__.py new file mode 100644 index 0000000..abdc85f --- /dev/null +++ b/src/skore_cli/hub/__init__.py @@ -0,0 +1,5 @@ +"""The ``skore hub`` command group to manage Skore Hub credentials.""" + +from skore_cli.hub._commands import hub + +__all__ = ["hub"] diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py new file mode 100644 index 0000000..324a874 --- /dev/null +++ b/src/skore_cli/hub/_commands.py @@ -0,0 +1,78 @@ +"""The ``skore hub`` command group to manage Skore Hub credentials.""" + +from __future__ import annotations + +import rich_click as click +from rich.table import Table + +from skore_cli._skore import auth as _auth +from skore_cli._style import console + +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli hub": [ + {"name": "Credentials", "commands": ["api-key"]}, + ], + "cli hub api-key": [ + {"name": "Manage", "commands": ["add", "delete", "list"]}, + ], +} + + +@click.group(invoke_without_command=True) +@click.pass_context +def hub(ctx) -> None: + """Manage Skore Hub credentials.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +def _registry(): + """Import ``skore``'s API-key registry, or fail if ``skore`` is missing.""" + return _auth("api_key").registry + + +def _host(host: str | None) -> str: + """Return ``host``, or skore's ``URI()`` when ``host`` is omitted.""" + return host or _auth("uri").URI() + + +@hub.group("api-key", invoke_without_command=True) +@click.pass_context +def api_key(ctx) -> None: + """Add, delete and list locally stored Hub API keys.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@api_key.command("add") +@click.argument("key") +@click.option("--host", default=None, help="Hub host URL.") +@click.option("--workspace", required=True, help="Hub workspace to attach the key to.") +def add(key: str, host: str | None, workspace: str) -> None: + """Store a Hub API key for a workspace.""" + _registry().set(host=_host(host), workspace=workspace, api_key=key) + + +@api_key.command("delete") +@click.option("--host", default=None, help="Hub host URL.") +@click.option("--workspace", required=True, help="Hub workspace whose key to delete.") +def delete(host: str | None, workspace: str) -> None: + """Delete a stored Hub API key for a workspace.""" + _registry().delete(host=_host(host), workspace=workspace) + + +@api_key.command("list") +def list_keys() -> None: + """List stored Hub API keys.""" + rows = list(_registry().keys()) + if not rows: + console.print("No API keys stored.") + return + + table = Table(title="Hub API keys") + table.add_column("host") + table.add_column("workspace") + for stored_host, stored_workspace in rows: + table.add_row(stored_host, stored_workspace) + console.print(table) diff --git a/tests/conftest.py b/tests/conftest.py index bcb3e6a..d58a315 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,6 +128,36 @@ def workspace(monkeypatch, tmp_path): return SimpleNamespace(home=home, project=project) +@pytest.fixture +def monkeypatch_home(monkeypatch, tmp_path): + """ + Change ``HOME`` used by ``os.path.expanduser()`` to point to ``tmp_path``, so + that it is automatically deleted after use, with no impact on user's environment. + + https://docs.python.org/3/library/os.path.html#os.path.expanduser + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + +@pytest.fixture +def monkeypatch_keyring(monkeypatch): + """ + Make the system keyring unavailable, so that tests can't read and write into the + user's real keyring. + + Force a re-init of the keyring backend, ``keyring.get_keyring()`` caching the + backend in ``keyring.core``. + + https://github.com/jaraco/keyring#disabling-keyring + https://github.com/jaraco/keyring/blob/7603e7cadc254b4c6e3fc2b2f0916a005e78087d/keyring/core.py#L32 + """ + import keyring.core + + monkeypatch.setenv("PYTHON_KEYRING_BACKEND", "keyring.backends.null.Keyring") + keyring.core.init_backend() + + @pytest.fixture(autouse=True) def monkeypatch_sdk_env(monkeypatch): """Unset the SDK credential variables.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index e172565..232fa65 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,7 +14,7 @@ def test_cli_exposes_builtin_commands(): from skore_cli import cli - assert {"skills", "agent", "sync"} == set(cli.commands) + assert {"skills", "agent", "hub", "sync"} == set(cli.commands) def test_cli_without_subcommand_shows_plain_help(): @@ -27,6 +27,7 @@ def test_cli_without_subcommand_shows_plain_help(): assert result.exit_code == 0 assert "Skore command-line interface." in result.output assert "agent" in result.output + assert "hub" in result.output assert "skills" in result.output assert "Quick start:" in result.output diff --git a/tests/test_hub.py b/tests/test_hub.py new file mode 100644 index 0000000..def8059 --- /dev/null +++ b/tests/test_hub.py @@ -0,0 +1,116 @@ +"""Tests for the ``skore hub`` command group.""" + +from __future__ import annotations + +import pytest +from click.testing import CliRunner + +from skore._plugins.hub.authentication.api_key import registry +from skore._plugins.hub.authentication.uri import URI +from skore_cli import cli + +pytestmark = pytest.mark.usefixtures("monkeypatch_home", "monkeypatch_keyring") + + +def _invoke(args: list[str]): + return CliRunner().invoke(cli, args) + + +def test_hub_no_subcommand_shows_help(): + result = _invoke(["hub"]) + + assert result.exit_code == 0 + assert "api-key" in result.output + + +def test_hub_api_key_no_subcommand_shows_help(): + result = _invoke(["hub", "api-key"]) + + assert result.exit_code == 0 + assert "add" in result.output + assert "delete" in result.output + assert "list" in result.output + + +def test_hub_api_key_add_requires_workspace(): + result = _invoke(["hub", "api-key", "add", "secret"]) + + assert result.exit_code != 0 + assert "workspace" in result.output.lower() + + +def test_hub_api_key_add_stores_key(): + result = _invoke( + [ + "hub", + "api-key", + "add", + "secret", + "--host=http://hub.test", + "--workspace=team", + ] + ) + + assert result.exit_code == 0 + assert registry.get(host="http://hub.test", workspace="team") == "secret" + + +def test_hub_api_key_add_without_host_uses_default_uri(monkeypatch): + monkeypatch.delenv("SKORE_HUB_URI", raising=False) + + result = _invoke(["hub", "api-key", "add", "secret", "--workspace=team"]) + + assert result.exit_code == 0 + assert registry.get(host=URI(), workspace="team") == "secret" + + +def test_hub_api_key_delete_requires_workspace(): + result = _invoke(["hub", "api-key", "delete"]) + + assert result.exit_code != 0 + assert "workspace" in result.output.lower() + + +def test_hub_api_key_delete_removes_key(): + registry.set(host="http://hub.test", workspace="team", api_key="secret") + + result = _invoke( + ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] + ) + + assert result.exit_code == 0 + assert registry.get(host="http://hub.test", workspace="team") is None + + +def test_hub_api_key_delete_without_host_uses_environment_uri(monkeypatch): + monkeypatch.setenv("SKORE_HUB_URI", "http://hub.test") + registry.set(host="http://hub.test", workspace="team", api_key="secret") + registry.set(host="http://other.test", workspace="team", api_key="other") + + result = _invoke(["hub", "api-key", "delete", "--workspace=team"]) + + assert result.exit_code == 0 + assert registry.get(host="http://hub.test", workspace="team") is None + assert registry.get(host="http://other.test", workspace="team") == "other" + + +def test_hub_api_key_list_empty(): + result = _invoke(["hub", "api-key", "list"]) + + assert result.exit_code == 0 + assert "No API keys stored." in result.output + + +def test_hub_api_key_list_shows_host_and_workspace(): + registry.set(host="http://a.test", workspace="w1", api_key="k1") + registry.set(host="http://b.test", workspace="w2", api_key="k2") + + result = _invoke(["hub", "api-key", "list"]) + + assert result.exit_code == 0 + assert "http://a.test" in result.output + assert "w1" in result.output + assert "http://b.test" in result.output + assert "w2" in result.output + assert "k1" not in result.output + assert "k2" not in result.output From 3e23b44fe81cc723df4d410b3233e0850dbc9695 Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:35:50 +0200 Subject: [PATCH 02/14] lint --- tests/test_hub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_hub.py b/tests/test_hub.py index def8059..a6d388d 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -4,9 +4,9 @@ import pytest from click.testing import CliRunner - from skore._plugins.hub.authentication.api_key import registry from skore._plugins.hub.authentication.uri import URI + from skore_cli import cli pytestmark = pytest.mark.usefixtures("monkeypatch_home", "monkeypatch_keyring") From 4adc6456ef9f45278a78629725c0a52f6ce35b2f Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:36:34 +0200 Subject: [PATCH 03/14] add skore constraint --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ac9de79..9e7536b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,7 @@ dependencies = [ "rich>=14.2", "rich-click>=1.9", "textual", - # The `agent` command reuses skore's authentication machinery and calls the - # hub identity API directly with httpx. - "skore[hub]>=0.24.0", + "skore[hub]>=0.27", "httpx", ] From 040532fae3cc822f629f39cdd234ac6162e454d3 Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:54:22 +0200 Subject: [PATCH 04/14] fix after skore refacto --- src/skore_cli/hub/_commands.py | 2 +- tests/test_hub.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index 324a874..f3ef168 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -29,7 +29,7 @@ def hub(ctx) -> None: def _registry(): """Import ``skore``'s API-key registry, or fail if ``skore`` is missing.""" - return _auth("api_key").registry + return _auth("registry") def _host(host: str | None) -> str: diff --git a/tests/test_hub.py b/tests/test_hub.py index a6d388d..dbec181 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -4,7 +4,7 @@ import pytest from click.testing import CliRunner -from skore._plugins.hub.authentication.api_key import registry +from skore._plugins.hub.authentication import registry from skore._plugins.hub.authentication.uri import URI from skore_cli import cli From ddb13b912487bddac3c0d854276d8eb0c294df29 Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:20:29 +0200 Subject: [PATCH 05/14] feat: Move HUB API key generation to its own command (#48) --- README.md | 24 ++- pyproject.toml | 2 + src/skore_cli/__init__.py | 13 ++ src/skore_cli/_agents.py | 24 +-- src/skore_cli/_hub_auth.py | 70 ------ src/skore_cli/_skore.py | 50 ----- src/skore_cli/agent/_commands.py | 154 ++++---------- src/skore_cli/agent/_skore_file.py | 7 +- src/skore_cli/{agent => hub}/_client.py | 11 +- src/skore_cli/hub/_commands.py | 117 +++++++++- src/skore_cli/hub/login.py | 253 ++++++++++++++++++++++ src/skore_cli/sync/_commands.py | 17 +- tests/conftest.py | 24 ++- tests/test_agent_commands.py | 271 ++++++++---------------- tests/test_cli.py | 55 +---- tests/test_client.py | 6 +- tests/test_harnesses.py | 25 +-- tests/test_hub.py | 118 ++++++++++- tests/test_hub_auth.py | 120 ----------- tests/test_sync.py | 27 +-- 20 files changed, 701 insertions(+), 687 deletions(-) delete mode 100644 src/skore_cli/_hub_auth.py delete mode 100644 src/skore_cli/_skore.py rename src/skore_cli/{agent => hub}/_client.py (90%) create mode 100644 src/skore_cli/hub/login.py delete mode 100644 tests/test_hub_auth.py diff --git a/README.md b/README.md index 4a07ffc..fde4f31 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Command-line interface for [skore](https://github.com/probabl-ai/skore). from the [probabl-ai/skills](https://github.com/probabl-ai/skills) catalog - **agent** — connect a project to the Skore Hub agent, write harness config and launch a local coding agent -- **hub** — manage locally stored Skore Hub API keys +- **hub** — generate, store and inspect Skore Hub API keys - **sync** — synchronize report projects across local storage, Skore Hub, and MLflow ## Installation @@ -57,19 +57,21 @@ skore skills remove # remove installed skills ### Agent On the first run, `skore agent` logs in when needed, lets you pick a workspace -and harness, creates a workspace API key, writes the harness configuration and -launches the agent. Supported harnesses: **Bob Shell**, **Bob IDE**, **Claude**, +and harness, and saves the workspace and hub URI to `.skore` in the project +directory (gitignored). The API key is not stored there: it comes from the +credential registry, and `skore agent` runs `skore hub api-key generate +--host= --workspace=` for you when no key is stored yet. + +Supported harnesses: **Bob Shell**, **Bob IDE**, **Claude**, **Cursor**, **OpenCode**, **Pi**, **GitHub Copilot** and **Codex** (all must be on `PATH`; on macOS, Bob IDE is found via its application bundle). Bob IDE also accepts `--harness bobide` — the name of the command its installer puts on -`PATH` — as an alias for `--harness bob-ide`. Later runs reuse -`.skore` in the project directory (gitignored). Use `SKORE_HUB_URI` (or +`PATH` — as an alias for `--harness bob-ide`. Use `SKORE_HUB_URI` (or `--hub-url`) to point at a non-default hub. -Launching a harness exports the `.skore` credentials as `SKORE_HUB_API_KEY` and -`SKORE_HUB_URI`, so `skore.login()` in the scripts the agent runs authenticates -with that key instead of opening a browser. Values already set in your -environment are left untouched. +Reading `.skore` sets `SKORE_HUB_URI` so the `skore` package talks to the same +hub. The workspace API key stays in the credential registry and is picked up +automatically. `--hub-url` / `--host` override the URI for that process. ```bash skore agent @@ -82,9 +84,11 @@ skore agent --workspace ./myapp # configure another project directory Store and inspect Hub API keys locally via skore's credential registry (`~/.skore.hub/credentials.json`). `--host` selects a non-default hub (omit it to use `SKORE_HUB_URI` or the public hub); `--workspace` is required when -adding or deleting a key. +generating, adding or deleting a key. ```bash +skore hub api-key generate --workspace= +skore hub api-key generate --host= --workspace= skore hub api-key add --workspace= skore hub api-key add --host= --workspace= skore hub api-key delete --workspace= diff --git a/pyproject.toml b/pyproject.toml index 9e7536b..1d91453 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,6 +171,8 @@ module = [ "textual.*", "httpx", "httpx.*", + "skore", + "skore.*", ] [tool.ty.environment] diff --git a/src/skore_cli/__init__.py b/src/skore_cli/__init__.py index 8353723..fca435b 100644 --- a/src/skore_cli/__init__.py +++ b/src/skore_cli/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import importlib.util + import rich_click as click import skore_cli._style # noqa: F401 (applies the CLI palette and rich-click config) @@ -110,3 +112,14 @@ def cli(ctx) -> None: # group. Kept for third-party extensibility; the built-in `agent` command no # longer goes through it so the CLI never imports `skore` just to show help. load_plugins(cli) + +# Ensure ``skore`` is available without importing it explicitly +try: + importlib.util.find_spec("skore", package=None) +except ModuleNotFoundError: + message = ( + "this command needs the `skore` package (install it with `pip install " + "skore-cli` or `pip install skore`)." + ) + + raise click.ClickException(message) from None diff --git a/src/skore_cli/_agents.py b/src/skore_cli/_agents.py index c6a3a68..5d7b47b 100644 --- a/src/skore_cli/_agents.py +++ b/src/skore_cli/_agents.py @@ -63,8 +63,6 @@ CODEX_PROVIDER_NAME = "Skore Agent" CODEX_PROJECT_CONFIG = ".codex/skore-provider.toml" CODEX_API_KEY_ENV = "SKORE_AGENT_API_KEY" -SDK_API_KEY_ENV = "SKORE_HUB_API_KEY" -SDK_URI_ENV = "SKORE_HUB_URI" @dataclass(frozen=True) @@ -611,24 +609,6 @@ def _launch_codex(workspace: Path, model_id: str) -> None: _exec_harness("codex", argv, env=env) -def _export_sdk_credentials(workspace: Path) -> None: - """Publish the ``.skore`` credentials the ``skore`` package reads. - - The harness config only authenticates the harness itself. ``skore.login()`` - looks the API key up in ``SKORE_HUB_API_KEY`` and otherwise falls back to an - interactive browser flow, so every experiment process the agent spawns would - open its own OAuth tab. Values already present in the environment win, so a - user's own key or hub keeps precedence. - """ - from skore_cli.agent._skore_file import SkoreConfig - - config = SkoreConfig.load(workspace) - if config is None: - return - os.environ.setdefault(SDK_API_KEY_ENV, config.api_key) - os.environ.setdefault(SDK_URI_ENV, config.hub_url) - - def _exec_harness( name: str, argv: list[str], *, env: dict[str, str] | None = None ) -> None: @@ -922,5 +902,7 @@ def launch_harness( console.print( f"[skore.ok]Launching[/] [skore.skill]{agent.harness_display_name}[/] ..." ) - _export_sdk_credentials(workspace) + from skore_cli.agent._skore_file import SkoreConfig + + SkoreConfig.load(workspace) agent.launch(workspace, model_id) diff --git a/src/skore_cli/_hub_auth.py b/src/skore_cli/_hub_auth.py deleted file mode 100644 index 7338842..0000000 --- a/src/skore_cli/_hub_auth.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Bridge ``skore-cli`` to ``skore``'s in-process hub authentication.""" - -from __future__ import annotations - -import os -from typing import Literal - -import rich_click as click - -from skore_cli._skore import auth as _auth - -API_KEY_ENV = "SKORE_HUB_API_KEY" -AuthKind = Literal["api_key", "bearer", "none"] - - -def _login_module(): - return _auth("login") - - -def auth_kind() -> AuthKind: - """Return how the current process authenticates to the hub.""" - credentials = _login_module().credentials - if credentials is None: - if os.environ.get(API_KEY_ENV): - return "api_key" - return "none" - headers = credentials() - if "Authorization" in headers: - return "bearer" - if "X-API-Key" in headers: - return "api_key" - return "none" - - -def bearer_token() -> str | None: - """Return the current OAuth access token, if logged in interactively.""" - credentials = _login_module().credentials - if credentials is None: - return None - authorization = credentials().get("Authorization", "") - if authorization.startswith("Bearer "): - return authorization.removeprefix("Bearer ") - return None - - -def ensure_login(*, timeout: int = 600) -> str: - """Ensure an interactive session exists and return its bearer access token.""" - if auth_kind() == "api_key": - raise click.ClickException( - "set up a workspace API key with an interactive login first; " - f"`{API_KEY_ENV}` alone cannot mint project keys." - ) - - login_mod = _login_module() - if login_mod.credentials is None: - login_mod.login(timeout=timeout) - - token = bearer_token() - if not token: - raise click.ClickException("not logged in; run `skore agent` again.") - return token - - -def clear_login() -> bool: - """Drop in-process hub credentials. Returns whether a session was cleared.""" - login_mod = _login_module() - if login_mod.credentials is None: - return False - login_mod.credentials = None - return True diff --git a/src/skore_cli/_skore.py b/src/skore_cli/_skore.py deleted file mode 100644 index bfc844d..0000000 --- a/src/skore_cli/_skore.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Lazy access to the (heavy, optional) ``skore`` package for the agent command. - -The ``agent`` command reuses the authentication machinery that lives in -``skore`` (``skore._plugins.hub.authentication``). Importing it is expensive and -only needed when a command actually runs, so it is deferred here and surfaced as -a friendly error when ``skore`` is not installed. -""" - -from __future__ import annotations - -import importlib -import os -from collections.abc import Callable -from types import ModuleType - -import rich_click as click - -_MISSING = ( - "this command needs the `skore` package (install it with `pip install " - "skore-cli` or `pip install skore`)." -) - -# Mirrors ``skore._plugins.hub.authentication``'s env var; kept as a local literal -# so showing help never imports the (heavy) ``skore`` package. -URI_ENV = "SKORE_HUB_URI" - - -def auth(submodule: str) -> ModuleType: - """Import ``skore._plugins.hub.authentication.`` or fail nicely.""" - try: - return importlib.import_module(f"skore._plugins.hub.authentication.{submodule}") - except ImportError as error: # pragma: no cover - exercised via the CLI - raise click.ClickException(_MISSING) from error - - -def resolve_hub_uri( - hub_url: str | None, auth_fn: Callable[[str], ModuleType] = auth -) -> str: - """Resolve the hub base URL. - - An explicit ``hub_url`` seeds the ``SKORE_HUB_URI`` environment variable; - resolution then defers to ``skore``'s canonical ``URI()`` (which reads that - env var, falling back to the public hub). - - ``auth_fn`` defaults to :func:`auth` but is injectable so each command can - pass the ``_auth`` accessor that its tests monkeypatch. - """ - if hub_url: - os.environ[URI_ENV] = hub_url - return auth_fn("uri").URI() diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index 88e2333..68495e7 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from pathlib import Path import rich_click as click @@ -19,19 +20,11 @@ launch_harness, normalize_harness_name, ) -from skore_cli._hub_auth import ensure_login -from skore_cli._skore import URI_ENV, resolve_hub_uri -from skore_cli._skore import auth as _auth from skore_cli._style import console -from skore_cli.agent import _client from skore_cli.agent._skore_file import SkoreConfig, ensure_gitignore_entry - -PROJECT_PERMISSIONS = ( - "create:project", - "read:project", - "update:project", - "delete:project", -) +from skore_cli.hub import _client +from skore_cli.hub._commands import _registry, generate +from skore_cli.hub.login import login def _pick_workspace( @@ -70,79 +63,46 @@ def _pick_harness(workspace: Path) -> str: return app.result -def _resolve_api_key_name(harness: str, existing_names: list[str]) -> str: - if harness not in existing_names: - return harness - index = 2 - while f"{harness}-{index}" in existing_names: - index += 1 - return f"{harness}-{index}" +def _api_key_for(ctx, config: SkoreConfig, *, login_timeout: int) -> str: + """Return the workspace API key, minting one through ``generate`` if absent.""" + registry = _registry() + api_key = registry.get(host=config.hub_url, workspace=config.workspace) + if api_key: + return api_key -def _ensure_login(hub_url: str, *, timeout: int) -> str: - """Return a bearer token, running interactive login when needed.""" - return ensure_login(timeout=timeout) + ctx.invoke( + generate, + host=config.hub_url, + workspace=config.workspace, + name=None, + login_timeout=login_timeout, + ) + api_key = registry.get(host=config.hub_url, workspace=config.workspace) -def _create_workspace_api_key( - hub_url: str, - token: str, - user_id: str, - membership: _client.Membership, - harness: str, -) -> str: - """Mint a workspace-scoped API key for the chosen harness.""" - grantable = set(membership.permissions) - permissions = [p for p in PROJECT_PERMISSIONS if p in grantable] - if not permissions: + if not api_key: raise click.ClickException( - f"you cannot create project API keys in workspace '{membership.public_id}'." + f"could not read an API key for workspace '{config.workspace}'." ) - existing = _client.list_api_keys(hub_url, token, user_id) - workspace_names = [ - key.name or "" - for key in existing - if key.workspace_id == membership.workspace_id - ] - key_name = _resolve_api_key_name(harness, workspace_names) - _api_key_id, secret = _client.create_api_key( - hub_url, - token, - user_id, - name=key_name, - permissions=permissions, - workspace_id=membership.workspace_id, - expires_at=None, - ) - return secret + return api_key def _resolve_membership( memberships: list[_client.Membership], - workspace_public_id: str | None, ) -> _client.Membership: - if workspace_public_id is None: - if len(memberships) == 1: - return memberships[0] - if is_non_interactive(): - raise click.UsageError( - "pass a saved workspace in .skore or run interactively to pick one." - ) - return _pick_workspace(memberships) - - membership = next( - (m for m in memberships if m.public_id == workspace_public_id), - None, - ) - if membership is None: - raise click.ClickException( - f"workspace '{workspace_public_id}' is not in your memberships." + if len(memberships) == 1: + return memberships[0] + if is_non_interactive(): + raise click.UsageError( + "pass a saved workspace in .skore or run interactively to pick one." ) - return membership + return _pick_workspace(memberships) @click.command() +@click.pass_context @click.option( "--workspace", "-w", @@ -155,7 +115,7 @@ def _resolve_membership( default=None, help=( "Base URL of the hub (e.g. http://127.0.0.1:8000). Defaults to the " - f"{URI_ENV} env var or the public hub." + "SKORE_HUB_URI env var or the public hub." ), ) @click.option( @@ -179,6 +139,7 @@ def _resolve_membership( help="Seconds to wait for interactive device login.", ) def agent( + ctx, workspace: Path, hub_url: str | None, harness_name: str | None, @@ -188,9 +149,9 @@ def agent( """Authenticate, configure and launch a Skore Hub agent harness. On the first run, ``skore agent`` logs in to the hub (when needed), lets - you pick a workspace and harness, creates a workspace API key, writes the - harness config, and launches the agent. Later runs reuse ``.skore`` in the - project directory. + you pick a workspace and harness, and saves the workspace and hub URI to + ``.skore``. The API key itself comes from ``skore hub api-key generate``, + which is run automatically when no key is stored for that workspace. Supported harnesses: Bob Shell, Bob IDE, Claude, Cursor, OpenCode, Pi, GitHub Copilot and Codex (all must be on ``PATH``; on macOS, Bob IDE is found @@ -203,57 +164,35 @@ def agent( raise click.ClickException(f"workspace does not exist: {workspace}") config = SkoreConfig.load(workspace) + if hub_url: + os.environ["SKORE_HUB_URI"] = hub_url - if config is not None and config.api_key and config.workspace: - resolved_hub_url = ( - resolve_hub_uri(hub_url, _auth) if hub_url is not None else config.hub_url - ) + if config is not None and config.workspace: harness_name = harness_name or config.harness else: - resolved_hub_url = resolve_hub_uri(hub_url, _auth) - token = _ensure_login(resolved_hub_url, timeout=login_timeout) - user_id, memberships = _client.me(resolved_hub_url, token) + from skore._plugins.hub.authentication.uri import URI + + resolved_hub_url = URI() + token = login(timeout=login_timeout) + _, memberships = _client.me(resolved_hub_url, token.access) if not memberships: raise click.ClickException( "you are not a member of any hub workspace; create or join one first." ) - saved_workspace = config.workspace if config else None - membership = _resolve_membership(memberships, saved_workspace) - - if config is None or not config.api_key: - if harness_name is None: - if is_non_interactive(): - detected = detect_agent() - if ( - detected - and detected.harness_name - and is_harness_installed(detected) - ): - harness_name = detected.harness_name - else: - raise click.UsageError( - "pass --harness to create an API key non-interactively." - ) - else: - harness_name = _pick_harness(workspace) - api_key = _create_workspace_api_key( - resolved_hub_url, token, user_id, membership, harness_name - ) - else: - api_key = config.api_key - + membership = _resolve_membership(memberships) config = SkoreConfig( hub_url=resolved_hub_url, workspace=membership.public_id, workspace_id=membership.workspace_id, - api_key=api_key, - harness=harness_name or (config.harness if config else None), + harness=harness_name, ) config_path = config.save(workspace) ensure_gitignore_entry(workspace) console.print(f"[skore.ok]+[/] saved [skore.path]{config_path}[/]") + api_key = _api_key_for(ctx, config, login_timeout=login_timeout) + if harness_name is None: if is_non_interactive(): detected = detect_agent() @@ -277,7 +216,6 @@ def agent( hub_url=config.hub_url, workspace=config.workspace, workspace_id=config.workspace_id, - api_key=config.api_key, harness=harness_name, ) config.save(workspace) @@ -291,7 +229,7 @@ def agent( HarnessContext( workspace=workspace, hub_url=config.hub_url, - api_key=config.api_key, + api_key=api_key, model_id=model_id, ) ) diff --git a/src/skore_cli/agent/_skore_file.py b/src/skore_cli/agent/_skore_file.py index 01524df..5df643d 100644 --- a/src/skore_cli/agent/_skore_file.py +++ b/src/skore_cli/agent/_skore_file.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from dataclasses import asdict, dataclass from pathlib import Path @@ -18,7 +19,6 @@ class SkoreConfig: hub_url: str workspace: str workspace_id: int - api_key: str harness: str | None = None @classmethod @@ -34,15 +34,14 @@ def load(cls, path: Path) -> SkoreConfig | None: hub_url = data.get("hub_url") workspace = data.get("workspace") workspace_id = data.get("workspace_id") - api_key = data.get("api_key") - if not hub_url or not workspace or workspace_id is None or not api_key: + if not hub_url or not workspace or workspace_id is None: return None harness = normalize_harness_name(data.get("harness")) + os.environ["SKORE_HUB_URI"] = hub_url return cls( hub_url=hub_url, workspace=workspace, workspace_id=int(workspace_id), - api_key=api_key, harness=harness, ) diff --git a/src/skore_cli/agent/_client.py b/src/skore_cli/hub/_client.py similarity index 90% rename from src/skore_cli/agent/_client.py rename to src/skore_cli/hub/_client.py index 17f1c18..503b503 100644 --- a/src/skore_cli/agent/_client.py +++ b/src/skore_cli/hub/_client.py @@ -1,10 +1,9 @@ -"""Thin HTTP client for the hub API used by the ``skore agent`` command. +"""Thin HTTP client for the hub identity API. -Pure, testable functions over the hub's ``/identity`` endpoints, limited to what -``skore agent`` needs: reading the user's profile and minting a workspace-scoped -API key. ``httpx`` is imported lazily inside the calls so building the CLI stays -cheap. All calls authenticate with the stored interactive login token as a -bearer. +Pure, testable functions over the hub's ``/identity`` endpoints: reading the +user's profile and minting a workspace-scoped API key. ``httpx`` is imported +lazily inside the calls so building the CLI stays cheap. All calls authenticate +with the stored interactive login token as a bearer. """ from __future__ import annotations diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index f3ef168..93c67e3 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -2,11 +2,14 @@ from __future__ import annotations +import os + import rich_click as click from rich.table import Table -from skore_cli._skore import auth as _auth from skore_cli._style import console +from skore_cli.hub import _client +from skore_cli.hub.login import login click.rich_click.COMMAND_GROUPS = { **getattr(click.rich_click, "COMMAND_GROUPS", {}), @@ -14,10 +17,17 @@ {"name": "Credentials", "commands": ["api-key"]}, ], "cli hub api-key": [ - {"name": "Manage", "commands": ["add", "delete", "list"]}, + {"name": "Manage", "commands": ["generate", "add", "delete", "list"]}, ], } +PROJECT_PERMISSIONS = ( + "create:project", + "read:project", + "update:project", + "delete:project", +) + @click.group(invoke_without_command=True) @click.pass_context @@ -28,23 +38,118 @@ def hub(ctx) -> None: def _registry(): - """Import ``skore``'s API-key registry, or fail if ``skore`` is missing.""" - return _auth("registry") + """Return ``skore``'s API-key registry.""" + from skore._plugins.hub.authentication.api_key import registry + + return registry def _host(host: str | None) -> str: """Return ``host``, or skore's ``URI()`` when ``host`` is omitted.""" - return host or _auth("uri").URI() + from skore._plugins.hub.authentication.uri import URI + + return host or URI() + + +def _resolve_api_key_name(base: str, existing_names: list[str]) -> str: + if base not in existing_names: + return base + index = 2 + while f"{base}-{index}" in existing_names: + index += 1 + return f"{base}-{index}" + + +def _create_workspace_api_key( + hub_url: str, + token: str, + user_id: str, + membership: _client.Membership, + name: str, +) -> str: + """Mint a workspace-scoped API key.""" + grantable = set(membership.permissions) + permissions = [p for p in PROJECT_PERMISSIONS if p in grantable] + if not permissions: + raise click.ClickException( + f"you cannot create project API keys in workspace '{membership.public_id}'." + ) + + existing = _client.list_api_keys(hub_url, token, user_id) + workspace_names = [ + key.name or "" + for key in existing + if key.workspace_id == membership.workspace_id + ] + key_name = _resolve_api_key_name(name, workspace_names) + _api_key_id, secret = _client.create_api_key( + hub_url, + token, + user_id, + name=key_name, + permissions=permissions, + workspace_id=membership.workspace_id, + expires_at=None, + ) + return secret + + +def _membership_for( + memberships: list[_client.Membership], workspace: str +) -> _client.Membership: + membership = next((m for m in memberships if m.public_id == workspace), None) + if membership is None: + raise click.ClickException( + f"workspace '{workspace}' is not in your memberships." + ) + return membership @hub.group("api-key", invoke_without_command=True) @click.pass_context def api_key(ctx) -> None: - """Add, delete and list locally stored Hub API keys.""" + """Generate, add, delete and list Hub API keys.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) +@api_key.command("generate") +@click.option("--host", default=None, help="Hub host URL.") +@click.option("--workspace", required=True, help="Hub workspace to mint the key for.") +@click.option( + "--name", + default=None, + help="Name stored on the hub for this key (default: the workspace id).", +) +@click.option( + "--login-timeout", + default=600, + show_default=True, + help="Seconds to wait for interactive device login.", +) +def generate( + host: str | None, workspace: str, name: str | None, login_timeout: int +) -> None: + """Mint a workspace-scoped Hub API key and store it locally.""" + if host: + os.environ["SKORE_HUB_URI"] = host + hub_url = _host(host) + token = login(timeout=login_timeout) + user_id, memberships = _client.me(hub_url, token.access) + if not memberships: + raise click.ClickException( + "you are not a member of any hub workspace; create or join one first." + ) + membership = _membership_for(memberships, workspace) + secret = _create_workspace_api_key( + hub_url, token.access, user_id, membership, name or workspace + ) + _registry().set(host=hub_url, workspace=workspace, api_key=secret) + console.print( + f"[skore.ok]+[/] generated API key for workspace [skore.skill]{workspace}[/]" + ) + + @api_key.command("add") @click.argument("key") @click.option("--host", default=None, help="Hub host URL.") diff --git a/src/skore_cli/hub/login.py b/src/skore_cli/hub/login.py new file mode 100644 index 0000000..fa43ca8 --- /dev/null +++ b/src/skore_cli/hub/login.py @@ -0,0 +1,253 @@ +"""Login to ``skore hub``.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from functools import cache +from threading import RLock +from time import sleep +from urllib.parse import urljoin +from webbrowser import open as open_webbrowser + +from httpx import HTTPStatusError, TimeoutException +from rich.align import Align +from rich.panel import Panel + + +def get_oauth_device_login(success_uri: str | None = None) -> tuple[str, str, str]: + """ + Initiate device OAuth flow. + + Initiates the OAuth device flow. + Provides the user with a URL and a OTP code to authenticate the device. + + Parameters + ---------- + success_uri : str, optional + The URI to redirect to after successful authentication. + If not provided, defaults to None. + + Returns + ------- + tuple + A tuple containing: + - authorization_url: str + The URL to which the user needs to navigate + - device_code: str + The device code used for authentication + - user_code: str + The user code that needs to be entered on the authorization page + """ + from skore._plugins.hub.authentication.uri import URI + from skore._plugins.hub.client.client import Client + + url = "identity/oauth/device/login" + params = {"success_uri": success_uri} if success_uri is not None else {} + + with Client() as client: + response = client.get(urljoin(URI(), url), params=params).json() + + return ( + response["authorization_url"], + response["device_code"], + response["user_code"], + ) + + +def get_oauth_device_code_probe(device_code: str, *, timeout: int = 600) -> None: + """ + Ensure authorization code is acknowledged. + + Start polling, wait for the authorization code to be acknowledged by the hub. + This is mandatory to be authorize to exchange with a token. + + Parameters + ---------- + device_code : str + The device code to exchange for tokens. + """ + from skore._plugins.hub.authentication.uri import URI + from skore._plugins.hub.client.client import Client + + url = "identity/oauth/device/code-probe" + params = {"device_code": device_code} + + with Client() as client: + start = datetime.now() + + while True: + try: + client.get(urljoin(URI(), url), params=params) + except HTTPStatusError as exc: + if exc.response.status_code != 400: + raise + + if (datetime.now() - start).total_seconds() >= timeout: + raise TimeoutException("Authentication timeout") from exc + + sleep(0.5) + else: + break + + +def post_oauth_device_callback(state: str, user_code: str) -> None: + """ + Validate the user-provided device code. + + This endpoint verifies the code entered by the user during the device auth flow. + + Parameters + ---------- + state: str + The unique value identifying the device flow. + user_code: str + The code entered by the user. + """ + from skore._plugins.hub.authentication.uri import URI + from skore._plugins.hub.client.client import Client + + url = "identity/oauth/device/callback" + data = {"state": state, "user_code": user_code} + + with Client() as client: + client.post(urljoin(URI(), url), data=data) + + +def get_oauth_device_token(device_code: str) -> tuple[str, str, str]: + """ + Exchanges the device code for an access token. + + This endpoint completes the device authorization flow + by exchanging the validated device code for an access token. + + Parameters + ---------- + device_code : str + The device code to exchange for tokens + + Returns + ------- + tuple + A tuple containing: + - access_token : str + The OAuth access token + - refresh_token : str + The OAuth refresh token + - expires_at : str + The expiration datetime as ISO 8601 str of the access token + """ + from skore._plugins.hub.authentication.uri import URI + from skore._plugins.hub.client.client import Client + + url = "identity/oauth/device/token" + params = {"device_code": device_code} + + with Client() as client: + response = client.get(urljoin(URI(), url), params=params).json() + tokens = response["token"] + + return ( + tokens["access_token"], + tokens["refresh_token"], + tokens["expires_at"], + ) + + +def post_oauth_refresh_token(refresh_token: str) -> tuple[str, str, str]: + """ + Refresh an access token using a provided refresh token. + + This endpoint allows a client to obtain a new access token + by providing a valid refresh token. + + Parameters + ---------- + refresh_token : str + A valid refresh token + + Returns + ------- + tuple + A tuple containing: + - access_token : str + The OAuth access token + - refresh_token : str + The OAuth refresh token + - expires_at : str + The expiration datetime as ISO 8601 str of the access token + """ + from skore._plugins.hub.authentication.uri import URI + from skore._plugins.hub.client.client import Client + + url = "identity/oauth/token/refresh" + json = {"refresh_token": refresh_token} + + with Client() as client: + response = client.post(urljoin(URI(), url), json=json).json() + + return ( + response["access_token"], + response["refresh_token"], + response["expires_at"], + ) + + +class Token: + """ + Token used for ``skore hub`` authentication, as HTTP header parameters. + + Notes + ----- + Refresh the token on-the-fly if necessary. + """ + + def __init__(self, *, timeout: int = 600) -> None: + from skore import console + + url, device_code, user_code = get_oauth_device_login() + + console.print( + Panel( + Align.center( + "Starting interactive authentication for the session.\n" + "Opening browser for interactive authentication; if this fails, " + f"please visit:\n[link={url}]{url}[/link]" + ), + title="[cyan]Login to [bold]Skore Hub", + border_style="cyan", + padding=1, + ), + soft_wrap=True, + ) + + open_webbrowser(url) + + get_oauth_device_code_probe(device_code, timeout=timeout) + post_oauth_device_callback(device_code, user_code) + + access, refreshment, expiration = get_oauth_device_token(device_code) + + self.__lock = RLock() + self.__access = access + self.__refreshment = refreshment + self.__expiration = datetime.fromisoformat(expiration) + + @property + def access(self) -> str: + with self.__lock: + if self.__expiration <= datetime.now(UTC): + access, refreshment, expiration = post_oauth_refresh_token( + self.__refreshment + ) + + self.__access = access + self.__refreshment = refreshment + self.__expiration = datetime.fromisoformat(expiration) + + return self.__access + + +@cache +def login(*, timeout: int = 600) -> Token: + """Login to ``skore hub``.""" + return Token(timeout=timeout) diff --git a/src/skore_cli/sync/_commands.py b/src/skore_cli/sync/_commands.py index 401680b..2c25fdf 100644 --- a/src/skore_cli/sync/_commands.py +++ b/src/skore_cli/sync/_commands.py @@ -2,28 +2,20 @@ from __future__ import annotations -import importlib import os from pathlib import Path from typing import Any import rich_click as click -from skore_cli._skore import URI_ENV, resolve_hub_uri - MODES = ("local", "hub", "mlflow") API_KEY_ENV = "SKORE_HUB_API_KEY" def _project_api(): """Import the public project API only when synchronization runs.""" - try: - skore = importlib.import_module("skore") - except ImportError as error: # pragma: no cover - exercised through the command - raise click.ClickException( - "this command needs the `skore` package (install it with `pip install " - "skore-cli`)." - ) from error + import skore + if not hasattr(skore.Project, "sync"): raise click.ClickException( "synchronization requires `skore>=0.24.0`; upgrade it with " @@ -82,7 +74,7 @@ def _render_result(result, *, dry_run: bool) -> None: default=None, help=( "Base URL of the Hub API. Defaults to the " - f"{URI_ENV} environment variable or the public Hub." + "SKORE_HUB_URI environment variable or the public Hub." ), ) @click.option( @@ -140,7 +132,8 @@ def sync( try: Project, login = _project_api() if uses_hub: - resolve_hub_uri(hub_url) + if hub_url: + os.environ["SKORE_HUB_URI"] = hub_url login(mode="hub") source = Project(source_project, mode=source_mode, **source_options) destination = Project( diff --git a/tests/conftest.py b/tests/conftest.py index d58a315..f5407a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,6 +128,24 @@ def workspace(monkeypatch, tmp_path): return SimpleNamespace(home=home, project=project) +@pytest.fixture(autouse=True) +def monkeypatch_tmpdir(monkeypatch, tmp_path): + """ + Change ``TMPDIR`` used by ``tempfile.gettempdir()`` to point to ``tmp_path``, so + that it is automatically deleted after use, with no impact on user's environment. + + Force the reload of the ``tempfile`` module to change the cached return of + ``tempfile.gettempdir()``. + + https://docs.python.org/3/library/tempfile.html#tempfile.gettempdir + """ + import importlib + import tempfile + + monkeypatch.setenv("TMPDIR", str(tmp_path)) + importlib.reload(tempfile) + + @pytest.fixture def monkeypatch_home(monkeypatch, tmp_path): """ @@ -161,7 +179,5 @@ def monkeypatch_keyring(monkeypatch): @pytest.fixture(autouse=True) def monkeypatch_sdk_env(monkeypatch): """Unset the SDK credential variables.""" - from skore_cli._agents import SDK_API_KEY_ENV, SDK_URI_ENV - - monkeypatch.delenv(SDK_API_KEY_ENV, raising=False) - monkeypatch.delenv(SDK_URI_ENV, raising=False) + monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) + monkeypatch.delenv("SKORE_HUB_URI", raising=False) diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index 2d7fa3b..d51d7a1 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import re from types import SimpleNamespace @@ -12,7 +13,7 @@ from skore_cli import _agents from skore_cli._agents import AGENTS, DEFAULT_MODEL_ID, HARNESS_NAMES, HarnessContext -from skore_cli.agent import _client, _commands +from skore_cli.agent import _commands from skore_cli.agent import app as _agent_app from skore_cli.agent._commands import agent from skore_cli.agent._skore_file import ( @@ -20,6 +21,8 @@ SkoreConfig, ensure_gitignore_entry, ) +from skore_cli.hub import _client +from skore_cli.hub._commands import PROJECT_PERMISSIONS _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") @@ -42,16 +45,31 @@ def _membership(public_id: str = "ws-1", workspace_id: int = 1): return _client.Membership( workspace_id=workspace_id, public_id=public_id, - permissions=frozenset(_commands.PROJECT_PERMISSIONS), + permissions=frozenset(PROJECT_PERMISSIONS), ) +@pytest.fixture(autouse=True) +def stub_registry(monkeypatch): + """Serve a stored API key so runs never reach the real credential registry.""" + monkeypatch.setattr( + _commands, + "_registry", + lambda: SimpleNamespace(get=lambda **kwargs: "new-secret"), + ) + + +@pytest.fixture(autouse=True) +def default_hub_uri(monkeypatch): + """Seed the hub URI so first-run `URI()` does not hit the public hub.""" + monkeypatch.setenv("SKORE_HUB_URI", "http://hub.test") + + def _write_skore(directory, **overrides): payload = { "hub_url": "http://hub.test", "workspace": "ws-1", "workspace_id": 1, - "api_key": "secret-key", "harness": "opencode", } payload.update(overrides) @@ -64,7 +82,6 @@ def test_skore_config_round_trip(tmp_path): hub_url="http://hub.test", workspace="ws-1", workspace_id=1, - api_key="secret", harness="pi", ) path = config.save(tmp_path) @@ -73,6 +90,13 @@ def test_skore_config_round_trip(tmp_path): assert loaded == config +def test_skore_config_load_sets_hub_uri_env(tmp_path, monkeypatch): + monkeypatch.delenv("SKORE_HUB_URI", raising=False) + _write_skore(tmp_path) + SkoreConfig.load(tmp_path) + assert os.environ["SKORE_HUB_URI"] == "http://hub.test" + + def test_skore_config_load_invalid_returns_none(tmp_path): (tmp_path / SKORE_FILENAME).write_text("{ not json") assert SkoreConfig.load(tmp_path) is None @@ -91,17 +115,24 @@ def test_skore_config_load_returns_none_when_absent(tmp_path): def test_skore_config_load_returns_none_when_required_field_missing(tmp_path): (tmp_path / SKORE_FILENAME).write_text( - json.dumps({"hub_url": "http://hub.test", "workspace": "ws-1"}) + "\n" + json.dumps({"hub_url": "http://hub.test"}) + "\n" ) assert SkoreConfig.load(tmp_path) is None +def test_skore_config_save_omits_the_api_key(tmp_path): + SkoreConfig(hub_url="http://hub.test", workspace="ws-1", workspace_id=1).save( + tmp_path + ) + payload = json.loads((tmp_path / SKORE_FILENAME).read_text()) + assert "api_key" not in payload + + def test_skore_config_save_omits_none_harness(tmp_path): config = SkoreConfig( hub_url="http://hub.test", workspace="ws-1", workspace_id=1, - api_key="secret", ) config.save(tmp_path) payload = json.loads((tmp_path / SKORE_FILENAME).read_text()) @@ -171,9 +202,6 @@ def test_agent_invalid_harness_lists_all_supported_harnesses(tmp_path): def test_agent_uses_existing_skore_config(tmp_path, monkeypatch): _write_skore(tmp_path) _mock_harness_on_path(monkeypatch, "opencode") - monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test" - ) launched: list[str] = [] monkeypatch.setattr( _commands, @@ -196,24 +224,13 @@ def test_agent_uses_existing_skore_config(tmp_path, monkeypatch): def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): _mock_harness_on_path(monkeypatch, "opencode") monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]), ) - monkeypatch.setattr( - _commands._client, - "list_api_keys", - lambda *a, **k: [], - ) - monkeypatch.setattr( - _commands._client, - "create_api_key", - lambda *a, **k: (42, "new-secret"), - ) monkeypatch.setattr( _commands, "launch_harness", @@ -227,8 +244,9 @@ def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): assert result.exit_code == 0, result.output saved = json.loads((tmp_path / SKORE_FILENAME).read_text()) - assert saved["api_key"] == "new-secret" assert saved["workspace"] == "ws-1" + assert saved["hub_url"] == "http://hub.test" + assert "api_key" not in saved assert ".skore" in (tmp_path / ".gitignore").read_text().splitlines() @@ -243,20 +261,13 @@ def test_agent_bob_ide_first_run_on_linux_writes_mcp_config( monkeypatch.setattr(_agents.sys, "platform", "linux") _mock_harness_on_path(monkeypatch, "bobide") monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]), ) - monkeypatch.setattr(_commands._client, "list_api_keys", lambda *a, **k: []) - monkeypatch.setattr( - _commands._client, - "create_api_key", - lambda *a, **k: (42, "new-secret"), - ) monkeypatch.setattr( _commands, "launch_harness", @@ -270,17 +281,16 @@ def test_agent_bob_ide_first_run_on_linux_writes_mcp_config( assert result.exit_code == 0, result.output assert "not installed or not on PATH" not in _plain_output(result.output) saved = json.loads((tmp_path / SKORE_FILENAME).read_text()) - assert saved["api_key"] == "new-secret" assert saved["harness"] == "bob-ide" mcp = json.loads((tmp_path / ".bob" / "mcp.json").read_text()) assert mcp["mcpServers"]["skore"]["url"] == "http://hub.test/mcp" def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): + _clear_agent_envs(monkeypatch) monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, "me", @@ -294,13 +304,44 @@ def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): assert "pass --harness" in _plain_output(result.output) -def test_resolve_api_key_name_deduplicates(): - assert _commands._resolve_api_key_name("opencode", []) == "opencode" - assert _commands._resolve_api_key_name("opencode", ["opencode"]) == "opencode-2" - assert ( - _commands._resolve_api_key_name("opencode", ["opencode", "opencode-2"]) - == "opencode-3" +def test_agent_generates_api_key_when_none_is_stored(tmp_path, monkeypatch): + _mock_harness_on_path(monkeypatch, "opencode") + monkeypatch.setattr( + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") + ) + monkeypatch.setattr( + _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) + ) + monkeypatch.setattr( + _commands, + "launch_harness", + lambda selected, workspace, model_id=DEFAULT_MODEL_ID: None, + ) + + stored: dict[tuple[str, str], str] = {} + generated = [] + + def fake_generate(*, host, workspace, name, login_timeout): + generated.append((host, workspace)) + stored[(host, workspace)] = "minted-secret" + + monkeypatch.setattr( + _commands, + "_registry", + lambda: SimpleNamespace( + get=lambda *, host, workspace: stored.get((host, workspace)) + ), ) + monkeypatch.setattr(_commands, "generate", fake_generate) + + result = CliRunner().invoke( + agent, ["--workspace", str(tmp_path), "--harness", "opencode"] + ) + + assert result.exit_code == 0, result.output + assert generated == [("http://hub.test", "ws-1")] + provider = json.loads((tmp_path / "opencode.json").read_text())["provider"] + assert provider["skore"]["options"]["apiKey"] == "minted-secret" # --------------------------------------------------------------------------- # @@ -324,20 +365,6 @@ def test_is_non_interactive_without_tty(monkeypatch): assert _commands.is_non_interactive() is True -# --------------------------------------------------------------------------- # -# _ensure_login -# --------------------------------------------------------------------------- # - - -def test_ensure_login_delegates_to_hub_auth(monkeypatch): - seen = {} - monkeypatch.setattr( - _commands, "ensure_login", lambda *, timeout: seen.setdefault("t", timeout) - ) - _commands._ensure_login("http://hub.test", timeout=42) - assert seen["t"] == 42 - - # --------------------------------------------------------------------------- # # _pick_workspace # --------------------------------------------------------------------------- # @@ -408,91 +435,21 @@ def test_pick_harness_aborts_when_cancelled(tmp_path, monkeypatch): def test_resolve_membership_single_membership_auto_selected(): only = _membership("ws-1") - assert _commands._resolve_membership([only], None) is only - - -def test_resolve_membership_matches_saved_workspace(): - memberships = [_membership("ws-1"), _membership("ws-2", workspace_id=2)] - assert _commands._resolve_membership(memberships, "ws-2").public_id == "ws-2" - - -def test_resolve_membership_unknown_workspace_errors(): - with pytest.raises(click.ClickException, match="not in your memberships"): - _commands._resolve_membership([_membership("ws-1")], "ws-missing") + assert _commands._resolve_membership([only]) is only def test_resolve_membership_multiple_non_interactive_errors(monkeypatch): monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) memberships = [_membership("ws-1"), _membership("ws-2", workspace_id=2)] with pytest.raises(click.UsageError, match="run interactively"): - _commands._resolve_membership(memberships, None) + _commands._resolve_membership(memberships) def test_resolve_membership_multiple_interactive_picks(monkeypatch): monkeypatch.setattr(_commands, "is_non_interactive", lambda: False) memberships = [_membership("ws-1"), _membership("ws-2", workspace_id=2)] monkeypatch.setattr(_commands, "_pick_workspace", lambda m: m[1]) - assert _commands._resolve_membership(memberships, None).public_id == "ws-2" - - -# --------------------------------------------------------------------------- # -# _create_workspace_api_key -# --------------------------------------------------------------------------- # - - -def test_create_workspace_api_key_mints_secret(monkeypatch): - monkeypatch.setattr(_commands._client, "list_api_keys", lambda *a, **k: []) - captured = {} - - def fake_create(hub_url, token, user_id, **kwargs): - captured.update(kwargs) - return 7, "the-secret" - - monkeypatch.setattr(_commands._client, "create_api_key", fake_create) - - secret = _commands._create_workspace_api_key( - "http://hub.test", "tok", "user-1", _membership(), "opencode" - ) - - assert secret == "the-secret" - assert captured["name"] == "opencode" - assert set(captured["permissions"]) == set(_commands.PROJECT_PERMISSIONS) - - -def test_create_workspace_api_key_requires_permissions(): - membership = _client.Membership( - workspace_id=1, public_id="ws-1", permissions=frozenset() - ) - with pytest.raises(click.ClickException, match="cannot create project API keys"): - _commands._create_workspace_api_key( - "http://hub.test", "tok", "user-1", membership, "opencode" - ) - - -def test_create_workspace_api_key_dedupes_name_within_workspace(monkeypatch): - existing = [ - _client.ApiKeyInfo( - id=1, - name="opencode", - workspace_id=1, - created_at=None, - expires_at=None, - ) - ] - monkeypatch.setattr(_commands._client, "list_api_keys", lambda *a, **k: existing) - captured = {} - - def fake_create(hub_url, token, user_id, **kwargs): - captured.update(kwargs) - return 2, "secret" - - monkeypatch.setattr(_commands._client, "create_api_key", fake_create) - - _commands._create_workspace_api_key( - "http://hub.test", "tok", "user-1", _membership(), "opencode" - ) - - assert captured["name"] == "opencode-2" + assert _commands._resolve_membership(memberships).public_id == "ws-2" # --------------------------------------------------------------------------- # @@ -502,9 +459,8 @@ def fake_create(hub_url, token, user_id, **kwargs): def test_agent_no_memberships_errors(tmp_path, monkeypatch): monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr(_commands._client, "me", lambda hub_url, token: ("user-1", [])) result = CliRunner().invoke( @@ -518,10 +474,6 @@ def test_agent_no_memberships_errors(tmp_path, monkeypatch): def test_agent_errors_when_harness_not_installed(tmp_path, monkeypatch): _write_skore(tmp_path) monkeypatch.setattr(_agents.shutil, "which", lambda cmd: None) - monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test" - ) - result = CliRunner().invoke( agent, ["--workspace", str(tmp_path), "--harness", "opencode"] ) @@ -533,17 +485,14 @@ def test_agent_errors_when_harness_not_installed(tmp_path, monkeypatch): def test_agent_valid_config_without_harness_non_interactive_errors( tmp_path, monkeypatch ): - # Config is complete (api_key + workspace) but no harness was ever saved. + # Config is complete (hub_url + workspace) but no harness was ever saved. + _clear_agent_envs(monkeypatch) payload = { "hub_url": "http://hub.test", "workspace": "ws-1", "workspace_id": 1, - "api_key": "secret-key", } (tmp_path / SKORE_FILENAME).write_text(json.dumps(payload) + "\n") - monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test" - ) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) result = CliRunner().invoke(agent, ["--workspace", str(tmp_path)]) @@ -557,13 +506,9 @@ def test_agent_valid_config_without_harness_picks_interactively(tmp_path, monkey "hub_url": "http://hub.test", "workspace": "ws-1", "workspace_id": 1, - "api_key": "secret-key", } (tmp_path / SKORE_FILENAME).write_text(json.dumps(payload) + "\n") _mock_harness_on_path(monkeypatch, "opencode") - monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test" - ) monkeypatch.setattr(_commands, "is_non_interactive", lambda: False) monkeypatch.setattr(_commands, "_pick_harness", lambda workspace: "opencode") monkeypatch.setattr( @@ -582,18 +527,13 @@ def test_agent_valid_config_without_harness_picks_interactively(tmp_path, monkey def test_agent_first_run_picks_harness_interactively(tmp_path, monkeypatch): _mock_harness_on_path(monkeypatch, "opencode") monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) ) monkeypatch.setattr(_commands, "is_non_interactive", lambda: False) monkeypatch.setattr(_commands, "_pick_harness", lambda workspace: "opencode") - monkeypatch.setattr(_commands._client, "list_api_keys", lambda *a, **k: []) - monkeypatch.setattr( - _commands._client, "create_api_key", lambda *a, **k: (1, "new-secret") - ) monkeypatch.setattr( _commands, "launch_harness", @@ -605,15 +545,11 @@ def test_agent_first_run_picks_harness_interactively(tmp_path, monkeypatch): assert result.exit_code == 0, result.output saved = json.loads((tmp_path / SKORE_FILENAME).read_text()) assert saved["harness"] == "opencode" - assert saved["api_key"] == "new-secret" def test_agent_rewrites_config_when_harness_changes(tmp_path, monkeypatch): _write_skore(tmp_path, harness="claude") _mock_harness_on_path(monkeypatch, "opencode") - monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test" - ) monkeypatch.setattr( _commands, "launch_harness", @@ -654,17 +590,12 @@ def test_agent_non_interactive_auto_selects_claude(tmp_path, monkeypatch): monkeypatch.setenv("CLAUDECODE", "1") _mock_harness_on_path(monkeypatch, "claude") monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) ) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) - monkeypatch.setattr(_commands._client, "list_api_keys", lambda *a, **k: []) - monkeypatch.setattr( - _commands._client, "create_api_key", lambda *a, **k: (1, "new-secret") - ) launched = [] monkeypatch.setattr( _commands, @@ -688,17 +619,12 @@ def test_agent_non_interactive_auto_selects_opencode(tmp_path, monkeypatch): monkeypatch.setenv("OPENCODE_CLIENT", "1") _mock_harness_on_path(monkeypatch, "opencode") monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) ) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) - monkeypatch.setattr(_commands._client, "list_api_keys", lambda *a, **k: []) - monkeypatch.setattr( - _commands._client, "create_api_key", lambda *a, **k: (1, "new-secret") - ) launched = [] monkeypatch.setattr( _commands, @@ -721,17 +647,12 @@ def test_agent_non_interactive_auto_selects_pi(tmp_path, monkeypatch): monkeypatch.setenv("PI_CODING_AGENT", "true") _mock_harness_on_path(monkeypatch, "pi") monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) ) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) - monkeypatch.setattr(_commands._client, "list_api_keys", lambda *a, **k: []) - monkeypatch.setattr( - _commands._client, "create_api_key", lambda *a, **k: (1, "new-secret") - ) launched = [] monkeypatch.setattr( _commands, @@ -756,9 +677,8 @@ def test_agent_non_interactive_detected_harness_not_on_path_errors( monkeypatch.setenv("CLAUDECODE", "1") monkeypatch.setattr(_agents.shutil, "which", lambda cmd: None) monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") ) - monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) ) @@ -776,9 +696,6 @@ def test_agent_detected_different_harness_still_launches(tmp_path, monkeypatch): monkeypatch.setenv("CLAUDECODE", "1") _mock_harness_on_path(monkeypatch, "opencode") _write_skore(tmp_path) - monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test" - ) launched = [] monkeypatch.setattr( _commands, @@ -797,20 +714,16 @@ def test_agent_detected_different_harness_still_launches(tmp_path, monkeypatch): def test_agent_reuse_path_auto_selects_detected_harness(tmp_path, monkeypatch): - """Config exists with api_key but no harness; non-interactive + detected.""" + """Config exists but no harness; non-interactive + detected.""" _clear_agent_envs(monkeypatch) monkeypatch.setenv("OPENCODE_CLIENT", "1") payload = { "hub_url": "http://hub.test", "workspace": "ws-1", "workspace_id": 1, - "api_key": "secret-key", } (tmp_path / SKORE_FILENAME).write_text(json.dumps(payload) + "\n") _mock_harness_on_path(monkeypatch, "opencode") - monkeypatch.setattr( - _commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test" - ) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) launched = [] monkeypatch.setattr( diff --git a/tests/test_cli.py b/tests/test_cli.py index 232fa65..70c2d95 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,14 +1,10 @@ -"""Tests for the CLI wiring, the lazy `skore` accessor and the plugin host.""" +"""Tests for the CLI wiring and the plugin host.""" from __future__ import annotations -import os -from types import SimpleNamespace - -import pytest import rich_click as click -from skore_cli import _plugins, _skore +from skore_cli import _plugins def test_cli_exposes_builtin_commands(): @@ -155,53 +151,6 @@ def test_cli_subcommand_help_omits_banner(): assert "Usage" in result.output -# --------------------------------------------------------------------------- # -# _skore.auth -# --------------------------------------------------------------------------- # - - -def test_auth_returns_module(monkeypatch): - sentinel = SimpleNamespace(name="fake-module") - monkeypatch.setattr(_skore.importlib, "import_module", lambda name: sentinel) - - assert _skore.auth("uri") is sentinel - - -def test_auth_imports_expected_path(monkeypatch): - seen = {} - - def fake_import(name): - seen["name"] = name - return SimpleNamespace() - - monkeypatch.setattr(_skore.importlib, "import_module", fake_import) - - _skore.auth("token") - assert seen["name"] == "skore._plugins.hub.authentication.token" - - -def test_auth_missing_skore_raises_click_exception(monkeypatch): - def fake_import(name): - raise ImportError("no skore") - - monkeypatch.setattr(_skore.importlib, "import_module", fake_import) - - with pytest.raises(click.ClickException) as excinfo: - _skore.auth("store") - assert "skore" in str(excinfo.value) - - -def test_resolve_hub_uri_sets_explicit_url(monkeypatch): - module = SimpleNamespace(URI=lambda: "https://resolved.test") - - monkeypatch.delenv(_skore.URI_ENV, raising=False) - - assert _skore.resolve_hub_uri("https://hub.test", lambda _: module) == ( - "https://resolved.test" - ) - assert os.environ[_skore.URI_ENV] == "https://hub.test" - - # --------------------------------------------------------------------------- # # _plugins.load_plugins # --------------------------------------------------------------------------- # diff --git a/tests/test_client.py b/tests/test_client.py index 179bbee..2eee22b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,4 @@ -"""Tests for the thin hub HTTP client backing ``skore agent``.""" +"""Tests for the thin hub HTTP client backing ``skore hub``.""" from __future__ import annotations @@ -6,8 +6,8 @@ import pytest import rich_click as click -from skore_cli.agent import _client -from skore_cli.agent._client import ApiKeyInfo, Membership +from skore_cli.hub import _client +from skore_cli.hub._client import ApiKeyInfo, Membership def _transport(handler): diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index 3525e10..e8a4358 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import os import tomllib from typing import Any @@ -497,8 +496,7 @@ def fake_exec(name, argv, *, env=None): lambda cmd: "/usr/bin/claude" if cmd == "claude" else None, ) monkeypatch.setattr(_agents, "_exec_harness", fake_exec) - monkeypatch.delenv(_agents.SDK_API_KEY_ENV, raising=False) - monkeypatch.delenv(_agents.SDK_URI_ENV, raising=False) + monkeypatch.delenv("SKORE_HUB_URI", raising=False) return captured @@ -507,32 +505,29 @@ def _write_skore_file(workspace, **overrides): "hub_url": "http://hub.test", "workspace": "acme", "workspace_id": 1, - "api_key": "secret-key", } payload.update(overrides) (workspace / ".skore").write_text(json.dumps(payload)) -def test_launch_exports_sdk_credentials_from_skore_file(tmp_path, monkeypatch): +def test_launch_exports_sdk_uri_from_skore_file(tmp_path, monkeypatch): captured = _prepare_claude_launch(tmp_path, monkeypatch) _write_skore_file(tmp_path) _agents.launch_harness(AGENTS["claude-code"], tmp_path) - assert captured["env"][_agents.SDK_API_KEY_ENV] == "secret-key" - assert captured["env"][_agents.SDK_URI_ENV] == "http://hub.test" + assert captured["env"]["SKORE_HUB_URI"] == "http://hub.test" + assert "SKORE_HUB_API_KEY" not in captured["env"] -def test_launch_keeps_sdk_credentials_already_in_the_environment(tmp_path, monkeypatch): +def test_launch_overwrites_sdk_uri_from_skore_file(tmp_path, monkeypatch): captured = _prepare_claude_launch(tmp_path, monkeypatch) _write_skore_file(tmp_path) - monkeypatch.setenv(_agents.SDK_API_KEY_ENV, "user-key") - monkeypatch.setenv(_agents.SDK_URI_ENV, "http://user.hub") + monkeypatch.setenv("SKORE_HUB_URI", "http://user.hub") _agents.launch_harness(AGENTS["claude-code"], tmp_path) - assert captured["env"][_agents.SDK_API_KEY_ENV] == "user-key" - assert captured["env"][_agents.SDK_URI_ENV] == "http://user.hub" + assert captured["env"]["SKORE_HUB_URI"] == "http://hub.test" def test_launch_exports_nothing_without_a_skore_file(tmp_path, monkeypatch): @@ -540,8 +535,7 @@ def test_launch_exports_nothing_without_a_skore_file(tmp_path, monkeypatch): _agents.launch_harness(AGENTS["claude-code"], tmp_path) - assert _agents.SDK_API_KEY_ENV not in captured["env"] - assert _agents.SDK_URI_ENV not in captured["env"] + assert "SKORE_HUB_URI" not in captured["env"] def test_launch_exports_nothing_from_an_incomplete_skore_file(tmp_path, monkeypatch): @@ -550,8 +544,7 @@ def test_launch_exports_nothing_from_an_incomplete_skore_file(tmp_path, monkeypa _agents.launch_harness(AGENTS["claude-code"], tmp_path) - assert _agents.SDK_API_KEY_ENV not in captured["env"] - assert os.environ.get(_agents.SDK_API_KEY_ENV) is None + assert "SKORE_HUB_URI" not in captured["env"] def test_launch_harness_errors_when_not_detected(tmp_path, monkeypatch): diff --git a/tests/test_hub.py b/tests/test_hub.py index dbec181..a82e9f5 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -2,12 +2,18 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest +import rich_click as click from click.testing import CliRunner -from skore._plugins.hub.authentication import registry +from skore._plugins.hub.authentication.api_key import registry from skore._plugins.hub.authentication.uri import URI from skore_cli import cli +from skore_cli.hub import _client +from skore_cli.hub import _commands as _hub +from skore_cli.hub._commands import PROJECT_PERMISSIONS pytestmark = pytest.mark.usefixtures("monkeypatch_home", "monkeypatch_keyring") @@ -16,6 +22,14 @@ def _invoke(args: list[str]): return CliRunner().invoke(cli, args) +def _membership(public_id: str = "ws-1", workspace_id: int = 1): + return _client.Membership( + workspace_id=workspace_id, + public_id=public_id, + permissions=frozenset(PROJECT_PERMISSIONS), + ) + + def test_hub_no_subcommand_shows_help(): result = _invoke(["hub"]) @@ -27,6 +41,7 @@ def test_hub_api_key_no_subcommand_shows_help(): result = _invoke(["hub", "api-key"]) assert result.exit_code == 0 + assert "generate" in result.output assert "add" in result.output assert "delete" in result.output assert "list" in result.output @@ -114,3 +129,104 @@ def test_hub_api_key_list_shows_host_and_workspace(): assert "w2" in result.output assert "k1" not in result.output assert "k2" not in result.output + + +def test_resolve_api_key_name_deduplicates(): + assert _hub._resolve_api_key_name("opencode", []) == "opencode" + assert _hub._resolve_api_key_name("opencode", ["opencode"]) == "opencode-2" + assert ( + _hub._resolve_api_key_name("opencode", ["opencode", "opencode-2"]) + == "opencode-3" + ) + + +def test_create_workspace_api_key_mints_secret(monkeypatch): + monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: []) + captured = {} + + def fake_create(hub_url, token, user_id, **kwargs): + captured.update(kwargs) + return 7, "the-secret" + + monkeypatch.setattr(_hub._client, "create_api_key", fake_create) + + secret = _hub._create_workspace_api_key( + "http://hub.test", "tok", "user-1", _membership(), "opencode" + ) + + assert secret == "the-secret" + assert captured["name"] == "opencode" + assert set(captured["permissions"]) == set(PROJECT_PERMISSIONS) + + +def test_create_workspace_api_key_requires_permissions(): + membership = _client.Membership( + workspace_id=1, public_id="ws-1", permissions=frozenset() + ) + with pytest.raises(click.ClickException, match="cannot create project API keys"): + _hub._create_workspace_api_key( + "http://hub.test", "tok", "user-1", membership, "opencode" + ) + + +def test_create_workspace_api_key_dedupes_name_within_workspace(monkeypatch): + existing = [ + _client.ApiKeyInfo( + id=1, + name="opencode", + workspace_id=1, + created_at=None, + expires_at=None, + ) + ] + monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: existing) + captured = {} + + def fake_create(hub_url, token, user_id, **kwargs): + captured.update(kwargs) + return 2, "secret" + + monkeypatch.setattr(_hub._client, "create_api_key", fake_create) + + _hub._create_workspace_api_key( + "http://hub.test", "tok", "user-1", _membership(), "opencode" + ) + + assert captured["name"] == "opencode-2" + + +def test_hub_api_key_generate_requires_workspace(): + result = _invoke(["hub", "api-key", "generate"]) + + assert result.exit_code != 0 + assert "workspace" in result.output.lower() + + +def test_hub_api_key_generate_stores_key(monkeypatch): + monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") + monkeypatch.setattr(_hub, "login", lambda *, timeout: SimpleNamespace(access="tok")) + monkeypatch.setattr( + _hub._client, "me", lambda hub_url, token: ("user-1", [_membership("team")]) + ) + monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: []) + monkeypatch.setattr( + _hub._client, "create_api_key", lambda *a, **k: (42, "minted-secret") + ) + + result = _invoke(["hub", "api-key", "generate", "--workspace=team"]) + + assert result.exit_code == 0, result.output + assert registry.get(host="http://hub.test", workspace="team") == "minted-secret" + + +def test_hub_api_key_generate_unknown_workspace(monkeypatch): + monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") + monkeypatch.setattr(_hub, "login", lambda *, timeout: SimpleNamespace(access="tok")) + monkeypatch.setattr( + _hub._client, "me", lambda hub_url, token: ("user-1", [_membership("team")]) + ) + + result = _invoke(["hub", "api-key", "generate", "--workspace=other"]) + + assert result.exit_code != 0 + assert "not in your memberships" in result.output diff --git a/tests/test_hub_auth.py b/tests/test_hub_auth.py deleted file mode 100644 index 0adbf00..0000000 --- a/tests/test_hub_auth.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tests for ``_hub_auth`` login gating.""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest -import rich_click as click - -from skore_cli import _hub_auth - - -class _TokenCreds: - def __init__(self, token: str = "acc"): - self._token = token - - def __call__(self): - return {"Authorization": f"Bearer {self._token}"} - - -def _make_login(*, credentials=None): - login_mod = SimpleNamespace(credentials=credentials, login_calls=0) - - def login(*, timeout=600): - login_mod.login_calls += 1 - login_mod.credentials = _TokenCreds() - - login_mod.login = login - return lambda name: login_mod - - -def test_auth_kind_none(monkeypatch): - monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) - monkeypatch.setattr(_hub_auth, "_auth", _make_login()) - assert _hub_auth.auth_kind() == "none" - - -def test_auth_kind_api_key_from_env(monkeypatch): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - monkeypatch.setattr(_hub_auth, "_auth", _make_login()) - assert _hub_auth.auth_kind() == "api_key" - - -def test_auth_kind_bearer(monkeypatch): - monkeypatch.setattr(_hub_auth, "_auth", _make_login(credentials=_TokenCreds("tok"))) - assert _hub_auth.auth_kind() == "bearer" - assert _hub_auth.bearer_token() == "tok" - - -def test_ensure_login_runs_login_when_needed(monkeypatch): - auth = _make_login() - monkeypatch.setattr(_hub_auth, "_auth", auth) - token = _hub_auth.ensure_login(timeout=30) - assert token == "acc" - assert auth("login").login_calls == 1 - - -def test_ensure_login_rejects_env_api_key(monkeypatch): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - monkeypatch.setattr( - _hub_auth, "_auth", _make_login(credentials=lambda: {"X-API-Key": "x"}) - ) - with pytest.raises(click.ClickException): - _hub_auth.ensure_login() - - -def test_auth_kind_none_when_headers_have_no_known_key(monkeypatch): - monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) - monkeypatch.setattr(_hub_auth, "_auth", _make_login(credentials=lambda: {})) - assert _hub_auth.auth_kind() == "none" - - -def test_auth_kind_api_key_from_header(monkeypatch): - monkeypatch.setattr( - _hub_auth, "_auth", _make_login(credentials=lambda: {"X-API-Key": "x"}) - ) - assert _hub_auth.auth_kind() == "api_key" - - -def test_bearer_token_none_without_credentials(monkeypatch): - monkeypatch.setattr(_hub_auth, "_auth", _make_login()) - assert _hub_auth.bearer_token() is None - - -def test_bearer_token_none_when_not_a_bearer(monkeypatch): - monkeypatch.setattr( - _hub_auth, "_auth", _make_login(credentials=lambda: {"X-API-Key": "x"}) - ) - assert _hub_auth.bearer_token() is None - - -def test_ensure_login_skips_login_when_already_authenticated(monkeypatch): - monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) - auth = _make_login(credentials=_TokenCreds("tok")) - monkeypatch.setattr(_hub_auth, "_auth", auth) - assert _hub_auth.ensure_login() == "tok" - assert auth("login").login_calls == 0 - - -def test_ensure_login_raises_when_token_missing(monkeypatch): - monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) - # Credentials present but with no bearer token: auth_kind is "none", - # login is skipped, and bearer_token() returns None. - monkeypatch.setattr( - _hub_auth, "_auth", _make_login(credentials=lambda: {"Cookie": "x"}) - ) - with pytest.raises(click.ClickException, match="not logged in"): - _hub_auth.ensure_login() - - -def test_clear_login_returns_false_without_session(monkeypatch): - monkeypatch.setattr(_hub_auth, "_auth", _make_login()) - assert _hub_auth.clear_login() is False - - -def test_clear_login_drops_session(monkeypatch): - auth = _make_login(credentials=_TokenCreds("tok")) - monkeypatch.setattr(_hub_auth, "_auth", auth) - assert _hub_auth.clear_login() is True - assert auth("login").credentials is None diff --git a/tests/test_sync.py b/tests/test_sync.py index b019748..04f8191 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -5,7 +5,6 @@ import re from dataclasses import dataclass from pathlib import Path -from types import SimpleNamespace import pandas as pd import pytest @@ -50,7 +49,6 @@ def login(*, mode): logins.append(mode) monkeypatch.setattr(_commands, "_project_api", lambda: (project, login)) - monkeypatch.setattr(_commands, "resolve_hub_uri", lambda _: "http://hub.test") return created, logins @@ -192,11 +190,7 @@ def test_sync_requires_hub_api_key(): def test_sync_requires_skore_with_sync(monkeypatch): - monkeypatch.setattr( - _commands.importlib, - "import_module", - lambda _: SimpleNamespace(Project=object), - ) + monkeypatch.setattr("skore.Project", object) result = CliRunner().invoke(sync, ["experiment", "--to=mlflow"]) @@ -207,27 +201,12 @@ def test_sync_requires_skore_with_sync(monkeypatch): def test_project_api_imports_supported_skore(monkeypatch): project = type("Project", (), {"sync": None}) login = object() - monkeypatch.setattr( - _commands.importlib, - "import_module", - lambda _: SimpleNamespace(Project=project, login=login), - ) + monkeypatch.setattr("skore.Project", project) + monkeypatch.setattr("skore.login", login) assert _commands._project_api() == (project, login) -def test_sync_requires_skore(monkeypatch): - def import_skore(_): - raise ImportError - - monkeypatch.setattr(_commands.importlib, "import_module", import_skore) - - result = CliRunner().invoke(sync, ["experiment", "--to=mlflow"]) - - assert result.exit_code != 0 - assert "needs the `skore` package" in result.output - - def test_sync_reports_empty_result(capsys): _commands._render_result(pd.DataFrame(), dry_run=False) From 688a4306b915dcca8cc725dd7359428721790259 Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:48:44 +0200 Subject: [PATCH 06/14] fix imports after skore refacto --- src/skore_cli/hub/_commands.py | 4 ++-- src/skore_cli/sync/_commands.py | 21 ++++--------------- tests/test_hub.py | 3 +-- tests/test_sync.py | 37 +++++++-------------------------- 4 files changed, 14 insertions(+), 51 deletions(-) diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index 93c67e3..6d80082 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -39,14 +39,14 @@ def hub(ctx) -> None: def _registry(): """Return ``skore``'s API-key registry.""" - from skore._plugins.hub.authentication.api_key import registry + from skore._plugins.hub.authentication import registry return registry def _host(host: str | None) -> str: """Return ``host``, or skore's ``URI()`` when ``host`` is omitted.""" - from skore._plugins.hub.authentication.uri import URI + from skore._plugins.hub.authentication import URI return host or URI() diff --git a/src/skore_cli/sync/_commands.py b/src/skore_cli/sync/_commands.py index 2c25fdf..3ded1f6 100644 --- a/src/skore_cli/sync/_commands.py +++ b/src/skore_cli/sync/_commands.py @@ -12,18 +12,6 @@ API_KEY_ENV = "SKORE_HUB_API_KEY" -def _project_api(): - """Import the public project API only when synchronization runs.""" - import skore - - if not hasattr(skore.Project, "sync"): - raise click.ClickException( - "synchronization requires `skore>=0.24.0`; upgrade it with " - "`pip install --upgrade skore`." - ) - return skore.Project, skore.login - - def _endpoint_options( mode: str, *, @@ -129,12 +117,11 @@ def sync( if uses_hub and not os.environ.get(API_KEY_ENV): raise click.ClickException(f"Hub synchronization requires {API_KEY_ENV}.") + from skore import Project + try: - Project, login = _project_api() - if uses_hub: - if hub_url: - os.environ["SKORE_HUB_URI"] = hub_url - login(mode="hub") + if uses_hub and hub_url: + os.environ["SKORE_HUB_URI"] = hub_url source = Project(source_project, mode=source_mode, **source_options) destination = Project( destination_project, diff --git a/tests/test_hub.py b/tests/test_hub.py index a82e9f5..6e3a092 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -7,8 +7,7 @@ import pytest import rich_click as click from click.testing import CliRunner -from skore._plugins.hub.authentication.api_key import registry -from skore._plugins.hub.authentication.uri import URI +from skore._plugins.hub.authentication import URI, registry from skore_cli import cli from skore_cli.hub import _client diff --git a/tests/test_sync.py b/tests/test_sync.py index 04f8191..66b6c24 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -38,22 +38,18 @@ def sync(self, other, *, bidirectional, dry_run): @pytest.fixture def projects(monkeypatch): created = [] - logins = [] def project(name, *, mode, **kwargs): instance = _Project(name=name, mode=mode, **kwargs) created.append(instance) return instance - def login(*, mode): - logins.append(mode) - - monkeypatch.setattr(_commands, "_project_api", lambda: (project, login)) - return created, logins + monkeypatch.setattr("skore.Project", project) + return created def test_sync_defaults_source_to_local_and_forwards_options(projects): - created, logins = projects + created = projects result = CliRunner().invoke( sync, @@ -67,12 +63,11 @@ def test_sync_defaults_source_to_local_and_forwards_options(projects): ("experiment", "hub", "team"), ] assert created[0].sync_args == (created[1], True, True) - assert logins == ["hub"] assert "Dry run complete" in result.output def test_sync_defaults_destination_to_local_with_custom_name(projects): - created, _ = projects + created = projects result = CliRunner().invoke( sync, @@ -94,7 +89,7 @@ def test_sync_defaults_destination_to_local_with_custom_name(projects): def test_sync_uses_one_mlflow_tracking_uri(projects): - created, _ = projects + created = projects result = CliRunner().invoke( sync, @@ -141,7 +136,7 @@ def test_sync_validates_options(args, message): def test_sync_rejects_same_endpoint_before_construction(projects): - created, _ = projects + created = projects result = CliRunner().invoke(sync, ["experiment", "--from=local", "--to=local"]) @@ -189,24 +184,6 @@ def test_sync_requires_hub_api_key(): assert "SKORE_HUB_API_KEY" in result.output -def test_sync_requires_skore_with_sync(monkeypatch): - monkeypatch.setattr("skore.Project", object) - - result = CliRunner().invoke(sync, ["experiment", "--to=mlflow"]) - - assert result.exit_code != 0 - assert "skore>=0.24.0" in result.output - - -def test_project_api_imports_supported_skore(monkeypatch): - project = type("Project", (), {"sync": None}) - login = object() - monkeypatch.setattr("skore.Project", project) - monkeypatch.setattr("skore.login", login) - - assert _commands._project_api() == (project, login) - - def test_sync_reports_empty_result(capsys): _commands._render_result(pd.DataFrame(), dry_run=False) @@ -217,7 +194,7 @@ def test_sync_converts_backend_errors(monkeypatch): def project(*args, **kwargs): raise RuntimeError("backend error") - monkeypatch.setattr(_commands, "_project_api", lambda: (project, None)) + monkeypatch.setattr("skore.Project", project) result = CliRunner().invoke(sync, ["experiment", "--to=mlflow"]) From d22d77a71e9b0e73560eebf992c8856b15759c2c Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:34:46 +0200 Subject: [PATCH 07/14] fix if not skore/raise --- README.md | 5 +++-- src/skore_cli/__init__.py | 8 ++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fde4f31..bd930f6 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,9 @@ SKORE_HUB_API_KEY=... skore sync production \ skore sync experiment --to=mlflow --tracking-uri=http://localhost:5000 ``` -Hub synchronization requires `SKORE_HUB_API_KEY`. Use `--hub-url` to target a custom -Hub API. Install `skore[mlflow]` to synchronize with MLflow. +Hub synchronization uses `SKORE_HUB_API_KEY` when set, otherwise the key stored by +`skore hub api-key`. Use `--hub-url` to target a custom Hub API. Install +`skore[mlflow]` to synchronize with MLflow. ## Agent detection diff --git a/src/skore_cli/__init__.py b/src/skore_cli/__init__.py index fca435b..345fc4c 100644 --- a/src/skore_cli/__init__.py +++ b/src/skore_cli/__init__.py @@ -114,12 +114,8 @@ def cli(ctx) -> None: load_plugins(cli) # Ensure ``skore`` is available without importing it explicitly -try: - importlib.util.find_spec("skore", package=None) -except ModuleNotFoundError: - message = ( +if importlib.util.find_spec("skore") is None: + raise click.ClickException( "this command needs the `skore` package (install it with `pip install " "skore-cli` or `pip install skore`)." ) - - raise click.ClickException(message) from None From f865c967ee5da5d2b346155ec679a7c7a6faf1ec Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:39:54 +0200 Subject: [PATCH 08/14] lets sync rely on the lib for authentication --- src/skore_cli/sync/_commands.py | 3 --- tests/test_sync.py | 11 ++++++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/skore_cli/sync/_commands.py b/src/skore_cli/sync/_commands.py index 3ded1f6..945fc1c 100644 --- a/src/skore_cli/sync/_commands.py +++ b/src/skore_cli/sync/_commands.py @@ -9,7 +9,6 @@ import rich_click as click MODES = ("local", "hub", "mlflow") -API_KEY_ENV = "SKORE_HUB_API_KEY" def _endpoint_options( @@ -114,8 +113,6 @@ def sync( uses_hub = "hub" in (source_mode, destination_mode) if hub_url is not None and not uses_hub: raise click.UsageError("--hub-url requires a Hub endpoint.") - if uses_hub and not os.environ.get(API_KEY_ENV): - raise click.ClickException(f"Hub synchronization requires {API_KEY_ENV}.") from skore import Project diff --git a/tests/test_sync.py b/tests/test_sync.py index 66b6c24..f42e5e0 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -174,14 +174,19 @@ def test_sync_transfers_between_real_local_projects(tmp_path): assert destination.summarize().frame()["key"].tolist() == ["model"] -def test_sync_requires_hub_api_key(): +def test_sync_hub_does_not_require_env_api_key(projects): + created = projects + result = CliRunner().invoke( sync, ["experiment", "--to=hub", "--to-workspace=team"], ) - assert result.exit_code != 0 - assert "SKORE_HUB_API_KEY" in result.output + assert result.exit_code == 0, result.output + assert [(project.name, project.mode, project.workspace) for project in created] == [ + ("experiment", "local", None), + ("experiment", "hub", "team"), + ] def test_sync_reports_empty_result(capsys): From 777f1716a1202715e58ce6d8002490ed83167848 Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:50:31 +0200 Subject: [PATCH 09/14] agent overrides hub URL --- src/skore_cli/_agents.py | 3 ++- src/skore_cli/agent/_commands.py | 13 +++++++++- tests/test_agent_commands.py | 42 ++++++++++++++++++++++++++++++++ tests/test_harnesses.py | 4 +-- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/skore_cli/_agents.py b/src/skore_cli/_agents.py index 5d7b47b..2ce9b56 100644 --- a/src/skore_cli/_agents.py +++ b/src/skore_cli/_agents.py @@ -904,5 +904,6 @@ def launch_harness( ) from skore_cli.agent._skore_file import SkoreConfig - SkoreConfig.load(workspace) + if "SKORE_HUB_URI" not in os.environ: + SkoreConfig.load(workspace) agent.launch(workspace, model_id) diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index 68495e7..c6f342e 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -164,13 +164,24 @@ def agent( raise click.ClickException(f"workspace does not exist: {workspace}") config = SkoreConfig.load(workspace) + if hub_url: os.environ["SKORE_HUB_URI"] = hub_url + if config is not None: + from skore._plugins.hub.authentication import URI + + config = SkoreConfig( + hub_url=URI(), + workspace=config.workspace, + workspace_id=config.workspace_id, + harness=config.harness, + ) + if config is not None and config.workspace: harness_name = harness_name or config.harness else: - from skore._plugins.hub.authentication.uri import URI + from skore._plugins.hub.authentication import URI resolved_hub_url = URI() token = login(timeout=login_timeout) diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index d51d7a1..e6695ed 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -221,6 +221,48 @@ def test_agent_uses_existing_skore_config(tmp_path, monkeypatch): assert json.loads((tmp_path / "opencode.json").read_text())["provider"]["skore"] +def test_agent_hub_url_overrides_saved_config(tmp_path, monkeypatch): + _write_skore(tmp_path, hub_url="http://old.test") + _mock_harness_on_path(monkeypatch, "opencode") + lookups = [] + + def get(*, host, workspace): + lookups.append((host, workspace)) + return "new-secret" + + monkeypatch.setattr( + _commands, + "_registry", + lambda: SimpleNamespace(get=get), + ) + uris = [] + monkeypatch.setattr( + _commands, + "launch_harness", + lambda *args, **kwargs: uris.append(os.environ.get("SKORE_HUB_URI")), + ) + + result = CliRunner().invoke( + agent, + [ + "--workspace", + str(tmp_path), + "--harness", + "opencode", + "--hub-url", + "http://new.test", + ], + ) + + assert result.exit_code == 0, result.output + assert lookups == [("http://new.test", "ws-1")] + assert uris == ["http://new.test"] + provider = json.loads((tmp_path / "opencode.json").read_text())["provider"]["skore"] + assert provider["options"]["baseURL"] == "http://new.test/v1" + saved = json.loads((tmp_path / SKORE_FILENAME).read_text()) + assert saved["hub_url"] == "http://old.test" + + def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): _mock_harness_on_path(monkeypatch, "opencode") monkeypatch.setattr( diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index e8a4358..858e001 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -520,14 +520,14 @@ def test_launch_exports_sdk_uri_from_skore_file(tmp_path, monkeypatch): assert "SKORE_HUB_API_KEY" not in captured["env"] -def test_launch_overwrites_sdk_uri_from_skore_file(tmp_path, monkeypatch): +def test_launch_keeps_existing_sdk_uri(tmp_path, monkeypatch): captured = _prepare_claude_launch(tmp_path, monkeypatch) _write_skore_file(tmp_path) monkeypatch.setenv("SKORE_HUB_URI", "http://user.hub") _agents.launch_harness(AGENTS["claude-code"], tmp_path) - assert captured["env"]["SKORE_HUB_URI"] == "http://hub.test" + assert captured["env"]["SKORE_HUB_URI"] == "http://user.hub" def test_launch_exports_nothing_without_a_skore_file(tmp_path, monkeypatch): From 5525f7da9d848381676a9e6f2f32d0615d0fcb4f Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Thu, 24 Sep 2026 22:55:09 +0200 Subject: [PATCH 10/14] chore: Remove the skore hub api-key add option --- README.md | 4 +--- src/skore_cli/hub/_commands.py | 13 ++----------- tests/test_hub.py | 35 +--------------------------------- 3 files changed, 4 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index d74ed87..1d4337e 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,11 @@ skore agent --workspace ./myapp # configure another project directory Store and inspect Hub API keys locally via skore's credential registry (`~/.skore.hub/credentials.json`). `--host` selects a non-default hub (omit it to use `SKORE_HUB_URI` or the public hub); `--workspace` is required when -generating, adding or deleting a key. +generating or deleting a key. ```bash skore hub api-key generate --workspace= skore hub api-key generate --host= --workspace= -skore hub api-key add --workspace= -skore hub api-key add --host= --workspace= skore hub api-key delete --workspace= skore hub api-key delete --host= --workspace= skore hub api-key list diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index 6d80082..536d600 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -17,7 +17,7 @@ {"name": "Credentials", "commands": ["api-key"]}, ], "cli hub api-key": [ - {"name": "Manage", "commands": ["generate", "add", "delete", "list"]}, + {"name": "Manage", "commands": ["generate", "delete", "list"]}, ], } @@ -108,7 +108,7 @@ def _membership_for( @hub.group("api-key", invoke_without_command=True) @click.pass_context def api_key(ctx) -> None: - """Generate, add, delete and list Hub API keys.""" + """Generate, delete and list Hub API keys.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @@ -150,15 +150,6 @@ def generate( ) -@api_key.command("add") -@click.argument("key") -@click.option("--host", default=None, help="Hub host URL.") -@click.option("--workspace", required=True, help="Hub workspace to attach the key to.") -def add(key: str, host: str | None, workspace: str) -> None: - """Store a Hub API key for a workspace.""" - _registry().set(host=_host(host), workspace=workspace, api_key=key) - - @api_key.command("delete") @click.option("--host", default=None, help="Hub host URL.") @click.option("--workspace", required=True, help="Hub workspace whose key to delete.") diff --git a/tests/test_hub.py b/tests/test_hub.py index 6e3a092..1d2b794 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -7,7 +7,7 @@ import pytest import rich_click as click from click.testing import CliRunner -from skore._plugins.hub.authentication import URI, registry +from skore._plugins.hub.authentication import registry from skore_cli import cli from skore_cli.hub import _client @@ -41,43 +41,10 @@ def test_hub_api_key_no_subcommand_shows_help(): assert result.exit_code == 0 assert "generate" in result.output - assert "add" in result.output assert "delete" in result.output assert "list" in result.output -def test_hub_api_key_add_requires_workspace(): - result = _invoke(["hub", "api-key", "add", "secret"]) - - assert result.exit_code != 0 - assert "workspace" in result.output.lower() - - -def test_hub_api_key_add_stores_key(): - result = _invoke( - [ - "hub", - "api-key", - "add", - "secret", - "--host=http://hub.test", - "--workspace=team", - ] - ) - - assert result.exit_code == 0 - assert registry.get(host="http://hub.test", workspace="team") == "secret" - - -def test_hub_api_key_add_without_host_uses_default_uri(monkeypatch): - monkeypatch.delenv("SKORE_HUB_URI", raising=False) - - result = _invoke(["hub", "api-key", "add", "secret", "--workspace=team"]) - - assert result.exit_code == 0 - assert registry.get(host=URI(), workspace="team") == "secret" - - def test_hub_api_key_delete_requires_workspace(): result = _invoke(["hub", "api-key", "delete"]) From f81f871f0f79d4077edd96a67557aeedec6040be Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Thu, 24 Sep 2026 23:07:13 +0200 Subject: [PATCH 11/14] feat: Add --expires option when generating api-key --- README.md | 1 + src/skore_cli/hub/_commands.py | 58 ++++++++++++++++++++++++++++-- tests/test_hub.py | 65 ++++++++++++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1d4337e..a8719ce 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ generating or deleting a key. ```bash skore hub api-key generate --workspace= +skore hub api-key generate --workspace= --expires 3 skore hub api-key generate --host= --workspace= skore hub api-key delete --workspace= skore hub api-key delete --host= --workspace= diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index 536d600..23abf1b 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -2,7 +2,9 @@ from __future__ import annotations +import calendar import os +from datetime import datetime, timezone import rich_click as click from rich.table import Table @@ -60,12 +62,39 @@ def _resolve_api_key_name(base: str, existing_names: list[str]) -> str: return f"{base}-{index}" +def _add_calendar_months(when: datetime, months: int) -> datetime: + """Add ``months`` to ``when``, clamping the day like date-fns ``addMonths``.""" + month = when.month - 1 + months + year = when.year + month // 12 + month = month % 12 + 1 + day = min(when.day, calendar.monthrange(year, month)[1]) + return when.replace(year=year, month=month, day=day) + + +def _expires_at_from_months(months: int, *, now: datetime | None = None) -> str: + """Return an ISO-8601 UTC instant ``months`` calendar months after ``now``.""" + when = now or datetime.now(timezone.utc) + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + else: + when = when.astimezone(timezone.utc) + expiry = _add_calendar_months(when, months) + return expiry.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _expires_at_from_choice(choice: str) -> str | None: + if choice == "never": + return None + return _expires_at_from_months(int(choice)) + + def _create_workspace_api_key( hub_url: str, token: str, user_id: str, membership: _client.Membership, name: str, + expires_at: str | None = None, ) -> str: """Mint a workspace-scoped API key.""" grantable = set(membership.permissions) @@ -89,7 +118,7 @@ def _create_workspace_api_key( name=key_name, permissions=permissions, workspace_id=membership.workspace_id, - expires_at=None, + expires_at=expires_at, ) return secret @@ -127,8 +156,19 @@ def api_key(ctx) -> None: show_default=True, help="Seconds to wait for interactive device login.", ) +@click.option( + "--expires", + type=click.Choice(["1", "3", "6", "never"]), + default="never", + show_default=True, + help="Lifetime in months (1, 3, or 6 months), or never.", +) def generate( - host: str | None, workspace: str, name: str | None, login_timeout: int + host: str | None, + workspace: str, + name: str | None, + login_timeout: int, + expires: str, ) -> None: """Mint a workspace-scoped Hub API key and store it locally.""" if host: @@ -141,10 +181,22 @@ def generate( "you are not a member of any hub workspace; create or join one first." ) membership = _membership_for(memberships, workspace) + expires_at = _expires_at_from_choice(expires) secret = _create_workspace_api_key( - hub_url, token.access, user_id, membership, name or workspace + hub_url, + token.access, + user_id, + membership, + name or workspace, + expires_at=expires_at, ) _registry().set(host=hub_url, workspace=workspace, api_key=secret) + if expires_at: + console.print( + f"[skore.ok]+[/] generated API key for workspace " + f"[skore.skill]{workspace}[/] (expires {expires_at})" + ) + return console.print( f"[skore.ok]+[/] generated API key for workspace [skore.skill]{workspace}[/]" ) diff --git a/tests/test_hub.py b/tests/test_hub.py index 1d2b794..16f0051 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, timezone from types import SimpleNamespace import pytest @@ -122,6 +123,7 @@ def fake_create(hub_url, token, user_id, **kwargs): assert secret == "the-secret" assert captured["name"] == "opencode" + assert captured["expires_at"] is None assert set(captured["permissions"]) == set(PROJECT_PERMISSIONS) @@ -161,6 +163,16 @@ def fake_create(hub_url, token, user_id, **kwargs): assert captured["name"] == "opencode-2" +def test_expires_at_from_months_clamps_end_of_month(): + now = datetime(2026, 1, 31, 12, 0, 0, tzinfo=timezone.utc) + assert _hub._expires_at_from_months(1, now=now) == "2026-02-28T12:00:00Z" + assert _hub._expires_at_from_months(3, now=now) == "2026-04-30T12:00:00Z" + + +def test_expires_at_from_choice_never_is_none(): + assert _hub._expires_at_from_choice("never") is None + + def test_hub_api_key_generate_requires_workspace(): result = _invoke(["hub", "api-key", "generate"]) @@ -175,14 +187,61 @@ def test_hub_api_key_generate_stores_key(monkeypatch): _hub._client, "me", lambda hub_url, token: ("user-1", [_membership("team")]) ) monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: []) - monkeypatch.setattr( - _hub._client, "create_api_key", lambda *a, **k: (42, "minted-secret") - ) + captured = {} + + def fake_create(*a, **k): + captured.update(k) + return 42, "minted-secret" + + monkeypatch.setattr(_hub._client, "create_api_key", fake_create) result = _invoke(["hub", "api-key", "generate", "--workspace=team"]) assert result.exit_code == 0, result.output assert registry.get(host="http://hub.test", workspace="team") == "minted-secret" + assert captured["expires_at"] is None + assert "expires " not in result.output + + +def test_hub_api_key_generate_expires_in_three_months(monkeypatch): + now = datetime(2026, 1, 15, 8, 30, 0, tzinfo=timezone.utc) + + class _FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + return now if tz is None else now.astimezone(tz) + + monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") + monkeypatch.setattr(_hub, "login", lambda *, timeout: SimpleNamespace(access="tok")) + monkeypatch.setattr( + _hub._client, "me", lambda hub_url, token: ("user-1", [_membership("team")]) + ) + monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: []) + captured = {} + + def fake_create(*a, **k): + captured.update(k) + return 42, "minted-secret" + + monkeypatch.setattr(_hub._client, "create_api_key", fake_create) + monkeypatch.setattr(_hub, "datetime", _FrozenDateTime) + + result = _invoke( + ["hub", "api-key", "generate", "--workspace=team", "--expires", "3"] + ) + + assert result.exit_code == 0, result.output + assert captured["expires_at"] == "2026-04-15T08:30:00Z" + assert "expires 2026-04-15T08:30:00Z" in result.output + + +def test_hub_api_key_generate_rejects_unknown_expires(monkeypatch): + result = _invoke( + ["hub", "api-key", "generate", "--workspace=team", "--expires", "12"] + ) + + assert result.exit_code != 0 + assert "expires" in result.output.lower() def test_hub_api_key_generate_unknown_workspace(monkeypatch): From dcd3f1ea8d3035a9e3cda2361e740e1c01f091a2 Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Thu, 24 Sep 2026 23:48:40 +0200 Subject: [PATCH 12/14] chore: Align hub-keyring delete and add --force option to generate --- README.md | 6 +- src/skore_cli/hub/_client.py | 14 ++ src/skore_cli/hub/_commands.py | 110 ++++++++++---- tests/test_client.py | 34 +++++ tests/test_hub.py | 258 ++++++++++++++++++++++++++------- 5 files changed, 341 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index a8719ce..c9bebfa 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,15 @@ skore agent --workspace ./myapp # configure another project directory Store and inspect Hub API keys locally via skore's credential registry (`~/.skore.hub/credentials.json`). `--host` selects a non-default hub (omit it to use `SKORE_HUB_URI` or the public hub); `--workspace` is required when -generating or deleting a key. +generating or deleting a key. `generate` stores one key per machine for a host +and workspace; pass `--force` to revoke **this** machine's Hub key and mint a +replacement. `delete` removes the local entry and revokes only that stored Hub +key. ```bash skore hub api-key generate --workspace= skore hub api-key generate --workspace= --expires 3 +skore hub api-key generate --workspace= --force skore hub api-key generate --host= --workspace= skore hub api-key delete --workspace= skore hub api-key delete --host= --workspace= diff --git a/src/skore_cli/hub/_client.py b/src/skore_cli/hub/_client.py index 503b503..71c599a 100644 --- a/src/skore_cli/hub/_client.py +++ b/src/skore_cli/hub/_client.py @@ -135,3 +135,17 @@ def list_api_keys( ) for item in response.json() ] + + +def delete_api_key( + hub_url: str, + token: str, + user_id: str, + api_key_id: int, + *, + transport: Any = None, +) -> None: + """Delete a Hub API key by id.""" + with _client(hub_url, token, transport) as client: + response = client.delete(f"/identity/users/{user_id}/api-keys/{api_key_id}") + _raise_for(response, context="deleting the API key") diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index 23abf1b..28f5cc7 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -4,6 +4,8 @@ import calendar import os +import re +import socket from datetime import datetime, timezone import rich_click as click @@ -53,13 +55,44 @@ def _host(host: str | None) -> str: return host or URI() -def _resolve_api_key_name(base: str, existing_names: list[str]) -> str: - if base not in existing_names: - return base - index = 2 - while f"{base}-{index}" in existing_names: - index += 1 - return f"{base}-{index}" +def _sanitize_hostname(hostname: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", hostname).strip("-.") + return cleaned or "host" + + +def _default_api_key_name(workspace: str) -> str: + hostname = _sanitize_hostname(socket.gethostname()) + return f"{workspace}-{hostname}"[:150] + + +def _stored_api_key_id(*, host: str, workspace: str) -> int | None: + """Return the Hub id stored for this host/workspace, if any.""" + registry = _registry() + secret = registry.get(host=host, workspace=workspace) + api_key_id = registry.get_api_key_id(host=host, workspace=workspace) + if secret is None and api_key_id is None: + return None + if secret is None or api_key_id is None: + raise click.ClickException( + f"the stored API key for workspace '{workspace}' is incomplete; " + "delete the local entry and generate a new key." + ) + return api_key_id + + +def _revoke_stored_api_key( + hub_url: str, + token: str, + user_id: str, + workspace: str, +) -> bool: + """Revoke the locally stored Hub key. Return whether a key was stored.""" + api_key_id = _stored_api_key_id(host=hub_url, workspace=workspace) + if api_key_id is None: + return False + _client.delete_api_key(hub_url, token, user_id, api_key_id) + _registry().delete(host=hub_url, workspace=workspace) + return True def _add_calendar_months(when: datetime, months: int) -> datetime: @@ -95,7 +128,7 @@ def _create_workspace_api_key( membership: _client.Membership, name: str, expires_at: str | None = None, -) -> str: +) -> tuple[int, str]: """Mint a workspace-scoped API key.""" grantable = set(membership.permissions) permissions = [p for p in PROJECT_PERMISSIONS if p in grantable] @@ -104,23 +137,15 @@ def _create_workspace_api_key( f"you cannot create project API keys in workspace '{membership.public_id}'." ) - existing = _client.list_api_keys(hub_url, token, user_id) - workspace_names = [ - key.name or "" - for key in existing - if key.workspace_id == membership.workspace_id - ] - key_name = _resolve_api_key_name(name, workspace_names) - _api_key_id, secret = _client.create_api_key( + return _client.create_api_key( hub_url, token, user_id, - name=key_name, + name=name, permissions=permissions, workspace_id=membership.workspace_id, expires_at=expires_at, ) - return secret def _membership_for( @@ -148,7 +173,7 @@ def api_key(ctx) -> None: @click.option( "--name", default=None, - help="Name stored on the hub for this key (default: the workspace id).", + help="Name stored on the hub for this key (default: workspace-hostname).", ) @click.option( "--login-timeout", @@ -163,17 +188,30 @@ def api_key(ctx) -> None: show_default=True, help="Lifetime in months (1, 3, or 6 months), or never.", ) +@click.option( + "--force", + is_flag=True, + default=False, + help="Replace a stored key: revoke it on the hub, then mint a new one.", +) def generate( host: str | None, workspace: str, name: str | None, login_timeout: int, expires: str, + force: bool, ) -> None: """Mint a workspace-scoped Hub API key and store it locally.""" if host: os.environ["SKORE_HUB_URI"] = host hub_url = _host(host) + key_name = name or _default_api_key_name(workspace) + if _registry().get(host=hub_url, workspace=workspace) and not force: + raise click.ClickException( + f"an API key for workspace '{workspace}' is already stored; " + "pass --force to replace it." + ) token = login(timeout=login_timeout) user_id, memberships = _client.me(hub_url, token.access) if not memberships: @@ -181,16 +219,20 @@ def generate( "you are not a member of any hub workspace; create or join one first." ) membership = _membership_for(memberships, workspace) + if force: + _revoke_stored_api_key(hub_url, token.access, user_id, workspace) expires_at = _expires_at_from_choice(expires) - secret = _create_workspace_api_key( + api_key_id, secret = _create_workspace_api_key( hub_url, token.access, user_id, membership, - name or workspace, + key_name, expires_at=expires_at, ) - _registry().set(host=hub_url, workspace=workspace, api_key=secret) + _registry().set( + host=hub_url, workspace=workspace, api_key=secret, api_key_id=api_key_id + ) if expires_at: console.print( f"[skore.ok]+[/] generated API key for workspace " @@ -205,9 +247,27 @@ def generate( @api_key.command("delete") @click.option("--host", default=None, help="Hub host URL.") @click.option("--workspace", required=True, help="Hub workspace whose key to delete.") -def delete(host: str | None, workspace: str) -> None: - """Delete a stored Hub API key for a workspace.""" - _registry().delete(host=_host(host), workspace=workspace) +@click.option( + "--login-timeout", + default=600, + show_default=True, + help="Seconds to wait for interactive device login.", +) +def delete(host: str | None, workspace: str, login_timeout: int) -> None: + """Delete a stored Hub API key locally and on the hub.""" + if host: + os.environ["SKORE_HUB_URI"] = host + hub_url = _host(host) + token = login(timeout=login_timeout) + user_id, memberships = _client.me(hub_url, token.access) + if not memberships: + raise click.ClickException( + "you are not a member of any hub workspace; create or join one first." + ) + _membership_for(memberships, workspace) + if not _revoke_stored_api_key(hub_url, token.access, user_id, workspace): + console.print("No API key stored.") + return @api_key.command("list") diff --git a/tests/test_client.py b/tests/test_client.py index 2eee22b..e0195e8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -217,3 +217,37 @@ def handler(request): ) assert "no details" in str(excinfo.value) + + +# --------------------------------------------------------------------------- # +# delete_api_key +# --------------------------------------------------------------------------- # + + +def test_delete_api_key_sends_delete(): + def handler(request): + assert request.method == "DELETE" + assert request.url.path == "/identity/users/user-1/api-keys/42" + return httpx.Response(204) + + _client.delete_api_key( + "http://hub.test", + "tok", + "user-1", + 42, + transport=_transport(handler), + ) + + +def test_delete_api_key_maps_not_found(): + def handler(request): + return httpx.Response(404, json={"detail": "missing"}) + + with pytest.raises(click.ClickException, match="not found"): + _client.delete_api_key( + "http://hub.test", + "tok", + "user-1", + 42, + transport=_transport(handler), + ) diff --git a/tests/test_hub.py b/tests/test_hub.py index 16f0051..8a03c7d 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -30,6 +30,23 @@ def _membership(public_id: str = "ws-1", workspace_id: int = 1): ) +def _stub_hub_auth(monkeypatch, membership=None): + membership = membership or _membership("team") + monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") + monkeypatch.setattr(_hub, "login", lambda *, timeout: SimpleNamespace(access="tok")) + monkeypatch.setattr( + _hub._client, "me", lambda hub_url, token: ("user-1", [membership]) + ) + monkeypatch.setattr(_hub.socket, "gethostname", lambda: "laptop") + monkeypatch.setattr( + _hub._client, + "list_api_keys", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("must not list hub keys to revoke") + ), + ) + + def test_hub_no_subcommand_shows_help(): result = _invoke(["hub"]) @@ -53,27 +70,121 @@ def test_hub_api_key_delete_requires_workspace(): assert "workspace" in result.output.lower() -def test_hub_api_key_delete_removes_key(): - registry.set(host="http://hub.test", workspace="team", api_key="secret") +def test_hub_api_key_delete_removes_local_and_hub_key(monkeypatch): + registry.set( + host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 + ) + deleted: list[int] = [] + _stub_hub_auth(monkeypatch) + monkeypatch.setattr( + _hub._client, + "delete_api_key", + lambda hub_url, token, user_id, api_key_id: deleted.append(api_key_id), + ) result = _invoke( ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] ) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output assert registry.get(host="http://hub.test", workspace="team") is None + assert registry.get_api_key_id(host="http://hub.test", workspace="team") is None + assert deleted == [7] + + +def test_hub_api_key_delete_does_not_revoke_other_workspace_keys(monkeypatch): + registry.set( + host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 + ) + deleted: list[int] = [] + _stub_hub_auth(monkeypatch) + monkeypatch.setattr( + _hub._client, + "delete_api_key", + lambda hub_url, token, user_id, api_key_id: deleted.append(api_key_id), + ) + + result = _invoke( + ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] + ) + + assert result.exit_code == 0, result.output + assert deleted == [7] def test_hub_api_key_delete_without_host_uses_environment_uri(monkeypatch): monkeypatch.setenv("SKORE_HUB_URI", "http://hub.test") - registry.set(host="http://hub.test", workspace="team", api_key="secret") - registry.set(host="http://other.test", workspace="team", api_key="other") + registry.set( + host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 + ) + registry.set( + host="http://other.test", workspace="team", api_key="other", api_key_id=8 + ) + _stub_hub_auth(monkeypatch) + monkeypatch.setattr( + _hub._client, + "delete_api_key", + lambda hub_url, token, user_id, api_key_id: None, + ) result = _invoke(["hub", "api-key", "delete", "--workspace=team"]) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output assert registry.get(host="http://hub.test", workspace="team") is None assert registry.get(host="http://other.test", workspace="team") == "other" + assert registry.get_api_key_id(host="http://other.test", workspace="team") == 8 + + +def test_hub_api_key_delete_reports_when_nothing_stored(monkeypatch): + _stub_hub_auth(monkeypatch) + + result = _invoke( + ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] + ) + + assert result.exit_code == 0, result.output + assert "No API key stored." in result.output + + +def test_hub_api_key_delete_incomplete_local_key_errors(monkeypatch): + registry.set( + host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 + ) + filepath = registry.setup() + filepath.write_text( + '{"type": "plaintext", "keys": [' + '{"host": "http://hub.test", "workspace": "team", "key": "secret"}]}' + ) + _stub_hub_auth(monkeypatch) + + result = _invoke( + ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] + ) + + assert result.exit_code != 0 + assert "incomplete" in result.output + assert registry.get(host="http://hub.test", workspace="team") == "secret" + + +def test_hub_api_key_delete_keeps_local_key_when_hub_revoke_fails(monkeypatch): + registry.set( + host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 + ) + _stub_hub_auth(monkeypatch) + monkeypatch.setattr( + _hub._client, + "delete_api_key", + lambda *a, **k: (_ for _ in ()).throw(click.ClickException("hub down")), + ) + + result = _invoke( + ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] + ) + + assert result.exit_code != 0 + assert "hub down" in result.output + assert registry.get(host="http://hub.test", workspace="team") == "secret" + assert registry.get_api_key_id(host="http://hub.test", workspace="team") == 7 def test_hub_api_key_list_empty(): @@ -84,8 +195,8 @@ def test_hub_api_key_list_empty(): def test_hub_api_key_list_shows_host_and_workspace(): - registry.set(host="http://a.test", workspace="w1", api_key="k1") - registry.set(host="http://b.test", workspace="w2", api_key="k2") + registry.set(host="http://a.test", workspace="w1", api_key="k1", api_key_id=1) + registry.set(host="http://b.test", workspace="w2", api_key="k2", api_key_id=2) result = _invoke(["hub", "api-key", "list"]) @@ -98,17 +209,18 @@ def test_hub_api_key_list_shows_host_and_workspace(): assert "k2" not in result.output -def test_resolve_api_key_name_deduplicates(): - assert _hub._resolve_api_key_name("opencode", []) == "opencode" - assert _hub._resolve_api_key_name("opencode", ["opencode"]) == "opencode-2" - assert ( - _hub._resolve_api_key_name("opencode", ["opencode", "opencode-2"]) - == "opencode-3" - ) +def test_default_api_key_name_uses_sanitized_hostname(monkeypatch): + monkeypatch.setattr(_hub.socket, "gethostname", lambda: "laptop.local") + assert _hub._default_api_key_name("team") == "team-laptop.local" + + monkeypatch.setattr(_hub.socket, "gethostname", lambda: "lap top/foo") + assert _hub._default_api_key_name("team") == "team-lap-top-foo" + + monkeypatch.setattr(_hub.socket, "gethostname", lambda: "x" * 200) + assert len(_hub._default_api_key_name("ws")) == 150 def test_create_workspace_api_key_mints_secret(monkeypatch): - monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: []) captured = {} def fake_create(hub_url, token, user_id, **kwargs): @@ -117,10 +229,11 @@ def fake_create(hub_url, token, user_id, **kwargs): monkeypatch.setattr(_hub._client, "create_api_key", fake_create) - secret = _hub._create_workspace_api_key( + secret_id, secret = _hub._create_workspace_api_key( "http://hub.test", "tok", "user-1", _membership(), "opencode" ) + assert secret_id == 7 assert secret == "the-secret" assert captured["name"] == "opencode" assert captured["expires_at"] is None @@ -137,32 +250,6 @@ def test_create_workspace_api_key_requires_permissions(): ) -def test_create_workspace_api_key_dedupes_name_within_workspace(monkeypatch): - existing = [ - _client.ApiKeyInfo( - id=1, - name="opencode", - workspace_id=1, - created_at=None, - expires_at=None, - ) - ] - monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: existing) - captured = {} - - def fake_create(hub_url, token, user_id, **kwargs): - captured.update(kwargs) - return 2, "secret" - - monkeypatch.setattr(_hub._client, "create_api_key", fake_create) - - _hub._create_workspace_api_key( - "http://hub.test", "tok", "user-1", _membership(), "opencode" - ) - - assert captured["name"] == "opencode-2" - - def test_expires_at_from_months_clamps_end_of_month(): now = datetime(2026, 1, 31, 12, 0, 0, tzinfo=timezone.utc) assert _hub._expires_at_from_months(1, now=now) == "2026-02-28T12:00:00Z" @@ -181,12 +268,7 @@ def test_hub_api_key_generate_requires_workspace(): def test_hub_api_key_generate_stores_key(monkeypatch): - monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") - monkeypatch.setattr(_hub, "login", lambda *, timeout: SimpleNamespace(access="tok")) - monkeypatch.setattr( - _hub._client, "me", lambda hub_url, token: ("user-1", [_membership("team")]) - ) - monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: []) + _stub_hub_auth(monkeypatch) captured = {} def fake_create(*a, **k): @@ -199,10 +281,81 @@ def fake_create(*a, **k): assert result.exit_code == 0, result.output assert registry.get(host="http://hub.test", workspace="team") == "minted-secret" + assert registry.get_api_key_id(host="http://hub.test", workspace="team") == 42 assert captured["expires_at"] is None + assert captured["name"] == "team-laptop" assert "expires " not in result.output +def test_hub_api_key_generate_errors_when_key_already_stored(monkeypatch): + monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") + registry.set( + host="http://hub.test", + workspace="team", + api_key="old-secret", + api_key_id=9, + ) + logged_in = [] + monkeypatch.setattr( + _hub, "login", lambda *, timeout: logged_in.append(timeout) or None + ) + + result = _invoke(["hub", "api-key", "generate", "--workspace=team"]) + + assert result.exit_code != 0 + assert "already stored" in result.output + assert "--force" in result.output + assert logged_in == [] + assert registry.get(host="http://hub.test", workspace="team") == "old-secret" + + +def test_hub_api_key_generate_force_revokes_then_remints(monkeypatch): + registry.set( + host="http://hub.test", workspace="team", api_key="old-secret", api_key_id=9 + ) + deleted: list[int] = [] + _stub_hub_auth(monkeypatch) + monkeypatch.setattr( + _hub._client, + "delete_api_key", + lambda hub_url, token, user_id, api_key_id: deleted.append(api_key_id), + ) + captured = {} + + def fake_create(*a, **k): + captured.update(k) + return 42, "new-secret" + + monkeypatch.setattr(_hub._client, "create_api_key", fake_create) + + result = _invoke(["hub", "api-key", "generate", "--workspace=team", "--force"]) + + assert result.exit_code == 0, result.output + assert deleted == [9] + assert captured["name"] == "team-laptop" + assert registry.get(host="http://hub.test", workspace="team") == "new-secret" + assert registry.get_api_key_id(host="http://hub.test", workspace="team") == 42 + + +def test_hub_api_key_generate_name_override(monkeypatch): + _stub_hub_auth(monkeypatch) + captured = {} + + def fake_create(*a, **k): + captured.update(k) + return 42, "minted-secret" + + monkeypatch.setattr(_hub._client, "create_api_key", fake_create) + + result = _invoke( + ["hub", "api-key", "generate", "--workspace=team", "--name=ui-laptop"] + ) + + assert result.exit_code == 0, result.output + assert captured["name"] == "ui-laptop" + assert registry.get_api_key_id(host="http://hub.test", workspace="team") == 42 + + def test_hub_api_key_generate_expires_in_three_months(monkeypatch): now = datetime(2026, 1, 15, 8, 30, 0, tzinfo=timezone.utc) @@ -211,12 +364,7 @@ class _FrozenDateTime(datetime): def now(cls, tz=None): return now if tz is None else now.astimezone(tz) - monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") - monkeypatch.setattr(_hub, "login", lambda *, timeout: SimpleNamespace(access="tok")) - monkeypatch.setattr( - _hub._client, "me", lambda hub_url, token: ("user-1", [_membership("team")]) - ) - monkeypatch.setattr(_hub._client, "list_api_keys", lambda *a, **k: []) + _stub_hub_auth(monkeypatch) captured = {} def fake_create(*a, **k): From f03736e242cd622c384984be18a987bf905a411d Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Thu, 24 Sep 2026 23:54:35 +0200 Subject: [PATCH 13/14] lint --- src/skore_cli/hub/_commands.py | 9 +++------ src/skore_cli/hub/login.py | 1 + tests/test_hub.py | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index 28f5cc7..cc109b4 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -6,7 +6,7 @@ import os import re import socket -from datetime import datetime, timezone +from datetime import UTC, datetime import rich_click as click from rich.table import Table @@ -106,11 +106,8 @@ def _add_calendar_months(when: datetime, months: int) -> datetime: def _expires_at_from_months(months: int, *, now: datetime | None = None) -> str: """Return an ISO-8601 UTC instant ``months`` calendar months after ``now``.""" - when = now or datetime.now(timezone.utc) - if when.tzinfo is None: - when = when.replace(tzinfo=timezone.utc) - else: - when = when.astimezone(timezone.utc) + when = now or datetime.now(UTC) + when = when.replace(tzinfo=UTC) if when.tzinfo is None else when.astimezone(UTC) expiry = _add_calendar_months(when, months) return expiry.isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/src/skore_cli/hub/login.py b/src/skore_cli/hub/login.py index fa43ca8..93d845a 100644 --- a/src/skore_cli/hub/login.py +++ b/src/skore_cli/hub/login.py @@ -234,6 +234,7 @@ def __init__(self, *, timeout: int = 600) -> None: @property def access(self) -> str: + """Return a valid access token, refreshing it if it has expired.""" with self.__lock: if self.__expiration <= datetime.now(UTC): access, refreshment, expiration = post_oauth_refresh_token( diff --git a/tests/test_hub.py b/tests/test_hub.py index 8a03c7d..efbae68 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from types import SimpleNamespace import pytest @@ -251,7 +251,7 @@ def test_create_workspace_api_key_requires_permissions(): def test_expires_at_from_months_clamps_end_of_month(): - now = datetime(2026, 1, 31, 12, 0, 0, tzinfo=timezone.utc) + now = datetime(2026, 1, 31, 12, 0, 0, tzinfo=UTC) assert _hub._expires_at_from_months(1, now=now) == "2026-02-28T12:00:00Z" assert _hub._expires_at_from_months(3, now=now) == "2026-04-30T12:00:00Z" @@ -357,7 +357,7 @@ def fake_create(*a, **k): def test_hub_api_key_generate_expires_in_three_months(monkeypatch): - now = datetime(2026, 1, 15, 8, 30, 0, tzinfo=timezone.utc) + now = datetime(2026, 1, 15, 8, 30, 0, tzinfo=UTC) class _FrozenDateTime(datetime): @classmethod From bdb89d262bf20c50d41d449bec0e4c39db10f9c1 Mon Sep 17 00:00:00 2001 From: "Thomas S." <2565098+thomass-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:05:30 +0200 Subject: [PATCH 14/14] updates after https://github.com/probabl-ai/skore/pull/3255/changes/b0bb0de --- README.md | 8 +- pixi.lock | 1 - pyproject.toml | 3 - src/skore_cli/_agents.py | 5 +- src/skore_cli/agent/_commands.py | 31 +-- src/skore_cli/hub/_client.py | 151 ------------- src/skore_cli/hub/_commands.py | 215 ++++--------------- src/skore_cli/hub/login.py | 254 ---------------------- tests/test_agent_commands.py | 150 +++++-------- tests/test_client.py | 253 ---------------------- tests/test_harnesses.py | 10 +- tests/test_hub.py | 354 ++++++++----------------------- uv.lock | 2 - 13 files changed, 201 insertions(+), 1236 deletions(-) delete mode 100644 src/skore_cli/hub/_client.py delete mode 100644 src/skore_cli/hub/login.py delete mode 100644 tests/test_client.py diff --git a/README.md b/README.md index c9bebfa..c661a0b 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,9 @@ skore agent --workspace ./myapp # configure another project directory Store and inspect Hub API keys locally via skore's credential registry (`~/.skore.hub/credentials.json`). `--host` selects a non-default hub (omit it to use `SKORE_HUB_URI` or the public hub); `--workspace` is required when -generating or deleting a key. `generate` stores one key per machine for a host +generating or revoking a key. `generate` stores one key per machine for a host and workspace; pass `--force` to revoke **this** machine's Hub key and mint a -replacement. `delete` removes the local entry and revokes only that stored Hub +replacement. `revoke` removes the local entry and revokes only that stored Hub key. ```bash @@ -103,8 +103,8 @@ skore hub api-key generate --workspace= skore hub api-key generate --workspace= --expires 3 skore hub api-key generate --workspace= --force skore hub api-key generate --host= --workspace= -skore hub api-key delete --workspace= -skore hub api-key delete --host= --workspace= +skore hub api-key revoke --workspace= +skore hub api-key revoke --host= --workspace= skore hub api-key list ``` diff --git a/pixi.lock b/pixi.lock index 1270cb8..62fb167 100644 --- a/pixi.lock +++ b/pixi.lock @@ -4459,7 +4459,6 @@ packages: - rich-click>=1.9 - textual - skore[hub]>=0.24.0 - - httpx - pre-commit ; extra == 'test' - pytest ; extra == 'test' - pytest-asyncio ; extra == 'test' diff --git a/pyproject.toml b/pyproject.toml index 84a74f3..c498579 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dependencies = [ "rich-click>=1.9", "textual", "skore[hub]>=0.27", - "httpx", ] [project.optional-dependencies] @@ -169,8 +168,6 @@ ignore_missing_imports = true module = [ "rich_click.*", "textual.*", - "httpx", - "httpx.*", "skore", "skore.*", ] diff --git a/src/skore_cli/_agents.py b/src/skore_cli/_agents.py index f45933f..89f9feb 100644 --- a/src/skore_cli/_agents.py +++ b/src/skore_cli/_agents.py @@ -788,15 +788,16 @@ def _configure_copilot_cli(_ctx: HarnessContext) -> dict[str, Any]: def _launch_copilot_cli(workspace: Path, model_id: str) -> None: """Start ``copilot`` with the same provider env as ``skore-copilot cli``.""" + from skore._plugins.hub.authentication import key + from skore_cli.agent._skore_file import SkoreConfig - from skore_cli.hub._commands import _registry config = SkoreConfig.load(workspace) if config is None or not config.hub_url: raise RuntimeError( "missing .skore; run skore agent --harness copilot-cli first." ) - api_key = _registry().get(host=config.hub_url, workspace=config.workspace) + api_key = key.get(host=config.hub_url, workspace=config.workspace) if not api_key: raise RuntimeError( "missing Hub API key; run skore agent --harness copilot-cli first." diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index 5adf52e..2766780 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -4,6 +4,7 @@ import os from pathlib import Path +from typing import TYPE_CHECKING import rich_click as click @@ -26,14 +27,13 @@ ) from skore_cli._style import console from skore_cli.agent._skore_file import SkoreConfig, ensure_gitignore_entry -from skore_cli.hub import _client -from skore_cli.hub._commands import _registry, generate -from skore_cli.hub.login import login +from skore_cli.hub._commands import generate +if TYPE_CHECKING: + from skore._plugins.hub.authentication.registry.distant import Workspace -def _pick_workspace( - memberships: list[_client.Membership], -) -> _client.Membership: + +def _pick_workspace(memberships: list[Workspace]) -> Workspace: """Launch the Textual workspace picker and return the chosen membership.""" from skore_cli.agent.app import WorkspacePicker @@ -79,8 +79,9 @@ def _pick_harness(workspace: Path) -> str: def _api_key_for(ctx, config: SkoreConfig, *, login_timeout: int) -> str: """Return the workspace API key, minting one through ``generate`` if absent.""" - registry = _registry() - api_key = registry.get(host=config.hub_url, workspace=config.workspace) + from skore._plugins.hub.authentication import key + + api_key = key.get(host=config.hub_url, workspace=config.workspace) if api_key: return api_key @@ -93,7 +94,7 @@ def _api_key_for(ctx, config: SkoreConfig, *, login_timeout: int) -> str: login_timeout=login_timeout, ) - api_key = registry.get(host=config.hub_url, workspace=config.workspace) + api_key = key.get(host=config.hub_url, workspace=config.workspace) if not api_key: raise click.ClickException( @@ -103,9 +104,7 @@ def _api_key_for(ctx, config: SkoreConfig, *, login_timeout: int) -> str: return api_key -def _resolve_membership( - memberships: list[_client.Membership], -) -> _client.Membership: +def _resolve_membership(memberships: list[Workspace]) -> Workspace: if len(memberships) == 1: return memberships[0] if is_non_interactive(): @@ -201,10 +200,12 @@ def agent( harness_name = harness_name or config.harness else: from skore._plugins.hub.authentication import URI + from skore._plugins.hub.authentication.login import login + from skore._plugins.hub.authentication.registry import distant resolved_hub_url = URI() - token = login(timeout=login_timeout) - _, memberships = _client.me(resolved_hub_url, token.access) + token = login(host=resolved_hub_url, timeout=login_timeout) + memberships = distant.identity(host=resolved_hub_url, token=token).workspaces if not memberships: raise click.ClickException( "you are not a member of any hub workspace; create or join one first." @@ -214,7 +215,7 @@ def agent( config = SkoreConfig( hub_url=resolved_hub_url, workspace=membership.public_id, - workspace_id=membership.workspace_id, + workspace_id=membership.id, harness=harness_name, ) config_path = config.save(workspace) diff --git a/src/skore_cli/hub/_client.py b/src/skore_cli/hub/_client.py deleted file mode 100644 index 71c599a..0000000 --- a/src/skore_cli/hub/_client.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Thin HTTP client for the hub identity API. - -Pure, testable functions over the hub's ``/identity`` endpoints: reading the -user's profile and minting a workspace-scoped API key. ``httpx`` is imported -lazily inside the calls so building the CLI stays cheap. All calls authenticate -with the stored interactive login token as a bearer. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -import rich_click as click - -_TIMEOUT = 30 - - -@dataclass(frozen=True) -class Membership: - """A workspace the user belongs to, with the permissions grantable there.""" - - workspace_id: int - public_id: str - permissions: frozenset[str] - - -@dataclass(frozen=True) -class ApiKeyInfo: - """Metadata for an existing API key (the secret is never returned here).""" - - id: int - name: str | None - workspace_id: int - created_at: str | None - expires_at: str | None - - -def _client(hub_url: str, token: str, transport: Any = None): - import httpx - - return httpx.Client( - base_url=hub_url.rstrip("/"), - headers={"Authorization": f"Bearer {token}"}, - timeout=_TIMEOUT, - follow_redirects=True, - transport=transport, - ) - - -def _raise_for(response: Any, *, context: str) -> None: - if response.is_success: - return - code = response.status_code - try: - detail = response.json().get("detail") - except Exception: # noqa: BLE001 - non-JSON error body - detail = None - detail = detail or (response.text or "").strip() or "no details" - if code == 401: - raise click.ClickException( - f"authentication failed while {context}; run `skore agent` again." - ) - if code == 403: - raise click.ClickException(f"not allowed while {context} ({detail}).") - if code == 404: - raise click.ClickException(f"not found while {context} ({detail}).") - raise click.ClickException( - f"hub request failed while {context} ({code}: {detail})." - ) - - -def me( - hub_url: str, token: str, *, transport: Any = None -) -> tuple[str, list[Membership]]: - """Return ``(user_id, memberships)`` from ``GET /identity/users/me``.""" - with _client(hub_url, token, transport) as client: - response = client.get("/identity/users/me") - _raise_for(response, context="fetching your profile") - data = response.json() - memberships = [ - Membership( - workspace_id=int(item["workspace_id"]), - public_id=item["public_id"], - permissions=frozenset(item.get("permissions") or []), - ) - for item in data.get("workspace_memberships") or [] - ] - return data["id"], memberships - - -def create_api_key( - hub_url: str, - token: str, - user_id: str, - *, - name: str | None, - permissions: list[str], - workspace_id: int, - expires_at: str | None, - transport: Any = None, -) -> tuple[int, str]: - """Create a workspace-scoped API key; return ``(api_key_id, secret)``. - - The plaintext secret is returned only here, once, by the hub. - """ - body: dict[str, Any] = { - "name": name, - "permissions": list(permissions), - "workspace_id": workspace_id, - } - if expires_at is not None: - body["expires_at"] = expires_at - with _client(hub_url, token, transport) as client: - response = client.post(f"/identity/users/{user_id}/api-keys", json=body) - _raise_for(response, context="creating the API key") - data = response.json() - return data["api_key_id"], data["api_key"] - - -def list_api_keys( - hub_url: str, token: str, user_id: str, *, transport: Any = None -) -> list[ApiKeyInfo]: - """Return the user's API keys (metadata only) from the hub.""" - with _client(hub_url, token, transport) as client: - response = client.get(f"/identity/users/{user_id}/api-keys") - _raise_for(response, context="listing your API keys") - return [ - ApiKeyInfo( - id=item["id"], - name=item.get("name"), - workspace_id=int(item["workspace_id"]), - created_at=item.get("created_at"), - expires_at=item.get("expires_at"), - ) - for item in response.json() - ] - - -def delete_api_key( - hub_url: str, - token: str, - user_id: str, - api_key_id: int, - *, - transport: Any = None, -) -> None: - """Delete a Hub API key by id.""" - with _client(hub_url, token, transport) as client: - response = client.delete(f"/identity/users/{user_id}/api-keys/{api_key_id}") - _raise_for(response, context="deleting the API key") diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index cc109b4..383319c 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -2,18 +2,12 @@ from __future__ import annotations -import calendar import os -import re -import socket -from datetime import UTC, datetime import rich_click as click from rich.table import Table from skore_cli._style import console -from skore_cli.hub import _client -from skore_cli.hub.login import login click.rich_click.COMMAND_GROUPS = { **getattr(click.rich_click, "COMMAND_GROUPS", {}), @@ -21,17 +15,10 @@ {"name": "Credentials", "commands": ["api-key"]}, ], "cli hub api-key": [ - {"name": "Manage", "commands": ["generate", "delete", "list"]}, + {"name": "Manage", "commands": ["generate", "revoke", "list"]}, ], } -PROJECT_PERMISSIONS = ( - "create:project", - "read:project", - "update:project", - "delete:project", -) - @click.group(invoke_without_command=True) @click.pass_context @@ -41,125 +28,10 @@ def hub(ctx) -> None: click.echo(ctx.get_help()) -def _registry(): - """Return ``skore``'s API-key registry.""" - from skore._plugins.hub.authentication import registry - - return registry - - -def _host(host: str | None) -> str: - """Return ``host``, or skore's ``URI()`` when ``host`` is omitted.""" - from skore._plugins.hub.authentication import URI - - return host or URI() - - -def _sanitize_hostname(hostname: str) -> str: - cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", hostname).strip("-.") - return cleaned or "host" - - -def _default_api_key_name(workspace: str) -> str: - hostname = _sanitize_hostname(socket.gethostname()) - return f"{workspace}-{hostname}"[:150] - - -def _stored_api_key_id(*, host: str, workspace: str) -> int | None: - """Return the Hub id stored for this host/workspace, if any.""" - registry = _registry() - secret = registry.get(host=host, workspace=workspace) - api_key_id = registry.get_api_key_id(host=host, workspace=workspace) - if secret is None and api_key_id is None: - return None - if secret is None or api_key_id is None: - raise click.ClickException( - f"the stored API key for workspace '{workspace}' is incomplete; " - "delete the local entry and generate a new key." - ) - return api_key_id - - -def _revoke_stored_api_key( - hub_url: str, - token: str, - user_id: str, - workspace: str, -) -> bool: - """Revoke the locally stored Hub key. Return whether a key was stored.""" - api_key_id = _stored_api_key_id(host=hub_url, workspace=workspace) - if api_key_id is None: - return False - _client.delete_api_key(hub_url, token, user_id, api_key_id) - _registry().delete(host=hub_url, workspace=workspace) - return True - - -def _add_calendar_months(when: datetime, months: int) -> datetime: - """Add ``months`` to ``when``, clamping the day like date-fns ``addMonths``.""" - month = when.month - 1 + months - year = when.year + month // 12 - month = month % 12 + 1 - day = min(when.day, calendar.monthrange(year, month)[1]) - return when.replace(year=year, month=month, day=day) - - -def _expires_at_from_months(months: int, *, now: datetime | None = None) -> str: - """Return an ISO-8601 UTC instant ``months`` calendar months after ``now``.""" - when = now or datetime.now(UTC) - when = when.replace(tzinfo=UTC) if when.tzinfo is None else when.astimezone(UTC) - expiry = _add_calendar_months(when, months) - return expiry.isoformat(timespec="seconds").replace("+00:00", "Z") - - -def _expires_at_from_choice(choice: str) -> str | None: - if choice == "never": - return None - return _expires_at_from_months(int(choice)) - - -def _create_workspace_api_key( - hub_url: str, - token: str, - user_id: str, - membership: _client.Membership, - name: str, - expires_at: str | None = None, -) -> tuple[int, str]: - """Mint a workspace-scoped API key.""" - grantable = set(membership.permissions) - permissions = [p for p in PROJECT_PERMISSIONS if p in grantable] - if not permissions: - raise click.ClickException( - f"you cannot create project API keys in workspace '{membership.public_id}'." - ) - - return _client.create_api_key( - hub_url, - token, - user_id, - name=name, - permissions=permissions, - workspace_id=membership.workspace_id, - expires_at=expires_at, - ) - - -def _membership_for( - memberships: list[_client.Membership], workspace: str -) -> _client.Membership: - membership = next((m for m in memberships if m.public_id == workspace), None) - if membership is None: - raise click.ClickException( - f"workspace '{workspace}' is not in your memberships." - ) - return membership - - @hub.group("api-key", invoke_without_command=True) @click.pass_context def api_key(ctx) -> None: - """Generate, delete and list Hub API keys.""" + """Generate, revoke and list Hub API keys.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @@ -170,7 +42,7 @@ def api_key(ctx) -> None: @click.option( "--name", default=None, - help="Name stored on the hub for this key (default: workspace-hostname).", + help="Name stored on the hub for this key. A random name is used when omitted.", ) @click.option( "--login-timeout", @@ -200,77 +72,62 @@ def generate( force: bool, ) -> None: """Mint a workspace-scoped Hub API key and store it locally.""" + from skore._plugins.hub.authentication import key as hub_key + if host: os.environ["SKORE_HUB_URI"] = host - hub_url = _host(host) - key_name = name or _default_api_key_name(workspace) - if _registry().get(host=hub_url, workspace=workspace) and not force: + try: + hub_key.generate( + host=host, + workspace=workspace, + name=name, + expires=expires, + timeout=login_timeout, + force=force, + ) + except hub_key.KeyExistsError as error: raise click.ClickException( f"an API key for workspace '{workspace}' is already stored; " "pass --force to replace it." - ) - token = login(timeout=login_timeout) - user_id, memberships = _client.me(hub_url, token.access) - if not memberships: - raise click.ClickException( - "you are not a member of any hub workspace; create or join one first." - ) - membership = _membership_for(memberships, workspace) - if force: - _revoke_stored_api_key(hub_url, token.access, user_id, workspace) - expires_at = _expires_at_from_choice(expires) - api_key_id, secret = _create_workspace_api_key( - hub_url, - token.access, - user_id, - membership, - key_name, - expires_at=expires_at, - ) - _registry().set( - host=hub_url, workspace=workspace, api_key=secret, api_key_id=api_key_id - ) - if expires_at: - console.print( - f"[skore.ok]+[/] generated API key for workspace " - f"[skore.skill]{workspace}[/] (expires {expires_at})" - ) - return - console.print( + ) from error + except PermissionError as error: + raise click.ClickException(str(error)) from error + message = ( f"[skore.ok]+[/] generated API key for workspace [skore.skill]{workspace}[/]" ) + if expires != "never": + unit = "month" if expires == "1" else "months" + message = f"{message} (expires in {expires} {unit})" + console.print(message) -@api_key.command("delete") +@api_key.command("revoke") @click.option("--host", default=None, help="Hub host URL.") -@click.option("--workspace", required=True, help="Hub workspace whose key to delete.") +@click.option("--workspace", required=True, help="Hub workspace whose key to revoke.") @click.option( "--login-timeout", default=600, show_default=True, help="Seconds to wait for interactive device login.", ) -def delete(host: str | None, workspace: str, login_timeout: int) -> None: - """Delete a stored Hub API key locally and on the hub.""" +def revoke(host: str | None, workspace: str, login_timeout: int) -> None: + """Revoke a stored Hub API key locally and on the hub.""" + from skore._plugins.hub.authentication import key as hub_key + if host: os.environ["SKORE_HUB_URI"] = host - hub_url = _host(host) - token = login(timeout=login_timeout) - user_id, memberships = _client.me(hub_url, token.access) - if not memberships: - raise click.ClickException( - "you are not a member of any hub workspace; create or join one first." - ) - _membership_for(memberships, workspace) - if not _revoke_stored_api_key(hub_url, token.access, user_id, workspace): + before = list(hub_key.keys()) + hub_key.revoke(host=host, workspace=workspace, timeout=login_timeout) + if list(hub_key.keys()) == before: console.print("No API key stored.") - return @api_key.command("list") def list_keys() -> None: """List stored Hub API keys.""" - rows = list(_registry().keys()) + from skore._plugins.hub.authentication import key as hub_key + + rows = list(hub_key.keys()) if not rows: console.print("No API keys stored.") return @@ -278,6 +135,6 @@ def list_keys() -> None: table = Table(title="Hub API keys") table.add_column("host") table.add_column("workspace") - for stored_host, stored_workspace in rows: + for _key_id, stored_host, stored_workspace in rows: table.add_row(stored_host, stored_workspace) console.print(table) diff --git a/src/skore_cli/hub/login.py b/src/skore_cli/hub/login.py deleted file mode 100644 index 93d845a..0000000 --- a/src/skore_cli/hub/login.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Login to ``skore hub``.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from functools import cache -from threading import RLock -from time import sleep -from urllib.parse import urljoin -from webbrowser import open as open_webbrowser - -from httpx import HTTPStatusError, TimeoutException -from rich.align import Align -from rich.panel import Panel - - -def get_oauth_device_login(success_uri: str | None = None) -> tuple[str, str, str]: - """ - Initiate device OAuth flow. - - Initiates the OAuth device flow. - Provides the user with a URL and a OTP code to authenticate the device. - - Parameters - ---------- - success_uri : str, optional - The URI to redirect to after successful authentication. - If not provided, defaults to None. - - Returns - ------- - tuple - A tuple containing: - - authorization_url: str - The URL to which the user needs to navigate - - device_code: str - The device code used for authentication - - user_code: str - The user code that needs to be entered on the authorization page - """ - from skore._plugins.hub.authentication.uri import URI - from skore._plugins.hub.client.client import Client - - url = "identity/oauth/device/login" - params = {"success_uri": success_uri} if success_uri is not None else {} - - with Client() as client: - response = client.get(urljoin(URI(), url), params=params).json() - - return ( - response["authorization_url"], - response["device_code"], - response["user_code"], - ) - - -def get_oauth_device_code_probe(device_code: str, *, timeout: int = 600) -> None: - """ - Ensure authorization code is acknowledged. - - Start polling, wait for the authorization code to be acknowledged by the hub. - This is mandatory to be authorize to exchange with a token. - - Parameters - ---------- - device_code : str - The device code to exchange for tokens. - """ - from skore._plugins.hub.authentication.uri import URI - from skore._plugins.hub.client.client import Client - - url = "identity/oauth/device/code-probe" - params = {"device_code": device_code} - - with Client() as client: - start = datetime.now() - - while True: - try: - client.get(urljoin(URI(), url), params=params) - except HTTPStatusError as exc: - if exc.response.status_code != 400: - raise - - if (datetime.now() - start).total_seconds() >= timeout: - raise TimeoutException("Authentication timeout") from exc - - sleep(0.5) - else: - break - - -def post_oauth_device_callback(state: str, user_code: str) -> None: - """ - Validate the user-provided device code. - - This endpoint verifies the code entered by the user during the device auth flow. - - Parameters - ---------- - state: str - The unique value identifying the device flow. - user_code: str - The code entered by the user. - """ - from skore._plugins.hub.authentication.uri import URI - from skore._plugins.hub.client.client import Client - - url = "identity/oauth/device/callback" - data = {"state": state, "user_code": user_code} - - with Client() as client: - client.post(urljoin(URI(), url), data=data) - - -def get_oauth_device_token(device_code: str) -> tuple[str, str, str]: - """ - Exchanges the device code for an access token. - - This endpoint completes the device authorization flow - by exchanging the validated device code for an access token. - - Parameters - ---------- - device_code : str - The device code to exchange for tokens - - Returns - ------- - tuple - A tuple containing: - - access_token : str - The OAuth access token - - refresh_token : str - The OAuth refresh token - - expires_at : str - The expiration datetime as ISO 8601 str of the access token - """ - from skore._plugins.hub.authentication.uri import URI - from skore._plugins.hub.client.client import Client - - url = "identity/oauth/device/token" - params = {"device_code": device_code} - - with Client() as client: - response = client.get(urljoin(URI(), url), params=params).json() - tokens = response["token"] - - return ( - tokens["access_token"], - tokens["refresh_token"], - tokens["expires_at"], - ) - - -def post_oauth_refresh_token(refresh_token: str) -> tuple[str, str, str]: - """ - Refresh an access token using a provided refresh token. - - This endpoint allows a client to obtain a new access token - by providing a valid refresh token. - - Parameters - ---------- - refresh_token : str - A valid refresh token - - Returns - ------- - tuple - A tuple containing: - - access_token : str - The OAuth access token - - refresh_token : str - The OAuth refresh token - - expires_at : str - The expiration datetime as ISO 8601 str of the access token - """ - from skore._plugins.hub.authentication.uri import URI - from skore._plugins.hub.client.client import Client - - url = "identity/oauth/token/refresh" - json = {"refresh_token": refresh_token} - - with Client() as client: - response = client.post(urljoin(URI(), url), json=json).json() - - return ( - response["access_token"], - response["refresh_token"], - response["expires_at"], - ) - - -class Token: - """ - Token used for ``skore hub`` authentication, as HTTP header parameters. - - Notes - ----- - Refresh the token on-the-fly if necessary. - """ - - def __init__(self, *, timeout: int = 600) -> None: - from skore import console - - url, device_code, user_code = get_oauth_device_login() - - console.print( - Panel( - Align.center( - "Starting interactive authentication for the session.\n" - "Opening browser for interactive authentication; if this fails, " - f"please visit:\n[link={url}]{url}[/link]" - ), - title="[cyan]Login to [bold]Skore Hub", - border_style="cyan", - padding=1, - ), - soft_wrap=True, - ) - - open_webbrowser(url) - - get_oauth_device_code_probe(device_code, timeout=timeout) - post_oauth_device_callback(device_code, user_code) - - access, refreshment, expiration = get_oauth_device_token(device_code) - - self.__lock = RLock() - self.__access = access - self.__refreshment = refreshment - self.__expiration = datetime.fromisoformat(expiration) - - @property - def access(self) -> str: - """Return a valid access token, refreshing it if it has expired.""" - with self.__lock: - if self.__expiration <= datetime.now(UTC): - access, refreshment, expiration = post_oauth_refresh_token( - self.__refreshment - ) - - self.__access = access - self.__refreshment = refreshment - self.__expiration = datetime.fromisoformat(expiration) - - return self.__access - - -@cache -def login(*, timeout: int = 600) -> Token: - """Login to ``skore hub``.""" - return Token(timeout=timeout) diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index 5cc551a..36b39d3 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -10,6 +10,10 @@ import pytest import rich_click as click from click.testing import CliRunner +from skore._plugins.hub.authentication import key as hub_key +from skore._plugins.hub.authentication import login as hub_login +from skore._plugins.hub.authentication.registry import distant +from skore._plugins.hub.authentication.registry.distant import Workspace from skore_cli import _agents from skore_cli._agents import AGENTS, DEFAULT_MODEL_ID, HARNESS_NAMES, HarnessContext @@ -21,8 +25,6 @@ SkoreConfig, ensure_gitignore_entry, ) -from skore_cli.hub import _client -from skore_cli.hub._commands import PROJECT_PERMISSIONS _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") @@ -42,21 +44,29 @@ def _mock_harness_on_path(monkeypatch, name: str) -> None: def _membership(public_id: str = "ws-1", workspace_id: int = 1): - return _client.Membership( - workspace_id=workspace_id, - public_id=public_id, - permissions=frozenset(PROJECT_PERMISSIONS), + return Workspace(id=workspace_id, public_id=public_id, permissions=frozenset()) + + +def _stub_login(monkeypatch): + monkeypatch.setattr( + hub_login, + "login", + lambda *, host, timeout: SimpleNamespace(access="tok"), + ) + + +def _stub_identity(monkeypatch, workspaces): + monkeypatch.setattr( + distant, + "identity", + lambda *, host, token: SimpleNamespace(workspaces=workspaces), ) @pytest.fixture(autouse=True) def stub_registry(monkeypatch): """Serve a stored API key so runs never reach the real credential registry.""" - monkeypatch.setattr( - _commands, - "_registry", - lambda: SimpleNamespace(get=lambda **kwargs: "new-secret"), - ) + monkeypatch.setattr(hub_key, "get", lambda **kwargs: "new-secret") @pytest.fixture(autouse=True) @@ -230,11 +240,7 @@ def get(*, host, workspace): lookups.append((host, workspace)) return "new-secret" - monkeypatch.setattr( - _commands, - "_registry", - lambda: SimpleNamespace(get=get), - ) + monkeypatch.setattr(hub_key, "get", get) uris = [] monkeypatch.setattr( _commands, @@ -265,14 +271,8 @@ def get(*, host, workspace): def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): _mock_harness_on_path(monkeypatch, "opencode") - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, - "me", - lambda hub_url, token: ("user-1", [_membership()]), - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr( _commands, "launch_harness", @@ -307,14 +307,8 @@ def test_agent_saves_claude_plugin_ide(tmp_path, monkeypatch): ) monkeypatch.setattr(_agents, "is_non_interactive", lambda: False) monkeypatch.setattr(_agents, "_prompt_ide", lambda options: "cursor") - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, - "me", - lambda hub_url, token: ("user-1", [_membership()]), - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr( _commands, "launch_harness", @@ -348,14 +342,8 @@ def test_agent_claude_plugin_ide_flag_skips_the_menu(tmp_path, monkeypatch): monkeypatch.setattr( _agents, "_prompt_ide", lambda options: prompted.append(options) ) - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, - "me", - lambda hub_url, token: ("user-1", [_membership()]), - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr( _commands, "launch_harness", @@ -441,14 +429,8 @@ def test_agent_bob_ide_first_run_on_linux_writes_mcp_config( and stored under the canonical ``bob-ide`` name.""" monkeypatch.setattr(_agents.sys, "platform", "linux") _mock_harness_on_path(monkeypatch, "bobide") - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, - "me", - lambda hub_url, token: ("user-1", [_membership()]), - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr( _commands, "launch_harness", @@ -469,14 +451,8 @@ def test_agent_bob_ide_first_run_on_linux_writes_mcp_config( def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): _clear_agent_envs(monkeypatch) - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, - "me", - lambda hub_url, token: ("user-1", [_membership()]), - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) result = CliRunner().invoke(agent, ["--workspace", str(tmp_path)]) @@ -487,12 +463,8 @@ def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): def test_agent_generates_api_key_when_none_is_stored(tmp_path, monkeypatch): _mock_harness_on_path(monkeypatch, "opencode") - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr( _commands, "launch_harness", @@ -507,11 +479,9 @@ def fake_generate(*, host, workspace, name, login_timeout): stored[(host, workspace)] = "minted-secret" monkeypatch.setattr( - _commands, - "_registry", - lambda: SimpleNamespace( - get=lambda *, host, workspace: stored.get((host, workspace)) - ), + hub_key, + "get", + lambda *, host, workspace: stored.get((host, workspace)), ) monkeypatch.setattr(_commands, "generate", fake_generate) @@ -694,10 +664,8 @@ def test_resolve_membership_multiple_interactive_picks(monkeypatch): def test_agent_no_memberships_errors(tmp_path, monkeypatch): - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr(_commands._client, "me", lambda hub_url, token: ("user-1", [])) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, []) result = CliRunner().invoke( agent, ["--workspace", str(tmp_path), "--harness", "opencode"] @@ -765,12 +733,8 @@ def test_agent_valid_config_without_harness_picks_interactively(tmp_path, monkey def test_agent_first_run_picks_harness_interactively(tmp_path, monkeypatch): _mock_harness_on_path(monkeypatch, "opencode") - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr(_commands, "is_non_interactive", lambda: False) monkeypatch.setattr(_commands, "_pick_harness", lambda workspace: "opencode") monkeypatch.setattr( @@ -829,12 +793,8 @@ def test_agent_non_interactive_auto_selects_claude(tmp_path, monkeypatch): _clear_agent_envs(monkeypatch) monkeypatch.setenv("CLAUDECODE", "1") _mock_harness_on_path(monkeypatch, "claude") - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) launched = [] monkeypatch.setattr( @@ -858,12 +818,8 @@ def test_agent_non_interactive_auto_selects_opencode(tmp_path, monkeypatch): _clear_agent_envs(monkeypatch) monkeypatch.setenv("OPENCODE_CLIENT", "1") _mock_harness_on_path(monkeypatch, "opencode") - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) launched = [] monkeypatch.setattr( @@ -886,12 +842,8 @@ def test_agent_non_interactive_auto_selects_pi(tmp_path, monkeypatch): _clear_agent_envs(monkeypatch) monkeypatch.setenv("PI_CODING_AGENT", "true") _mock_harness_on_path(monkeypatch, "pi") - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) launched = [] monkeypatch.setattr( @@ -916,12 +868,8 @@ def test_agent_non_interactive_detected_harness_not_on_path_errors( _clear_agent_envs(monkeypatch) monkeypatch.setenv("CLAUDECODE", "1") monkeypatch.setattr(_agents.shutil, "which", lambda cmd: None) - monkeypatch.setattr( - _commands, "login", lambda *, timeout: SimpleNamespace(access="tok") - ) - monkeypatch.setattr( - _commands._client, "me", lambda hub_url, token: ("user-1", [_membership()]) - ) + _stub_login(monkeypatch) + _stub_identity(monkeypatch, [_membership()]) monkeypatch.setattr(_commands, "is_non_interactive", lambda: True) result = CliRunner().invoke(agent, ["--workspace", str(tmp_path)]) diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index e0195e8..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Tests for the thin hub HTTP client backing ``skore hub``.""" - -from __future__ import annotations - -import httpx -import pytest -import rich_click as click - -from skore_cli.hub import _client -from skore_cli.hub._client import ApiKeyInfo, Membership - - -def _transport(handler): - """Wrap a request handler in an ``httpx.MockTransport``.""" - return httpx.MockTransport(handler) - - -# --------------------------------------------------------------------------- # -# me -# --------------------------------------------------------------------------- # - - -def test_me_parses_user_and_memberships(): - def handler(request): - assert request.url.path == "/identity/users/me" - assert request.headers["Authorization"] == "Bearer tok" - return httpx.Response( - 200, - json={ - "id": "user-1", - "workspace_memberships": [ - { - "workspace_id": "7", - "public_id": "ws-1", - "permissions": ["read:project", "create:project"], - } - ], - }, - ) - - user_id, memberships = _client.me( - "http://hub.test/", "tok", transport=_transport(handler) - ) - - assert user_id == "user-1" - assert memberships == [ - Membership( - workspace_id=7, - public_id="ws-1", - permissions=frozenset({"read:project", "create:project"}), - ) - ] - - -def test_me_handles_missing_memberships(): - def handler(request): - return httpx.Response(200, json={"id": "user-1"}) - - user_id, memberships = _client.me( - "http://hub.test", "tok", transport=_transport(handler) - ) - - assert user_id == "user-1" - assert memberships == [] - - -def test_me_membership_without_permissions_is_empty_frozenset(): - def handler(request): - return httpx.Response( - 200, - json={ - "id": "user-1", - "workspace_memberships": [ - {"workspace_id": 3, "public_id": "ws-x", "permissions": None} - ], - }, - ) - - _, memberships = _client.me("http://hub.test", "tok", transport=_transport(handler)) - - assert memberships[0].permissions == frozenset() - - -# --------------------------------------------------------------------------- # -# create_api_key -# --------------------------------------------------------------------------- # - - -def test_create_api_key_returns_id_and_secret(): - def handler(request): - assert request.method == "POST" - assert request.url.path == "/identity/users/user-1/api-keys" - import json - - body = json.loads(request.content) - assert body == { - "name": "opencode", - "permissions": ["read:project"], - "workspace_id": 7, - } - return httpx.Response(200, json={"api_key_id": 42, "api_key": "secret"}) - - key_id, secret = _client.create_api_key( - "http://hub.test", - "tok", - "user-1", - name="opencode", - permissions=["read:project"], - workspace_id=7, - expires_at=None, - transport=_transport(handler), - ) - - assert key_id == 42 - assert secret == "secret" - - -def test_create_api_key_includes_expires_at_when_given(): - def handler(request): - import json - - body = json.loads(request.content) - assert body["expires_at"] == "2030-01-01T00:00:00Z" - return httpx.Response(200, json={"api_key_id": 1, "api_key": "s"}) - - _client.create_api_key( - "http://hub.test", - "tok", - "user-1", - name=None, - permissions=["read:project"], - workspace_id=7, - expires_at="2030-01-01T00:00:00Z", - transport=_transport(handler), - ) - - -# --------------------------------------------------------------------------- # -# list_api_keys -# --------------------------------------------------------------------------- # - - -def test_list_api_keys_parses_metadata(): - def handler(request): - assert request.url.path == "/identity/users/user-1/api-keys" - return httpx.Response( - 200, - json=[ - { - "id": 1, - "name": "opencode", - "workspace_id": "7", - "created_at": "2024-01-01", - "expires_at": None, - }, - {"id": 2, "workspace_id": 8}, - ], - ) - - keys = _client.list_api_keys( - "http://hub.test", "tok", "user-1", transport=_transport(handler) - ) - - assert keys == [ - ApiKeyInfo( - id=1, - name="opencode", - workspace_id=7, - created_at="2024-01-01", - expires_at=None, - ), - ApiKeyInfo(id=2, name=None, workspace_id=8, created_at=None, expires_at=None), - ] - - -# --------------------------------------------------------------------------- # -# _raise_for error mapping -# --------------------------------------------------------------------------- # - - -@pytest.mark.parametrize( - ("status", "expected"), - [ - (401, "authentication failed"), - (403, "not allowed"), - (404, "not found"), - (500, "hub request failed"), - ], -) -def test_error_status_codes_map_to_click_exceptions(status, expected): - def handler(request): - return httpx.Response(status, json={"detail": "boom"}) - - with pytest.raises(click.ClickException) as excinfo: - _client.me("http://hub.test", "tok", transport=_transport(handler)) - - assert expected in str(excinfo.value) - - -def test_error_falls_back_to_text_when_body_not_json(): - def handler(request): - return httpx.Response(500, text="plain text failure") - - with pytest.raises(click.ClickException) as excinfo: - _client.me("http://hub.test", "tok", transport=_transport(handler)) - - assert "plain text failure" in str(excinfo.value) - - -def test_error_uses_no_details_when_body_empty(): - def handler(request): - return httpx.Response(500, text="") - - with pytest.raises(click.ClickException) as excinfo: - _client.list_api_keys( - "http://hub.test", "tok", "user-1", transport=_transport(handler) - ) - - assert "no details" in str(excinfo.value) - - -# --------------------------------------------------------------------------- # -# delete_api_key -# --------------------------------------------------------------------------- # - - -def test_delete_api_key_sends_delete(): - def handler(request): - assert request.method == "DELETE" - assert request.url.path == "/identity/users/user-1/api-keys/42" - return httpx.Response(204) - - _client.delete_api_key( - "http://hub.test", - "tok", - "user-1", - 42, - transport=_transport(handler), - ) - - -def test_delete_api_key_maps_not_found(): - def handler(request): - return httpx.Response(404, json={"detail": "missing"}) - - with pytest.raises(click.ClickException, match="not found"): - _client.delete_api_key( - "http://hub.test", - "tok", - "user-1", - 42, - transport=_transport(handler), - ) diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index 661491b..c8fb394 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -5,7 +5,6 @@ import json import os import tomllib -from types import SimpleNamespace from typing import Any import pytest @@ -21,14 +20,9 @@ def _stub_hub_registry(monkeypatch, api_key): - import importlib + from skore._plugins.hub.authentication import key - hub_commands = importlib.import_module("skore_cli.hub._commands") - monkeypatch.setattr( - hub_commands, - "_registry", - lambda: SimpleNamespace(get=lambda **k: api_key), - ) + monkeypatch.setattr(key, "get", lambda **k: api_key) def _ctx(workspace, **kwargs): diff --git a/tests/test_hub.py b/tests/test_hub.py index efbae68..231bc4a 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -2,18 +2,14 @@ from __future__ import annotations -from datetime import UTC, datetime -from types import SimpleNamespace +import os import pytest -import rich_click as click from click.testing import CliRunner -from skore._plugins.hub.authentication import registry +from skore._plugins.hub.authentication import key as hub_key +from skore._plugins.hub.authentication.registry import local from skore_cli import cli -from skore_cli.hub import _client -from skore_cli.hub import _commands as _hub -from skore_cli.hub._commands import PROJECT_PERMISSIONS pytestmark = pytest.mark.usefixtures("monkeypatch_home", "monkeypatch_keyring") @@ -22,29 +18,14 @@ def _invoke(args: list[str]): return CliRunner().invoke(cli, args) -def _membership(public_id: str = "ws-1", workspace_id: int = 1): - return _client.Membership( - workspace_id=workspace_id, - public_id=public_id, - permissions=frozenset(PROJECT_PERMISSIONS), - ) +def _patch_generate(monkeypatch): + calls = [] + def generate(**kwargs): + calls.append(kwargs) -def _stub_hub_auth(monkeypatch, membership=None): - membership = membership or _membership("team") - monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") - monkeypatch.setattr(_hub, "login", lambda *, timeout: SimpleNamespace(access="tok")) - monkeypatch.setattr( - _hub._client, "me", lambda hub_url, token: ("user-1", [membership]) - ) - monkeypatch.setattr(_hub.socket, "gethostname", lambda: "laptop") - monkeypatch.setattr( - _hub._client, - "list_api_keys", - lambda *a, **k: (_ for _ in ()).throw( - AssertionError("must not list hub keys to revoke") - ), - ) + monkeypatch.setattr(hub_key, "generate", generate) + return calls def test_hub_no_subcommand_shows_help(): @@ -59,134 +40,72 @@ def test_hub_api_key_no_subcommand_shows_help(): assert result.exit_code == 0 assert "generate" in result.output - assert "delete" in result.output + assert "revoke" in result.output assert "list" in result.output -def test_hub_api_key_delete_requires_workspace(): - result = _invoke(["hub", "api-key", "delete"]) +def test_hub_api_key_revoke_requires_workspace(): + result = _invoke(["hub", "api-key", "revoke"]) assert result.exit_code != 0 assert "workspace" in result.output.lower() -def test_hub_api_key_delete_removes_local_and_hub_key(monkeypatch): - registry.set( - host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 - ) - deleted: list[int] = [] - _stub_hub_auth(monkeypatch) - monkeypatch.setattr( - _hub._client, - "delete_api_key", - lambda hub_url, token, user_id, api_key_id: deleted.append(api_key_id), - ) - - result = _invoke( - ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] - ) +def test_hub_api_key_revoke_stored_key(monkeypatch): + rows = [(7, "http://hub.test", "team")] + revoked = [] - assert result.exit_code == 0, result.output - assert registry.get(host="http://hub.test", workspace="team") is None - assert registry.get_api_key_id(host="http://hub.test", workspace="team") is None - assert deleted == [7] + def keys(): + yield from rows + def revoke(*, host, workspace, timeout): + revoked.append((host, workspace, timeout, os.environ.get("SKORE_HUB_URI"))) + rows.clear() -def test_hub_api_key_delete_does_not_revoke_other_workspace_keys(monkeypatch): - registry.set( - host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 - ) - deleted: list[int] = [] - _stub_hub_auth(monkeypatch) - monkeypatch.setattr( - _hub._client, - "delete_api_key", - lambda hub_url, token, user_id, api_key_id: deleted.append(api_key_id), - ) + monkeypatch.setattr(hub_key, "keys", keys) + monkeypatch.setattr(hub_key, "revoke", revoke) result = _invoke( - ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] + [ + "hub", + "api-key", + "revoke", + "--host=http://hub.test", + "--workspace=team", + "--login-timeout=42", + ] ) assert result.exit_code == 0, result.output - assert deleted == [7] + assert "No API key stored." not in result.output + assert revoked == [("http://hub.test", "team", 42, "http://hub.test")] -def test_hub_api_key_delete_without_host_uses_environment_uri(monkeypatch): - monkeypatch.setenv("SKORE_HUB_URI", "http://hub.test") - registry.set( - host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 - ) - registry.set( - host="http://other.test", workspace="team", api_key="other", api_key_id=8 - ) - _stub_hub_auth(monkeypatch) +def test_hub_api_key_revoke_without_host_forwards_none(monkeypatch): + revoked = [] + monkeypatch.setattr(hub_key, "keys", lambda: iter(())) monkeypatch.setattr( - _hub._client, - "delete_api_key", - lambda hub_url, token, user_id, api_key_id: None, + hub_key, + "revoke", + lambda *, host, workspace, timeout: revoked.append((host, workspace, timeout)), ) - result = _invoke(["hub", "api-key", "delete", "--workspace=team"]) + result = _invoke(["hub", "api-key", "revoke", "--workspace=team"]) assert result.exit_code == 0, result.output - assert registry.get(host="http://hub.test", workspace="team") is None - assert registry.get(host="http://other.test", workspace="team") == "other" - assert registry.get_api_key_id(host="http://other.test", workspace="team") == 8 - + assert revoked == [(None, "team", 600)] + assert "No API key stored." in result.output -def test_hub_api_key_delete_reports_when_nothing_stored(monkeypatch): - _stub_hub_auth(monkeypatch) +def test_hub_api_key_revoke_reports_when_nothing_stored(): result = _invoke( - ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] + ["hub", "api-key", "revoke", "--host=http://hub.test", "--workspace=team"] ) assert result.exit_code == 0, result.output assert "No API key stored." in result.output -def test_hub_api_key_delete_incomplete_local_key_errors(monkeypatch): - registry.set( - host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 - ) - filepath = registry.setup() - filepath.write_text( - '{"type": "plaintext", "keys": [' - '{"host": "http://hub.test", "workspace": "team", "key": "secret"}]}' - ) - _stub_hub_auth(monkeypatch) - - result = _invoke( - ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] - ) - - assert result.exit_code != 0 - assert "incomplete" in result.output - assert registry.get(host="http://hub.test", workspace="team") == "secret" - - -def test_hub_api_key_delete_keeps_local_key_when_hub_revoke_fails(monkeypatch): - registry.set( - host="http://hub.test", workspace="team", api_key="secret", api_key_id=7 - ) - _stub_hub_auth(monkeypatch) - monkeypatch.setattr( - _hub._client, - "delete_api_key", - lambda *a, **k: (_ for _ in ()).throw(click.ClickException("hub down")), - ) - - result = _invoke( - ["hub", "api-key", "delete", "--host=http://hub.test", "--workspace=team"] - ) - - assert result.exit_code != 0 - assert "hub down" in result.output - assert registry.get(host="http://hub.test", workspace="team") == "secret" - assert registry.get_api_key_id(host="http://hub.test", workspace="team") == 7 - - def test_hub_api_key_list_empty(): result = _invoke(["hub", "api-key", "list"]) @@ -195,8 +114,8 @@ def test_hub_api_key_list_empty(): def test_hub_api_key_list_shows_host_and_workspace(): - registry.set(host="http://a.test", workspace="w1", api_key="k1", api_key_id=1) - registry.set(host="http://b.test", workspace="w2", api_key="k2", api_key_id=2) + local.set(id=1, host="http://a.test", workspace="w1", key="k1") + local.set(id=2, host="http://b.test", workspace="w2", key="k2") result = _invoke(["hub", "api-key", "list"]) @@ -209,57 +128,6 @@ def test_hub_api_key_list_shows_host_and_workspace(): assert "k2" not in result.output -def test_default_api_key_name_uses_sanitized_hostname(monkeypatch): - monkeypatch.setattr(_hub.socket, "gethostname", lambda: "laptop.local") - assert _hub._default_api_key_name("team") == "team-laptop.local" - - monkeypatch.setattr(_hub.socket, "gethostname", lambda: "lap top/foo") - assert _hub._default_api_key_name("team") == "team-lap-top-foo" - - monkeypatch.setattr(_hub.socket, "gethostname", lambda: "x" * 200) - assert len(_hub._default_api_key_name("ws")) == 150 - - -def test_create_workspace_api_key_mints_secret(monkeypatch): - captured = {} - - def fake_create(hub_url, token, user_id, **kwargs): - captured.update(kwargs) - return 7, "the-secret" - - monkeypatch.setattr(_hub._client, "create_api_key", fake_create) - - secret_id, secret = _hub._create_workspace_api_key( - "http://hub.test", "tok", "user-1", _membership(), "opencode" - ) - - assert secret_id == 7 - assert secret == "the-secret" - assert captured["name"] == "opencode" - assert captured["expires_at"] is None - assert set(captured["permissions"]) == set(PROJECT_PERMISSIONS) - - -def test_create_workspace_api_key_requires_permissions(): - membership = _client.Membership( - workspace_id=1, public_id="ws-1", permissions=frozenset() - ) - with pytest.raises(click.ClickException, match="cannot create project API keys"): - _hub._create_workspace_api_key( - "http://hub.test", "tok", "user-1", membership, "opencode" - ) - - -def test_expires_at_from_months_clamps_end_of_month(): - now = datetime(2026, 1, 31, 12, 0, 0, tzinfo=UTC) - assert _hub._expires_at_from_months(1, now=now) == "2026-02-28T12:00:00Z" - assert _hub._expires_at_from_months(3, now=now) == "2026-04-30T12:00:00Z" - - -def test_expires_at_from_choice_never_is_none(): - assert _hub._expires_at_from_choice("never") is None - - def test_hub_api_key_generate_requires_workspace(): result = _invoke(["hub", "api-key", "generate"]) @@ -267,123 +135,84 @@ def test_hub_api_key_generate_requires_workspace(): assert "workspace" in result.output.lower() -def test_hub_api_key_generate_stores_key(monkeypatch): - _stub_hub_auth(monkeypatch) - captured = {} - - def fake_create(*a, **k): - captured.update(k) - return 42, "minted-secret" - - monkeypatch.setattr(_hub._client, "create_api_key", fake_create) +def test_hub_api_key_generate_delegates_to_skore(monkeypatch): + calls = _patch_generate(monkeypatch) - result = _invoke(["hub", "api-key", "generate", "--workspace=team"]) + result = _invoke( + [ + "hub", + "api-key", + "generate", + "--host=http://hub.test", + "--workspace=team", + "--login-timeout=42", + ] + ) assert result.exit_code == 0, result.output - assert registry.get(host="http://hub.test", workspace="team") == "minted-secret" - assert registry.get_api_key_id(host="http://hub.test", workspace="team") == 42 - assert captured["expires_at"] is None - assert captured["name"] == "team-laptop" + assert calls == [ + { + "host": "http://hub.test", + "workspace": "team", + "name": None, + "expires": "never", + "timeout": 42, + "force": False, + } + ] + assert os.environ["SKORE_HUB_URI"] == "http://hub.test" assert "expires " not in result.output + assert "team" in result.output -def test_hub_api_key_generate_errors_when_key_already_stored(monkeypatch): - monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") - registry.set( - host="http://hub.test", - workspace="team", - api_key="old-secret", - api_key_id=9, - ) - logged_in = [] - monkeypatch.setattr( - _hub, "login", lambda *, timeout: logged_in.append(timeout) or None - ) +def test_hub_api_key_generate_errors_when_key_already_stored(): + local.set(id=9, host="http://hub.test", workspace="team", key="old-secret") - result = _invoke(["hub", "api-key", "generate", "--workspace=team"]) + result = _invoke( + ["hub", "api-key", "generate", "--host=http://hub.test", "--workspace=team"] + ) assert result.exit_code != 0 assert "already stored" in result.output assert "--force" in result.output - assert logged_in == [] - assert registry.get(host="http://hub.test", workspace="team") == "old-secret" - - -def test_hub_api_key_generate_force_revokes_then_remints(monkeypatch): - registry.set( - host="http://hub.test", workspace="team", api_key="old-secret", api_key_id=9 - ) - deleted: list[int] = [] - _stub_hub_auth(monkeypatch) - monkeypatch.setattr( - _hub._client, - "delete_api_key", - lambda hub_url, token, user_id, api_key_id: deleted.append(api_key_id), - ) - captured = {} + assert hub_key.get(host="http://hub.test", workspace="team") == "old-secret" - def fake_create(*a, **k): - captured.update(k) - return 42, "new-secret" - monkeypatch.setattr(_hub._client, "create_api_key", fake_create) +def test_hub_api_key_generate_forwards_force(monkeypatch): + calls = _patch_generate(monkeypatch) result = _invoke(["hub", "api-key", "generate", "--workspace=team", "--force"]) assert result.exit_code == 0, result.output - assert deleted == [9] - assert captured["name"] == "team-laptop" - assert registry.get(host="http://hub.test", workspace="team") == "new-secret" - assert registry.get_api_key_id(host="http://hub.test", workspace="team") == 42 + assert calls[0]["force"] is True + assert calls[0]["host"] is None + assert calls[0]["workspace"] == "team" def test_hub_api_key_generate_name_override(monkeypatch): - _stub_hub_auth(monkeypatch) - captured = {} - - def fake_create(*a, **k): - captured.update(k) - return 42, "minted-secret" - - monkeypatch.setattr(_hub._client, "create_api_key", fake_create) + calls = _patch_generate(monkeypatch) result = _invoke( ["hub", "api-key", "generate", "--workspace=team", "--name=ui-laptop"] ) assert result.exit_code == 0, result.output - assert captured["name"] == "ui-laptop" - assert registry.get_api_key_id(host="http://hub.test", workspace="team") == 42 + assert calls[0]["name"] == "ui-laptop" def test_hub_api_key_generate_expires_in_three_months(monkeypatch): - now = datetime(2026, 1, 15, 8, 30, 0, tzinfo=UTC) - - class _FrozenDateTime(datetime): - @classmethod - def now(cls, tz=None): - return now if tz is None else now.astimezone(tz) - - _stub_hub_auth(monkeypatch) - captured = {} - - def fake_create(*a, **k): - captured.update(k) - return 42, "minted-secret" - - monkeypatch.setattr(_hub._client, "create_api_key", fake_create) - monkeypatch.setattr(_hub, "datetime", _FrozenDateTime) + calls = _patch_generate(monkeypatch) result = _invoke( ["hub", "api-key", "generate", "--workspace=team", "--expires", "3"] ) assert result.exit_code == 0, result.output - assert captured["expires_at"] == "2026-04-15T08:30:00Z" - assert "expires 2026-04-15T08:30:00Z" in result.output + assert calls[0]["expires"] == "3" + assert "expires in 3 months" in result.output -def test_hub_api_key_generate_rejects_unknown_expires(monkeypatch): +def test_hub_api_key_generate_rejects_unknown_expires(): result = _invoke( ["hub", "api-key", "generate", "--workspace=team", "--expires", "12"] ) @@ -393,13 +222,12 @@ def test_hub_api_key_generate_rejects_unknown_expires(monkeypatch): def test_hub_api_key_generate_unknown_workspace(monkeypatch): - monkeypatch.setattr(_hub, "_host", lambda host: host or "http://hub.test") - monkeypatch.setattr(_hub, "login", lambda *, timeout: SimpleNamespace(access="tok")) - monkeypatch.setattr( - _hub._client, "me", lambda hub_url, token: ("user-1", [_membership("team")]) - ) + def generate(**kwargs): + raise PermissionError("You are not member of 'other'") + + monkeypatch.setattr(hub_key, "generate", generate) result = _invoke(["hub", "api-key", "generate", "--workspace=other"]) assert result.exit_code != 0 - assert "not in your memberships" in result.output + assert "not member" in result.output diff --git a/uv.lock b/uv.lock index 9d485a0..cc1e4e0 100644 --- a/uv.lock +++ b/uv.lock @@ -2042,7 +2042,6 @@ version = "0.4.0" source = { editable = "." } dependencies = [ { name = "click" }, - { name = "httpx" }, { name = "rich" }, { name = "rich-click" }, { name = "skore", extra = ["hub"] }, @@ -2064,7 +2063,6 @@ test = [ [package.metadata] requires-dist = [ { name = "click" }, - { name = "httpx" }, { name = "pre-commit", marker = "extra == 'test'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" },