From daab2218bd2e75068bf1fb884c7dd9f098358b0e Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Sun, 14 Jun 2026 23:49:43 +0200 Subject: [PATCH 1/7] feat: Add agent and hub sub-command --- pyproject.toml | 3 + src/skore_cli/__init__.py | 14 + src/skore_cli/_plugins.py | 60 +++ src/skore_cli/_skore.py | 27 ++ src/skore_cli/agent/__init__.py | 21 + src/skore_cli/agent/_commands.py | 363 +++++++++++++++++ src/skore_cli/agent/_harnesses.py | 587 ++++++++++++++++++++++++++++ src/skore_cli/agent/app/__init__.py | 5 + src/skore_cli/agent/app/_picker.py | 149 +++++++ src/skore_cli/hub.py | 170 ++++++++ src/skore_cli/skills/_commands.py | 1 + tests/test_agent_commands.py | 163 ++++++++ tests/test_agent_workspace.py | 133 +++++++ tests/test_cli.py | 130 ++++++ tests/test_harnesses.py | 339 ++++++++++++++++ tests/test_hub.py | 216 ++++++++++ tests/test_pickers.py | 83 ++++ 17 files changed, 2464 insertions(+) create mode 100644 src/skore_cli/_plugins.py create mode 100644 src/skore_cli/_skore.py create mode 100644 src/skore_cli/agent/__init__.py create mode 100644 src/skore_cli/agent/_commands.py create mode 100644 src/skore_cli/agent/_harnesses.py create mode 100644 src/skore_cli/agent/app/__init__.py create mode 100644 src/skore_cli/agent/app/_picker.py create mode 100644 src/skore_cli/hub.py create mode 100644 tests/test_agent_commands.py create mode 100644 tests/test_agent_workspace.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_harnesses.py create mode 100644 tests/test_hub.py create mode 100644 tests/test_pickers.py diff --git a/pyproject.toml b/pyproject.toml index d415e54..64ffb40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ ] [project.optional-dependencies] +# The `hub` and `agent` commands reuse skore's authentication machinery; the +# Continue and Claude Code harness writers need PyYAML to (de)serialize configs. +agent = ["skore[hub]", "pyyaml"] test = [ "pre-commit", "pytest", diff --git a/src/skore_cli/__init__.py b/src/skore_cli/__init__.py index fa01716..164cbd8 100644 --- a/src/skore_cli/__init__.py +++ b/src/skore_cli/__init__.py @@ -5,6 +5,13 @@ import rich_click as click import skore_cli._style # noqa: F401 (applies the CLI palette and rich-click config) +from skore_cli._plugins import load_plugins + +# These command modules defer their heavy `skore` imports into the callbacks, so +# 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 @@ -15,3 +22,10 @@ def cli() -> None: cli.add_command(skills) +cli.add_command(hub) +cli.add_command(agent) + +# Commands contributed by other packages via the `skore_cli.plugins` entry-point +# group. Kept for third-party extensibility; the built-in `hub`/`agent` commands +# no longer go through it so the CLI never imports `skore` just to show help. +load_plugins(cli) diff --git a/src/skore_cli/_plugins.py b/src/skore_cli/_plugins.py new file mode 100644 index 0000000..3c86fba --- /dev/null +++ b/src/skore_cli/_plugins.py @@ -0,0 +1,60 @@ +"""Entry-point plugin host for the ``skore`` CLI. + +Lets other packages (``skore``, ``skore-hub``, ...) contribute command groups to +the ``skore`` CLI without ``skore-cli`` importing them directly. A plugin is a +Python entry point in the ``skore_cli.plugins`` group whose loaded object is +either a ``click.Command`` / ``click.Group`` or a zero-argument callable +returning one. + +Example (in another package's ``pyproject.toml``):: + + [project.entry-points."skore_cli.plugins"] + agent = "skore._cli.agent:agent" + +Plugins that fail to load are skipped with a warning so a broken third-party +plugin never takes down the whole CLI. +""" + +from __future__ import annotations + +from importlib.metadata import entry_points + +import rich_click as click + +PLUGIN_GROUP = "skore_cli.plugins" + + +def _iter_plugin_entry_points(): + try: + return list(entry_points(group=PLUGIN_GROUP)) + except TypeError: # pragma: no cover - Python < 3.10 selection API + return list(entry_points().get(PLUGIN_GROUP, [])) + + +def load_plugins(group: click.Group) -> None: + """Discover ``skore_cli.plugins`` entry points and attach their commands.""" + for entry_point in _iter_plugin_entry_points(): + try: + obj = entry_point.load() + command = obj if isinstance(obj, click.Command) else obj() + except Exception as error: # noqa: BLE001 - never break the CLI on a plugin + click.echo( + click.style( + f"warning: failed to load CLI plugin {entry_point.name!r}: {error}", + fg="yellow", + ), + err=True, + ) + continue + + if isinstance(command, click.Command): + group.add_command(command) + else: + click.echo( + click.style( + f"warning: CLI plugin {entry_point.name!r} did not return a " + "click command; skipping", + fg="yellow", + ), + err=True, + ) diff --git a/src/skore_cli/_skore.py b/src/skore_cli/_skore.py new file mode 100644 index 0000000..d27088c --- /dev/null +++ b/src/skore_cli/_skore.py @@ -0,0 +1,27 @@ +"""Lazy access to the (heavy, optional) ``skore`` package for hub/agent commands. + +The ``hub`` and ``agent`` command groups reuse 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 +from types import ModuleType + +import rich_click as click + +_MISSING = ( + "this command needs the `skore` package (install it with `pip install " + "'skore-cli[agent]'` or `pip install skore`)." +) + + +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 diff --git a/src/skore_cli/agent/__init__.py b/src/skore_cli/agent/__init__.py new file mode 100644 index 0000000..f05dfc1 --- /dev/null +++ b/src/skore_cli/agent/__init__.py @@ -0,0 +1,21 @@ +"""The ``skore agent`` command group to wire a workspace to the Skore Hub agent. + +Architecture B (IP-isolated): an agent harness runs locally and is pointed at +``skore-hub`` as an OpenAI-compatible model provider. A PydanticAI agent on the +hub owns the orchestration and loads the probabl-skills *server-side*; the harness +only executes the tool calls the hub emits. Skills are therefore **not** installed +locally, keeping the orchestration IP on the hub. + +The transport is a plain OpenAI-compatible model endpoint, so the agent is +harness-agnostic. ``skore agent init`` configures a specific harness for you: pass +``--harness `` to do it non-interactively, or omit it in a terminal to pick +one interactively (mirroring ``skore skills``). A ``generic`` fallback just prints +the connection values for any other OpenAI-compatible client. + +Heavy ``skore`` (and ``textual``) imports are deferred into the command callbacks +so building the CLI (and ``--help``) never imports them. +""" + +from skore_cli.agent._commands import agent + +__all__ = ["agent"] diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py new file mode 100644 index 0000000..f742042 --- /dev/null +++ b/src/skore_cli/agent/_commands.py @@ -0,0 +1,363 @@ +"""The ``skore agent`` click group: ``init`` and ``status``.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import rich_click as click + +from skore_cli._style import console +from skore_cli.agent._harnesses import ( + API_KEY_ENV, + DEFAULT_HUB_URL, + DEFAULT_MODEL_ID, + HARNESS_NAMES, + HARNESSES, + MARKER_FILENAME, + ConfigureContext, + Credential, + base_url, + detect_harnesses, + fetch_workspaces, + resolve_credential, +) + +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli agent": [ + {"name": "Workspace", "commands": ["init", "status"]}, + ], +} + + +@click.group(invoke_without_command=True) +@click.pass_context +def agent(ctx) -> None: + """Connect this workspace to the Skore Hub agent (any harness).""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +def _is_interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _print_credential(cred: Credential) -> None: + if cred.kind == "api_key": + console.print( + f"Using the API key from [bold]{API_KEY_ENV}[/] " + "[skore.muted](never written to a file; only referenced).[/]" + ) + elif cred.kind == "bearer": + console.print( + "Using your interactive [bold]skore hub login[/] token " + "[skore.muted](refreshed automatically).[/]" + ) + else: + console.print( + f"[yellow]No credential found. Set {API_KEY_ENV} (recommended) or run " + "`skore hub login` for interactive authentication, then re-run.[/]" + ) + + +def _install_skills(workspace: Path, install: bool) -> None: + if not install: + return + console.print( + "[yellow]Note: installing skills locally is OFF by default for IP " + "isolation. The hub agent loads skills server-side; local skills are not " + "needed.[/]" + ) + console.print( + "Installing probabl-skills into the workspace (--skills override) ..." + ) + try: + result = subprocess.run( + [sys.executable, "-m", "skore_cli", "skills", "install", "--all"], + cwd=workspace, + check=False, + capture_output=True, + text=True, + ) + except OSError as error: + console.print(f"[yellow] could not run skills install: {error}[/]") + return + if result.returncode != 0: + console.print( + "[yellow] skills install failed (continuing); run " + "`skore skills install --all` manually.\n " + f"{(result.stderr or '').strip()}[/]" + ) + else: + console.print("[skore.ok] skills installed.[/]") + + +def _pick_harness(workspace: Path) -> str | None: + """Launch the Textual picker (textual imported lazily) and return a name.""" + from skore_cli.agent.app import HarnessPicker + + detected = set(detect_harnesses(workspace)) + rows = [ + (name, harness.label, name in detected) for name, harness in HARNESSES.items() + ] + preselect = next((i for i, row in enumerate(rows) if row[2]), 0) + + app = HarnessPicker(rows, preselect=preselect) + app.run() + return app.result + + +def _pick_workspace(workspaces: list[tuple[str, str]]) -> str | None: + """Launch the Textual workspace picker and return the chosen public id.""" + from skore_cli.agent.app import WorkspacePicker + + app = WorkspacePicker(workspaces) + app.run() + return app.result + + +def _resolve_hub_workspace( + cred: Credential, hub_url: str, hub_workspace: str | None +) -> str | None: + """Resolve the hub workspace the agent should attach to. + + API keys are already workspace-bound server-side, so nothing is attached for + them. For the interactive (bearer) path the workspace must be explicit: from + ``--hub-workspace`` or an interactive picker; otherwise this errors, because + the hub now rejects agent calls that resolve to no workspace. + """ + if cred.kind == "api_key": + if hub_workspace: + console.print( + "[yellow]Ignoring --hub-workspace: the workspace is fixed by your " + f"API key ({API_KEY_ENV}).[/]" + ) + else: + console.print( + f"[skore.muted]Workspace is fixed by your API key ({API_KEY_ENV}).[/]" + ) + return None + + if cred.kind != "bearer": + # No credential: nothing can be attached; init already warns about this. + return None + + if hub_workspace: + return hub_workspace + + try: + workspaces = fetch_workspaces(hub_url, cred) + except Exception as error: # noqa: BLE001 - surfaced as a friendly CLI error + raise click.ClickException( + f"could not list your hub workspaces ({error}); pass " + "--hub-workspace to attach one explicitly." + ) from error + + if not workspaces: + raise click.ClickException( + "you are not a member of any hub workspace; create or join one, then " + "re-run `skore agent init`." + ) + + if not _is_interactive(): + raise click.UsageError( + "pass --hub-workspace to attach a workspace non-interactively " + f"(one of: {', '.join(public_id for public_id, _ in workspaces)})." + ) + + chosen = _pick_workspace(workspaces) + if chosen is None: + raise click.ClickException("no workspace selected.") + return chosen + + +@agent.command("init") +@click.option( + "--workspace", + "-w", + default=".", + type=click.Path(file_okay=False, path_type=Path), + help="Workspace directory to configure (default: current directory).", +) +@click.option( + "--harness", + "-H", + "harness_name", + type=click.Choice(HARNESS_NAMES), + default=None, + help="Harness to configure non-interactively (omit to pick interactively).", +) +@click.option( + "--hub-url", + default=DEFAULT_HUB_URL, + help=f"Base URL of the Skore Hub agent endpoint (default: {DEFAULT_HUB_URL}).", +) +@click.option( + "--hub-workspace", + default=None, + help=( + "Hub workspace (public id) to attach the agent to. Required for " + "interactive-login (bearer) auth in non-interactive mode; ignored when a " + "workspace-scoped API key is used." + ), +) +@click.option( + "--model-id", + default=DEFAULT_MODEL_ID, + help="Model id advertised by the hub (default: skore-agent).", +) +@click.option( + "--skills/--no-skills", + "install_skills", + default=False, + help="Install probabl-skills locally (default: off; the hub serves skills).", +) +@click.option( + "--session-plugin/--no-session-plugin", + "write_session_plugin", + default=True, + help="opencode only: write the session-binding plugin (default: on).", +) +@click.option( + "--config-file/--no-config-file", + "write_file", + default=True, + help="generic only: also write skore-agent.json (default: on).", +) +def init( + workspace: Path, + harness_name: str | None, + hub_url: str, + hub_workspace: str | None, + model_id: str, + install_skills: bool, + write_session_plugin: bool, + write_file: bool, +) -> None: + """Configure an agent harness to talk to the Skore Hub agent. + + Pass ``--harness `` to configure a specific harness non-interactively; + omit it in a terminal to pick one interactively (like ``skore skills``). The + ``generic`` harness just prints the connection values for any other + OpenAI-compatible client. + """ + workspace = workspace.resolve() + if not workspace.is_dir(): + raise click.ClickException(f"workspace does not exist: {workspace}") + + if harness_name is None: + if _is_interactive(): + harness_name = _pick_harness(workspace) + if harness_name is None: + console.print("Nothing selected.") + return + else: + raise click.UsageError( + "Specify --harness to configure non-interactively " + f"(one of: {', '.join(HARNESS_NAMES)})." + ) + + harness = HARNESSES[harness_name] + console.print( + f"Configuring [skore.skill]{harness.name}[/] in: [skore.path]{workspace}[/]" + ) + + cred = resolve_credential() + _print_credential(cred) + + attached_workspace = _resolve_hub_workspace(cred, hub_url, hub_workspace) + if attached_workspace: + console.print( + f"Attaching to hub workspace [skore.skill]{attached_workspace}[/]." + ) + + _install_skills(workspace, install_skills) + + ctx = ConfigureContext( + workspace=workspace, + hub_url=hub_url, + model_id=model_id, + cred=cred, + hub_workspace=attached_workspace, + write_session_plugin=write_session_plugin, + write_file=write_file, + ) + extra = harness.configure(ctx) + + marker = { + "harness": harness.name, + "hub_url": hub_url, + "base_url": base_url(hub_url), + "hub_workspace": attached_workspace, + "model_id": model_id, + "auth": cred.kind, + "session_binding": harness.session_binding, + **extra, + } + marker_path = workspace / MARKER_FILENAME + marker_path.write_text(json.dumps(marker, indent=2) + "\n") + console.print(f"\n[skore.muted]Recorded the setup in {marker_path}.[/]") + + +@agent.command("status") +@click.option( + "--workspace", + "-w", + default=".", + type=click.Path(file_okay=False, path_type=Path), + help="Workspace directory to inspect (default: current directory).", +) +def status(workspace: Path) -> None: + """Show the agent wiring for this workspace.""" + workspace = workspace.resolve() + marker_path = workspace / MARKER_FILENAME + if not marker_path.exists(): + raise click.ClickException( + f"no {MARKER_FILENAME} in {workspace}; run `skore agent init` first." + ) + + marker = json.loads(marker_path.read_text() or "{}") + harness_name = marker.get("harness", "?") + base = marker.get("base_url", "?") + model_id = marker.get("model_id", "?") + auth = marker.get("auth", "none") + binding = marker.get("session_binding", "fallback") + hub_workspace = marker.get("hub_workspace") + + skills_dir = workspace / ".agents" / "skills" + n_skills = len(list(skills_dir.iterdir())) if skills_dir.is_dir() else 0 + skills_note = ( + f"{n_skills} local (override)" if n_skills else "served by hub (none local)" + ) + + auth_note = { + "api_key": f"[skore.ok]API key[/] (from {API_KEY_ENV})", + "bearer": "[skore.ok]interactive token[/]", + "none": "[skore.muted]none[/]", + }.get(auth, auth) + + session_note = ( + "[skore.ok]bound[/] via the X-Skore-Session-Id header" + if binding == "plugin" + else "[skore.muted]via the OpenAI 'user' field, else content-hash fallback[/]" + ) + + workspace_note = ( + f"[skore.skill]{hub_workspace}[/]" + if hub_workspace + else "[skore.muted]bound by API key (no header)[/]" + if auth == "api_key" + else "[skore.muted]none[/]" + ) + + console.print(f"workspace : [skore.path]{workspace}[/]") + console.print(f"harness : [skore.skill]{harness_name}[/]") + console.print(f"hub URL : [skore.path]{base}[/]") + console.print(f"hub ws : {workspace_note}") + console.print(f"model : {model_id}") + console.print(f"auth : {auth_note}") + console.print(f"skills : {skills_note}") + console.print(f"session : {session_note}") diff --git a/src/skore_cli/agent/_harnesses.py b/src/skore_cli/agent/_harnesses.py new file mode 100644 index 0000000..dc605bb --- /dev/null +++ b/src/skore_cli/agent/_harnesses.py @@ -0,0 +1,587 @@ +"""Harness registry, detection and per-harness configuration writers. + +Each :class:`Harness` knows how to (best-effort) *detect* whether it is present and +how to *configure* it so it talks to the Skore Hub agent's OpenAI-compatible +endpoint. The writers deliberately differ because harnesses are configured very +differently (project file vs. global file vs. env vars vs. a translation proxy). + +Credentials stay user-managed: the API key is never written to a file, only +*referenced* (``{env:...}`` for opencode, ``$SKORE_HUB_API_KEY`` in env files, +``${{ env.SKORE_HUB_API_KEY }}`` for Continue). Interactive device-flow tokens are +refreshed on the fly and emitted as a bearer. +""" + +from __future__ import annotations + +import json +import os +import shutil +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from skore_cli._skore import auth as _auth +from skore_cli._style import console + +API_KEY_ENV = "SKORE_HUB_API_KEY" +DEFAULT_HUB_URL = "http://127.0.0.1:8000" +DEFAULT_MODEL_ID = "skore-agent" + +# Marker recording what `skore agent init` configured, so `status` is trivial. +MARKER_FILENAME = ".skore-agent.json" + +# Visible connection file written by the generic harness (paste-friendly). +GENERIC_CONFIG_FILENAME = "skore-agent.json" + +# Header naming the hub workspace the agent is attached to (a workspace public id). +WORKSPACE_HEADER = "X-Skore-Workspace" + +# opencode provider name: no dots (avoids the opencode option-dropping bug #5674). +OPENCODE_PROVIDER_KEY = "skore" +OPENCODE_SCHEMA = "https://opencode.ai/config.json" +SESSION_HEADER = "X-Skore-Session-Id" +SESSION_PLUGIN_FILENAME = "skore-session.js" +SESSION_PLUGIN_SOURCE = f"""\ +// Auto-generated by `skore agent init`. Forwards opencode's session id to the +// Skore Hub agent so the hub can resume the matching conversation history. +export const SkoreSession = async () => ({{ + "chat.headers": async (input, output) => {{ + const id = input?.provider?.info?.id ?? input?.provider?.id; + if (id === "{OPENCODE_PROVIDER_KEY}" && input?.sessionID) {{ + output.headers["{SESSION_HEADER}"] = input.sessionID; + }} + }}, +}}); +""" + + +def base_url(hub_url: str) -> str: + """Return the OpenAI-compatible base URL the harness should point at.""" + return f"{hub_url.rstrip('/')}/v1" + + +@dataclass(frozen=True) +class Credential: + """A resolved hub credential. + + ``kind`` is ``"api_key"`` (user-managed via ``SKORE_HUB_API_KEY``, never + embedded), ``"bearer"`` (an interactive device-flow access token, embedded as + a literal) or ``"none"``. + """ + + kind: str + token: str = "" + + +def resolve_credential() -> Credential: + """Resolve the hub credential, mirroring the Python authentication. + + Prefers the user-managed API key (referenced, never stored); otherwise + refreshes the persisted interactive token (relaunching the device login if the + refresh token is dead) and returns it as a bearer. + """ + if os.environ.get(API_KEY_ENV): + return Credential("api_key") + + token = _auth("token").fresh_token(relogin=True) + if token and token.get("access_token"): + return Credential("bearer", token["access_token"]) + return Credential("none") + + +def header_pair(cred: Credential) -> tuple[str, str] | None: + """Return the ``(name, value)`` auth header, opencode/generic ``{env:}`` style.""" + if cred.kind == "api_key": + return ("X-API-Key", f"{{env:{API_KEY_ENV}}}") + if cred.kind == "bearer": + return ("Authorization", f"Bearer {cred.token}") + return None + + +def workspace_headers(ctx: ConfigureContext) -> dict[str, str]: + """Return the workspace-attachment header, if a workspace slug is set. + + API-key credentials are already bound to a workspace server-side, so the + header is only meaningful (and only written) for the interactive bearer path. + """ + if ctx.hub_workspace: + return {WORKSPACE_HEADER: ctx.hub_workspace} + return {} + + +def fetch_workspaces(hub_url: str, cred: Credential) -> list[tuple[str, str]]: + """List the workspaces the user can attach to, as ``(public_id, name)`` pairs. + + Calls the hub ``GET /identity/workspaces`` with the resolved credential. Used + only by the interactive bearer picker; API-key auth needs no selection. + """ + import httpx + + if cred.kind == "api_key": + token = os.environ.get(API_KEY_ENV, "") + headers = {"X-API-Key": token} + elif cred.kind == "bearer": + headers = {"Authorization": f"Bearer {cred.token}"} + else: + headers = {} + + url = f"{hub_url.rstrip('/')}/identity/workspaces" + response = httpx.get(url, headers=headers, timeout=30, follow_redirects=True) + response.raise_for_status() + payload = response.json() + items = payload.get("items", payload) if isinstance(payload, dict) else payload + + workspaces: list[tuple[str, str]] = [] + for item in items or []: + public_id = item.get("public_id") or item.get("slug") + if not public_id: + continue + workspaces.append((public_id, item.get("name") or public_id)) + return workspaces + + +@dataclass +class ConfigureContext: + """Inputs shared by every harness writer.""" + + workspace: Path + hub_url: str + model_id: str + cred: Credential + # Hub workspace slug (public id) the agent is attached to; only written for + # the bearer path (API keys are already workspace-bound server-side). + hub_workspace: str | None = None + # opencode-only: + write_session_plugin: bool = True + # generic-only: + write_file: bool = True + + @property + def base_url(self) -> str: + return base_url(self.hub_url) + + +def _no_credential_note() -> None: + console.print( + f" [skore.muted](no credential resolved -- set {API_KEY_ENV} or run " + "`skore hub login`, then re-run init)[/]" + ) + + +def _warn_workspace_header_unsupported(ctx: ConfigureContext) -> None: + """Warn when a bearer setup cannot carry the workspace header. + + This harness cannot send custom headers, so an interactive-login (bearer) + token cannot be scoped to a workspace and the hub now rejects unscoped agent + calls. A workspace-scoped API key carries the binding intrinsically. + """ + if ctx.cred.kind == "bearer": + console.print( + f"[yellow] This harness cannot send the {WORKSPACE_HEADER} header, so " + "an interactive-login token cannot be scoped to a workspace and the " + f"hub will reject the call. Use a workspace-scoped API key " + f"({API_KEY_ENV}) instead.[/]" + ) + + +# --------------------------------------------------------------------------- # +# Writers +# --------------------------------------------------------------------------- # +def _configure_opencode(ctx: ConfigureContext) -> dict[str, Any]: + """Write/merge ``opencode.json`` + the session-binding plugin.""" + config_path = ctx.workspace / "opencode.json" + config: dict = {} + if config_path.exists(): + try: + config = json.loads(config_path.read_text() or "{}") + except json.JSONDecodeError: + console.print( + "[yellow] existing opencode.json is invalid JSON; backing it up.[/]" + ) + config_path.rename(config_path.with_suffix(".json.bak")) + config = {} + + options: dict = {"baseURL": ctx.base_url} + headers: dict[str, str] = {} + pair = header_pair(ctx.cred) + if pair: + headers[pair[0]] = pair[1] + headers.update(workspace_headers(ctx)) + if headers: + options["headers"] = headers + + config.setdefault("$schema", OPENCODE_SCHEMA) + config.setdefault("provider", {}) + config["provider"][OPENCODE_PROVIDER_KEY] = { + "npm": "@ai-sdk/openai-compatible", + "name": "Skore Hub Agent", + "options": options, + "models": {ctx.model_id: {"name": "Skore Agent"}}, + } + config_path.write_text(json.dumps(config, indent=2) + "\n") + console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") + + session_plugin = False + if ctx.write_session_plugin: + plugin_path = ctx.workspace / ".opencode" / "plugin" / SESSION_PLUGIN_FILENAME + plugin_path.parent.mkdir(parents=True, exist_ok=True) + plugin_path.write_text(SESSION_PLUGIN_SOURCE) + session_plugin = True + console.print( + f"[skore.ok]+[/] wrote [skore.path]{plugin_path}[/] " + "[skore.muted](binds the hub session to the opencode conversation)[/]" + ) + + console.print( + "\nNext steps:\n" + f" 1. Run [skore.skill]opencode[/] in [skore.path]{ctx.workspace}[/]\n" + f" 2. Pick the '[skore.skill]{ctx.model_id}[/]' model and start iterating." + ) + if ctx.cred.kind == "api_key": + console.print( + f" [skore.muted](export {API_KEY_ENV} in opencode's shell so the " + "{env:...} reference resolves)[/]" + ) + return {"config_path": str(config_path), "session_plugin": session_plugin} + + +def _configure_copilot(ctx: ConfigureContext) -> dict[str, Any]: + """Write a sourceable env file for the GitHub Copilot CLI (BYOK).""" + env_path = ctx.workspace / "skore-agent.env" + if ctx.cred.kind == "bearer": + api_key_line = f'export COPILOT_PROVIDER_API_KEY="{ctx.cred.token}"' + else: + api_key_line = f'export COPILOT_PROVIDER_API_KEY="${API_KEY_ENV}"' + lines = [ + "# Source this before running `copilot`: source skore-agent.env", + f'export COPILOT_PROVIDER_BASE_URL="{ctx.base_url}"', + 'export COPILOT_PROVIDER_TYPE="openai"', + f'export COPILOT_MODEL="{ctx.model_id}"', + api_key_line, + "", + ] + env_path.write_text("\n".join(lines)) + console.print(f"[skore.ok]+[/] wrote [skore.path]{env_path}[/]") + _warn_workspace_header_unsupported(ctx) + console.print( + "\nNext steps:\n" + f" 1. [skore.skill]source {env_path.name}[/] " + f"in [skore.path]{ctx.workspace}[/]" + ) + if ctx.cred.kind == "api_key": + console.print(f" [skore.muted](with {API_KEY_ENV} exported)[/]") + console.print(" 2. Run [skore.skill]copilot[/] and start iterating.") + return {"env_file": str(env_path)} + + +def _configure_continue(ctx: ConfigureContext) -> dict[str, Any]: + """Merge a model block into the global ``~/.continue/config.yaml``.""" + try: + import yaml + except ImportError as error: # pragma: no cover - exercised via the CLI + raise _yaml_missing() from error + + config_dir = Path.home() / ".continue" + config_dir.mkdir(parents=True, exist_ok=True) + config_path = config_dir / "config.yaml" + + config: dict = {} + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text()) or {} + except yaml.YAMLError: + console.print( + "[yellow] existing config.yaml is invalid YAML; backing it up.[/]" + ) + config_path.rename(config_path.with_suffix(".yaml.bak")) + config = {} + else: + backup = config_path.with_suffix(".yaml.bak") + backup.write_text(config_path.read_text()) + console.print(f"[skore.muted] backed up existing config to {backup}[/]") + + config.setdefault("name", "My Config") + config.setdefault("version", "0.0.1") + config.setdefault("schema", "v1") + + pair = header_pair(ctx.cred) + if ctx.cred.kind == "api_key": + headers = {"X-API-Key": f"${{{{ env.{API_KEY_ENV} }}}}"} + elif pair: + headers = {pair[0]: pair[1]} + else: + headers = {} + headers.update(workspace_headers(ctx)) + + model_block = { + "name": "Skore Hub Agent", + "provider": "openai", + "model": ctx.model_id, + "apiBase": ctx.base_url, + "roles": ["chat", "edit", "apply"], + "capabilities": ["tool_use"], + } + if headers: + model_block["requestOptions"] = {"headers": headers} + + models = [m for m in config.get("models", []) if m.get("name") != "Skore Hub Agent"] + models.append(model_block) + config["models"] = models + + config_path.write_text(yaml.safe_dump(config, sort_keys=False)) + console.print(f"[skore.ok]+[/] updated [skore.path]{config_path}[/]") + console.print( + "\nNext steps:\n" + " 1. Reload the Continue extension in your IDE.\n" + " 2. Pick the '[skore.skill]Skore Hub Agent[/]' model in Continue." + ) + if ctx.cred.kind == "api_key": + console.print( + f" [skore.muted](Continue resolves ${{{{ env.{API_KEY_ENV} }}}}; " + f"export {API_KEY_ENV} in the IDE's environment)[/]" + ) + return {"config_path": str(config_path), "scope": "global"} + + +def _configure_cline(ctx: ConfigureContext) -> dict[str, Any]: + """Best-effort: write the workspace ``.vscode/settings.json`` provider keys. + + Cline stores the API key in VS Code's secret storage (not settings.json) and + its setting keys vary by version, so we write the provider/base-URL/model and + ask the user to paste the key in the Cline panel. + """ + settings_path = ctx.workspace / ".vscode" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + settings: dict = {} + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text() or "{}") + except json.JSONDecodeError: + console.print( + "[yellow] existing settings.json is invalid JSON; backing it up.[/]" + ) + settings_path.rename(settings_path.with_suffix(".json.bak")) + settings = {} + + settings["cline.apiProvider"] = "openai-compatible" + settings["cline.openAiBaseUrl"] = ctx.base_url + settings["cline.apiModelId"] = ctx.model_id + settings_path.write_text(json.dumps(settings, indent=2) + "\n") + console.print(f"[skore.ok]+[/] wrote [skore.path]{settings_path}[/]") + _warn_workspace_header_unsupported(ctx) + console.print( + "\nNext steps:\n" + " 1. Open the Cline panel in VS Code (gear icon) and confirm provider " + "[skore.skill]OpenAI Compatible[/], base URL and model.\n" + " 2. Paste your hub API key in the [bold]API Key[/] field " + "[skore.muted](Cline stores secrets in VS Code, not settings.json).[/]\n" + "[skore.muted] Setting keys vary by Cline version; if base URL/model did " + "not pre-fill, set them in the panel.[/]" + ) + return {"settings_path": str(settings_path), "scope": "vscode"} + + +def _configure_claude_code(ctx: ConfigureContext) -> dict[str, Any]: + """Experimental: generate a LiteLLM proxy config + env for Claude Code. + + Claude Code speaks the Anthropic wire format, so it needs a proxy translating + Anthropic <-> our OpenAI ``/v1``. We generate the LiteLLM config and the env + pointing Claude Code at it; the user runs the proxy. + """ + try: + import yaml + except ImportError as error: # pragma: no cover - exercised via the CLI + raise _yaml_missing() from error + + proxy_path = ctx.workspace / "litellm.skore.yaml" + env_path = ctx.workspace / "skore-agent.env" + + pair = header_pair(ctx.cred) + if ctx.cred.kind == "api_key": + headers = {"X-API-Key": f"os.environ/{API_KEY_ENV}"} + elif pair: + headers = {pair[0]: pair[1]} + else: + headers = {} + headers.update(workspace_headers(ctx)) + + litellm_params: dict = { + "model": f"openai/{ctx.model_id}", + "api_base": ctx.base_url, + "api_key": "skore-unused", + } + if headers: + litellm_params["headers"] = headers + + proxy_config = { + "model_list": [{"model_name": ctx.model_id, "litellm_params": litellm_params}] + } + proxy_path.write_text(yaml.safe_dump(proxy_config, sort_keys=False)) + env_path.write_text( + "# Source this before running `claude`: source skore-agent.env\n" + 'export ANTHROPIC_BASE_URL="http://127.0.0.1:4000"\n' + 'export ANTHROPIC_AUTH_TOKEN="skore-litellm"\n' + ) + console.print(f"[skore.ok]+[/] wrote [skore.path]{proxy_path}[/]") + console.print(f"[skore.ok]+[/] wrote [skore.path]{env_path}[/]") + console.print( + "\n[yellow]Claude Code support is experimental[/] (needs an " + "Anthropic<->OpenAI proxy).\n" + "Next steps:\n" + " 1. [skore.skill]pip install 'litellm\\[proxy]'[/]\n" + f" 2. [skore.skill]litellm --config {proxy_path.name}[/] " + "[skore.muted](serves on :4000)[/]\n" + f" 3. [skore.skill]source {env_path.name}[/]" + ) + if ctx.cred.kind == "api_key": + console.print(f" [skore.muted](with {API_KEY_ENV} exported)[/]") + console.print(" 4. Run [skore.skill]claude[/] and pick the model.") + return {"proxy_config": str(proxy_path), "env_file": str(env_path)} + + +def _configure_generic(ctx: ConfigureContext) -> dict[str, Any]: + """Print (and optionally write) connection values for any OpenAI client.""" + console.print( + "\nConnection values for any OpenAI-compatible harness:\n" + f" base URL : [skore.path]{ctx.base_url}[/]\n" + f" model id : [skore.skill]{ctx.model_id}[/]" + ) + pair = header_pair(ctx.cred) + headers: dict[str, str] = {} + if pair: + headers[pair[0]] = pair[1] + headers.update(workspace_headers(ctx)) + if pair: + console.print(f" header : {pair[0]}: {pair[1]}") + else: + _no_credential_note() + for name, value in workspace_headers(ctx).items(): + console.print(f" header : {name}: {value}") + + extra: dict[str, Any] = {} + if ctx.write_file: + out_path = ctx.workspace / GENERIC_CONFIG_FILENAME + payload = { + "baseURL": ctx.base_url, + "model": ctx.model_id, + "headers": headers, + } + out_path.write_text(json.dumps(payload, indent=2) + "\n") + console.print(f"\n[skore.ok]+[/] wrote [skore.path]{out_path}[/]") + extra["config_file"] = str(out_path) + + console.print( + "\n[skore.muted]Paste these into your harness's OpenAI-compatible provider " + "settings. The hub loads probabl-skills server-side (IP isolation).[/]" + ) + return extra + + +def _yaml_missing(): + import rich_click as click + + return click.ClickException( + "this harness needs PyYAML (install it with `pip install " + "'skore-cli[agent]'` or `pip install pyyaml`)." + ) + + +# --------------------------------------------------------------------------- # +# Detection +# --------------------------------------------------------------------------- # +def _detect_opencode(workspace: Path) -> bool: + if (workspace / "opencode.json").exists(): + return True + return shutil.which("opencode") is not None + + +def _detect_copilot(workspace: Path) -> bool: + return shutil.which("copilot") is not None + + +def _detect_continue(workspace: Path) -> bool: + return (Path.home() / ".continue").exists() + + +def _detect_cline(workspace: Path) -> bool: + if (workspace / ".vscode").exists(): + return True + ext_dir = Path.home() / ".vscode" / "extensions" + return ext_dir.is_dir() and any(ext_dir.glob("saoudrizwan.claude-dev*")) + + +def _detect_claude_code(workspace: Path) -> bool: + return (Path.home() / ".claude").exists() or shutil.which("claude") is not None + + +def _detect_never(workspace: Path) -> bool: + return False + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class Harness: + """A configurable agent harness.""" + + name: str + label: str + detect: Callable[[Path], bool] + configure: Callable[[ConfigureContext], dict[str, Any]] + session_binding: str = "fallback" # "plugin" | "fallback" + extras: tuple[str, ...] = field(default=()) + + +HARNESSES: dict[str, Harness] = { + "opencode": Harness( + "opencode", + "opencode - project config + session binding (turnkey)", + _detect_opencode, + _configure_opencode, + session_binding="plugin", + ), + "copilot": Harness( + "copilot", + "GitHub Copilot CLI - sourceable env file", + _detect_copilot, + _configure_copilot, + ), + "continue": Harness( + "continue", + "Continue - global ~/.continue/config.yaml", + _detect_continue, + _configure_continue, + ), + "cline": Harness( + "cline", + "Cline - VS Code settings (best-effort; paste key in panel)", + _detect_cline, + _configure_cline, + ), + "claude-code": Harness( + "claude-code", + "Claude Code - LiteLLM proxy config (experimental)", + _detect_claude_code, + _configure_claude_code, + ), + "generic": Harness( + "generic", + "generic - print values for any OpenAI-compatible client", + _detect_never, + _configure_generic, + ), +} + +HARNESS_NAMES = list(HARNESSES) + + +def detect_harnesses(workspace: Path) -> list[str]: + """Return the names of harnesses that look present, in registry order.""" + return [ + name + for name, harness in HARNESSES.items() + if name != "generic" and harness.detect(workspace) + ] diff --git a/src/skore_cli/agent/app/__init__.py b/src/skore_cli/agent/app/__init__.py new file mode 100644 index 0000000..523e166 --- /dev/null +++ b/src/skore_cli/agent/app/__init__.py @@ -0,0 +1,5 @@ +"""Textual applications backing the interactive ``skore agent`` commands.""" + +from skore_cli.agent.app._picker import HarnessPicker, WorkspacePicker + +__all__ = ["HarnessPicker", "WorkspacePicker"] diff --git a/src/skore_cli/agent/app/_picker.py b/src/skore_cli/agent/app/_picker.py new file mode 100644 index 0000000..3789b68 --- /dev/null +++ b/src/skore_cli/agent/app/_picker.py @@ -0,0 +1,149 @@ +"""The single-select picker backing interactive ``skore agent init``.""" + +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.widgets import Footer, Header, Label, RadioButton + +from skore_cli.skills.app._widgets import AutoRadioSet + +_INTRO = ( + "Choose the agent harness to configure.\n" + "Detected harnesses are marked and pre-selected.\n" + "[reverse] ↑/↓ [/] choose [reverse] Enter [/] confirm" +) + + +class HarnessPicker(App[str | None]): + """Pick a single harness name from a radio set.""" + + CSS = """ + Screen { + align: center middle; + } + #picker { + width: 90%; + height: 90%; + } + .picker-intro { + margin: 1 1; + color: $text-muted; + } + AutoRadioSet { + margin: 1 1; + width: 100%; + } + """ + + BINDINGS = [ + Binding("enter", "confirm", "Confirm", priority=True), + Binding("escape", "cancel", "Cancel"), + ] + + def __init__( + self, + harnesses: list[tuple[str, str, bool]], + *, + preselect: int = 0, + ) -> None: + super().__init__() + self._harnesses = harnesses + self._preselect = preselect + self.result: str | None = None + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(id="picker"): + yield Label(_INTRO, classes="picker-intro") + with AutoRadioSet(id="harnesses"): + for index, (_, label, detected) in enumerate(self._harnesses): + text = f"{label} (detected)" if detected else label + yield RadioButton(text, value=index == self._preselect) + yield Footer() + + def on_mount(self) -> None: + self.query_one("#harnesses", AutoRadioSet).select_index(self._preselect) + + def action_confirm(self) -> None: + index = self.query_one("#harnesses", AutoRadioSet).pressed_index + if index < 0: + self.notify("Select a harness.", severity="warning") + return + self.result = self._harnesses[index][0] + self.exit() + + def action_cancel(self) -> None: + self.result = None + self.exit() + + +_WORKSPACE_INTRO = ( + "Choose the Skore Hub workspace to attach the agent to.\n" + "The agent uses this workspace's LLM provider configuration.\n" + "[reverse] ↑/↓ [/] choose [reverse] Enter [/] confirm" +) + + +class WorkspacePicker(App[str | None]): + """Pick a single workspace public id from a radio set.""" + + CSS = """ + Screen { + align: center middle; + } + #picker { + width: 90%; + height: 90%; + } + .picker-intro { + margin: 1 1; + color: $text-muted; + } + AutoRadioSet { + margin: 1 1; + width: 100%; + } + """ + + BINDINGS = [ + Binding("enter", "confirm", "Confirm", priority=True), + Binding("escape", "cancel", "Cancel"), + ] + + def __init__( + self, + workspaces: list[tuple[str, str]], + *, + preselect: int = 0, + ) -> None: + super().__init__() + self._workspaces = workspaces + self._preselect = preselect + self.result: str | None = None + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(id="picker"): + yield Label(_WORKSPACE_INTRO, classes="picker-intro") + with AutoRadioSet(id="workspaces"): + for index, (public_id, name) in enumerate(self._workspaces): + text = f"{name} ({public_id})" if name != public_id else public_id + yield RadioButton(text, value=index == self._preselect) + yield Footer() + + def on_mount(self) -> None: + self.query_one("#workspaces", AutoRadioSet).select_index(self._preselect) + + def action_confirm(self) -> None: + index = self.query_one("#workspaces", AutoRadioSet).pressed_index + if index < 0: + self.notify("Select a workspace.", severity="warning") + return + self.result = self._workspaces[index][0] + self.exit() + + def action_cancel(self) -> None: + self.result = None + self.exit() diff --git a/src/skore_cli/hub.py b/src/skore_cli/hub.py new file mode 100644 index 0000000..3a827db --- /dev/null +++ b/src/skore_cli/hub.py @@ -0,0 +1,170 @@ +"""The ``skore hub`` command group to authenticate with a Skore Hub instance. + +Auth mirrors the existing Python authentication: + +* an **API key** is user-managed through the ``SKORE_HUB_API_KEY`` environment + variable -- we never store it; opencode reads it from the environment; +* otherwise an interactive **device-flow** login obtains a (short-lived) token, + which is the only thing we persist (so a separate opencode process can use it). + +Heavy ``skore`` imports are deferred into the command callbacks so building the +CLI (and ``--help``) never imports the ``skore`` package. +""" + +from __future__ import annotations + +import os + +import rich_click as click + +from skore_cli._skore import auth as _auth +from skore_cli._style import console + +# These mirror ``skore._plugins.hub.authentication`` env var names; kept as local +# literals so showing help never imports the (heavy) ``skore`` package. +API_KEY_ENV = "SKORE_HUB_API_KEY" +URI_ENV = "SKORE_HUB_URI" + +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli hub": [ + {"name": "Authentication", "commands": ["login", "logout", "status"]}, + ], +} + + +@click.group(invoke_without_command=True) +@click.pass_context +def hub(ctx) -> None: + """Authenticate this machine with a Skore Hub instance.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@hub.command("login") +@click.option( + "--hub-url", + default=None, + help=( + "Base URL of the hub (e.g. http://127.0.0.1:8000). Defaults to " + f"the {URI_ENV} env var or the public hub." + ), +) +@click.option( + "--timeout", + default=600, + show_default=True, + help="Seconds to wait for the interactive device login to complete.", +) +def login(hub_url: str | None, timeout: int) -> None: + """Log in to the hub. + + With an API key (``SKORE_HUB_API_KEY``) there is nothing to do: it is + user-managed and read from the environment. Without one, run the interactive + device flow and persist the resulting token locally. + """ + if hub_url: + os.environ[URI_ENV] = hub_url + + uri = _auth("uri").URI() + + if os.environ.get(API_KEY_ENV): + console.print( + f"[skore.ok]Using the API key from[/] [bold]{API_KEY_ENV}[/] " + f"[skore.ok]for {uri}.[/]\n" + " [skore.muted]Nothing is stored; opencode reads the key from the " + "environment.[/]" + ) + return + + # No API key: fall back to the interactive OAuth device flow and persist the + # token (the only thing we manage -- mirrors the Python `Token` device flow). + store = _auth("store") + + console.print(f"Logging in to [skore.path]{uri}[/] via interactive device auth.") + access_token, refresh_token, expires_at = _auth("token").interactive_device_login( + timeout=timeout + ) + + saved = store.save( + { + "uri": uri, + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + } + ) + console.print( + f"[skore.ok]+[/] logged in to [skore.path]{uri}[/] (interactive)\n" + f" [skore.muted]token -> {saved}[/]\n" + "[yellow]Note: this token is short-lived; re-run `skore hub login` when it " + f"expires. For a durable setup, prefer an API key via {API_KEY_ENV}.[/]" + ) + + +@hub.command("logout") +def logout() -> None: + """Revoke the interactive token on the hub and remove it locally. + + The API key (``SKORE_HUB_API_KEY``) is user-managed and not revoked here. + """ + store = _auth("store") + + token = store.load() + if token and token.get("access_token"): + post_oauth_logout = _auth("token").post_oauth_logout + + try: + post_oauth_logout(token["access_token"], token.get("refresh_token")) + console.print("[skore.ok]+[/] revoked the token on the hub") + except Exception as error: # noqa: BLE001 - best-effort revoke; still clear + console.print( + f"[yellow]Could not revoke the token on the hub ({error}); " + "removing it locally anyway.[/]" + ) + + removed = store.clear() + if removed: + console.print(f"[skore.ok]-[/] removed stored token ([skore.path]{removed}[/])") + console.print( + " [skore.muted]An existing opencode.json still holds the now-revoked " + "bearer; re-run `skore hub login` then `skore agent init` to " + "reconnect.[/]" + ) + elif os.environ.get(API_KEY_ENV): + console.print( + f"No stored token. The API key from [bold]{API_KEY_ENV}[/] is " + "user-managed; unset it yourself to stop using it." + ) + else: + console.print("No stored token to remove.") + + +@hub.command("status") +def status() -> None: + """Show how this machine will authenticate to the hub.""" + store = _auth("store") + token_expired = _auth("token")._token_expired + + has_env_key = bool(os.environ.get(API_KEY_ENV)) + token = store.load() + + console.print(f"hub URI : [skore.path]{_auth('uri').URI()}[/]") + if has_env_key: + console.print(f"API key (env): [skore.ok]set[/] ({API_KEY_ENV})") + else: + console.print("API key (env): [skore.muted]not set[/]") + if token and token.get("access_token"): + expires_at = token.get("expires_at", "?") + if token_expired(token.get("expires_at")): + console.print(f"token : stored, [yellow]expired[/] ({expires_at})") + else: + console.print(f"token : stored, [skore.ok]valid[/] ({expires_at})") + console.print(f"token path : [skore.path]{store.path()}[/]") + else: + console.print("token : [skore.muted]none[/]") + + if not has_env_key and not (token and token.get("access_token")): + raise click.ClickException( + "Not authenticated. Set SKORE_HUB_API_KEY or run `skore hub login`." + ) diff --git a/src/skore_cli/skills/_commands.py b/src/skore_cli/skills/_commands.py index 68f3619..c8bf30a 100644 --- a/src/skore_cli/skills/_commands.py +++ b/src/skore_cli/skills/_commands.py @@ -27,6 +27,7 @@ SIDECAR = ".skore-skill.json" click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), "cli skills": [ {"name": "Discover", "commands": ["find", "list"]}, {"name": "Manage", "commands": ["install", "update", "remove"]}, diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py new file mode 100644 index 0000000..84601f1 --- /dev/null +++ b/tests/test_agent_commands.py @@ -0,0 +1,163 @@ +"""Tests for the ``skore agent`` commands (status, init guards, skills install). + +These complement ``test_agent_workspace.py`` (which already covers the opencode +writer, the init marker and ``_resolve_hub_workspace``) without duplicating it. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest +from click.testing import CliRunner + +from skore_cli.agent import _commands +from skore_cli.agent._commands import init, status +from skore_cli.agent._harnesses import MARKER_FILENAME, Credential + + +def _write_marker(directory, **overrides): + marker = { + "harness": "opencode", + "hub_url": "http://hub.test", + "base_url": "http://hub.test/v1", + "hub_workspace": "ws-1", + "model_id": "skore-agent", + "auth": "bearer", + "session_binding": "plugin", + } + marker.update(overrides) + (directory / MARKER_FILENAME).write_text(json.dumps(marker) + "\n") + return marker + + +# --------------------------------------------------------------------------- # +# status +# --------------------------------------------------------------------------- # + + +def test_status_missing_marker_errors(tmp_path): + result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) + + assert result.exit_code != 0 + assert "run `skore agent init`" in result.output + + +def test_status_prints_marker_fields(tmp_path): + _write_marker(tmp_path) + + result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "opencode" in result.output + assert "http://hub.test/v1" in result.output + assert "skore-agent" in result.output + assert "ws-1" in result.output + + +def test_status_counts_local_skills(tmp_path): + _write_marker(tmp_path) + skills_dir = tmp_path / ".agents" / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "alpha").mkdir() + (skills_dir / "beta").mkdir() + + result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "2 local" in result.output + + +def test_status_no_local_skills_note(tmp_path): + _write_marker(tmp_path) + + result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) + + assert "served by hub" in result.output + + +# --------------------------------------------------------------------------- # +# init guards +# --------------------------------------------------------------------------- # + + +def test_init_non_interactive_without_harness_errors(tmp_path, monkeypatch): + monkeypatch.setattr(_commands, "_is_interactive", lambda: False) + + result = CliRunner().invoke(init, ["--workspace", str(tmp_path), "--no-skills"]) + + assert result.exit_code != 0 + assert "Specify --harness" in result.output + + +def test_init_nonexistent_workspace_errors(tmp_path): + missing = tmp_path / "does-not-exist" + + result = CliRunner().invoke( + init, ["--workspace", str(missing), "--harness", "generic", "--no-skills"] + ) + + assert result.exit_code != 0 + assert "workspace does not exist" in result.output + + +# --------------------------------------------------------------------------- # +# init happy path (generic harness) +# --------------------------------------------------------------------------- # + + +def test_init_generic_happy_path(tmp_path, monkeypatch): + monkeypatch.setattr( + _commands, "resolve_credential", lambda: Credential("api_key") + ) + + result = CliRunner().invoke( + init, + [ + "--workspace", + str(tmp_path), + "--harness", + "generic", + "--hub-url", + "http://hub.test", + "--no-skills", + ], + ) + + assert result.exit_code == 0, result.output + marker = json.loads((tmp_path / MARKER_FILENAME).read_text()) + assert marker["harness"] == "generic" + assert marker["auth"] == "api_key" + assert (tmp_path / "skore-agent.json").is_file() + + +# --------------------------------------------------------------------------- # +# _install_skills +# --------------------------------------------------------------------------- # + + +def test_install_skills_off_skips_subprocess(tmp_path, monkeypatch): + called = [] + monkeypatch.setattr( + _commands.subprocess, "run", lambda *a, **k: called.append((a, k)) + ) + + _commands._install_skills(tmp_path, install=False) + + assert called == [] + + +def test_install_skills_on_invokes_subprocess(tmp_path, monkeypatch): + called = [] + + def fake_run(argv, **kwargs): + called.append(argv) + return SimpleNamespace(returncode=0, stderr="") + + monkeypatch.setattr(_commands.subprocess, "run", fake_run) + + _commands._install_skills(tmp_path, install=True) + + assert len(called) == 1 + assert called[0][1:] == ["-m", "skore_cli", "skills", "install", "--all"] diff --git a/tests/test_agent_workspace.py b/tests/test_agent_workspace.py new file mode 100644 index 0000000..ef7336c --- /dev/null +++ b/tests/test_agent_workspace.py @@ -0,0 +1,133 @@ +"""Tests for attaching the agent to a hub workspace (`skore agent init`).""" + +from __future__ import annotations + +import json + +import pytest +import rich_click as click +from click.testing import CliRunner + +from skore_cli.agent import _commands +from skore_cli.agent._commands import init +from skore_cli.agent._harnesses import ( + WORKSPACE_HEADER, + ConfigureContext, + Credential, + HARNESSES, +) + + +def _opencode_headers(workspace, cred, hub_workspace): + ctx = ConfigureContext( + workspace=workspace, + hub_url="http://hub.test", + model_id="skore-agent", + cred=cred, + hub_workspace=hub_workspace, + write_session_plugin=False, + ) + HARNESSES["opencode"].configure(ctx) + config = json.loads((workspace / "opencode.json").read_text()) + return config["provider"]["skore"]["options"].get("headers", {}) + + +def test_opencode_writes_workspace_header_for_bearer(tmp_path): + headers = _opencode_headers( + tmp_path, Credential("bearer", "tok"), hub_workspace="ws-x" + ) + assert headers[WORKSPACE_HEADER] == "ws-x" + assert headers["Authorization"] == "Bearer tok" + + +def test_opencode_no_workspace_header_for_api_key(tmp_path, monkeypatch): + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + # API keys are workspace-bound server-side: no header is written. + headers = _opencode_headers( + tmp_path, Credential("api_key"), hub_workspace=None + ) + assert WORKSPACE_HEADER not in headers + assert headers["X-API-Key"] == "{env:SKORE_HUB_API_KEY}" + + +def test_init_records_attached_workspace_in_marker(tmp_path, monkeypatch): + monkeypatch.setattr( + _commands, "resolve_credential", lambda: Credential("bearer", "tok") + ) + result = CliRunner().invoke( + init, + [ + "--workspace", + str(tmp_path), + "--harness", + "opencode", + "--hub-url", + "http://hub.test", + "--hub-workspace", + "ws-x", + "--no-session-plugin", + "--no-skills", + ], + ) + assert result.exit_code == 0, result.output + marker = json.loads((tmp_path / ".skore-agent.json").read_text()) + assert marker["hub_workspace"] == "ws-x" + + +# --------------------------------------------------------------------------- # +# _resolve_hub_workspace +# --------------------------------------------------------------------------- # + + +def test_resolve_api_key_ignores_workspace(): + # API key path resolves to no attached slug (bound server-side). + assert ( + _commands._resolve_hub_workspace( + Credential("api_key"), "http://hub.test", "ws-x" + ) + is None + ) + + +def test_resolve_bearer_uses_flag(): + assert ( + _commands._resolve_hub_workspace( + Credential("bearer", "tok"), "http://hub.test", "ws-1" + ) + == "ws-1" + ) + + +def test_resolve_bearer_non_interactive_without_flag_errors(monkeypatch): + monkeypatch.setattr( + _commands, "fetch_workspaces", lambda hub_url, cred: [("ws-1", "ws-1")] + ) + monkeypatch.setattr(_commands, "_is_interactive", lambda: False) + with pytest.raises(click.UsageError): + _commands._resolve_hub_workspace( + Credential("bearer", "tok"), "http://hub.test", None + ) + + +def test_resolve_bearer_no_workspaces_errors(monkeypatch): + monkeypatch.setattr(_commands, "fetch_workspaces", lambda hub_url, cred: []) + with pytest.raises(click.ClickException): + _commands._resolve_hub_workspace( + Credential("bearer", "tok"), "http://hub.test", None + ) + + +def test_resolve_bearer_interactive_uses_picker(monkeypatch): + monkeypatch.setattr( + _commands, + "fetch_workspaces", + lambda hub_url, cred: [("ws-1", "ws-1"), ("ws-2", "ws-2")], + ) + monkeypatch.setattr(_commands, "_is_interactive", lambda: True) + monkeypatch.setattr(_commands, "_pick_workspace", lambda workspaces: "ws-2") + assert ( + _commands._resolve_hub_workspace( + Credential("bearer", "tok"), "http://hub.test", None + ) + == "ws-2" + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..18b24a8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,130 @@ +"""Tests for the CLI wiring, the lazy `skore` accessor and the plugin host.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import rich_click as click + +from skore_cli import _plugins, _skore + + +def test_cli_exposes_builtin_commands(): + from skore_cli import cli + + assert {"skills", "hub", "agent"} <= set(cli.commands) + + +# --------------------------------------------------------------------------- # +# _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) + + +# --------------------------------------------------------------------------- # +# _plugins.load_plugins +# --------------------------------------------------------------------------- # + + +class _FakeEntryPoint: + def __init__(self, name, loader): + self.name = name + self._loader = loader + + def load(self): + return self._loader() + + +def _group(): + @click.group() + def cli(): # pragma: no cover - never invoked + ... + + return cli + + +def _patch_entry_points(monkeypatch, entry_points): + monkeypatch.setattr(_plugins, "_iter_plugin_entry_points", lambda: entry_points) + + +def test_load_plugins_attaches_command(monkeypatch): + @click.command("plugged") + def plugged(): # pragma: no cover - never invoked + ... + + _patch_entry_points(monkeypatch, [_FakeEntryPoint("plugged", lambda: plugged)]) + group = _group() + _plugins.load_plugins(group) + + assert "plugged" in group.commands + + +def test_load_plugins_accepts_zero_arg_callable(monkeypatch): + @click.command("made") + def made(): # pragma: no cover - never invoked + ... + + # The loaded object is a factory returning the command. + _patch_entry_points(monkeypatch, [_FakeEntryPoint("made", lambda: (lambda: made))]) + group = _group() + _plugins.load_plugins(group) + + assert "made" in group.commands + + +def test_load_plugins_survives_load_error(monkeypatch, capsys): + def boom(): + raise RuntimeError("kaboom") + + _patch_entry_points(monkeypatch, [_FakeEntryPoint("broken", boom)]) + group = _group() + + _plugins.load_plugins(group) # must not raise + + assert group.commands == {} + err = capsys.readouterr().err + assert "broken" in err + assert "kaboom" in err + + +def test_load_plugins_skips_non_command(monkeypatch, capsys): + _patch_entry_points( + monkeypatch, [_FakeEntryPoint("weird", lambda: (lambda: "not-a-command"))] + ) + group = _group() + + _plugins.load_plugins(group) + + assert group.commands == {} + assert "did not return a click command" in capsys.readouterr().err diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py new file mode 100644 index 0000000..70050c8 --- /dev/null +++ b/tests/test_harnesses.py @@ -0,0 +1,339 @@ +"""Tests for the harness registry, helpers, detection and config writers.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from skore_cli.agent import _harnesses +from skore_cli.agent._harnesses import ( + API_KEY_ENV, + HARNESSES, + WORKSPACE_HEADER, + ConfigureContext, + Credential, + base_url, + detect_harnesses, + fetch_workspaces, + header_pair, + resolve_credential, + workspace_headers, +) + + +@pytest.fixture +def cap(monkeypatch): + """Capture raw console markup emitted by the harness module.""" + messages: list[str] = [] + monkeypatch.setattr( + _harnesses, "console", SimpleNamespace(print=lambda *a, **k: messages.append( + " ".join(str(x) for x in a) + )) + ) + return messages + + +@pytest.fixture +def no_api_key(monkeypatch): + monkeypatch.delenv(API_KEY_ENV, raising=False) + + +def _ctx(workspace, cred, **kwargs): + return ConfigureContext( + workspace=workspace, + hub_url="http://hub.test", + model_id="skore-agent", + cred=cred, + **kwargs, + ) + + +# --------------------------------------------------------------------------- # +# Pure helpers +# --------------------------------------------------------------------------- # + + +def test_base_url_strips_and_appends_v1(): + assert base_url("http://hub.test/") == "http://hub.test/v1" + assert base_url("http://hub.test") == "http://hub.test/v1" + + +def test_header_pair_api_key(): + assert header_pair(Credential("api_key")) == ("X-API-Key", f"{{env:{API_KEY_ENV}}}") + + +def test_header_pair_bearer(): + assert header_pair(Credential("bearer", "tok")) == ("Authorization", "Bearer tok") + + +def test_header_pair_none(): + assert header_pair(Credential("none")) is None + + +def test_workspace_headers_set_and_unset(tmp_path): + with_ws = _ctx(tmp_path, Credential("bearer", "t"), hub_workspace="ws-1") + without_ws = _ctx(tmp_path, Credential("bearer", "t"), hub_workspace=None) + assert workspace_headers(with_ws) == {WORKSPACE_HEADER: "ws-1"} + assert workspace_headers(without_ws) == {} + + +# --------------------------------------------------------------------------- # +# resolve_credential +# --------------------------------------------------------------------------- # + + +def test_resolve_credential_api_key(monkeypatch, no_api_key): + monkeypatch.setenv(API_KEY_ENV, "uid:secret") + assert resolve_credential() == Credential("api_key") + + +def test_resolve_credential_bearer(monkeypatch, no_api_key): + monkeypatch.setattr( + _harnesses, + "_auth", + lambda name: SimpleNamespace( + fresh_token=lambda relogin: {"access_token": "tok"} + ), + ) + assert resolve_credential() == Credential("bearer", "tok") + + +def test_resolve_credential_none(monkeypatch, no_api_key): + monkeypatch.setattr( + _harnesses, + "_auth", + lambda name: SimpleNamespace(fresh_token=lambda relogin: None), + ) + assert resolve_credential() == Credential("none") + + +# --------------------------------------------------------------------------- # +# Detection +# --------------------------------------------------------------------------- # + + +def test_detect_opencode_by_file(tmp_path, monkeypatch): + monkeypatch.setattr(_harnesses.shutil, "which", lambda name: None) + (tmp_path / "opencode.json").write_text("{}") + assert _harnesses._detect_opencode(tmp_path) is True + + +def test_detect_opencode_by_binary(tmp_path, monkeypatch): + monkeypatch.setattr(_harnesses.shutil, "which", lambda name: "/usr/bin/opencode") + assert _harnesses._detect_opencode(tmp_path) is True + + +def test_detect_continue_by_home(workspace): + assert _harnesses._detect_continue(workspace.project) is False + (workspace.home / ".continue").mkdir() + assert _harnesses._detect_continue(workspace.project) is True + + +def test_detect_harnesses_excludes_generic(tmp_path, monkeypatch, workspace): + monkeypatch.setattr(_harnesses.shutil, "which", lambda name: None) + (workspace.project / "opencode.json").write_text("{}") + detected = detect_harnesses(workspace.project) + assert "opencode" in detected + assert "generic" not in detected + + +# --------------------------------------------------------------------------- # +# Writers +# --------------------------------------------------------------------------- # + + +def _opencode_config(workspace): + return json.loads((workspace / "opencode.json").read_text()) + + +def test_opencode_api_key_uses_env_reference(tmp_path, cap, monkeypatch, no_api_key): + monkeypatch.setenv(API_KEY_ENV, "uid:secret") + extra = HARNESSES["opencode"].configure( + _ctx(tmp_path, Credential("api_key"), write_session_plugin=False) + ) + headers = _opencode_config(tmp_path)["provider"]["skore"]["options"]["headers"] + assert headers["X-API-Key"] == f"{{env:{API_KEY_ENV}}}" + assert extra["session_plugin"] is False + + +def test_opencode_bearer_writes_session_plugin(tmp_path, cap): + extra = HARNESSES["opencode"].configure( + _ctx( + tmp_path, + Credential("bearer", "tok"), + hub_workspace="ws-1", + write_session_plugin=True, + ) + ) + assert extra["session_plugin"] is True + plugin = tmp_path / ".opencode" / "plugin" / "skore-session.js" + assert plugin.is_file() + headers = _opencode_config(tmp_path)["provider"]["skore"]["options"]["headers"] + assert headers["Authorization"] == "Bearer tok" + assert headers[WORKSPACE_HEADER] == "ws-1" + + +def test_opencode_backs_up_invalid_json(tmp_path, cap): + (tmp_path / "opencode.json").write_text("{ not json") + HARNESSES["opencode"].configure( + _ctx(tmp_path, Credential("none"), write_session_plugin=False) + ) + assert (tmp_path / "opencode.json.bak").is_file() + assert _opencode_config(tmp_path)["provider"]["skore"]["npm"] + + +def test_copilot_bearer_embeds_token(tmp_path, cap): + extra = HARNESSES["copilot"].configure(_ctx(tmp_path, Credential("bearer", "tok"))) + content = Path(extra["env_file"]).read_text() + assert 'COPILOT_PROVIDER_API_KEY="tok"' in content + assert 'COPILOT_PROVIDER_BASE_URL="http://hub.test/v1"' in content + + +def test_copilot_api_key_references_env(tmp_path, cap, monkeypatch, no_api_key): + monkeypatch.setenv(API_KEY_ENV, "uid:secret") + extra = HARNESSES["copilot"].configure(_ctx(tmp_path, Credential("api_key"))) + content = Path(extra["env_file"]).read_text() + assert f'COPILOT_PROVIDER_API_KEY="${API_KEY_ENV}"' in content + + +def test_cline_writes_settings(tmp_path, cap): + extra = HARNESSES["cline"].configure(_ctx(tmp_path, Credential("api_key"))) + settings = json.loads(Path(extra["settings_path"]).read_text()) + assert settings["cline.apiProvider"] == "openai-compatible" + assert settings["cline.openAiBaseUrl"] == "http://hub.test/v1" + assert settings["cline.apiModelId"] == "skore-agent" + + +def test_cline_backs_up_invalid_json(tmp_path, cap): + settings_dir = tmp_path / ".vscode" + settings_dir.mkdir() + (settings_dir / "settings.json").write_text("{ bad") + HARNESSES["cline"].configure(_ctx(tmp_path, Credential("none"))) + assert (settings_dir / "settings.json.bak").is_file() + + +def test_generic_writes_file_with_headers(tmp_path, cap): + extra = HARNESSES["generic"].configure( + _ctx(tmp_path, Credential("bearer", "tok"), hub_workspace="ws-1") + ) + payload = json.loads(Path(extra["config_file"]).read_text()) + assert payload["baseURL"] == "http://hub.test/v1" + assert payload["headers"]["Authorization"] == "Bearer tok" + assert payload["headers"][WORKSPACE_HEADER] == "ws-1" + + +def test_generic_no_file_when_disabled(tmp_path, cap): + extra = HARNESSES["generic"].configure( + _ctx(tmp_path, Credential("api_key"), write_file=False) + ) + assert "config_file" not in extra + assert not (tmp_path / "skore-agent.json").exists() + + +def test_generic_none_credential_note(tmp_path, cap): + HARNESSES["generic"].configure(_ctx(tmp_path, Credential("none"), write_file=False)) + assert any("no credential resolved" in m for m in cap) + + +def test_continue_merges_model_block(workspace, cap): + extra = HARNESSES["continue"].configure( + _ctx(workspace.project, Credential("bearer", "tok"), hub_workspace="ws-1") + ) + import yaml + + config = yaml.safe_load(Path(extra["config_path"]).read_text()) + blocks = [m for m in config["models"] if m["name"] == "Skore Hub Agent"] + assert len(blocks) == 1 + assert blocks[0]["apiBase"] == "http://hub.test/v1" + headers = blocks[0]["requestOptions"]["headers"] + assert headers["Authorization"] == "Bearer tok" + assert headers[WORKSPACE_HEADER] == "ws-1" + + +def test_continue_backs_up_existing(workspace, cap): + config_dir = workspace.home / ".continue" + config_dir.mkdir() + (config_dir / "config.yaml").write_text("name: Existing\nmodels: []\n") + HARNESSES["continue"].configure(_ctx(workspace.project, Credential("api_key"))) + assert (config_dir / "config.yaml.bak").is_file() + + +def test_claude_code_writes_proxy_and_env(workspace, cap): + extra = HARNESSES["claude-code"].configure( + _ctx(workspace.project, Credential("bearer", "tok")) + ) + import yaml + + proxy = yaml.safe_load(Path(extra["proxy_config"]).read_text()) + entry = proxy["model_list"][0] + assert entry["model_name"] == "skore-agent" + assert entry["litellm_params"]["api_base"] == "http://hub.test/v1" + env_text = Path(extra["env_file"]).read_text() + assert "ANTHROPIC_BASE_URL" in env_text + + +# --------------------------------------------------------------------------- # +# fetch_workspaces (httpx injected as a fake module) +# --------------------------------------------------------------------------- # + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +def _fake_httpx(payload, captured): + def get(url, headers=None, timeout=None, follow_redirects=None): + captured["url"] = url + captured["headers"] = headers + return _FakeResponse(payload) + + return SimpleNamespace(get=get) + + +def test_fetch_workspaces_api_key_header(monkeypatch, no_api_key): + monkeypatch.setenv(API_KEY_ENV, "uid:secret") + captured: dict = {} + monkeypatch.setitem( + sys.modules, "httpx", _fake_httpx({"items": []}, captured) + ) + fetch_workspaces("http://hub.test", Credential("api_key")) + assert captured["headers"] == {"X-API-Key": "uid:secret"} + assert captured["url"] == "http://hub.test/identity/workspaces" + + +def test_fetch_workspaces_bearer_header(monkeypatch, no_api_key): + captured: dict = {} + monkeypatch.setitem(sys.modules, "httpx", _fake_httpx([], captured)) + fetch_workspaces("http://hub.test", Credential("bearer", "tok")) + assert captured["headers"] == {"Authorization": "Bearer tok"} + + +def test_fetch_workspaces_parses_items_and_fallbacks(monkeypatch, no_api_key): + payload = { + "items": [ + {"public_id": "ws-1", "name": "First"}, + {"slug": "ws-2"}, + {"name": "no-id"}, + ] + } + monkeypatch.setitem(sys.modules, "httpx", _fake_httpx(payload, {})) + result = fetch_workspaces("http://hub.test", Credential("bearer", "tok")) + assert result == [("ws-1", "First"), ("ws-2", "ws-2")] + + +def test_fetch_workspaces_accepts_bare_list(monkeypatch, no_api_key): + payload = [{"public_id": "ws-1", "name": "First"}] + monkeypatch.setitem(sys.modules, "httpx", _fake_httpx(payload, {})) + result = fetch_workspaces("http://hub.test", Credential("bearer", "tok")) + assert result == [("ws-1", "First")] diff --git a/tests/test_hub.py b/tests/test_hub.py new file mode 100644 index 0000000..ad60208 --- /dev/null +++ b/tests/test_hub.py @@ -0,0 +1,216 @@ +"""Tests for the ``skore hub`` command group (login/logout/status). + +The hub commands reach into the optional ``skore`` package through the module +level ``_auth`` accessor. ``skore`` is not installed in the test environment, so +every test swaps ``_auth`` for an in-memory fake -- this keeps the tests fast and +hermetic while exercising the real command logic. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import importlib + +import pytest +from click.testing import CliRunner + +# ``skore_cli.__init__`` rebinds the ``hub`` attribute to the command group, which +# shadows the submodule for ``import skore_cli.hub as hub``; fetch the real module +# from ``sys.modules`` so we can monkeypatch its ``_auth`` accessor. +hub = importlib.import_module("skore_cli.hub") +hub_cli = hub.hub + + +class _Recorder: + def __init__(self, token): + self.token = dict(token) if token else None + self.saved = None + self.cleared = False + self.logout_args = None + self.interactive_called = False + self.logout_error = None + + +def _make_auth(recorder: _Recorder, *, uri="http://hub.test", expired=False): + def interactive_device_login(*, timeout=600): + recorder.interactive_called = True + return ("acc", "ref", "2099-01-01T00:00:00+00:00") + + def post_oauth_logout(access_token, refresh_token=None): + recorder.logout_args = (access_token, refresh_token) + if recorder.logout_error is not None: + raise recorder.logout_error + + def save(token): + recorder.saved = token + return "/tmp/hub.json" + + def clear(): + if recorder.token: + recorder.cleared = True + recorder.token = None + return "/tmp/hub.json" + return None + + store = SimpleNamespace( + load=lambda: recorder.token, + save=save, + clear=clear, + path=lambda: "/tmp/hub.json", + ) + token_mod = SimpleNamespace( + interactive_device_login=interactive_device_login, + post_oauth_logout=post_oauth_logout, + _token_expired=lambda expires_at: expired, + ) + uri_mod = SimpleNamespace(URI=lambda: uri) + + modules = {"uri": uri_mod, "store": store, "token": token_mod} + return lambda name: modules[name] + + +@pytest.fixture +def no_api_key(monkeypatch): + monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) + monkeypatch.delenv("SKORE_HUB_URI", raising=False) + + +# --------------------------------------------------------------------------- # +# status +# --------------------------------------------------------------------------- # + + +def test_status_with_api_key(monkeypatch, no_api_key): + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + monkeypatch.setattr(hub, "_auth", _make_auth(_Recorder(None))) + + result = CliRunner().invoke(hub_cli, ["status"]) + + assert result.exit_code == 0, result.output + assert "set" in result.output + + +def test_status_with_valid_token(monkeypatch, no_api_key): + recorder = _Recorder({"access_token": "acc", "expires_at": "2099-01-01"}) + monkeypatch.setattr(hub, "_auth", _make_auth(recorder, expired=False)) + + result = CliRunner().invoke(hub_cli, ["status"]) + + assert result.exit_code == 0, result.output + assert "valid" in result.output + + +def test_status_with_expired_token(monkeypatch, no_api_key): + recorder = _Recorder({"access_token": "acc", "expires_at": "2000-01-01"}) + monkeypatch.setattr(hub, "_auth", _make_auth(recorder, expired=True)) + + result = CliRunner().invoke(hub_cli, ["status"]) + + assert result.exit_code == 0, result.output + assert "expired" in result.output + + +def test_status_unauthenticated_errors(monkeypatch, no_api_key): + monkeypatch.setattr(hub, "_auth", _make_auth(_Recorder(None))) + + result = CliRunner().invoke(hub_cli, ["status"]) + + assert result.exit_code != 0 + assert "Not authenticated" in result.output + + +# --------------------------------------------------------------------------- # +# login +# --------------------------------------------------------------------------- # + + +def test_login_with_api_key_does_nothing(monkeypatch, no_api_key): + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + recorder = _Recorder(None) + monkeypatch.setattr(hub, "_auth", _make_auth(recorder)) + + result = CliRunner().invoke(hub_cli, ["login"]) + + assert result.exit_code == 0, result.output + assert recorder.interactive_called is False + assert recorder.saved is None + + +def test_login_without_key_runs_device_flow(monkeypatch, no_api_key): + recorder = _Recorder(None) + monkeypatch.setattr(hub, "_auth", _make_auth(recorder)) + + result = CliRunner().invoke(hub_cli, ["login"]) + + assert result.exit_code == 0, result.output + assert recorder.interactive_called is True + assert recorder.saved == { + "uri": "http://hub.test", + "access_token": "acc", + "refresh_token": "ref", + "expires_at": "2099-01-01T00:00:00+00:00", + } + + +def test_login_hub_url_sets_env(monkeypatch, no_api_key): + recorder = _Recorder(None) + # URI() echoes the env var the command is expected to set from --hub-url. + monkeypatch.setattr( + hub, "_auth", _make_auth(recorder, uri="http://127.0.0.1:9999") + ) + + result = CliRunner().invoke(hub_cli, ["login", "--hub-url", "http://127.0.0.1:9999"]) + + assert result.exit_code == 0, result.output + import os + + assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:9999" + + +# --------------------------------------------------------------------------- # +# logout +# --------------------------------------------------------------------------- # + + +def test_logout_revokes_and_clears(monkeypatch, no_api_key): + recorder = _Recorder({"access_token": "acc", "refresh_token": "ref"}) + monkeypatch.setattr(hub, "_auth", _make_auth(recorder)) + + result = CliRunner().invoke(hub_cli, ["logout"]) + + assert result.exit_code == 0, result.output + assert recorder.logout_args == ("acc", "ref") + assert recorder.cleared is True + assert "revoked the token" in result.output + + +def test_logout_still_clears_when_revoke_fails(monkeypatch, no_api_key): + recorder = _Recorder({"access_token": "acc", "refresh_token": "ref"}) + recorder.logout_error = RuntimeError("network down") + monkeypatch.setattr(hub, "_auth", _make_auth(recorder)) + + result = CliRunner().invoke(hub_cli, ["logout"]) + + assert result.exit_code == 0, result.output + assert recorder.cleared is True + assert "Could not revoke" in result.output + + +def test_logout_no_token_with_api_key(monkeypatch, no_api_key): + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + monkeypatch.setattr(hub, "_auth", _make_auth(_Recorder(None))) + + result = CliRunner().invoke(hub_cli, ["logout"]) + + assert result.exit_code == 0, result.output + assert "user-managed" in result.output + + +def test_logout_no_token_at_all(monkeypatch, no_api_key): + monkeypatch.setattr(hub, "_auth", _make_auth(_Recorder(None))) + + result = CliRunner().invoke(hub_cli, ["logout"]) + + assert result.exit_code == 0, result.output + assert "No stored token to remove" in result.output diff --git a/tests/test_pickers.py b/tests/test_pickers.py new file mode 100644 index 0000000..5cbb919 --- /dev/null +++ b/tests/test_pickers.py @@ -0,0 +1,83 @@ +"""Textual pilot tests for the interactive harness/workspace pickers.""" + +from __future__ import annotations + +from skore_cli.agent.app import HarnessPicker, WorkspacePicker + +HARNESSES = [ + ("opencode", "opencode", True), + ("copilot", "GitHub Copilot CLI", False), + ("generic", "generic", False), +] + +WORKSPACES = [("ws-1", "First"), ("ws-2", "Second")] + + +# --------------------------------------------------------------------------- # +# HarnessPicker +# --------------------------------------------------------------------------- # + + +async def test_harness_picker_confirms_preselected(): + app = HarnessPicker(HARNESSES, preselect=0) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result == "opencode" + + +async def test_harness_picker_honors_preselect(): + app = HarnessPicker(HARNESSES, preselect=1) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result == "copilot" + + +async def test_harness_picker_cancel_returns_none(): + app = HarnessPicker(HARNESSES, preselect=0) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + + assert app.result is None + + +# --------------------------------------------------------------------------- # +# WorkspacePicker +# --------------------------------------------------------------------------- # + + +async def test_workspace_picker_confirms_selection(): + app = WorkspacePicker(WORKSPACES, preselect=0) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result == "ws-1" + + +async def test_workspace_picker_honors_preselect(): + app = WorkspacePicker(WORKSPACES, preselect=1) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result == "ws-2" + + +async def test_workspace_picker_cancel_returns_none(): + app = WorkspacePicker(WORKSPACES, preselect=0) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + + assert app.result is None From dd6059f5825efc08985e9f7dad5fa0f776ad49d7 Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Fri, 19 Jun 2026 19:21:24 +0200 Subject: [PATCH 2/7] _ --- README.md | 16 + pyproject.toml | 13 +- src/skore_cli/_skore.py | 26 +- src/skore_cli/agent/__init__.py | 14 +- src/skore_cli/agent/_commands.py | 361 +------- src/skore_cli/agent/_harnesses.py | 2 +- src/skore_cli/agent/mcp/__init__.py | 22 + src/skore_cli/agent/mcp/_a2a_client.py | 302 +++++++ src/skore_cli/agent/mcp/_commands.py | 246 ++++++ src/skore_cli/agent/mcp/_executor.py | 206 +++++ src/skore_cli/agent/mcp/_handlers.py | 245 ++++++ src/skore_cli/agent/mcp/_hosts.py | 250 ++++++ src/skore_cli/agent/mcp/_jobs.py | 259 ++++++ src/skore_cli/agent/mcp/_server.py | 131 +++ src/skore_cli/agent/model/__init__.py | 10 + src/skore_cli/agent/model/_commands.py | 374 +++++++++ src/skore_cli/hub/__init__.py | 10 + src/skore_cli/hub/_api_keys.py | 305 +++++++ src/skore_cli/hub/_client.py | 179 ++++ src/skore_cli/{hub.py => hub/_commands.py} | 23 +- src/skore_cli/hub/app/__init__.py | 10 + src/skore_cli/hub/app/_form.py | 274 ++++++ tests/test_agent_commands.py | 34 +- tests/test_agent_mcp.py | 914 +++++++++++++++++++++ tests/test_agent_workspace.py | 18 +- tests/test_hub.py | 55 +- tests/test_hub_api_key_form.py | 119 +++ tests/test_hub_api_keys.py | 479 +++++++++++ 28 files changed, 4503 insertions(+), 394 deletions(-) create mode 100644 src/skore_cli/agent/mcp/__init__.py create mode 100644 src/skore_cli/agent/mcp/_a2a_client.py create mode 100644 src/skore_cli/agent/mcp/_commands.py create mode 100644 src/skore_cli/agent/mcp/_executor.py create mode 100644 src/skore_cli/agent/mcp/_handlers.py create mode 100644 src/skore_cli/agent/mcp/_hosts.py create mode 100644 src/skore_cli/agent/mcp/_jobs.py create mode 100644 src/skore_cli/agent/mcp/_server.py create mode 100644 src/skore_cli/agent/model/__init__.py create mode 100644 src/skore_cli/agent/model/_commands.py create mode 100644 src/skore_cli/hub/__init__.py create mode 100644 src/skore_cli/hub/_api_keys.py create mode 100644 src/skore_cli/hub/_client.py rename src/skore_cli/{hub.py => hub/_commands.py} (88%) create mode 100644 src/skore_cli/hub/app/__init__.py create mode 100644 src/skore_cli/hub/app/_form.py create mode 100644 tests/test_agent_mcp.py create mode 100644 tests/test_hub_api_key_form.py create mode 100644 tests/test_hub_api_keys.py diff --git a/README.md b/README.md index bae3a27..b7dbc63 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ terminal. pip install skore-cli ``` +The base install is batteries-included: it bundles the `hub`, `agent`, and +`agent mcp` features (so it pulls in `skore`, `pyyaml`, and the `mcp` SDK). No +extras are required. + ## Usage The package installs a `skore` command exposing a `skills` group: @@ -28,6 +32,18 @@ skore skills remove # remove installed skills Skills are installed into the current project by default; pass `--global`/`-g` to target the user directory, and `--agent`/`-a` to select specific agents. +It also exposes a `hub` group (authenticate with a Skore Hub instance), an +`agent` group (wire a workspace to the Skore Hub agent for any harness), and +`agent mcp` (a local, harness-agnostic MCP relay that delegates ML tasks to the +hub agent). After a `skore hub login`, the relay lets your harness's assistant +delegate a task to the Skore agent, streams the agent's activity back, runs the +workspace actions it requests, and relays its user questions to you: + +```bash +skore agent mcp install --host cursor # register the relay with a host +skore agent mcp serve # the stdio relay the host launches +``` + ## License MIT diff --git a/pyproject.toml b/pyproject.toml index 64ffb40..ffdcfc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,12 +32,18 @@ dependencies = [ "rich>=14.2", "rich-click>=1.9", "textual", + # The `hub` and `agent` commands reuse skore's authentication machinery; the + # Continue and Claude Code harness writers need PyYAML to (de)serialize + # configs; the `agent mcp` command runs a local MCP server (the `mcp` SDK) + # that drives a headless opencode process via subprocess; the `hub api-key` + # commands call the hub identity API directly with httpx. + "skore[hub]", + "pyyaml", + "mcp>=1.2", + "httpx", ] [project.optional-dependencies] -# The `hub` and `agent` commands reuse skore's authentication machinery; the -# Continue and Claude Code harness writers need PyYAML to (de)serialize configs. -agent = ["skore[hub]", "pyyaml"] test = [ "pre-commit", "pytest", @@ -163,4 +169,5 @@ ignore_missing_imports = true module = [ "rich_click.*", "textual.*", + "mcp.*", ] diff --git a/src/skore_cli/_skore.py b/src/skore_cli/_skore.py index d27088c..a079200 100644 --- a/src/skore_cli/_skore.py +++ b/src/skore_cli/_skore.py @@ -9,15 +9,21 @@ 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[agent]'` or `pip install skore`)." + "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.""" @@ -25,3 +31,21 @@ def auth(submodule: str) -> ModuleType: 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 exactly like ``skore hub login``. + + 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). This keeps ``hub login``, + ``agent init`` and ``agent mcp serve`` all pointing at the same 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/__init__.py b/src/skore_cli/agent/__init__.py index f05dfc1..913dc59 100644 --- a/src/skore_cli/agent/__init__.py +++ b/src/skore_cli/agent/__init__.py @@ -7,10 +7,16 @@ locally, keeping the orchestration IP on the hub. The transport is a plain OpenAI-compatible model endpoint, so the agent is -harness-agnostic. ``skore agent init`` configures a specific harness for you: pass -``--harness `` to do it non-interactively, or omit it in a terminal to pick -one interactively (mirroring ``skore skills``). A ``generic`` fallback just prints -the connection values for any other OpenAI-compatible client. +harness-agnostic. ``skore agent model install`` configures a specific harness for +you: pass ``--harness `` to do it non-interactively, or omit it in a +terminal to pick one interactively (mirroring ``skore skills``). A ``generic`` +fallback just prints the connection values for any other OpenAI-compatible client. + +The ``mcp`` subgroup (``skore agent mcp``) offers an alternative: instead of +pointing the harness *at* the hub model, it runs a local MCP relay so the +harness's own assistant can *delegate* a task to the Skore Hub agent (which +streams its activity back, requests workspace actions, and asks the user +questions through the relay). Same IP boundary; different integration shape. Heavy ``skore`` (and ``textual``) imports are deferred into the command callbacks so building the CLI (and ``--help``) never imports them. diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index f742042..2854b57 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -1,34 +1,24 @@ -"""The ``skore agent`` click group: ``init`` and ``status``.""" +"""The ``skore agent`` click group: wires the integration subgroups. -from __future__ import annotations +The hub agent can be consumed two ways, each its own subgroup: -import json -import subprocess -import sys -from pathlib import Path +- ``skore agent model`` -- the agent is served behind an OpenAI-compatible + endpoint and a local harness is pointed at it (agent-as-model). +- ``skore agent mcp`` -- a local MCP relay lets the harness's own assistant + delegate ML tasks to the hub agent (agent-as-tool). -import rich_click as click +Both subgroups keep their heavy ``skore``/``textual``/``mcp`` imports deferred +inside their command callbacks so building the CLI (and ``--help``) stays cheap. +""" + +from __future__ import annotations -from skore_cli._style import console -from skore_cli.agent._harnesses import ( - API_KEY_ENV, - DEFAULT_HUB_URL, - DEFAULT_MODEL_ID, - HARNESS_NAMES, - HARNESSES, - MARKER_FILENAME, - ConfigureContext, - Credential, - base_url, - detect_harnesses, - fetch_workspaces, - resolve_credential, -) +import rich_click as click click.rich_click.COMMAND_GROUPS = { **getattr(click.rich_click, "COMMAND_GROUPS", {}), "cli agent": [ - {"name": "Workspace", "commands": ["init", "status"]}, + {"name": "Integrations", "commands": ["model", "mcp"]}, ], } @@ -41,323 +31,10 @@ def agent(ctx) -> None: click.echo(ctx.get_help()) -def _is_interactive() -> bool: - return sys.stdin.isatty() and sys.stdout.isatty() - - -def _print_credential(cred: Credential) -> None: - if cred.kind == "api_key": - console.print( - f"Using the API key from [bold]{API_KEY_ENV}[/] " - "[skore.muted](never written to a file; only referenced).[/]" - ) - elif cred.kind == "bearer": - console.print( - "Using your interactive [bold]skore hub login[/] token " - "[skore.muted](refreshed automatically).[/]" - ) - else: - console.print( - f"[yellow]No credential found. Set {API_KEY_ENV} (recommended) or run " - "`skore hub login` for interactive authentication, then re-run.[/]" - ) - - -def _install_skills(workspace: Path, install: bool) -> None: - if not install: - return - console.print( - "[yellow]Note: installing skills locally is OFF by default for IP " - "isolation. The hub agent loads skills server-side; local skills are not " - "needed.[/]" - ) - console.print( - "Installing probabl-skills into the workspace (--skills override) ..." - ) - try: - result = subprocess.run( - [sys.executable, "-m", "skore_cli", "skills", "install", "--all"], - cwd=workspace, - check=False, - capture_output=True, - text=True, - ) - except OSError as error: - console.print(f"[yellow] could not run skills install: {error}[/]") - return - if result.returncode != 0: - console.print( - "[yellow] skills install failed (continuing); run " - "`skore skills install --all` manually.\n " - f"{(result.stderr or '').strip()}[/]" - ) - else: - console.print("[skore.ok] skills installed.[/]") - - -def _pick_harness(workspace: Path) -> str | None: - """Launch the Textual picker (textual imported lazily) and return a name.""" - from skore_cli.agent.app import HarnessPicker - - detected = set(detect_harnesses(workspace)) - rows = [ - (name, harness.label, name in detected) for name, harness in HARNESSES.items() - ] - preselect = next((i for i, row in enumerate(rows) if row[2]), 0) - - app = HarnessPicker(rows, preselect=preselect) - app.run() - return app.result - - -def _pick_workspace(workspaces: list[tuple[str, str]]) -> str | None: - """Launch the Textual workspace picker and return the chosen public id.""" - from skore_cli.agent.app import WorkspacePicker - - app = WorkspacePicker(workspaces) - app.run() - return app.result - - -def _resolve_hub_workspace( - cred: Credential, hub_url: str, hub_workspace: str | None -) -> str | None: - """Resolve the hub workspace the agent should attach to. - - API keys are already workspace-bound server-side, so nothing is attached for - them. For the interactive (bearer) path the workspace must be explicit: from - ``--hub-workspace`` or an interactive picker; otherwise this errors, because - the hub now rejects agent calls that resolve to no workspace. - """ - if cred.kind == "api_key": - if hub_workspace: - console.print( - "[yellow]Ignoring --hub-workspace: the workspace is fixed by your " - f"API key ({API_KEY_ENV}).[/]" - ) - else: - console.print( - f"[skore.muted]Workspace is fixed by your API key ({API_KEY_ENV}).[/]" - ) - return None - - if cred.kind != "bearer": - # No credential: nothing can be attached; init already warns about this. - return None - - if hub_workspace: - return hub_workspace - - try: - workspaces = fetch_workspaces(hub_url, cred) - except Exception as error: # noqa: BLE001 - surfaced as a friendly CLI error - raise click.ClickException( - f"could not list your hub workspaces ({error}); pass " - "--hub-workspace to attach one explicitly." - ) from error - - if not workspaces: - raise click.ClickException( - "you are not a member of any hub workspace; create or join one, then " - "re-run `skore agent init`." - ) - - if not _is_interactive(): - raise click.UsageError( - "pass --hub-workspace to attach a workspace non-interactively " - f"(one of: {', '.join(public_id for public_id, _ in workspaces)})." - ) - - chosen = _pick_workspace(workspaces) - if chosen is None: - raise click.ClickException("no workspace selected.") - return chosen - - -@agent.command("init") -@click.option( - "--workspace", - "-w", - default=".", - type=click.Path(file_okay=False, path_type=Path), - help="Workspace directory to configure (default: current directory).", -) -@click.option( - "--harness", - "-H", - "harness_name", - type=click.Choice(HARNESS_NAMES), - default=None, - help="Harness to configure non-interactively (omit to pick interactively).", -) -@click.option( - "--hub-url", - default=DEFAULT_HUB_URL, - help=f"Base URL of the Skore Hub agent endpoint (default: {DEFAULT_HUB_URL}).", -) -@click.option( - "--hub-workspace", - default=None, - help=( - "Hub workspace (public id) to attach the agent to. Required for " - "interactive-login (bearer) auth in non-interactive mode; ignored when a " - "workspace-scoped API key is used." - ), -) -@click.option( - "--model-id", - default=DEFAULT_MODEL_ID, - help="Model id advertised by the hub (default: skore-agent).", -) -@click.option( - "--skills/--no-skills", - "install_skills", - default=False, - help="Install probabl-skills locally (default: off; the hub serves skills).", -) -@click.option( - "--session-plugin/--no-session-plugin", - "write_session_plugin", - default=True, - help="opencode only: write the session-binding plugin (default: on).", -) -@click.option( - "--config-file/--no-config-file", - "write_file", - default=True, - help="generic only: also write skore-agent.json (default: on).", -) -def init( - workspace: Path, - harness_name: str | None, - hub_url: str, - hub_workspace: str | None, - model_id: str, - install_skills: bool, - write_session_plugin: bool, - write_file: bool, -) -> None: - """Configure an agent harness to talk to the Skore Hub agent. - - Pass ``--harness `` to configure a specific harness non-interactively; - omit it in a terminal to pick one interactively (like ``skore skills``). The - ``generic`` harness just prints the connection values for any other - OpenAI-compatible client. - """ - workspace = workspace.resolve() - if not workspace.is_dir(): - raise click.ClickException(f"workspace does not exist: {workspace}") - - if harness_name is None: - if _is_interactive(): - harness_name = _pick_harness(workspace) - if harness_name is None: - console.print("Nothing selected.") - return - else: - raise click.UsageError( - "Specify --harness to configure non-interactively " - f"(one of: {', '.join(HARNESS_NAMES)})." - ) - - harness = HARNESSES[harness_name] - console.print( - f"Configuring [skore.skill]{harness.name}[/] in: [skore.path]{workspace}[/]" - ) - - cred = resolve_credential() - _print_credential(cred) - - attached_workspace = _resolve_hub_workspace(cred, hub_url, hub_workspace) - if attached_workspace: - console.print( - f"Attaching to hub workspace [skore.skill]{attached_workspace}[/]." - ) - - _install_skills(workspace, install_skills) - - ctx = ConfigureContext( - workspace=workspace, - hub_url=hub_url, - model_id=model_id, - cred=cred, - hub_workspace=attached_workspace, - write_session_plugin=write_session_plugin, - write_file=write_file, - ) - extra = harness.configure(ctx) - - marker = { - "harness": harness.name, - "hub_url": hub_url, - "base_url": base_url(hub_url), - "hub_workspace": attached_workspace, - "model_id": model_id, - "auth": cred.kind, - "session_binding": harness.session_binding, - **extra, - } - marker_path = workspace / MARKER_FILENAME - marker_path.write_text(json.dumps(marker, indent=2) + "\n") - console.print(f"\n[skore.muted]Recorded the setup in {marker_path}.[/]") - - -@agent.command("status") -@click.option( - "--workspace", - "-w", - default=".", - type=click.Path(file_okay=False, path_type=Path), - help="Workspace directory to inspect (default: current directory).", -) -def status(workspace: Path) -> None: - """Show the agent wiring for this workspace.""" - workspace = workspace.resolve() - marker_path = workspace / MARKER_FILENAME - if not marker_path.exists(): - raise click.ClickException( - f"no {MARKER_FILENAME} in {workspace}; run `skore agent init` first." - ) - - marker = json.loads(marker_path.read_text() or "{}") - harness_name = marker.get("harness", "?") - base = marker.get("base_url", "?") - model_id = marker.get("model_id", "?") - auth = marker.get("auth", "none") - binding = marker.get("session_binding", "fallback") - hub_workspace = marker.get("hub_workspace") - - skills_dir = workspace / ".agents" / "skills" - n_skills = len(list(skills_dir.iterdir())) if skills_dir.is_dir() else 0 - skills_note = ( - f"{n_skills} local (override)" if n_skills else "served by hub (none local)" - ) - - auth_note = { - "api_key": f"[skore.ok]API key[/] (from {API_KEY_ENV})", - "bearer": "[skore.ok]interactive token[/]", - "none": "[skore.muted]none[/]", - }.get(auth, auth) - - session_note = ( - "[skore.ok]bound[/] via the X-Skore-Session-Id header" - if binding == "plugin" - else "[skore.muted]via the OpenAI 'user' field, else content-hash fallback[/]" - ) - - workspace_note = ( - f"[skore.skill]{hub_workspace}[/]" - if hub_workspace - else "[skore.muted]bound by API key (no header)[/]" - if auth == "api_key" - else "[skore.muted]none[/]" - ) +# The integration subgroups. Imported here so they attach to the agent group; +# their heavy deps stay deferred inside their own callbacks. +from skore_cli.agent.mcp import mcp as _mcp_group # noqa: E402 +from skore_cli.agent.model import model as _model_group # noqa: E402 - console.print(f"workspace : [skore.path]{workspace}[/]") - console.print(f"harness : [skore.skill]{harness_name}[/]") - console.print(f"hub URL : [skore.path]{base}[/]") - console.print(f"hub ws : {workspace_note}") - console.print(f"model : {model_id}") - console.print(f"auth : {auth_note}") - console.print(f"skills : {skills_note}") - console.print(f"session : {session_note}") +agent.add_command(_model_group) +agent.add_command(_mcp_group) diff --git a/src/skore_cli/agent/_harnesses.py b/src/skore_cli/agent/_harnesses.py index dc605bb..fcba396 100644 --- a/src/skore_cli/agent/_harnesses.py +++ b/src/skore_cli/agent/_harnesses.py @@ -484,7 +484,7 @@ def _yaml_missing(): return click.ClickException( "this harness needs PyYAML (install it with `pip install " - "'skore-cli[agent]'` or `pip install pyyaml`)." + "skore-cli` or `pip install pyyaml`)." ) diff --git a/src/skore_cli/agent/mcp/__init__.py b/src/skore_cli/agent/mcp/__init__.py new file mode 100644 index 0000000..cbc04b4 --- /dev/null +++ b/src/skore_cli/agent/mcp/__init__.py @@ -0,0 +1,22 @@ +"""The ``skore agent mcp`` command group: the delegation relay. + +Exposes a local, harness-agnostic MCP relay (``skore agent mcp serve``) that lets +the user's outer LLM delegate a machine-learning task to the Skore Hub agent +after a Skore Hub login, through a single blocking ``skore_ml_run`` tool (no +polling). Internally the relay is an A2A client: it opens one durable task on the +hub and consumes a pushed SSE event stream. The hub agent (its own model + +skills) runs the orchestration server-side; whenever it defers a tool call, the +relay executes it locally (file/shell ops) or puts a skill-gate question to the +user via MCP elicitation, and streams the result back. The orchestration IP stays +on the hub. + +``skore agent mcp install`` writes the per-host MCP configuration so the relay +launches the same way across every supported host. + +Heavy imports (the ``mcp`` SDK, ``httpx``) are deferred into the command +callbacks so building the CLI (and ``--help``) never imports them. +""" + +from skore_cli.agent.mcp._commands import mcp + +__all__ = ["mcp"] diff --git a/src/skore_cli/agent/mcp/_a2a_client.py b/src/skore_cli/agent/mcp/_a2a_client.py new file mode 100644 index 0000000..97f534f --- /dev/null +++ b/src/skore_cli/agent/mcp/_a2a_client.py @@ -0,0 +1,302 @@ +"""A2A client: drive the hub delegation task over a durable, streamed connection. + +The relay no longer polls. It opens ONE task on the hub's A2A endpoint +(``POST /v1/a2a``, method ``message/stream``) and consumes a pushed SSE event +stream. When the agent defers tool calls, the hub emits an ``input-required`` +event; the relay executes them locally (see :mod:`_handlers`) and posts the +results back with ``message/send`` -- the same open task resumes and streams the +next events. A dropped connection is transparently resumed with +``tasks/resubscribe`` from the last received event ``seq`` (the hub replays the +gap), so no events are lost and nothing is polled. + +The SSE ``data:`` payload is the compact task-event dict produced by the hub task +manager; :func:`iter_sse` is factored out (pure) for unit testing. +""" + +from __future__ import annotations + +import json +import logging +import os +from collections.abc import AsyncIterator +from dataclasses import dataclass +from pathlib import Path + +from skore_cli.agent._harnesses import ( + API_KEY_ENV, + WORKSPACE_HEADER, + Credential, + base_url, +) + +logger = logging.getLogger("skore_cli.agent.mcp") + +# Task states that end the stream (mirror hub.agent.tasks.TERMINAL_STATES). +TERMINAL_STATES = {"completed", "failed", "canceled"} + + +class A2AClientError(RuntimeError): + """Raised when the hub A2A endpoint returns an error response.""" + + +def _auth_headers(cred: Credential) -> dict[str, str]: + """Return the real auth header for an HTTP call (not the ``{env:}`` form).""" + if cred.kind == "api_key": + return {"X-API-Key": os.environ.get(API_KEY_ENV, "")} + if cred.kind == "bearer": + return {"Authorization": f"Bearer {cred.token}"} + return {} + + +def _error_message(status_code: int, body: bytes | str) -> str: + """Extract an actionable message from an error response body.""" + text = body.decode() if isinstance(body, bytes) else body + try: + payload = json.loads(text) + except (ValueError, json.JSONDecodeError): + return f"hub returned HTTP {status_code}: {text[:300]}" + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict) and error.get("message"): + return str(error["message"]) + return f"hub returned HTTP {status_code}." + + +async def iter_sse(lines: AsyncIterator[str]) -> AsyncIterator[dict]: + """Parse an SSE line stream into event dicts (one per ``data:`` frame).""" + data: list[str] = [] + async for line in lines: + if line == "": + if data: + raw = "\n".join(data) + data = [] + try: + yield json.loads(raw) + except json.JSONDecodeError: + continue + continue + if line.startswith(":"): + continue + if line.startswith("data:"): + data.append(line[5:].lstrip()) + if data: + try: + yield json.loads("\n".join(data)) + except json.JSONDecodeError: + return + + +class A2AClient: + """Drive a single hub delegation task over a resumable SSE stream.""" + + def __init__( + self, + *, + hub_url: str, + cred: Credential, + hub_workspace: str | None, + tools: list[dict], + timeout: float = 600.0, + ) -> None: + self._base = base_url(hub_url) + self._endpoint = self._base + "/a2a" + self._cred = cred + self._hub_workspace = hub_workspace + self._tools = tools + self._timeout = timeout + # Live task state, tracked from the event stream. + self.task_id = "" + self.state = "submitted" + self.result = "" + self.error = "" + self._last_seq = -1 + + def _headers(self, *, sse: bool) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + headers.update(_auth_headers(self._cred)) + if self._hub_workspace: + headers[WORKSPACE_HEADER] = self._hub_workspace + if sse: + headers["Accept"] = "text/event-stream" + return headers + + def _stream_payload(self, task_text: str) -> dict: + return { + "jsonrpc": "2.0", + "id": 1, + "method": "message/stream", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": task_text}], + "metadata": {"tools": self._tools}, + } + }, + } + + def _resubscribe_payload(self) -> dict: + return { + "jsonrpc": "2.0", + "id": 1, + "method": "tasks/resubscribe", + "params": {"id": self.task_id, "cursor": self._last_seq + 1}, + } + + def _track(self, event: dict) -> None: + """Update the live task state from a streamed event.""" + seq = event.get("seq") + if isinstance(seq, int): + self._last_seq = max(self._last_seq, seq) + if not self.task_id: + task_id = event.get("task_id") + if isinstance(task_id, str): + self.task_id = task_id + kind = event.get("kind") + if kind == "status": + self.state = event.get("state", self.state) + elif kind == "result": + self.state = "completed" + self.result = event.get("text", "") + elif kind == "error": + self.state = "failed" + self.error = event.get("message", "") + + @staticmethod + def _is_terminal(event: dict) -> bool: + if event.get("kind") in ("result", "error"): + return True + return event.get("kind") == "status" and event.get("state") in TERMINAL_STATES + + async def events(self, task_text: str) -> AsyncIterator[dict]: + """Yield the task's events, auto-resuming a dropped stream. + + Starts the task with ``message/stream`` and yields each event. On a + transport error (or a stream that closes before a terminal event), it + reconnects with ``tasks/resubscribe`` from the last seen ``seq`` until a + terminal event arrives. + """ + import httpx + + payload = self._stream_payload(task_text) + while True: + try: + async with ( + httpx.AsyncClient(timeout=self._timeout) as client, + client.stream( + "POST", + self._endpoint, + json=payload, + headers=self._headers(sse=True), + follow_redirects=True, + ) as response, + ): + if response.status_code >= 400: + body = await response.aread() + raise A2AClientError(_error_message(response.status_code, body)) + async for event in iter_sse(response.aiter_lines()): + self._track(event) + yield event + if self._is_terminal(event): + return + except httpx.HTTPError as exc: + if self.state in TERMINAL_STATES: + return + logger.warning( + "a2a stream dropped (%s); resubscribing from seq %d", + exc, + self._last_seq + 1, + ) + if self.state in TERMINAL_STATES: + return + if not self.task_id: + # Never received the task id: nothing to resume against. + raise A2AClientError( + "the hub connection failed before the task was created" + ) + payload = self._resubscribe_payload() + + async def send_results(self, results: dict[str, str]) -> None: + """Post the locally-executed tool results back to the waiting task.""" + import httpx + + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "message/send", + "params": { + "message": { + "taskId": self.task_id, + "role": "user", + "parts": [{"kind": "data", "data": {"tool_results": results}}], + } + }, + } + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.post( + self._endpoint, + json=payload, + headers=self._headers(sse=False), + follow_redirects=True, + ) + if response.status_code >= 400: + raise A2AClientError(_error_message(response.status_code, response.text)) + + async def cancel(self) -> None: + """Best-effort cancel of the task (e.g. when the MCP call is aborted).""" + if not self.task_id: + return + import httpx + + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "tasks/cancel", + "params": {"id": self.task_id}, + } + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + await client.post( + self._endpoint, + json=payload, + headers=self._headers(sse=False), + follow_redirects=True, + ) + except Exception: # noqa: BLE001 - cancellation is best-effort + logger.debug("a2a cancel failed for task %s", self.task_id) + + async def fetch_template(self, skill: str, path: str) -> str: + """Fetch a raw skill template over the non-LLM data-plane channel.""" + import httpx + + url = f"{self._base}/skills/{skill}/template" + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.get( + url, + params={"path": path}, + headers=self._headers(sse=False), + follow_redirects=True, + ) + if response.status_code >= 400: + raise A2AClientError(_error_message(response.status_code, response.text)) + return response.text + + +@dataclass +class RelayConfig: + """Resolved hub connection used to build per-task A2A clients.""" + + default_workspace: Path + hub_url: str + hub_workspace: str | None + cred: Credential + + def make_client(self) -> A2AClient: + """Build an :class:`A2AClient` advertising the canonical local toolset.""" + from skore_cli.agent.mcp._jobs import CANONICAL_TOOLS + + return A2AClient( + hub_url=self.hub_url, + cred=self.cred, + hub_workspace=self.hub_workspace, + tools=CANONICAL_TOOLS, + ) diff --git a/src/skore_cli/agent/mcp/_commands.py b/src/skore_cli/agent/mcp/_commands.py new file mode 100644 index 0000000..e20a871 --- /dev/null +++ b/src/skore_cli/agent/mcp/_commands.py @@ -0,0 +1,246 @@ +"""The ``skore agent mcp`` click group: ``serve``, ``install`` and ``status``. + +``serve`` runs the local stdio delegation relay (the outer LLM's bridge to the +Skore Hub agent); ``install`` registers that ``serve`` command with a specific +MCP host; ``status`` reports where the relay is registered. ``serve``/``install`` +resolve the hub URL the same way as ``skore hub login`` and gate on a Skore Hub +credential; ``status`` is read-only and never refreshes credentials. +""" + +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path + +import rich_click as click + +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._harnesses import ( + API_KEY_ENV, + DEFAULT_MODEL_ID, + Credential, + resolve_credential, +) +from skore_cli.agent.mcp._hosts import HOST_NAMES + +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli agent mcp": [ + {"name": "Delegation relay", "commands": ["serve", "install", "status"]}, + ], +} + + +@click.group() +def mcp() -> None: + """Delegate ML tasks to the Skore Hub agent from any MCP host.""" + + +def _resolve_serve_workspace(cred: Credential, hub_workspace: str | None) -> str | None: + """Resolve the hub workspace to send with relay requests. + + API keys are already workspace-bound server-side (nothing is sent). For the + interactive (bearer) path the workspace must be explicit, since the hub + rejects agent calls that resolve to no workspace. + """ + if cred.kind != "bearer": + return None + if hub_workspace: + return hub_workspace + raise click.UsageError( + "pass --hub-workspace to scope the agent for interactive-login " + "(bearer) auth, or use a workspace-scoped API key (SKORE_HUB_API_KEY)." + ) + + +def _print_credential(cred: Credential) -> None: + if cred.kind == "api_key": + console.print(f"Using the API key from [bold]{API_KEY_ENV}[/].") + elif cred.kind == "bearer": + console.print("Using your interactive [bold]skore hub login[/] token.") + else: + console.print( + f"[yellow]No credential found. Set {API_KEY_ENV} or run " + "`skore hub login` before launching the host.[/]" + ) + + +@mcp.command("serve") +@click.option( + "--workspace", + "-w", + default=".", + type=click.Path(file_okay=False, path_type=Path), + help="Workspace directory the agent acts in (default: current directory).", +) +@click.option( + "--hub-url", + 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, like `skore hub login`." + ), +) +@click.option( + "--hub-workspace", + default=None, + help=( + "Hub workspace (public id) to scope the agent to. Required for " + "interactive-login (bearer) auth; ignored with a workspace-scoped API key." + ), +) +@click.option( + "--model-id", + default=DEFAULT_MODEL_ID, + help="Model id advertised by the hub (default: skore-agent).", +) +def serve( + workspace: Path, + hub_url: str | None, + hub_workspace: str | None, + model_id: str, +) -> None: + """Run the local stdio MCP relay bridging the outer LLM to the Skore agent. + + This is the command MCP hosts launch (see ``skore agent mcp install``). It + speaks the MCP stdio protocol on stdout, so all diagnostics go to stderr. + """ + # stdout is the MCP transport: route logging to stderr only. + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(name)s: %(message)s")) + mcp_logger = logging.getLogger("skore_cli.agent.mcp") + mcp_logger.addHandler(handler) + mcp_logger.setLevel(logging.INFO) + + workspace = workspace.resolve() + if not workspace.is_dir(): + raise click.ClickException(f"workspace does not exist: {workspace}") + + cred = resolve_credential() + if cred.kind == "none": + raise click.ClickException( + f"no hub credential found. Set {API_KEY_ENV} (recommended) or run " + "`skore hub login`, then start `skore agent mcp serve` again." + ) + attached = _resolve_serve_workspace(cred, hub_workspace) + hub_url = resolve_hub_uri(hub_url, _auth) + + from skore_cli.agent.mcp._a2a_client import RelayConfig + from skore_cli.agent.mcp._server import build_mcp_server + + config = RelayConfig( + default_workspace=workspace, + hub_url=hub_url, + hub_workspace=attached, + cred=cred, + ) + server = build_mcp_server(config) + server.run() + + +@mcp.command("install") +@click.option( + "--host", + "-H", + "host_name", + type=click.Choice(HOST_NAMES), + default="generic", + help="MCP host to configure (default: generic, which prints the command).", +) +@click.option( + "--workspace", + "-w", + default=".", + type=click.Path(file_okay=False, path_type=Path), + help="Workspace directory to configure (default: current directory).", +) +@click.option( + "--hub-url", + 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. Baked into the registered " + "`serve` command." + ), +) +@click.option( + "--hub-workspace", + default=None, + help=( + "Hub workspace (public id) baked into the registered `serve` command. " + "Required for interactive-login (bearer) auth; ignored with an API key." + ), +) +def install( + host_name: str, + workspace: Path, + hub_url: str | None, + hub_workspace: str | None, +) -> None: + """Register ``skore agent mcp serve`` with an MCP host.""" + from skore_cli.agent.mcp._hosts import HOSTS, InstallContext + + workspace = workspace.resolve() + if not workspace.is_dir(): + raise click.ClickException(f"workspace does not exist: {workspace}") + + cred = resolve_credential() + _print_credential(cred) + + hub_url = resolve_hub_uri(hub_url, _auth) + attached = _resolve_serve_workspace(cred, hub_workspace) + + host = HOSTS[host_name] + console.print(f"Registering the Skore relay for [skore.skill]{host.name}[/].") + host.configure( + InstallContext(workspace=workspace, hub_url=hub_url, hub_workspace=attached) + ) + + +@mcp.command("status") +@click.option( + "--workspace", + "-w", + default=".", + type=click.Path(file_okay=False, path_type=Path), + help="Workspace directory to inspect (default: current directory).", +) +def status(workspace: Path) -> None: + """Show where the delegation relay is registered for this workspace. + + Read-only and non-interactive: it never refreshes credentials. It reports the + resolved hub URL (from the env/default), whether an API key is set, and which + MCP hosts have ``skore-ml`` registered (with the baked ``serve`` args). + """ + from skore_cli.agent.mcp._hosts import installed + + workspace = workspace.resolve() + + hub_url = resolve_hub_uri(None, _auth) + api_key_note = ( + f"[skore.ok]set[/] (from {API_KEY_ENV})" + if os.environ.get(API_KEY_ENV) + else f"[skore.muted]not set[/] (run `skore hub login` or set {API_KEY_ENV})" + ) + + console.print(f"workspace : [skore.path]{workspace}[/]") + console.print(f"hub URL : [skore.path]{hub_url}[/]") + console.print(f"API key : {api_key_note}") + console.print("hosts :") + + for host in installed(workspace): + if host.present: + baked = " ".join(host.serve_args or []) or "(defaults)" + console.print( + f" [skore.ok]+[/] [skore.skill]{host.name}[/] " + f"[skore.muted]{host.config_path}[/]\n" + f" args: [skore.path]{baked}[/]" + ) + else: + console.print( + f" [skore.muted]- {host.name} (not registered; {host.config_path})[/]" + ) diff --git a/src/skore_cli/agent/mcp/_executor.py b/src/skore_cli/agent/mcp/_executor.py new file mode 100644 index 0000000..2ce86dc --- /dev/null +++ b/src/skore_cli/agent/mcp/_executor.py @@ -0,0 +1,206 @@ +"""Local, workspace-confined execution of the relay's data-plane tools. + +The hub agent's file/command tool calls are executed here (in the user's +workspace) instead of being relayed to the outer LLM. Each operation returns an +:class:`ExecResult` carrying: + +* ``output`` -- the full result fed back to the hub agent as the tool result + (e.g. a file's content), so the agent keeps reasoning with real data; +* ``summary`` -- a compact one-line description surfaced to the outer LLM as + ``activity`` (e.g. ``wrote tests/test_x.py (42 lines)``), so bulk bytes never + enter the outer model's context. + +All paths are confined to the workspace: absolute paths and ``..`` escapes are +rejected. Blocking file IO runs in a thread and shell commands run as a +subprocess, so the asyncio event loop driving the jobs is never blocked. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from pathlib import Path + +# Cap how much command output we feed back to the hub (keeps hub input bounded); +# the middle of very long output is elided. +_MAX_OUTPUT_CHARS = 20_000 +# Default wall-clock budget for a shell command. +_DEFAULT_TIMEOUT = 600.0 + + +class ExecError(RuntimeError): + """Raised when a data-plane operation cannot be performed safely.""" + + +@dataclass +class ExecResult: + """The outcome of one data-plane operation.""" + + output: str + summary: str + + +def _safe_path(workspace: Path, path: str) -> Path: + """Resolve ``path`` strictly inside ``workspace``. + + Rejects absolute paths and any ``..`` traversal that would escape the + workspace root. + """ + if not isinstance(path, str) or not path.strip(): + raise ExecError("a non-empty path is required") + candidate = Path(path) + if candidate.is_absolute(): + raise ExecError(f"absolute paths are not allowed: {path!r}") + root = workspace.resolve() + target = (root / candidate).resolve() + if target != root and root not in target.parents: + raise ExecError(f"path escapes the workspace: {path!r}") + return target + + +def _rel(workspace: Path, target: Path) -> str: + """Best-effort workspace-relative display path.""" + try: + return target.relative_to(workspace.resolve()).as_posix() + except ValueError: + return target.as_posix() + + +def _truncate(text: str, limit: int = _MAX_OUTPUT_CHARS) -> str: + """Elide the middle of an over-long string, keeping head and tail.""" + if len(text) <= limit: + return text + head = limit // 2 + tail = limit - head + elided = len(text) - limit + return f"{text[:head]}\n... [{elided} chars elided] ...\n{text[-tail:]}" + + +def _read_file(workspace: Path, path: str) -> ExecResult: + target = _safe_path(workspace, path) + if not target.is_file(): + raise ExecError(f"file not found: {path!r}") + content = target.read_text() + lines = content.count("\n") + (1 if content and not content.endswith("\n") else 0) + return ExecResult( + output=content, + summary=f"read {_rel(workspace, target)} ({lines} lines)", + ) + + +def _write_file(workspace: Path, path: str, content: str) -> ExecResult: + target = _safe_path(workspace, path) + target.parent.mkdir(parents=True, exist_ok=True) + text = content if isinstance(content, str) else str(content) + target.write_text(text) + lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0) + summary = f"wrote {_rel(workspace, target)} ({lines} lines)" + return ExecResult(output=summary, summary=summary) + + +def _edit_file(workspace: Path, path: str, old: str, new: str) -> ExecResult: + target = _safe_path(workspace, path) + if not target.is_file(): + raise ExecError(f"file not found: {path!r}") + if not isinstance(old, str) or old == "": + raise ExecError("`old` must be a non-empty string") + content = target.read_text() + occurrences = content.count(old) + if occurrences == 0: + raise ExecError(f"`old` text not found in {path!r}") + if occurrences > 1: + raise ExecError( + f"`old` text is ambiguous in {path!r} ({occurrences} matches); " + "include more surrounding context to make it unique" + ) + updated = content.replace(old, new if isinstance(new, str) else str(new), 1) + target.write_text(updated) + summary = f"edited {_rel(workspace, target)}" + return ExecResult(output=summary, summary=summary) + + +def _list_dir(workspace: Path, path: str = ".") -> ExecResult: + target = _safe_path(workspace, path or ".") + if not target.is_dir(): + raise ExecError(f"directory not found: {path!r}") + entries = [ + child.name + ("/" if child.is_dir() else "") + for child in sorted(target.iterdir()) + ] + listing = "\n".join(entries) + return ExecResult( + output=listing, + summary=f"listed {_rel(workspace, target)} ({len(entries)} entries)", + ) + + +async def read_file(workspace: Path, path: str) -> ExecResult: + """Read a workspace text file.""" + return await asyncio.to_thread(_read_file, workspace, path) + + +async def write_file(workspace: Path, path: str, content: str) -> ExecResult: + """Create or overwrite a workspace file.""" + return await asyncio.to_thread(_write_file, workspace, path, content) + + +async def edit_file(workspace: Path, path: str, old: str, new: str) -> ExecResult: + """Replace an exact, unique text span in a workspace file.""" + return await asyncio.to_thread(_edit_file, workspace, path, old, new) + + +async def list_dir(workspace: Path, path: str = ".") -> ExecResult: + """List a workspace directory.""" + return await asyncio.to_thread(_list_dir, workspace, path) + + +async def run_bash( + workspace: Path, command: str, timeout: float = _DEFAULT_TIMEOUT +) -> ExecResult: + """Run a shell command in the workspace, returning exit code + output.""" + if not isinstance(command, str) or not command.strip(): + raise ExecError("a non-empty command is required") + root = workspace.resolve() + proc = await asyncio.create_subprocess_shell( + command, + cwd=str(root), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except TimeoutError: + proc.kill() + await proc.wait() + raise ExecError( + f"command timed out after {timeout:.0f}s: {command!r}" + ) from None + text = (stdout or b"").decode("utf-8", errors="replace") + code = proc.returncode if proc.returncode is not None else -1 + lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0) + output = f"exit code: {code}\n{_truncate(text)}" + return ExecResult( + output=output, + summary=f"ran `{command}` -> exit {code} ({lines} lines)", + ) + + +def materialize( + workspace: Path, dest_path: str, template_text: str, substitutions: object +) -> ExecResult: + """Write a fetched template to ``dest_path`` after literal substitutions. + + ``substitutions`` is a ``{find: replace}`` map applied as plain string + replacements (the agent supplies the placeholders it knows from the skill), + so the large template body never passes through any LLM. + """ + text = template_text + if isinstance(substitutions, dict): + for find, replace in substitutions.items(): + text = text.replace(str(find), str(replace)) + target = _safe_path(workspace, dest_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text) + lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0) + summary = f"materialized {_rel(workspace, target)} ({lines} lines)" + return ExecResult(output=summary, summary=summary) diff --git a/src/skore_cli/agent/mcp/_handlers.py b/src/skore_cli/agent/mcp/_handlers.py new file mode 100644 index 0000000..7a5d0f1 --- /dev/null +++ b/src/skore_cli/agent/mcp/_handlers.py @@ -0,0 +1,245 @@ +"""Resolve the hub agent's deferred tool calls on the user's machine. + +When the hub task streams an ``input-required`` event, it carries the agent's +deferred tool calls. This module classifies each call by its (canonical) name and +produces the result string fed back to the hub: + +* data-plane (``read_file``/``write_file``/``edit_file``/``list_dir``/ + ``materialize_template``) -- executed locally via :mod:`_executor`; only a + compact summary is surfaced to the outer LLM (``ctx.info``), never the bytes; +* ``run_bash`` -- the user approves it inline via MCP elicitation, then the relay + runs it; +* ``ask_user`` -- a skill gate: the questions are put to the human inline via MCP + elicitation (the outer model never answers them). + +Elicitation is the only path for human decisions (the no-poll design assumes an +elicitation-capable host). If the host lacks elicitation, gate calls fail with a +clear, actionable error rather than silently letting the outer model decide. +""" + +from __future__ import annotations + +import json +import logging +import re + +from pydantic import BaseModel, Field, create_model + +from skore_cli.agent.mcp import _executor +from skore_cli.agent.mcp._jobs import ( + ASK_USER_TOOL, + MATERIALIZE_TOOL, + RELAY_TOOLS, + RUN_BASH_TOOL, + _command_text, + _normalize_questions, +) + +logger = logging.getLogger("skore_cli.agent.mcp") + + +class ElicitationUnsupportedError(RuntimeError): + """Raised when a human gate needs elicitation but the host lacks it.""" + + +class _ConfirmSchema(BaseModel): + """Elicitation schema for inline shell-command approval.""" + + approve: bool = Field(description="Approve running the shell command?") + + +def _sanitize_field_name(qid: str, index: int, used: set[str]) -> str: + """Turn a question id (e.g. ``G-PKG-NAME``) into a unique valid field name.""" + safe = re.sub(r"\W", "_", qid).strip("_") + if not safe or safe[0].isdigit(): + safe = f"q_{safe}".rstrip("_") or f"q{index + 1}" + base = safe + counter = 2 + while safe in used: + safe = f"{base}_{counter}" + counter += 1 + return safe + + +def _build_questions_form( + questions: list[dict], +) -> tuple[type[BaseModel], dict[str, str]]: + """Build an elicitation schema (one field per question) + a field->id map. + + Elicitation schemas only allow primitive field types, so every field is a + ``str``: single-choice questions carry their ``options`` as a JSON-schema + ``enum`` (rendered as a dropdown by capable hosts), and multi-select degrades + to a free string whose description lists the options. + """ + fields: dict[str, tuple[type, object]] = {} + field_to_id: dict[str, str] = {} + used: set[str] = set() + for index, question in enumerate(questions): + qid = question.get("id") or f"q{index + 1}" + name = _sanitize_field_name(qid, index, used) + used.add(name) + field_to_id[name] = qid + + prompt = question.get("prompt") or "Your input is required to continue." + options = question.get("options") or [] + multiple = bool(question.get("multiple")) + default = question.get("default") + + description = prompt + field_kwargs: dict[str, object] = {} + if options and not multiple: + field_kwargs["json_schema_extra"] = {"enum": list(options)} + elif options and multiple: + joined = ", ".join(str(o) for o in options) + description = ( + f"{prompt} (choose one or more, comma-separated, from: {joined})" + ) + field_kwargs["description"] = description + + if isinstance(default, str) and default: + field_info = Field(default=default, **field_kwargs) + else: + field_info = Field(..., **field_kwargs) + fields[name] = (str, field_info) + + model = create_model("SkoreQuestions", **fields) # type: ignore[call-overload] + return model, field_to_id + + +async def _answer_questions(ctx: object, args: object) -> str: + """Put a skill gate's questions to the human inline; return JSON answers. + + Returns a JSON ``{question_id: answer}`` map. A decline/cancel yields an empty + map (the hub agent may then apply its defaults). Raises + :class:`ElicitationUnsupportedError` if the host cannot elicit. + """ + questions = _normalize_questions(args) + form, field_to_id = _build_questions_form(questions) + count = len(questions) + noun = "question" if count == 1 else "questions" + try: + result = await ctx.elicit( # type: ignore[attr-defined] + message=f"The Skore agent needs your input on {count} {noun} to continue.", + schema=form, + ) + except Exception as exc: # noqa: BLE001 - host may not support elicitation + raise ElicitationUnsupportedError( + "this host does not support MCP elicitation, which is required to " + "relay the Skore agent's skill-gate questions to you. Use an " + "elicitation-capable MCP host." + ) from exc + action = getattr(result, "action", None) + data = getattr(result, "data", None) + if action == "accept" and data is not None: + answers = { + qid: str(value) + for name, qid in field_to_id.items() + if (value := getattr(data, name, None)) is not None + } + return json.dumps(answers) + return json.dumps({}) + + +async def _confirm_and_run(ctx: object, workspace, args: object) -> str: + """Ask the user to approve a shell command inline, then run it if approved.""" + command = _command_text(args) + if not command: + return "no command provided" + try: + result = await ctx.elicit( # type: ignore[attr-defined] + message=f"The Skore agent wants to run: {command}", schema=_ConfirmSchema + ) + except Exception: # noqa: BLE001 - host may not support elicitation + return "command declined (host cannot confirm shell commands)" + action = getattr(result, "action", None) + data = getattr(result, "data", None) + approved = action == "accept" and bool(getattr(data, "approve", False)) + if not approved: + return "command declined by the user" + try: + exec_result = await _executor.run_bash(workspace, command) + except Exception as exc: # noqa: BLE001 - surfaced to the hub as tool output + message = f"run_bash failed: {exc}" + await _info(ctx, message) + return message + await _info(ctx, exec_result.summary) + return exec_result.output + + +async def _run_data_plane(ctx: object, client: object, workspace, name, args) -> str: + """Execute a data-plane tool locally; emit a compact summary; return output.""" + parsed = args + if isinstance(args, str): + try: + parsed = json.loads(args or "{}") + except json.JSONDecodeError: + parsed = {} + if not isinstance(parsed, dict): + parsed = {} + try: + if name == "read_file": + result = await _executor.read_file(workspace, parsed.get("path", "")) + elif name == "write_file": + result = await _executor.write_file( + workspace, parsed.get("path", ""), parsed.get("content", "") + ) + elif name == "edit_file": + result = await _executor.edit_file( + workspace, + parsed.get("path", ""), + parsed.get("old", ""), + parsed.get("new", ""), + ) + elif name == "list_dir": + result = await _executor.list_dir(workspace, parsed.get("path", ".")) + elif name == MATERIALIZE_TOOL: + template = await client.fetch_template( # type: ignore[attr-defined] + parsed.get("skill", ""), parsed.get("template_path", "") + ) + result = _executor.materialize( + workspace, + parsed.get("dest_path", ""), + template, + parsed.get("substitutions"), + ) + else: + message = f"unknown tool {name!r}" + await _info(ctx, message) + return message + except Exception as exc: # noqa: BLE001 - surfaced to the hub as tool output + message = f"{name} failed: {exc}" + await _info(ctx, message) + return message + await _info(ctx, result.summary) + return result.output + + +async def _info(ctx: object, message: str) -> None: + """Best-effort progress line to the host (never fatal).""" + try: + await ctx.info(message) # type: ignore[attr-defined] + except Exception: # noqa: BLE001 - host may not support notifications + logger.debug("ctx.info failed: %s", message) + + +async def resolve_tool_calls( + ctx: object, client: object, workspace, tool_calls: list[dict] +) -> dict[str, str]: + """Execute/relay each deferred tool call; return a ``{call_id: output}`` map.""" + results: dict[str, str] = {} + for call in tool_calls: + call_id = call.get("id", "") + fn = call.get("function") or {} + name = fn.get("name", "") + args = fn.get("arguments", "") + if name == ASK_USER_TOOL: + results[call_id] = await _answer_questions(ctx, args) + elif name == RUN_BASH_TOOL: + results[call_id] = await _confirm_and_run(ctx, workspace, args) + elif name in RELAY_TOOLS or name == MATERIALIZE_TOOL: + results[call_id] = await _run_data_plane(ctx, client, workspace, name, args) + else: + message = f"unknown tool {name!r}" + await _info(ctx, message) + results[call_id] = message + return results diff --git a/src/skore_cli/agent/mcp/_hosts.py b/src/skore_cli/agent/mcp/_hosts.py new file mode 100644 index 0000000..7f6e69b --- /dev/null +++ b/src/skore_cli/agent/mcp/_hosts.py @@ -0,0 +1,250 @@ +"""Per-host writers for ``skore agent mcp install``. + +``skore agent mcp install --host `` writes the configuration a given MCP +host needs to launch ``skore agent mcp serve`` over stdio. Hosts store MCP server +definitions differently (a project ``mcp.json``, an ``opencode.json`` block, a +global ``config.toml``), so each writer is bespoke. The resolved hub URL and +workspace are baked into the registered ``serve`` command so the relay points at +the same hub the user logged into. +""" + +from __future__ import annotations + +import json +import tomllib +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from skore_cli._style import console + +# The MCP server id every host registers (the relay tool namespace). +SERVER_NAME = "skore-ml" + +# The CLI subcommand the host launches over stdio. +LAUNCH_ARGS = ["agent", "mcp", "serve"] + +# The executable the host invokes (the installed `skore` entry point). +EXECUTABLE = "skore" + + +@dataclass +class InstallContext: + """Inputs for a host writer.""" + + workspace: Path + hub_url: str | None = None + hub_workspace: str | None = None + + +def _serve_args(ctx: InstallContext) -> list[str]: + """Build the ``serve`` argv, baking in the resolved hub URL/workspace.""" + args = list(LAUNCH_ARGS) + if ctx.hub_url: + args += ["--hub-url", ctx.hub_url] + if ctx.hub_workspace: + args += ["--hub-workspace", ctx.hub_workspace] + return args + + +def _server_block_command(ctx: InstallContext) -> dict[str, Any]: + """Return the standard ``{command, args}`` MCP server block.""" + return {"command": EXECUTABLE, "args": _serve_args(ctx)} + + +def _load_json(path: Path) -> dict: + if not path.exists(): + return {} + try: + return json.loads(path.read_text() or "{}") + except json.JSONDecodeError: + path.rename(path.with_suffix(path.suffix + ".bak")) + return {} + + +def _write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + + +def _write_mcp_servers_json(path: Path, ctx: InstallContext) -> dict[str, Any]: + """Merge a ``mcpServers`` entry into a JSON config (Cursor / Claude Code).""" + config = _load_json(path) + servers = config.setdefault("mcpServers", {}) + servers[SERVER_NAME] = _server_block_command(ctx) + _write_json(path, config) + return {"config": str(path)} + + +def _configure_cursor(ctx: InstallContext) -> dict[str, Any]: + return _write_mcp_servers_json(ctx.workspace / ".cursor" / "mcp.json", ctx) + + +def _configure_claude_code(ctx: InstallContext) -> dict[str, Any]: + return _write_mcp_servers_json(ctx.workspace / ".mcp.json", ctx) + + +def _configure_opencode(ctx: InstallContext) -> dict[str, Any]: + """Write an ``mcp`` block into ``opencode.json`` (local stdio server).""" + path = ctx.workspace / "opencode.json" + config = _load_json(path) + config.setdefault("$schema", "https://opencode.ai/config.json") + mcp = config.setdefault("mcp", {}) + mcp[SERVER_NAME] = { + "type": "local", + "command": [EXECUTABLE, *_serve_args(ctx)], + "enabled": True, + } + _write_json(path, config) + return {"config": str(path)} + + +def _configure_codex(ctx: InstallContext) -> dict[str, Any]: + """Append an idempotent ``[mcp_servers.skore-ml]`` block to ~/.codex/config.toml.""" + config_path = Path.home() / ".codex" / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + section = f"[mcp_servers.{SERVER_NAME}]" + existing = config_path.read_text() if config_path.exists() else "" + if section in existing: + return {"config": str(config_path)} + + args = _serve_args(ctx) + args_toml = ", ".join(json.dumps(arg) for arg in args) + block = f'\n{section}\ncommand = "{EXECUTABLE}"\nargs = [{args_toml}]\n' + with config_path.open("a") as handle: + handle.write(block) + return {"config": str(config_path)} + + +def _configure_generic(ctx: InstallContext) -> dict[str, Any]: + """Print the launch command for any other MCP host to register manually.""" + command = " ".join([EXECUTABLE, *_serve_args(ctx)]) + console.print( + "Register this stdio MCP server with your host (command):\n" + f" [skore.path]{command}[/]" + ) + return {"command": command} + + +@dataclass +class Host: + """A supported MCP host and its config writer.""" + + name: str + label: str + writer: Callable[[InstallContext], dict[str, Any]] + + def configure(self, ctx: InstallContext) -> dict[str, Any]: + return self.writer(ctx) + + +HOSTS: dict[str, Host] = { + "cursor": Host("cursor", "Cursor - .cursor/mcp.json", _configure_cursor), + "claude-code": Host( + "claude-code", "Claude Code - .mcp.json", _configure_claude_code + ), + "opencode": Host( + "opencode", "opencode - opencode.json (mcp block)", _configure_opencode + ), + "codex": Host("codex", "Codex - ~/.codex/config.toml", _configure_codex), + "generic": Host( + "generic", "generic - prints the launch command", _configure_generic + ), +} + +HOST_NAMES = list(HOSTS) + + +@dataclass(frozen=True) +class Installed: + """A host's detected ``skore-ml`` relay registration.""" + + name: str + label: str + config_path: Path + present: bool + serve_args: list[str] | None = None + + +def _codex_config_path() -> Path: + return Path.home() / ".codex" / "config.toml" + + +def _json_server_args(path: Path, *, key: str) -> list[str] | None: + """Return the ``skore-ml`` server's args from a ``{key: {...}}`` JSON config. + + Used for the hosts that store a ``{command, args}`` block (Cursor / Claude + Code, both under ``mcpServers``). Returns ``None`` when the entry is absent. + """ + config = _load_json(path) + entry = (config.get(key) or {}).get(SERVER_NAME) + if not isinstance(entry, dict): + return None + args = entry.get("args") + return list(args) if isinstance(args, list) else [] + + +def _opencode_server_args(path: Path) -> list[str] | None: + """Return the ``skore-ml`` args from ``opencode.json`` (``mcp`` block). + + opencode stores the launch command as a single ``command`` list + ``[EXECUTABLE, *args]``; drop the executable to mirror the other hosts. + """ + config = _load_json(path) + entry = (config.get("mcp") or {}).get(SERVER_NAME) + if not isinstance(entry, dict): + return None + command = entry.get("command") + if isinstance(command, list) and command: + return [str(part) for part in command[1:]] + return [] + + +def _codex_server_args(path: Path) -> list[str] | None: + """Return the ``skore-ml`` args from ``~/.codex/config.toml``.""" + if not path.exists(): + return None + try: + config = tomllib.loads(path.read_text()) + except (OSError, tomllib.TOMLDecodeError): + return None + entry = (config.get("mcp_servers") or {}).get(SERVER_NAME) + if not isinstance(entry, dict): + return None + args = entry.get("args") + return list(args) if isinstance(args, list) else [] + + +def installed(workspace: Path) -> list[Installed]: + """Detect which hosts have the ``skore-ml`` relay registered. + + Read-only: inspects each host's known config location (project files under + ``workspace`` for Cursor/Claude Code/opencode, the global + ``~/.codex/config.toml`` for Codex) and reports the baked ``serve`` args. + The ``generic`` host has no file, so it is skipped. + """ + cursor = workspace / ".cursor" / "mcp.json" + claude = workspace / ".mcp.json" + opencode = workspace / "opencode.json" + codex = _codex_config_path() + + probes: list[tuple[str, Path, list[str] | None]] = [ + ("cursor", cursor, _json_server_args(cursor, key="mcpServers")), + ("claude-code", claude, _json_server_args(claude, key="mcpServers")), + ("opencode", opencode, _opencode_server_args(opencode)), + ("codex", codex, _codex_server_args(codex)), + ] + + results: list[Installed] = [] + for name, path, args in probes: + results.append( + Installed( + name=name, + label=HOSTS[name].label, + config_path=path, + present=args is not None, + serve_args=args, + ) + ) + return results diff --git a/src/skore_cli/agent/mcp/_jobs.py b/src/skore_cli/agent/mcp/_jobs.py new file mode 100644 index 0000000..86d4bb5 --- /dev/null +++ b/src/skore_cli/agent/mcp/_jobs.py @@ -0,0 +1,259 @@ +"""Canonical delegation toolset + question/command normalization helpers. + +The delegation *loop* now lives on the hub (see ``hub.agent.tasks``); the relay is +a thin A2A client (:mod:`_a2a_client`) that executes the agent's deferred tool +calls locally (:mod:`_handlers`). This module holds the harness-agnostic pieces +shared by that client: + +* :data:`CANONICAL_TOOLS` -- the fixed tool set advertised to the hub agent (the + hub mirrors them as its deferred external toolset); +* :func:`_normalize_questions` / :func:`_command_text` -- coerce an ``ask_user`` / + ``run_bash`` call's arguments into the structured shapes the relay needs. +""" + +from __future__ import annotations + +import json +import re + +# The reserved tool requiring explicit user approval before the relay runs it. +RUN_BASH_TOOL = "run_bash" +# Tools the relay executes locally (data plane); their bulk output never reaches +# the outer LLM, only a compact activity summary does. +RELAY_TOOLS = {"read_file", "write_file", "edit_file", "list_dir"} +MATERIALIZE_TOOL = "materialize_template" + +# The reserved tool the hub agent calls to pause for a human decision (a skill +# gate). The relay routes it to the user instead of executing it. +ASK_USER_TOOL = "ask_user" + +# A fixed, harness-agnostic tool set advertised to the hub agent. The outer LLM +# maps these to whatever native tools it has; the hub never sees (or depends on) +# a specific harness's tool naming. +CANONICAL_TOOLS: list[dict] = [ + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read a UTF-8 text file from the workspace.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Create or overwrite a workspace file with new content.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["path", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "Replace an exact text span in a workspace file.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "old": {"type": "string"}, + "new": {"type": "string"}, + }, + "required": ["path", "old", "new"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_dir", + "description": "List the entries of a workspace directory.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "run_bash", + "description": "Run a shell command in the workspace and return output.", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": MATERIALIZE_TOOL, + "description": ( + "Deliver a bundled skill template to the workspace VERBATIM. " + "Prefer this over read_file/write_file when a skill instructs you " + "to copy a template and substitute placeholders: the template body " + "is fetched and written locally without passing through the " + "transcript. `template_path` is the skill-relative template path " + "(e.g. templates/experiment.py); `dest_path` is where to write it " + "in the workspace; `substitutions` is a map of the exact " + "placeholder text to its replacement (e.g. " + '{"{{PKG_NAME}}": "digichem"}).' + ), + "parameters": { + "type": "object", + "properties": { + "skill": {"type": "string"}, + "template_path": {"type": "string"}, + "dest_path": {"type": "string"}, + "substitutions": {"type": "object"}, + }, + "required": ["skill", "template_path", "dest_path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": ASK_USER_TOOL, + "description": ( + "Ask the human user one or more questions at a skill gate and wait " + "for THEIR answers (the relay surfaces them to the human; they are " + "never answered by the outer model). Pass ONE entry per gate/" + "decision in `questions` (never concatenate several gates into a " + "single prompt). Set each `id` to the gate code (e.g. G-PKG-NAME), " + "and fill `options`/`default` from the gate definition when known. " + "Answers come back keyed by `id`." + ), + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "prompt": {"type": "string"}, + "options": { + "type": "array", + "items": {"type": "string"}, + }, + "multiple": {"type": "boolean"}, + "default": {"type": "string"}, + }, + "required": ["prompt"], + }, + } + }, + "required": ["questions"], + }, + }, + }, +] + + +# A gate code like ``G-PKG-NAME`` used to identify a question deterministically. +_GATE_CODE = re.compile(r"\bG-[A-Z0-9][A-Z0-9-]*\b") +# Enumerated-list item markers, e.g. ``1.``/``2)`` at line start or inline. +_ENUM_MARKER = re.compile(r"(?:^|\s)\d+[.)]\s+") +# A gate code anchored at the start of a chunk (used as a fallback split point). +_GATE_CODE_SPLIT = re.compile(r"(?=\bG-[A-Z0-9][A-Z0-9-]*\b)") + + +def _coerce_question(raw: object, index: int) -> dict: + """Coerce one question item into the full ``{id, prompt, options, ...}`` shape.""" + if isinstance(raw, str): + raw = {"prompt": raw} + if not isinstance(raw, dict): + raw = {"prompt": str(raw)} + prompt = "" + for key in ("prompt", "question", "message", "text"): + value = raw.get(key) + if isinstance(value, str) and value.strip(): + prompt = value.strip() + break + raw_options = raw.get("options") + options = [str(o) for o in raw_options] if isinstance(raw_options, list) else [] + qid = raw.get("id") + if not isinstance(qid, str) or not qid.strip(): + match = _GATE_CODE.search(prompt) + qid = match.group(0) if match else f"q{index + 1}" + default = raw.get("default") + return { + "id": qid, + "prompt": prompt or "The Skore agent needs your input to continue.", + "options": options, + "multiple": bool(raw.get("multiple", False)), + "default": default if isinstance(default, str) else None, + } + + +def _split_questions(text: str) -> list[dict]: + """Fallback: split a single enumerated blob into separate questions. + + Used when the agent crams several gates into one free-text string instead of + the structured ``questions`` array. Splits on numbered markers (``1.``/``2)``) + and derives each id from a leading gate code when present. + """ + text = text.strip() + if not text: + return [_coerce_question("", 0)] + chunks = [c.strip() for c in _ENUM_MARKER.split(text) if c.strip()] + if len(chunks) <= 1: + # No numbered markers: fall back to splitting on gate codes if several + # appear, otherwise treat the whole blob as one question. + if len(_GATE_CODE.findall(text)) > 1: + chunks = [c.strip() for c in _GATE_CODE_SPLIT.split(text) if c.strip()] + else: + return [_coerce_question(text, 0)] + return [_coerce_question(chunk, i) for i, chunk in enumerate(chunks)] + + +def _command_text(args: object) -> str: + """Pull the shell command out of a ``run_bash`` call's arguments.""" + if isinstance(args, str): + try: + args = json.loads(args or "{}") + except json.JSONDecodeError: + return args.strip() + if isinstance(args, dict): + value = args.get("command") + if isinstance(value, str): + return value.strip() + return "" + + +def _normalize_questions(args: object) -> list[dict]: + """Normalize an ``ask_user`` call's arguments into a list of structured questions. + + Accepts the structured ``questions`` array, a legacy single ``question``/ + ``prompt`` string (split heuristically), or empty args. + """ + if isinstance(args, str): + try: + args = json.loads(args or "{}") + except json.JSONDecodeError: + return _split_questions(args) + if isinstance(args, dict): + questions = args.get("questions") + if isinstance(questions, list) and questions: + return [_coerce_question(item, i) for i, item in enumerate(questions)] + for key in ("question", "prompt", "message", "text"): + value = args.get(key) + if isinstance(value, str) and value.strip(): + return _split_questions(value) + return [_coerce_question("", 0)] diff --git a/src/skore_cli/agent/mcp/_server.py b/src/skore_cli/agent/mcp/_server.py new file mode 100644 index 0000000..50a8238 --- /dev/null +++ b/src/skore_cli/agent/mcp/_server.py @@ -0,0 +1,131 @@ +"""The FastMCP relay backing ``skore agent mcp serve`` (no-poll, A2A-backed). + +The user's outer LLM delegates a machine-learning task to the Skore Hub agent +through a SINGLE blocking tool: + +* ``skore_ml_run(task)`` -- delegate a task and return only when it finishes. + +There is no polling. Internally the relay opens one durable A2A task on the hub +(:mod:`_a2a_client`) and consumes a pushed SSE event stream. The hub agent (its +own model + skills) drives the orchestration server-side; whenever it defers tool +calls, the relay executes them locally (:mod:`_handlers`) -- file/shell ops on the +real workspace, and skill-gate questions put to the human via MCP elicitation -- +and posts the results back, all within the one blocking call. Progress is streamed +to the user via ``ctx.info``/``ctx.report_progress`` (so the outer model is not +re-invoked just to relay activity), and the bulk of file/command output never +enters the outer LLM's context. + +The server speaks MCP over stdio, so nothing here may write to stdout; logging +goes to stderr via the module logger. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from pathlib import Path + +from mcp.server.fastmcp import Context, FastMCP + +from skore_cli.agent.mcp._a2a_client import RelayConfig +from skore_cli.agent.mcp._handlers import ( + ElicitationUnsupportedError, + resolve_tool_calls, +) + +logger = logging.getLogger("skore_cli.agent.mcp") + +_SERVER_INSTRUCTIONS = """\ +These tools delegate machine-learning engineering to the Skore agent -- a +specialized senior ML engineer for scikit-learn / tabular workflows (data +exploration, pipelines, cross-validation, metrics, model selection, experiment +tracking) that works in the current workspace. + +Call skore_ml_run(task=) once and wait for it to return. It is a single +blocking call: the Skore agent runs the whole task server-side, the relay +performs any file/shell operations on the workspace itself, and any skill-gate +question is put to YOU/the user directly (via an interactive prompt) -- you never +read or write files and you never answer the agent's questions on the user's +behalf. The agent's progress is streamed to the user as it happens. When the call +returns, present the result; quote it rather than rephrasing. Let the Skore agent +decide the ML methodology; do not invent steps of your own. +""" + +_RUN_DESCRIPTION = """\ +Delegate a machine-learning engineering task to the Skore agent and return its +final result. This is a SINGLE blocking call -- there is no polling: it returns +only when the task is done (or fails). The relay performs all workspace file and +shell operations itself and surfaces any skill-gate question to the user +interactively, so you never read/write files and never answer those questions +yourself. `task` is the goal in natural language; `workspace` optionally overrides +the directory the agent acts in (defaults to the served workspace). +""" + + +async def _emit_progress(ctx: Context, text: str, step: int) -> None: + """Stream an activity line to the user without re-invoking the outer model.""" + try: + await ctx.info(text) + except Exception: # noqa: BLE001 - host may not support notifications + logger.debug("ctx.info failed") + # Keeps the blocking call alive on hosts that reset tool timeouts on progress; + # harmless when the host ignores progress. + with contextlib.suppress(Exception): + await ctx.report_progress(progress=step, total=None, message=text[:120]) + + +async def drive_task(ctx: Context, client: object, task: str, workspace: Path) -> str: + """Drive one delegation task to completion over the A2A stream (no polling). + + Consumes the hub's pushed events; on ``input-required`` it executes the + deferred tool calls locally (and elicits gates), posts the results back, and + keeps streaming until a terminal event. Returns the final result text. + """ + await _emit_progress(ctx, f"delegating to the Skore agent: {task[:80]}", 0) + step = 0 + try: + async for event in client.events(task): # type: ignore[attr-defined] + kind = event.get("kind") + if kind == "activity": + step += 1 + await _emit_progress(ctx, event.get("text", ""), step) + elif kind == "input-required": + results = await resolve_tool_calls( + ctx, client, workspace, event.get("tool_calls") or [] + ) + await client.send_results(results) # type: ignore[attr-defined] + elif kind == "result": + return event.get("text", "") or ( + "The Skore agent finished with no output." + ) + elif kind == "error": + return ( + f"The Skore agent failed: {event.get('message', 'unknown error')}" + ) + # Stream ended without an explicit result/error event. + if client.state == "completed": # type: ignore[attr-defined] + return client.result or "The Skore agent finished with no output." + if client.state == "failed": # type: ignore[attr-defined] + return f"The Skore agent failed: {client.error or 'unknown error'}" + return f"The Skore agent stopped (state={client.state})." + except ElicitationUnsupportedError as exc: + await client.cancel() # type: ignore[attr-defined] + return f"Cannot complete the task: {exc}" + except asyncio.CancelledError: + await client.cancel() # type: ignore[attr-defined] + raise + + +def build_mcp_server(config: RelayConfig) -> FastMCP: + """Build the FastMCP relay exposing the single ``skore_ml_run`` tool.""" + server = FastMCP("skore-ml", instructions=_SERVER_INSTRUCTIONS) + + @server.tool(name="skore_ml_run", description=_RUN_DESCRIPTION) + async def skore_ml_run( + task: str, ctx: Context, workspace: str | None = None + ) -> str: + target = Path(workspace).resolve() if workspace else config.default_workspace + return await drive_task(ctx, config.make_client(), task, target) + + return server diff --git a/src/skore_cli/agent/model/__init__.py b/src/skore_cli/agent/model/__init__.py new file mode 100644 index 0000000..810ac2e --- /dev/null +++ b/src/skore_cli/agent/model/__init__.py @@ -0,0 +1,10 @@ +"""The ``skore agent model`` command group (agent-as-model integration). + +Exposes the Skore Hub agent as an OpenAI-compatible model: ``install`` wires a +local harness to the hub endpoint and ``status`` reports that wiring. Heavy +``skore``/``textual`` imports stay deferred inside the command callbacks. +""" + +from skore_cli.agent.model._commands import model + +__all__ = ["model"] diff --git a/src/skore_cli/agent/model/_commands.py b/src/skore_cli/agent/model/_commands.py new file mode 100644 index 0000000..8888fbc --- /dev/null +++ b/src/skore_cli/agent/model/_commands.py @@ -0,0 +1,374 @@ +"""The ``skore agent model`` click group: ``install`` and ``status``. + +This is the "agent-as-model" integration: the Skore Hub agent is served behind +an OpenAI-compatible endpoint and a local harness is pointed at it. ``install`` +configures a harness (and records a ``.skore-agent.json`` marker); ``status`` +reports that wiring. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import rich_click as click + +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._harnesses import ( + API_KEY_ENV, + DEFAULT_MODEL_ID, + HARNESS_NAMES, + HARNESSES, + MARKER_FILENAME, + ConfigureContext, + Credential, + base_url, + detect_harnesses, + fetch_workspaces, + resolve_credential, +) + +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli agent model": [ + {"name": "OpenAI-compatible endpoint", "commands": ["install", "status"]}, + ], +} + + +@click.group() +def model() -> None: + """Use the Skore Hub agent as your harness's model (OpenAI-compatible).""" + + +def _is_interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _print_credential(cred: Credential) -> None: + if cred.kind == "api_key": + console.print( + f"Using the API key from [bold]{API_KEY_ENV}[/] " + "[skore.muted](never written to a file; only referenced).[/]" + ) + elif cred.kind == "bearer": + console.print( + "Using your interactive [bold]skore hub login[/] token " + "[skore.muted](refreshed automatically).[/]" + ) + else: + console.print( + f"[yellow]No credential found. Set {API_KEY_ENV} (recommended) or run " + "`skore hub login` for interactive authentication, then re-run.[/]" + ) + + +def _install_skills(workspace: Path, install: bool) -> None: + if not install: + return + console.print( + "[yellow]Note: installing skills locally is OFF by default for IP " + "isolation. The hub agent loads skills server-side; local skills are not " + "needed.[/]" + ) + console.print( + "Installing probabl-skills into the workspace (--skills override) ..." + ) + try: + result = subprocess.run( + [sys.executable, "-m", "skore_cli", "skills", "install", "--all"], + cwd=workspace, + check=False, + capture_output=True, + text=True, + ) + except OSError as error: + console.print(f"[yellow] could not run skills install: {error}[/]") + return + if result.returncode != 0: + console.print( + "[yellow] skills install failed (continuing); run " + "`skore skills install --all` manually.\n " + f"{(result.stderr or '').strip()}[/]" + ) + else: + console.print("[skore.ok] skills installed.[/]") + + +def _pick_harness(workspace: Path) -> str | None: + """Launch the Textual picker (textual imported lazily) and return a name.""" + from skore_cli.agent.app import HarnessPicker + + detected = set(detect_harnesses(workspace)) + rows = [ + (name, harness.label, name in detected) for name, harness in HARNESSES.items() + ] + preselect = next((i for i, row in enumerate(rows) if row[2]), 0) + + app = HarnessPicker(rows, preselect=preselect) + app.run() + return app.result + + +def _pick_workspace(workspaces: list[tuple[str, str]]) -> str | None: + """Launch the Textual workspace picker and return the chosen public id.""" + from skore_cli.agent.app import WorkspacePicker + + app = WorkspacePicker(workspaces) + app.run() + return app.result + + +def _resolve_hub_workspace( + cred: Credential, hub_url: str, hub_workspace: str | None +) -> str | None: + """Resolve the hub workspace the agent should attach to. + + API keys are already workspace-bound server-side, so nothing is attached for + them. For the interactive (bearer) path the workspace must be explicit: from + ``--hub-workspace`` or an interactive picker; otherwise this errors, because + the hub now rejects agent calls that resolve to no workspace. + """ + if cred.kind == "api_key": + if hub_workspace: + console.print( + "[yellow]Ignoring --hub-workspace: the workspace is fixed by your " + f"API key ({API_KEY_ENV}).[/]" + ) + else: + console.print( + f"[skore.muted]Workspace is fixed by your API key ({API_KEY_ENV}).[/]" + ) + return None + + if cred.kind != "bearer": + # No credential: nothing can be attached; install already warns about this. + return None + + if hub_workspace: + return hub_workspace + + try: + workspaces = fetch_workspaces(hub_url, cred) + except Exception as error: # noqa: BLE001 - surfaced as a friendly CLI error + raise click.ClickException( + f"could not list your hub workspaces ({error}); pass " + "--hub-workspace to attach one explicitly." + ) from error + + if not workspaces: + raise click.ClickException( + "you are not a member of any hub workspace; create or join one, then retry." + ) + + if not _is_interactive(): + raise click.UsageError( + "pass --hub-workspace to attach a workspace non-interactively " + f"(one of: {', '.join(public_id for public_id, _ in workspaces)})." + ) + + chosen = _pick_workspace(workspaces) + if chosen is None: + raise click.ClickException("no workspace selected.") + return chosen + + +@model.command("install") +@click.option( + "--workspace", + "-w", + default=".", + type=click.Path(file_okay=False, path_type=Path), + help="Workspace directory to configure (default: current directory).", +) +@click.option( + "--harness", + "-H", + "harness_name", + type=click.Choice(HARNESS_NAMES), + default=None, + help="Harness to configure non-interactively (omit to pick interactively).", +) +@click.option( + "--hub-url", + 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, like `skore hub login`." + ), +) +@click.option( + "--hub-workspace", + default=None, + help=( + "Hub workspace (public id) to attach the agent to. Required for " + "interactive-login (bearer) auth in non-interactive mode; ignored when a " + "workspace-scoped API key is used." + ), +) +@click.option( + "--model-id", + default=DEFAULT_MODEL_ID, + help="Model id advertised by the hub (default: skore-agent).", +) +@click.option( + "--skills/--no-skills", + "install_skills", + default=False, + help="Install probabl-skills locally (default: off; the hub serves skills).", +) +@click.option( + "--session-plugin/--no-session-plugin", + "write_session_plugin", + default=True, + help="opencode only: write the session-binding plugin (default: on).", +) +@click.option( + "--config-file/--no-config-file", + "write_file", + default=True, + help="generic only: also write skore-agent.json (default: on).", +) +def install( + workspace: Path, + harness_name: str | None, + hub_url: str | None, + hub_workspace: str | None, + model_id: str, + install_skills: bool, + write_session_plugin: bool, + write_file: bool, +) -> None: + """Configure an agent harness to talk to the Skore Hub agent. + + Pass ``--harness `` to configure a specific harness non-interactively; + omit it in a terminal to pick one interactively (like ``skore skills``). The + ``generic`` harness just prints the connection values for any other + OpenAI-compatible client. + """ + workspace = workspace.resolve() + if not workspace.is_dir(): + raise click.ClickException(f"workspace does not exist: {workspace}") + + if harness_name is None: + if _is_interactive(): + harness_name = _pick_harness(workspace) + if harness_name is None: + console.print("Nothing selected.") + return + else: + raise click.UsageError( + "Specify --harness to configure non-interactively " + f"(one of: {', '.join(HARNESS_NAMES)})." + ) + + harness = HARNESSES[harness_name] + console.print( + f"Configuring [skore.skill]{harness.name}[/] in: [skore.path]{workspace}[/]" + ) + + cred = resolve_credential() + _print_credential(cred) + + # Resolve the hub address the same way `skore hub login` does (explicit + # --hub-url, else SKORE_HUB_URI, else the public hub). + hub_url = resolve_hub_uri(hub_url, _auth) + + attached_workspace = _resolve_hub_workspace(cred, hub_url, hub_workspace) + if attached_workspace: + console.print( + f"Attaching to hub workspace [skore.skill]{attached_workspace}[/]." + ) + + _install_skills(workspace, install_skills) + + ctx = ConfigureContext( + workspace=workspace, + hub_url=hub_url, + model_id=model_id, + cred=cred, + hub_workspace=attached_workspace, + write_session_plugin=write_session_plugin, + write_file=write_file, + ) + extra = harness.configure(ctx) + + marker = { + "harness": harness.name, + "hub_url": hub_url, + "base_url": base_url(hub_url), + "hub_workspace": attached_workspace, + "model_id": model_id, + "auth": cred.kind, + "session_binding": harness.session_binding, + **extra, + } + marker_path = workspace / MARKER_FILENAME + marker_path.write_text(json.dumps(marker, indent=2) + "\n") + console.print(f"\n[skore.muted]Recorded the setup in {marker_path}.[/]") + + +@model.command("status") +@click.option( + "--workspace", + "-w", + default=".", + type=click.Path(file_okay=False, path_type=Path), + help="Workspace directory to inspect (default: current directory).", +) +def status(workspace: Path) -> None: + """Show the agent wiring for this workspace.""" + workspace = workspace.resolve() + marker_path = workspace / MARKER_FILENAME + if not marker_path.exists(): + raise click.ClickException( + f"no {MARKER_FILENAME} in {workspace}; " + "run `skore agent model install` first." + ) + + marker = json.loads(marker_path.read_text() or "{}") + harness_name = marker.get("harness", "?") + base = marker.get("base_url", "?") + model_id = marker.get("model_id", "?") + auth = marker.get("auth", "none") + binding = marker.get("session_binding", "fallback") + hub_workspace = marker.get("hub_workspace") + + skills_dir = workspace / ".agents" / "skills" + n_skills = len(list(skills_dir.iterdir())) if skills_dir.is_dir() else 0 + skills_note = ( + f"{n_skills} local (override)" if n_skills else "served by hub (none local)" + ) + + auth_note = { + "api_key": f"[skore.ok]API key[/] (from {API_KEY_ENV})", + "bearer": "[skore.ok]interactive token[/]", + "none": "[skore.muted]none[/]", + }.get(auth, auth) + + session_note = ( + "[skore.ok]bound[/] via the X-Skore-Session-Id header" + if binding == "plugin" + else "[skore.muted]via the OpenAI 'user' field, else content-hash fallback[/]" + ) + + workspace_note = ( + f"[skore.skill]{hub_workspace}[/]" + if hub_workspace + else "[skore.muted]bound by API key (no header)[/]" + if auth == "api_key" + else "[skore.muted]none[/]" + ) + + console.print(f"workspace : [skore.path]{workspace}[/]") + console.print(f"harness : [skore.skill]{harness_name}[/]") + console.print(f"hub URL : [skore.path]{base}[/]") + console.print(f"hub ws : {workspace_note}") + console.print(f"model : {model_id}") + console.print(f"auth : {auth_note}") + console.print(f"skills : {skills_note}") + console.print(f"session : {session_note}") diff --git a/src/skore_cli/hub/__init__.py b/src/skore_cli/hub/__init__.py new file mode 100644 index 0000000..e96f0e9 --- /dev/null +++ b/src/skore_cli/hub/__init__.py @@ -0,0 +1,10 @@ +"""The ``skore hub`` command group (authentication + workspace API keys). + +Re-exports the ``hub`` click group. Heavy ``skore``/``httpx``/``textual`` +imports stay deferred inside the command callbacks so building the CLI (and +``--help``) never imports them. +""" + +from skore_cli.hub._commands import hub + +__all__ = ["hub"] diff --git a/src/skore_cli/hub/_api_keys.py b/src/skore_cli/hub/_api_keys.py new file mode 100644 index 0000000..cb9be3f --- /dev/null +++ b/src/skore_cli/hub/_api_keys.py @@ -0,0 +1,305 @@ +"""The ``skore hub api-key`` click group: ``create``, ``list`` and ``revoke``. + +Mint, list and revoke workspace-scoped hub API keys, mirroring the hub UI. Every +command requires a prior ``skore hub login`` (a stored OAuth token); the current +user profile (``/identity/users/me``) provides the workspaces and the +permissions grantable in each. Heavy ``httpx``/``textual`` imports stay deferred +inside the callbacks. +""" + +from __future__ import annotations + +import calendar +import sys +from datetime import UTC, datetime + +import rich_click as click + +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.hub import _client +from skore_cli.hub._client import PERMISSIONS + +# Local literal (avoids importing the heavy ``skore`` package just for help). +API_KEY_ENV = "SKORE_HUB_API_KEY" + +VALIDITY_VALUES = ["1", "3", "6", "never"] + +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli hub api-key": [ + {"name": "Manage", "commands": ["create", "list", "revoke"]}, + ], +} + + +@click.group("api-key") +def api_key() -> None: + """Create, list and revoke workspace-scoped hub API keys.""" + + +def _is_interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _add_months(start: datetime, months: int) -> datetime: + """Add ``months`` to ``start``, clamping the day to the target month's end.""" + index = start.month - 1 + months + year = start.year + index // 12 + month = index % 12 + 1 + day = min(start.day, calendar.monthrange(year, month)[1]) + return start.replace(year=year, month=month, day=day) + + +def _expires_at(validity: str) -> str | None: + """Compute an ISO 8601 UTC expiry from a validity choice (``never`` -> None).""" + if validity == "never": + return None + return _add_months(datetime.now(UTC), int(validity)).isoformat() + + +def _resolve_session( + hub_url: str | None, +) -> tuple[str, str, str, list[_client.Membership]]: + """Resolve hub URL + login token + current user id + workspace memberships.""" + uri = resolve_hub_uri(hub_url, _auth) + token = _client.require_login_token() + user_id, memberships = _client.me(uri, token) + return uri, token, user_id, memberships + + +_HUB_URL_OPTION = click.option( + "--hub-url", + 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, like `skore hub login`." + ), +) + + +def _print_created( + secret: str, + api_key_id: int, + public_id: str, + permissions: list[str], + expires_at: str | None, +) -> None: + console.print( + f"\n[skore.ok]+ created API key[/] " + f"[skore.muted](id {api_key_id}, workspace {public_id})[/]" + ) + console.print("[yellow]Copy it now -- the hub shows the secret only once:[/]") + console.print(f"\n [bold]{secret}[/]\n") + console.print(f" permissions : {', '.join(permissions)}") + console.print(f" expires : {expires_at or 'never'}") + console.print( + "\n[skore.muted]Use it by exporting it in your shell:[/]\n" + f' export {API_KEY_ENV}="{secret}"' + ) + + +@api_key.command("create") +@_HUB_URL_OPTION +@click.option( + "--workspace", + "-w", + default=None, + help="Hub workspace (public id) to scope the key to.", +) +@click.option("--name", default=None, help="A label for the key.") +@click.option( + "--permission", + "-p", + "permissions", + multiple=True, + type=click.Choice(PERMISSIONS), + help="Permission to grant (repeatable). Must be grantable in the workspace.", +) +@click.option( + "--validity", + type=click.Choice(VALIDITY_VALUES), + default="3", + show_default=True, + help="Months until the key expires, or 'never'.", +) +@click.option( + "--yes", + is_flag=True, + default=False, + help="Skip the interactive form even in a terminal (use the flags as given).", +) +def create( + hub_url: str | None, + workspace: str | None, + name: str | None, + permissions: tuple[str, ...], + validity: str, + yes: bool, +) -> None: + """Create a workspace-scoped API key. + + In a terminal, omitting ``--workspace`` or ``--permission`` opens an + interactive form (prefilled with any flags). Non-interactively (or with + ``--yes``), ``--workspace``, ``--name`` and at least one ``--permission`` are + required. + """ + uri, token, user_id, memberships = _resolve_session(hub_url) + if not memberships: + raise click.ClickException( + "you are not a member of any hub workspace; create or join one first." + ) + + by_public = {m.public_id: m for m in memberships} + grantable = {m.workspace_id: sorted(m.permissions) for m in memberships} + + if _is_interactive() and not yes and (workspace is None or not permissions): + from skore_cli.hub.app import ApiKeyForm + + preselect = ( + by_public[workspace].workspace_id if workspace in by_public else None + ) + app = ApiKeyForm( + [(m.workspace_id, m.public_id) for m in memberships], + grantable, + name=name or "", + permissions=list(permissions), + validity=validity, + preselect_workspace_id=preselect, + ) + app.run() + if app.result is None: + console.print("Nothing created.") + return + result = app.result + chosen_name = result.name + workspace_id = result.workspace_id + public_id = result.workspace_public_id + perms = result.permissions + chosen_validity = result.validity + else: + if workspace is None: + raise click.UsageError( + "pass --workspace (one of: " + f"{', '.join(by_public) or 'none'})." + ) + if workspace not in by_public: + raise click.UsageError( + f"unknown workspace '{workspace}'; you belong to: " + f"{', '.join(by_public) or 'none'}." + ) + if not name: + raise click.UsageError("pass --name for the key.") + if not permissions: + raise click.UsageError( + f"pass at least one --permission (one of: {', '.join(PERMISSIONS)})." + ) + membership = by_public[workspace] + ungranted = [p for p in permissions if p not in membership.permissions] + if ungranted: + raise click.UsageError( + f"you cannot grant {', '.join(ungranted)} in '{workspace}'; " + f"grantable: {', '.join(sorted(membership.permissions)) or 'none'}." + ) + chosen_name = name + workspace_id = membership.workspace_id + public_id = membership.public_id + perms = list(permissions) + chosen_validity = validity + + expires_at = _expires_at(chosen_validity) + api_key_id, secret = _client.create_api_key( + uri, + token, + user_id, + name=chosen_name, + permissions=perms, + workspace_id=workspace_id, + expires_at=expires_at, + ) + _print_created(secret, api_key_id, public_id, perms, expires_at) + + +@api_key.command("list") +@_HUB_URL_OPTION +@click.option( + "--workspace", + "-w", + default=None, + help="Only show keys for this workspace (public id).", +) +def list_keys(hub_url: str | None, workspace: str | None) -> None: + """List your hub API keys (metadata only; secrets are never shown).""" + uri, token, user_id, memberships = _resolve_session(hub_url) + public_by_id = {m.workspace_id: m.public_id for m in memberships} + + keys = _client.list_api_keys(uri, token, user_id) + if workspace is not None: + wanted = {m.workspace_id for m in memberships if m.public_id == workspace} + keys = [key for key in keys if key.workspace_id in wanted] + + if not keys: + console.print("[skore.muted]No API keys.[/]") + return + + from rich.table import Table + + table = Table(box=None, pad_edge=False) + for column in ("id", "name", "workspace", "created", "expires"): + table.add_column(column) + for key in keys: + table.add_row( + str(key.id), + key.name or "-", + public_by_id.get(key.workspace_id, str(key.workspace_id)), + (key.created_at or "-")[:19], + (key.expires_at or "never")[:19] if key.expires_at else "never", + ) + console.print(table) + + +@api_key.command("revoke") +@_HUB_URL_OPTION +@click.option( + "--id", "api_key_id", type=int, default=None, help="API key id to revoke." +) +@click.option( + "--yes", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def revoke(hub_url: str | None, api_key_id: int | None, yes: bool) -> None: + """Revoke (delete) an API key by id, or pick one interactively.""" + uri, token, user_id, memberships = _resolve_session(hub_url) + public_by_id = {m.workspace_id: m.public_id for m in memberships} + + if api_key_id is None: + keys = _client.list_api_keys(uri, token, user_id) + if not keys: + console.print("[skore.muted]No API keys to revoke.[/]") + return + if not _is_interactive(): + raise click.UsageError( + "pass --id to revoke non-interactively (ids: " + f"{', '.join(str(key.id) for key in keys)})." + ) + from skore_cli.hub.app import ApiKeyPicker + + labels = [ + ( + key.id, + f"{key.id} {key.name or '-'} " + f"({public_by_id.get(key.workspace_id, key.workspace_id)})", + ) + for key in keys + ] + app = ApiKeyPicker(labels) + app.run() + if app.result is None: + console.print("Nothing revoked.") + return + api_key_id = app.result + + if not yes: + click.confirm(f"Revoke API key {api_key_id}?", abort=True) + _client.delete_api_key(uri, token, user_id, api_key_id) + console.print(f"[skore.ok]-[/] revoked API key {api_key_id}.") diff --git a/src/skore_cli/hub/_client.py b/src/skore_cli/hub/_client.py new file mode 100644 index 0000000..f14e104 --- /dev/null +++ b/src/skore_cli/hub/_client.py @@ -0,0 +1,179 @@ +"""Thin HTTP client for the hub identity API used by ``skore hub api-key``. + +Pure, testable functions over the hub's ``/identity`` endpoints. ``httpx`` is +imported lazily inside the calls so building the CLI stays cheap. All management +calls authenticate with the stored interactive login token as a bearer. + +The current user's profile (``GET /identity/users/me``) is the single source of +truth for both the workspaces the user belongs to (``workspace_id`` + +``public_id``) and the permissions grantable in each of them, mirroring how the +hub UI gates the create form. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import rich_click as click + +from skore_cli._skore import auth as _auth + +# The grantable permissions, kept as local literals so ``--help`` never imports +# the (heavy) ``skore``/hub packages. Mirrors hub ``Permission`` enum values. +PERMISSIONS = [ + "create:project", + "read:project", + "update:project", + "delete:project", + "create:invitation", + "read:invitation", + "delete:invitation", +] + +_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 require_login_token() -> str: + """Return a fresh OAuth bearer token, or error telling the user to log in. + + This gates ``skore hub api-key`` behind a prior ``skore hub login``: an + ``SKORE_HUB_API_KEY`` alone is intentionally not sufficient to mint new keys. + """ + token = _auth("token").fresh_token(relogin=False) + if not token or not token.get("access_token"): + raise click.ClickException( + "not logged in; run `skore hub login` first. API keys are minted with " + "your interactive login, not an existing SKORE_HUB_API_KEY." + ) + return token["access_token"] + + +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 hub login` 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: + """Revoke an 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=f"revoking API key {api_key_id}") diff --git a/src/skore_cli/hub.py b/src/skore_cli/hub/_commands.py similarity index 88% rename from src/skore_cli/hub.py rename to src/skore_cli/hub/_commands.py index 3a827db..66405ff 100644 --- a/src/skore_cli/hub.py +++ b/src/skore_cli/hub/_commands.py @@ -7,6 +7,10 @@ * otherwise an interactive **device-flow** login obtains a (short-lived) token, which is the only thing we persist (so a separate opencode process can use it). +The ``api-key`` subgroup (``skore hub api-key``) mints, lists and revokes +workspace-scoped API keys against the hub, mirroring the hub UI. It requires a +prior ``skore hub login`` (a stored OAuth token). + Heavy ``skore`` imports are deferred into the command callbacks so building the CLI (and ``--help``) never imports the ``skore`` package. """ @@ -17,18 +21,19 @@ import rich_click as click +from skore_cli._skore import URI_ENV, resolve_hub_uri from skore_cli._skore import auth as _auth from skore_cli._style import console -# These mirror ``skore._plugins.hub.authentication`` env var names; kept as local -# literals so showing help never imports the (heavy) ``skore`` package. +# Mirrors ``skore._plugins.hub.authentication`` env var name; kept as a local +# literal so showing help never imports the (heavy) ``skore`` package. API_KEY_ENV = "SKORE_HUB_API_KEY" -URI_ENV = "SKORE_HUB_URI" click.rich_click.COMMAND_GROUPS = { **getattr(click.rich_click, "COMMAND_GROUPS", {}), "cli hub": [ {"name": "Authentication", "commands": ["login", "logout", "status"]}, + {"name": "API keys", "commands": ["api-key"]}, ], } @@ -63,10 +68,7 @@ def login(hub_url: str | None, timeout: int) -> None: user-managed and read from the environment. Without one, run the interactive device flow and persist the resulting token locally. """ - if hub_url: - os.environ[URI_ENV] = hub_url - - uri = _auth("uri").URI() + uri = resolve_hub_uri(hub_url, _auth) if os.environ.get(API_KEY_ENV): console.print( @@ -168,3 +170,10 @@ def status() -> None: raise click.ClickException( "Not authenticated. Set SKORE_HUB_API_KEY or run `skore hub login`." ) + + +# The api-key subgroup is attached here; its heavy deps (httpx/textual) stay +# deferred inside its own command callbacks. +from skore_cli.hub._api_keys import api_key as _api_key_group # noqa: E402 + +hub.add_command(_api_key_group) diff --git a/src/skore_cli/hub/app/__init__.py b/src/skore_cli/hub/app/__init__.py new file mode 100644 index 0000000..02af699 --- /dev/null +++ b/src/skore_cli/hub/app/__init__.py @@ -0,0 +1,10 @@ +"""Textual applications backing the interactive ``skore hub api-key`` commands.""" + +from skore_cli.hub.app._form import ( + VALIDITY_CHOICES, + ApiKeyForm, + ApiKeyFormResult, + ApiKeyPicker, +) + +__all__ = ["VALIDITY_CHOICES", "ApiKeyForm", "ApiKeyFormResult", "ApiKeyPicker"] diff --git a/src/skore_cli/hub/app/_form.py b/src/skore_cli/hub/app/_form.py new file mode 100644 index 0000000..c7cecda --- /dev/null +++ b/src/skore_cli/hub/app/_form.py @@ -0,0 +1,274 @@ +"""Textual apps backing the interactive ``skore hub api-key`` commands. + +``ApiKeyForm`` is a single-screen form (name, workspace, permissions, validity) +mirroring the hub UI's create modal; ``ApiKeyPicker`` is a single-select picker +used by ``revoke``. Both follow the package convention: set ``self.result`` then +``exit()``; the caller reads ``app.result`` after ``app.run()``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import VerticalScroll +from textual.widgets import ( + Footer, + Header, + Input, + Label, + RadioButton, + SelectionList, +) +from textual.widgets.selection_list import Selection + +from skore_cli.skills.app._widgets import AutoRadioSet + +# (value, label) validity choices, mirroring the hub UI (default: 3 months). +VALIDITY_CHOICES: list[tuple[str, str]] = [ + ("1", "1 month"), + ("3", "3 months"), + ("6", "6 months"), + ("never", "Never"), +] +_DEFAULT_VALIDITY_INDEX = 1 + +_INTRO = ( + "Create a workspace-scoped API key.\n" + "Pick the workspace, the permissions to grant, and a validity.\n" + "[reverse] tab [/] next field [reverse] ↑/↓ space [/] choose " + "[reverse] enter [/] create [reverse] esc [/] cancel" +) + + +@dataclass(frozen=True) +class ApiKeyFormResult: + """The choices captured by :class:`ApiKeyForm`.""" + + name: str + workspace_id: int + workspace_public_id: str + permissions: list[str] + validity: str + + +class ApiKeyForm(App[ApiKeyFormResult | None]): + """Interactive form to mint a workspace-scoped API key.""" + + CSS = """ + Screen { + align: center middle; + } + #form { + width: 90%; + height: 90%; + } + .form-intro { + margin: 1 1; + color: $text-muted; + } + .field-label { + margin: 1 1 0 1; + text-style: bold; + } + #name { + margin: 0 1; + width: 100%; + } + AutoRadioSet { + margin: 0 1; + width: 100%; + } + #permissions { + margin: 0 1; + height: auto; + max-height: 9; + border: round $surface-lighten-2; + } + #permissions:focus { + border: thick $accent; + } + """ + + BINDINGS = [ + Binding("enter", "confirm", "Create", priority=True), + Binding("escape", "cancel", "Cancel"), + Binding("tab", "focus_next", "Next", show=False), + Binding("shift+tab", "focus_previous", "Previous", show=False), + ] + + def __init__( + self, + workspaces: list[tuple[int, str]], + grantable: dict[int, list[str]], + *, + name: str = "", + permissions: list[str] | None = None, + validity: str = "3", + preselect_workspace_id: int | None = None, + ) -> None: + super().__init__() + self._workspaces = workspaces + self._grantable = grantable + self._name = name + self._initial_permissions = list(permissions or []) + self._validity_index = next( + (i for i, (value, _) in enumerate(VALIDITY_CHOICES) if value == validity), + _DEFAULT_VALIDITY_INDEX, + ) + self._workspace_index = next( + ( + i + for i, (ws_id, _) in enumerate(workspaces) + if ws_id == preselect_workspace_id + ), + 0, + ) + self.result: ApiKeyFormResult | None = None + + def compose(self) -> ComposeResult: + yield Header() + with VerticalScroll(id="form"): + yield Label(_INTRO, classes="form-intro") + + yield Label("Name", classes="field-label") + yield Input(value=self._name, placeholder="e.g. laptop", id="name") + + yield Label("Workspace", classes="field-label") + with AutoRadioSet(id="workspaces"): + for index, (_, public_id) in enumerate(self._workspaces): + yield RadioButton(public_id, value=index == self._workspace_index) + + yield Label("Permissions", classes="field-label") + yield SelectionList[str](id="permissions") + + yield Label("Validity", classes="field-label") + with AutoRadioSet(id="validity"): + for index, (_, label) in enumerate(VALIDITY_CHOICES): + yield RadioButton(label, value=index == self._validity_index) + yield Footer() + + def on_mount(self) -> None: + self.query_one("#workspaces", AutoRadioSet).select_index(self._workspace_index) + self.query_one("#validity", AutoRadioSet).select_index(self._validity_index) + self._populate_permissions(self._initial_permissions) + self.query_one("#name", Input).focus() + + def _current_workspace_id(self) -> int: + index = self.query_one("#workspaces", AutoRadioSet).pressed_index + index = index if index >= 0 else self._workspace_index + return self._workspaces[index][0] + + def _populate_permissions(self, preselect: list[str]) -> None: + workspace_id = self._current_workspace_id() + grantable = self._grantable.get(workspace_id, []) + options = [ + Selection(permission, permission, permission in preselect) + for permission in grantable + ] + permissions = self.query_one("#permissions", SelectionList) + permissions.clear_options() + if options: + permissions.add_options(options) + + def on_radio_set_changed(self, event: AutoRadioSet.Changed) -> None: + # Switching workspace changes which permissions are grantable; rebuild + # the list (keeping any still-grantable selections). + if event.radio_set.id == "workspaces": + keep = list(self.query_one("#permissions", SelectionList).selected) + self._populate_permissions(keep) + + def action_confirm(self) -> None: + name = self.query_one("#name", Input).value.strip() + if not name: + self.notify("Enter a name for the key.", severity="warning") + return + permissions = list(self.query_one("#permissions", SelectionList).selected) + if not permissions: + self.notify("Select at least one permission.", severity="warning") + return + + workspace_index = self.query_one("#workspaces", AutoRadioSet).pressed_index + if workspace_index < 0: + self.notify("Select a workspace.", severity="warning") + return + validity_index = self.query_one("#validity", AutoRadioSet).pressed_index + validity = VALIDITY_CHOICES[max(validity_index, 0)][0] + + workspace_id, workspace_public_id = self._workspaces[workspace_index] + self.result = ApiKeyFormResult( + name=name, + workspace_id=workspace_id, + workspace_public_id=workspace_public_id, + permissions=permissions, + validity=validity, + ) + self.exit() + + def action_cancel(self) -> None: + self.result = None + self.exit() + + +_PICKER_INTRO = ( + "Choose the API key to revoke.\n" + "[reverse] ↑/↓ [/] choose [reverse] enter [/] confirm [reverse] esc [/] cancel" +) + + +class ApiKeyPicker(App[int | None]): + """Pick a single API key id to revoke.""" + + CSS = """ + Screen { + align: center middle; + } + #picker { + width: 90%; + height: 90%; + } + .picker-intro { + margin: 1 1; + color: $text-muted; + } + AutoRadioSet { + margin: 1 1; + width: 100%; + } + """ + + BINDINGS = [ + Binding("enter", "confirm", "Confirm", priority=True), + Binding("escape", "cancel", "Cancel"), + ] + + def __init__(self, keys: list[tuple[int, str]], *, preselect: int = 0) -> None: + super().__init__() + self._keys = keys + self._preselect = preselect + self.result: int | None = None + + def compose(self) -> ComposeResult: + yield Header() + with VerticalScroll(id="picker"): + yield Label(_PICKER_INTRO, classes="picker-intro") + with AutoRadioSet(id="keys"): + for index, (_, label) in enumerate(self._keys): + yield RadioButton(label, value=index == self._preselect) + yield Footer() + + def on_mount(self) -> None: + self.query_one("#keys", AutoRadioSet).select_index(self._preselect) + + def action_confirm(self) -> None: + index = self.query_one("#keys", AutoRadioSet).pressed_index + if index < 0: + self.notify("Select a key.", severity="warning") + return + self.result = self._keys[index][0] + self.exit() + + def action_cancel(self) -> None: + self.result = None + self.exit() diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index 84601f1..8b52021 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -1,7 +1,7 @@ -"""Tests for the ``skore agent`` commands (status, init guards, skills install). +"""Tests for ``skore agent model`` commands (status, install guards, skills). These complement ``test_agent_workspace.py`` (which already covers the opencode -writer, the init marker and ``_resolve_hub_workspace``) without duplicating it. +writer, the install marker and ``_resolve_hub_workspace``) without duplicating it. """ from __future__ import annotations @@ -9,12 +9,11 @@ import json from types import SimpleNamespace -import pytest from click.testing import CliRunner -from skore_cli.agent import _commands -from skore_cli.agent._commands import init, status from skore_cli.agent._harnesses import MARKER_FILENAME, Credential +from skore_cli.agent.model import _commands +from skore_cli.agent.model._commands import install, status def _write_marker(directory, **overrides): @@ -41,7 +40,7 @@ def test_status_missing_marker_errors(tmp_path): result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) assert result.exit_code != 0 - assert "run `skore agent init`" in result.output + assert "run `skore agent model install`" in result.output def test_status_prints_marker_fields(tmp_path): @@ -78,24 +77,24 @@ def test_status_no_local_skills_note(tmp_path): # --------------------------------------------------------------------------- # -# init guards +# install guards # --------------------------------------------------------------------------- # -def test_init_non_interactive_without_harness_errors(tmp_path, monkeypatch): +def test_install_non_interactive_without_harness_errors(tmp_path, monkeypatch): monkeypatch.setattr(_commands, "_is_interactive", lambda: False) - result = CliRunner().invoke(init, ["--workspace", str(tmp_path), "--no-skills"]) + result = CliRunner().invoke(install, ["--workspace", str(tmp_path), "--no-skills"]) assert result.exit_code != 0 assert "Specify --harness" in result.output -def test_init_nonexistent_workspace_errors(tmp_path): +def test_install_nonexistent_workspace_errors(tmp_path): missing = tmp_path / "does-not-exist" result = CliRunner().invoke( - init, ["--workspace", str(missing), "--harness", "generic", "--no-skills"] + install, ["--workspace", str(missing), "--harness", "generic", "--no-skills"] ) assert result.exit_code != 0 @@ -103,17 +102,17 @@ def test_init_nonexistent_workspace_errors(tmp_path): # --------------------------------------------------------------------------- # -# init happy path (generic harness) +# install happy path (generic harness) # --------------------------------------------------------------------------- # -def test_init_generic_happy_path(tmp_path, monkeypatch): - monkeypatch.setattr( - _commands, "resolve_credential", lambda: Credential("api_key") - ) +def test_install_generic_happy_path(tmp_path, monkeypatch): + monkeypatch.setattr(_commands, "resolve_credential", lambda: Credential("api_key")) + # Resolve the hub URL without importing the (absent) skore package. + monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: url) result = CliRunner().invoke( - init, + install, [ "--workspace", str(tmp_path), @@ -129,6 +128,7 @@ def test_init_generic_happy_path(tmp_path, monkeypatch): marker = json.loads((tmp_path / MARKER_FILENAME).read_text()) assert marker["harness"] == "generic" assert marker["auth"] == "api_key" + assert marker["hub_url"] == "http://hub.test" assert (tmp_path / "skore-agent.json").is_file() diff --git a/tests/test_agent_mcp.py b/tests/test_agent_mcp.py new file mode 100644 index 0000000..0f2b875 --- /dev/null +++ b/tests/test_agent_mcp.py @@ -0,0 +1,914 @@ +"""Tests for the ``skore agent mcp`` delegation relay (no-poll, A2A-backed). + +The relay opens ONE durable task on the hub's A2A endpoint and consumes a pushed +SSE event stream; it executes the agent's deferred tool calls locally and posts +results back, exposing a single blocking ``skore_ml_run`` tool to the outer LLM. +Tests use fakes (no network): a fake A2A client for the relay loop, a fake MCP +context for elicitation, and a fake ``httpx`` for the client's reconnect logic. +The MCP-SDK-dependent server build is guarded with ``importorskip``. +""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace + +import pytest +import rich_click as click +from click.testing import CliRunner + +from skore_cli.agent._harnesses import Credential +from skore_cli.agent.mcp import _a2a_client, _handlers, _hosts, _jobs +from skore_cli.agent.mcp import _commands as mcp_commands +from skore_cli.agent.mcp._a2a_client import RelayConfig +from skore_cli.agent.mcp._commands import ( + _resolve_serve_workspace, + install, + serve, + status, +) +from skore_cli.agent.mcp._jobs import ASK_USER_TOOL, CANONICAL_TOOLS + +# --------------------------------------------------------------------------- # +# fakes +# --------------------------------------------------------------------------- # + + +class _FakeContext: + """A minimal MCP Context fake exposing info(), report_progress() and elicit().""" + + def __init__( + self, + *, + action: str = "accept", + values: dict[str, str] | None = None, + approve: bool = True, + raise_on_elicit: bool = False, + ) -> None: + self.action = action + self.values = values or {} + self.approve = approve + self.raise_on_elicit = raise_on_elicit + self.infos: list[str] = [] + self.elicited: list[str] = [] + + async def info(self, message: str) -> None: + self.infos.append(message) + + async def report_progress(self, **_kwargs) -> None: + return None + + async def elicit(self, message: str, schema): + self.elicited.append(message) + if self.raise_on_elicit: + raise RuntimeError("host does not support elicitation") + if self.action != "accept": + return SimpleNamespace(action=self.action, data=None) + fields = list(schema.model_fields) + if fields == ["approve"]: + return SimpleNamespace( + action="accept", data=SimpleNamespace(approve=self.approve) + ) + data = {name: self.values.get(name, "x") for name in fields} + return SimpleNamespace(action="accept", data=SimpleNamespace(**data)) + + +class _FakeClient: + """A scripted A2A client yielding fixed events; records posted results.""" + + def __init__(self, events: list[dict], *, template: str = "") -> None: + self._events = events + self.sent: list[dict] = [] + self.cancelled = False + self.task_id = "task_fake" + self.state = "submitted" + self.result = "" + self.error = "" + self.template = template + self.template_calls: list[tuple[str, str]] = [] + + async def events(self, task_text: str): + for event in self._events: + kind = event.get("kind") + if kind == "status": + self.state = event.get("state", self.state) + elif kind == "result": + self.state = "completed" + self.result = event.get("text", "") + elif kind == "error": + self.state = "failed" + self.error = event.get("message", "") + yield event + + async def send_results(self, results: dict[str, str]) -> None: + self.sent.append(results) + + async def cancel(self) -> None: + self.cancelled = True + + async def fetch_template(self, skill: str, path: str) -> str: + self.template_calls.append((skill, path)) + return self.template + + +def _tool_call(call_id, name, args): + return { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}, + } + + +def _config(tmp_path): + return RelayConfig( + default_workspace=tmp_path, + hub_url="http://hub.test", + hub_workspace=None, + cred=Credential("api_key"), + ) + + +# --------------------------------------------------------------------------- # +# client transport: auth, headers, payloads +# --------------------------------------------------------------------------- # + + +def test_auth_headers_api_key(monkeypatch): + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + assert _a2a_client._auth_headers(Credential("api_key")) == { + "X-API-Key": "uid:secret" + } + + +def test_auth_headers_bearer(): + assert _a2a_client._auth_headers(Credential("bearer", "tok")) == { + "Authorization": "Bearer tok" + } + + +def test_auth_headers_none(): + assert _a2a_client._auth_headers(Credential("none")) == {} + + +def test_client_headers_include_workspace_and_accept(): + client = _a2a_client.A2AClient( + hub_url="http://hub.test", + cred=Credential("bearer", "tok"), + hub_workspace="ws-1", + tools=[], + ) + sse = client._headers(sse=True) + assert sse["X-Skore-Workspace"] == "ws-1" + assert sse["Authorization"] == "Bearer tok" + assert sse["Accept"] == "text/event-stream" + # The non-streaming POST omits the SSE Accept header. + assert "Accept" not in client._headers(sse=False) + + +def test_endpoint_targets_v1_a2a(): + client = _a2a_client.A2AClient( + hub_url="http://hub.test/", + cred=Credential("none"), + hub_workspace=None, + tools=[], + ) + assert client._endpoint == "http://hub.test/v1/a2a" + + +def test_stream_payload_carries_task_and_tools(): + tools = [{"type": "function", "function": {"name": "read_file"}}] + client = _a2a_client.A2AClient( + hub_url="http://hub.test", + cred=Credential("none"), + hub_workspace=None, + tools=tools, + ) + payload = client._stream_payload("explore the data") + assert payload["method"] == "message/stream" + message = payload["params"]["message"] + assert message["parts"][0]["text"] == "explore the data" + assert message["metadata"]["tools"] == tools + + +def test_resubscribe_payload_resumes_after_last_seq(): + client = _a2a_client.A2AClient( + hub_url="http://hub.test", cred=Credential("none"), hub_workspace=None, tools=[] + ) + client.task_id = "task_1" + client._last_seq = 4 + payload = client._resubscribe_payload() + assert payload["method"] == "tasks/resubscribe" + assert payload["params"] == {"id": "task_1", "cursor": 5} + + +def test_track_updates_state_task_id_and_seq(): + client = _a2a_client.A2AClient( + hub_url="http://hub.test", cred=Credential("none"), hub_workspace=None, tools=[] + ) + client._track({"seq": 0, "task_id": "task_9", "kind": "status", "state": "working"}) + assert client.task_id == "task_9" + assert client.state == "working" + assert client._last_seq == 0 + client._track({"seq": 3, "kind": "result", "text": "done"}) + assert client.state == "completed" + assert client.result == "done" + assert client._last_seq == 3 + + +def test_is_terminal_detects_end_events(): + assert _a2a_client.A2AClient._is_terminal({"kind": "result"}) + assert _a2a_client.A2AClient._is_terminal({"kind": "error"}) + assert _a2a_client.A2AClient._is_terminal({"kind": "status", "state": "canceled"}) + assert not _a2a_client.A2AClient._is_terminal( + {"kind": "status", "state": "working"} + ) + assert not _a2a_client.A2AClient._is_terminal({"kind": "activity", "text": "x"}) + + +# --------------------------------------------------------------------------- # +# SSE parsing +# --------------------------------------------------------------------------- # + + +def test_iter_sse_parses_frames(): + async def _lines(): + for line in [ + "id: 0", + 'data: {"seq": 0, "kind": "status", "state": "working"}', + "", + ": keep-alive comment", + "id: 1", + 'data: {"seq": 1, "kind": "result", "text": "hi"}', + "", + ]: + yield line + + async def _collect(): + return [event async for event in _a2a_client.iter_sse(_lines())] + + events = asyncio.run(_collect()) + assert [e["kind"] for e in events] == ["status", "result"] + assert events[1]["text"] == "hi" + + +# --------------------------------------------------------------------------- # +# client events(): reconnect via resubscribe on a dropped stream +# --------------------------------------------------------------------------- # + + +class _FakeStream: + def __init__(self, lines, status=200): + self._lines = lines + self.status_code = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def aread(self): + return b"{}" + + async def aiter_lines(self): + for line in self._lines: + if isinstance(line, Exception): + raise line + yield line + + +def _sse(seq, payload): + return [f"id: {seq}", f"data: {json.dumps(payload)}", ""] + + +def test_events_resubscribes_after_drop(monkeypatch): + import httpx + + # First connection: emits seq 0/1 then drops mid-stream. The reconnect must + # resume from seq 2 and complete. + scripts = [ + [ + *_sse( + 0, {"seq": 0, "task_id": "task_z", "kind": "status", "state": "working"} + ), + *_sse(1, {"seq": 1, "kind": "activity", "text": "step"}), + httpx.ReadError("connection reset"), + ], + [*_sse(2, {"seq": 2, "kind": "result", "text": "finished"})], + ] + seen_payloads: list[dict] = [] + + class _FakeAsyncClient: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def stream(self, method, url, *, json=None, **k): + seen_payloads.append(json) + return _FakeStream(scripts.pop(0)) + + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + + client = _a2a_client.A2AClient( + hub_url="http://hub.test", cred=Credential("none"), hub_workspace=None, tools=[] + ) + + async def _collect(): + return [event async for event in client.events("do ml")] + + events = asyncio.run(_collect()) + assert [e["kind"] for e in events] == ["status", "activity", "result"] + assert client.result == "finished" + # The second request was a resubscribe resuming from the last seen seq + 1. + assert seen_payloads[0]["method"] == "message/stream" + assert seen_payloads[1]["method"] == "tasks/resubscribe" + assert seen_payloads[1]["params"] == {"id": "task_z", "cursor": 2} + + +def test_events_raises_on_http_error(monkeypatch): + import httpx + + class _FakeAsyncClient: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def stream(self, method, url, **k): + return _FakeStream([], status=403) + + monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) + client = _a2a_client.A2AClient( + hub_url="http://hub.test", cred=Credential("none"), hub_workspace=None, tools=[] + ) + + async def _collect(): + return [event async for event in client.events("x")] + + with pytest.raises(_a2a_client.A2AClientError): + asyncio.run(_collect()) + + +# --------------------------------------------------------------------------- # +# canonical toolset (shared schema) +# --------------------------------------------------------------------------- # + + +def test_canonical_tools_include_ask_user(): + names = [t["function"]["name"] for t in CANONICAL_TOOLS] + assert ASK_USER_TOOL in names + assert {"read_file", "write_file", "run_bash", "materialize_template"} <= set(names) + + +def test_ask_user_tool_advertises_questions_array(): + ask = next(t for t in CANONICAL_TOOLS if t["function"]["name"] == ASK_USER_TOOL) + params = ask["function"]["parameters"] + assert params["required"] == ["questions"] + questions = params["properties"]["questions"] + assert questions["type"] == "array" + item_props = questions["items"]["properties"] + assert {"id", "prompt", "options", "multiple", "default"} <= set(item_props) + + +def test_normalize_questions_structured_passthrough(): + args = { + "questions": [ + {"id": "G-A", "prompt": "Pick env", "options": ["pixi", "uv"]}, + {"prompt": "Tabular lib?", "default": "pandas"}, + ] + } + qs = _jobs._normalize_questions(args) + assert [q["id"] for q in qs] == ["G-A", "q2"] + assert qs[0]["options"] == ["pixi", "uv"] + assert qs[1]["prompt"] == "Tabular lib?" + assert qs[1]["default"] == "pandas" + + +def test_normalize_questions_legacy_single_string(): + qs = _jobs._normalize_questions('{"question": "Which CV?"}') + assert len(qs) == 1 + assert qs[0]["prompt"] == "Which CV?" + assert _jobs._normalize_questions("{}")[0]["prompt"] + + +def test_normalize_questions_splits_enumerated_blob(): + text = "1. G-PKG-NAME what package name? 2. G-ENV-MGR which environment manager?" + qs = _jobs._normalize_questions({"question": text}) + assert [q["id"] for q in qs] == ["G-PKG-NAME", "G-ENV-MGR"] + + +# --------------------------------------------------------------------------- # +# elicitation form +# --------------------------------------------------------------------------- # + + +def test_build_questions_form_options_default_and_sanitized_ids(): + questions = [ + { + "id": "G-PKG-NAME", + "prompt": "Package name?", + "options": [], + "multiple": False, + "default": "digichem", + }, + { + "id": "G-TABULAR", + "prompt": "Tabular lib?", + "options": ["pandas", "polars"], + "multiple": False, + "default": None, + }, + { + "id": "G-EXTRAS", + "prompt": "Extras?", + "options": ["a", "b", "c"], + "multiple": True, + "default": None, + }, + ] + model, field_to_id = _handlers._build_questions_form(questions) + schema = model.model_json_schema() + props = schema["properties"] + + assert field_to_id == { + "G_PKG_NAME": "G-PKG-NAME", + "G_TABULAR": "G-TABULAR", + "G_EXTRAS": "G-EXTRAS", + } + assert props["G_PKG_NAME"]["default"] == "digichem" + assert "G_PKG_NAME" not in schema.get("required", []) + assert props["G_TABULAR"]["enum"] == ["pandas", "polars"] + assert "G_TABULAR" in schema["required"] + assert props["G_EXTRAS"]["type"] == "string" + assert "enum" not in props["G_EXTRAS"] + assert "comma-separated" in props["G_EXTRAS"]["description"] + + +# --------------------------------------------------------------------------- # +# tool-call resolution (data plane + gates) +# --------------------------------------------------------------------------- # + + +def test_resolve_write_file_executes_and_summarizes(tmp_path): + ctx = _FakeContext() + client = _FakeClient([]) + calls = [_tool_call("c1", "write_file", {"path": "out/foo.txt", "content": "hi\n"})] + results = asyncio.run(_handlers.resolve_tool_calls(ctx, client, tmp_path, calls)) + assert (tmp_path / "out" / "foo.txt").read_text() == "hi\n" + assert "wrote out/foo.txt" in results["c1"] + assert any("wrote out/foo.txt" in m for m in ctx.infos) + + +def test_resolve_read_file_returns_content(tmp_path): + (tmp_path / "data.txt").write_text("hello\nworld\n") + ctx = _FakeContext() + calls = [_tool_call("c1", "read_file", {"path": "data.txt"})] + results = asyncio.run( + _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) + ) + assert results["c1"] == "hello\nworld\n" + + +def test_resolve_read_file_error_is_surfaced(tmp_path): + ctx = _FakeContext() + calls = [_tool_call("c1", "read_file", {"path": "missing.txt"})] + results = asyncio.run( + _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) + ) + assert "read_file failed" in results["c1"] + + +def test_resolve_materialize_fetches_template_and_substitutes(tmp_path): + ctx = _FakeContext() + client = _FakeClient([], template="name = ''\n") + calls = [ + _tool_call( + "m1", + "materialize_template", + { + "skill": "demo", + "template_path": "templates/exp.py", + "dest_path": "exp.py", + "substitutions": {"": "churn"}, + }, + ) + ] + results = asyncio.run(_handlers.resolve_tool_calls(ctx, client, tmp_path, calls)) + assert (tmp_path / "exp.py").read_text() == "name = 'churn'\n" + assert client.template_calls == [("demo", "templates/exp.py")] + assert "materialized exp.py" in results["m1"] + + +def test_resolve_run_bash_approve_runs(tmp_path): + ctx = _FakeContext(approve=True) + calls = [_tool_call("c1", "run_bash", {"command": "echo hi"})] + results = asyncio.run( + _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) + ) + assert "exit code: 0" in results["c1"] + assert "hi" in results["c1"] + assert ctx.elicited # the user was asked to confirm + + +def test_resolve_run_bash_decline_does_not_run(tmp_path): + ctx = _FakeContext(approve=False) + calls = [_tool_call("c1", "run_bash", {"command": "rm -rf /"})] + results = asyncio.run( + _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) + ) + assert "declined" in results["c1"] + + +def test_resolve_ask_user_elicits_and_keys_answers(tmp_path): + ctx = _FakeContext(action="accept", values={"G_TGT": "churn"}) + calls = [ + _tool_call( + "a1", ASK_USER_TOOL, {"questions": [{"id": "G-TGT", "prompt": "Target?"}]} + ) + ] + results = asyncio.run( + _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) + ) + assert json.loads(results["a1"]) == {"G-TGT": "churn"} + assert ctx.elicited + + +def test_resolve_ask_user_decline_returns_empty(tmp_path): + ctx = _FakeContext(action="decline") + calls = [ + _tool_call( + "a1", ASK_USER_TOOL, {"questions": [{"id": "G-TGT", "prompt": "Target?"}]} + ) + ] + results = asyncio.run( + _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) + ) + assert json.loads(results["a1"]) == {} + + +def test_resolve_ask_user_unsupported_elicitation_raises(tmp_path): + ctx = _FakeContext(raise_on_elicit=True) + calls = [ + _tool_call( + "a1", ASK_USER_TOOL, {"questions": [{"id": "G-TGT", "prompt": "Target?"}]} + ) + ] + with pytest.raises(_handlers.ElicitationUnsupportedError): + asyncio.run(_handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls)) + + +# --------------------------------------------------------------------------- # +# drive_task: the blocking relay loop +# --------------------------------------------------------------------------- # + + +def test_drive_task_executes_tools_and_returns_result(tmp_path): + from skore_cli.agent.mcp._server import drive_task + + events = [ + {"seq": 0, "task_id": "t", "kind": "status", "state": "working"}, + { + "seq": 1, + "kind": "input-required", + "tool_calls": [ + _tool_call("w1", "write_file", {"path": "a.txt", "content": "x"}), + _tool_call( + "a1", ASK_USER_TOOL, {"questions": [{"id": "G-A", "prompt": "?"}]} + ), + ], + }, + {"seq": 2, "kind": "activity", "text": "scaffolding"}, + {"seq": 3, "kind": "result", "text": "All done."}, + ] + client = _FakeClient(events) + ctx = _FakeContext(values={"G_A": "pick"}) + + result = asyncio.run(drive_task(ctx, client, "build a model", tmp_path)) + assert result == "All done." + # The write executed locally; the gate was elicited; both fed back to the hub. + assert (tmp_path / "a.txt").read_text() == "x" + assert client.sent == [{"w1": "wrote a.txt (1 lines)", "a1": '{"G-A": "pick"}'}] + assert any("scaffolding" in m for m in ctx.infos) + + +def test_drive_task_surfaces_error(tmp_path): + from skore_cli.agent.mcp._server import drive_task + + events = [ + {"seq": 0, "kind": "status", "state": "working"}, + {"seq": 1, "kind": "error", "message": "workspace not found"}, + ] + result = asyncio.run(drive_task(_FakeContext(), _FakeClient(events), "x", tmp_path)) + assert "workspace not found" in result + + +def test_drive_task_unsupported_elicitation_cancels(tmp_path): + from skore_cli.agent.mcp._server import drive_task + + events = [ + {"seq": 0, "kind": "status", "state": "working"}, + { + "seq": 1, + "kind": "input-required", + "tool_calls": [ + _tool_call( + "a1", ASK_USER_TOOL, {"questions": [{"id": "G-A", "prompt": "?"}]} + ) + ], + }, + ] + client = _FakeClient(events) + ctx = _FakeContext(raise_on_elicit=True) + result = asyncio.run(drive_task(ctx, client, "x", tmp_path)) + assert "does not support MCP elicitation" in result + assert client.cancelled + + +# --------------------------------------------------------------------------- # +# host writers +# --------------------------------------------------------------------------- # + + +def test_serve_args_embeds_hub_url_and_workspace(tmp_path): + ctx = _hosts.InstallContext( + workspace=tmp_path, hub_url="http://hub.test", hub_workspace="ws-1" + ) + assert _hosts._serve_args(ctx) == [ + "agent", + "mcp", + "serve", + "--hub-url", + "http://hub.test", + "--hub-workspace", + "ws-1", + ] + + +def test_install_cursor_writes_mcp_json(tmp_path): + _hosts.HOSTS["cursor"].configure(_hosts.InstallContext(workspace=tmp_path)) + config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) + server = config["mcpServers"]["skore-ml"] + assert server["command"] == "skore" + assert server["args"] == ["agent", "mcp", "serve"] + + +def test_install_cursor_embeds_flags(tmp_path): + ctx = _hosts.InstallContext( + workspace=tmp_path, hub_url="http://hub.test", hub_workspace="ws-1" + ) + _hosts.HOSTS["cursor"].configure(ctx) + config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) + args = config["mcpServers"]["skore-ml"]["args"] + assert args[-4:] == ["--hub-url", "http://hub.test", "--hub-workspace", "ws-1"] + + +def test_install_claude_code_writes_mcp_json(tmp_path): + _hosts.HOSTS["claude-code"].configure(_hosts.InstallContext(workspace=tmp_path)) + config = json.loads((tmp_path / ".mcp.json").read_text()) + assert config["mcpServers"]["skore-ml"]["command"] == "skore" + + +def test_install_opencode_writes_mcp_block(tmp_path): + _hosts.HOSTS["opencode"].configure(_hosts.InstallContext(workspace=tmp_path)) + config = json.loads((tmp_path / "opencode.json").read_text()) + entry = config["mcp"]["skore-ml"] + assert entry["type"] == "local" + assert entry["command"] == ["skore", "agent", "mcp", "serve"] + assert entry["enabled"] is True + + +def test_install_codex_appends_idempotent_block(tmp_path, monkeypatch): + monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) + _hosts.HOSTS["codex"].configure(_hosts.InstallContext(workspace=tmp_path)) + config_path = tmp_path / ".codex" / "config.toml" + text = config_path.read_text() + assert "[mcp_servers.skore-ml]" in text + assert 'command = "skore"' in text + _hosts.HOSTS["codex"].configure(_hosts.InstallContext(workspace=tmp_path)) + assert config_path.read_text().count("[mcp_servers.skore-ml]") == 1 + + +def test_install_generic_prints_command(tmp_path, monkeypatch): + printed: list[str] = [] + monkeypatch.setattr( + _hosts, + "console", + SimpleNamespace(print=lambda *a, **k: printed.append(" ".join(map(str, a)))), + ) + _hosts.HOSTS["generic"].configure(_hosts.InstallContext(workspace=tmp_path)) + assert any("skore agent mcp serve" in line for line in printed) + + +def test_install_backs_up_invalid_json(tmp_path): + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text("{ not json") + _hosts.HOSTS["cursor"].configure(_hosts.InstallContext(workspace=tmp_path)) + assert (tmp_path / ".cursor" / "mcp.json.bak").is_file() + config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) + assert config["mcpServers"]["skore-ml"] + + +# --------------------------------------------------------------------------- # +# install command (CliRunner) +# --------------------------------------------------------------------------- # + + +def test_install_command_writes_config(tmp_path, monkeypatch): + monkeypatch.setattr(mcp_commands, "resolve_credential", lambda: Credential("none")) + monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") + result = CliRunner().invoke( + install, ["--host", "cursor", "--workspace", str(tmp_path)] + ) + assert result.exit_code == 0, result.output + assert (tmp_path / ".cursor" / "mcp.json").is_file() + + +def test_install_command_nonexistent_workspace_errors(tmp_path): + missing = tmp_path / "nope" + result = CliRunner().invoke( + install, ["--host", "cursor", "--workspace", str(missing)] + ) + assert result.exit_code != 0 + assert "workspace does not exist" in result.output + + +def test_install_command_bearer_embeds_workspace_and_url(tmp_path, monkeypatch): + monkeypatch.setattr( + mcp_commands, "resolve_credential", lambda: Credential("bearer", "tok") + ) + monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") + result = CliRunner().invoke( + install, + ["--host", "cursor", "--workspace", str(tmp_path), "--hub-workspace", "ws-1"], + ) + assert result.exit_code == 0, result.output + args = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())["mcpServers"][ + "skore-ml" + ]["args"] + assert args == [ + "agent", + "mcp", + "serve", + "--hub-url", + "http://hub.test", + "--hub-workspace", + "ws-1", + ] + + +def test_install_command_api_key_ignores_hub_workspace(tmp_path, monkeypatch): + monkeypatch.setattr( + mcp_commands, "resolve_credential", lambda: Credential("api_key") + ) + monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") + result = CliRunner().invoke( + install, + ["--host", "cursor", "--workspace", str(tmp_path), "--hub-workspace", "ws-1"], + ) + assert result.exit_code == 0, result.output + args = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())["mcpServers"][ + "skore-ml" + ]["args"] + assert args == ["agent", "mcp", "serve", "--hub-url", "http://hub.test"] + + +# --------------------------------------------------------------------------- # +# installed() detection + status command +# --------------------------------------------------------------------------- # + + +def test_installed_reports_registered_host(tmp_path, monkeypatch): + # Point codex's global config at the temp dir so nothing on disk leaks in. + monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) + ctx = _hosts.InstallContext( + workspace=tmp_path, hub_url="http://hub.test", hub_workspace="ws-1" + ) + _hosts.HOSTS["cursor"].configure(ctx) + + by_name = {row.name: row for row in _hosts.installed(tmp_path)} + cursor = by_name["cursor"] + assert cursor.present is True + assert cursor.serve_args[-4:] == [ + "--hub-url", + "http://hub.test", + "--hub-workspace", + "ws-1", + ] + # opencode was never configured here. + assert by_name["opencode"].present is False + assert by_name["opencode"].serve_args is None + + +def test_installed_reads_opencode_and_codex(tmp_path, monkeypatch): + monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) + _hosts.HOSTS["opencode"].configure(_hosts.InstallContext(workspace=tmp_path)) + _hosts.HOSTS["codex"].configure(_hosts.InstallContext(workspace=tmp_path)) + + by_name = {row.name: row for row in _hosts.installed(tmp_path)} + # opencode stores [EXECUTABLE, *args]; the executable is dropped. + assert by_name["opencode"].serve_args == ["agent", "mcp", "serve"] + assert by_name["codex"].present is True + assert by_name["codex"].serve_args == ["agent", "mcp", "serve"] + + +def test_status_command_reports_registered(tmp_path, monkeypatch): + monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") + monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) + _hosts.HOSTS["cursor"].configure( + _hosts.InstallContext(workspace=tmp_path, hub_url="http://hub.test") + ) + + result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "http://hub.test" in result.output + assert "cursor" in result.output + assert "not set" in result.output + + +def test_status_command_nothing_installed(tmp_path, monkeypatch): + monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + + result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "not registered" in result.output + assert "set" in result.output + + +# --------------------------------------------------------------------------- # +# serve guards +# --------------------------------------------------------------------------- # + + +def test_resolve_serve_workspace_api_key_returns_none(): + assert _resolve_serve_workspace(Credential("api_key"), "ws-1") is None + + +def test_resolve_serve_workspace_bearer_uses_flag(): + assert _resolve_serve_workspace(Credential("bearer", "t"), "ws-1") == "ws-1" + + +def test_resolve_serve_workspace_bearer_without_flag_errors(): + with pytest.raises(click.UsageError): + _resolve_serve_workspace(Credential("bearer", "t"), None) + + +def test_serve_nonexistent_workspace_errors(tmp_path): + missing = tmp_path / "nope" + result = CliRunner().invoke(serve, ["--workspace", str(missing)]) + assert result.exit_code != 0 + assert "workspace does not exist" in result.output + + +def test_serve_no_credential_errors(tmp_path, monkeypatch): + monkeypatch.setattr(mcp_commands, "resolve_credential", lambda: Credential("none")) + result = CliRunner().invoke(serve, ["--workspace", str(tmp_path)]) + assert result.exit_code != 0 + assert "no hub credential" in result.output + + +# --------------------------------------------------------------------------- # +# executor path confinement +# --------------------------------------------------------------------------- # + + +def test_executor_confines_paths_to_workspace(tmp_path): + from skore_cli.agent.mcp import _executor + + with pytest.raises(_executor.ExecError): + _executor._safe_path(tmp_path, "../escape.txt") + with pytest.raises(_executor.ExecError): + _executor._safe_path(tmp_path, "/etc/passwd") + resolved = _executor._safe_path(tmp_path, "sub/dir/file.txt") + assert str(resolved).startswith(str(tmp_path.resolve())) + + +# --------------------------------------------------------------------------- # +# MCP server build (requires the `mcp` SDK) +# --------------------------------------------------------------------------- # + + +def test_build_mcp_server_exposes_single_run_tool(tmp_path): + pytest.importorskip("mcp") + from skore_cli.agent.mcp._server import build_mcp_server + + server = build_mcp_server(_config(tmp_path)) + + async def _names(): + return sorted(t.name for t in await server.list_tools()) + + assert asyncio.run(_names()) == ["skore_ml_run"] diff --git a/tests/test_agent_workspace.py b/tests/test_agent_workspace.py index ef7336c..07de71e 100644 --- a/tests/test_agent_workspace.py +++ b/tests/test_agent_workspace.py @@ -1,4 +1,4 @@ -"""Tests for attaching the agent to a hub workspace (`skore agent init`).""" +"""Tests for attaching the agent to a hub workspace (`skore agent model install`).""" from __future__ import annotations @@ -8,14 +8,14 @@ import rich_click as click from click.testing import CliRunner -from skore_cli.agent import _commands -from skore_cli.agent._commands import init from skore_cli.agent._harnesses import ( + HARNESSES, WORKSPACE_HEADER, ConfigureContext, Credential, - HARNESSES, ) +from skore_cli.agent.model import _commands +from skore_cli.agent.model._commands import install def _opencode_headers(workspace, cred, hub_workspace): @@ -43,19 +43,19 @@ def test_opencode_writes_workspace_header_for_bearer(tmp_path): def test_opencode_no_workspace_header_for_api_key(tmp_path, monkeypatch): monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") # API keys are workspace-bound server-side: no header is written. - headers = _opencode_headers( - tmp_path, Credential("api_key"), hub_workspace=None - ) + headers = _opencode_headers(tmp_path, Credential("api_key"), hub_workspace=None) assert WORKSPACE_HEADER not in headers assert headers["X-API-Key"] == "{env:SKORE_HUB_API_KEY}" -def test_init_records_attached_workspace_in_marker(tmp_path, monkeypatch): +def test_install_records_attached_workspace_in_marker(tmp_path, monkeypatch): monkeypatch.setattr( _commands, "resolve_credential", lambda: Credential("bearer", "tok") ) + # Resolve the hub URL without importing the (absent) skore package. + monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: url) result = CliRunner().invoke( - init, + install, [ "--workspace", str(tmp_path), diff --git a/tests/test_hub.py b/tests/test_hub.py index ad60208..5307c18 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -10,15 +10,14 @@ from types import SimpleNamespace -import importlib - import pytest from click.testing import CliRunner -# ``skore_cli.__init__`` rebinds the ``hub`` attribute to the command group, which -# shadows the submodule for ``import skore_cli.hub as hub``; fetch the real module -# from ``sys.modules`` so we can monkeypatch its ``_auth`` accessor. -hub = importlib.import_module("skore_cli.hub") +# The commands live in ``skore_cli.hub._commands``; import that module so +# ``monkeypatch.setattr(hub, "_auth", ...)`` targets the namespace the command +# callbacks actually resolve ``_auth`` from. +from skore_cli.hub import _commands as hub + hub_cli = hub.hub @@ -156,11 +155,11 @@ def test_login_without_key_runs_device_flow(monkeypatch, no_api_key): def test_login_hub_url_sets_env(monkeypatch, no_api_key): recorder = _Recorder(None) # URI() echoes the env var the command is expected to set from --hub-url. - monkeypatch.setattr( - hub, "_auth", _make_auth(recorder, uri="http://127.0.0.1:9999") - ) + monkeypatch.setattr(hub, "_auth", _make_auth(recorder, uri="http://127.0.0.1:9999")) - result = CliRunner().invoke(hub_cli, ["login", "--hub-url", "http://127.0.0.1:9999"]) + result = CliRunner().invoke( + hub_cli, ["login", "--hub-url", "http://127.0.0.1:9999"] + ) assert result.exit_code == 0, result.output import os @@ -168,6 +167,42 @@ def test_login_hub_url_sets_env(monkeypatch, no_api_key): assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:9999" +# --------------------------------------------------------------------------- # +# resolve_hub_uri (shared by hub login, agent init, agent mcp serve) +# --------------------------------------------------------------------------- # + + +def test_resolve_hub_uri_seeds_env_and_resolves(no_api_key): + import os + + from skore_cli import _skore + + seen = {} + + def fake_auth(name): + seen["name"] = name + return SimpleNamespace(URI=lambda: "http://resolved") + + uri = _skore.resolve_hub_uri("http://127.0.0.1:8000", fake_auth) + + assert seen["name"] == "uri" + assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:8000" + assert uri == "http://resolved" + + +def test_resolve_hub_uri_without_url_leaves_env(no_api_key): + import os + + from skore_cli import _skore + + uri = _skore.resolve_hub_uri( + None, lambda name: SimpleNamespace(URI=lambda: "http://default") + ) + + assert "SKORE_HUB_URI" not in os.environ + assert uri == "http://default" + + # --------------------------------------------------------------------------- # # logout # --------------------------------------------------------------------------- # diff --git a/tests/test_hub_api_key_form.py b/tests/test_hub_api_key_form.py new file mode 100644 index 0000000..db1dc66 --- /dev/null +++ b/tests/test_hub_api_key_form.py @@ -0,0 +1,119 @@ +"""Textual pilot tests for the interactive ``skore hub api-key`` form/picker.""" + +from __future__ import annotations + +from textual.widgets import SelectionList + +from skore_cli.hub.app import ApiKeyForm, ApiKeyPicker + +WORKSPACES = [(7, "ws-a"), (8, "ws-b")] +GRANTABLE = {7: ["create:project", "read:project"], 8: ["read:project"]} + + +# --------------------------------------------------------------------------- # +# ApiKeyForm +# --------------------------------------------------------------------------- # + + +async def test_form_confirm_returns_result(): + app = ApiKeyForm( + WORKSPACES, GRANTABLE, name="laptop", validity="never", preselect_workspace_id=7 + ) + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#permissions", SelectionList).select_all() + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result is not None + assert app.result.name == "laptop" + assert app.result.workspace_id == 7 + assert app.result.workspace_public_id == "ws-a" + assert set(app.result.permissions) == {"create:project", "read:project"} + assert app.result.validity == "never" + + +async def test_form_requires_name_stays_open(): + app = ApiKeyForm(WORKSPACES, GRANTABLE, name="", preselect_workspace_id=7) + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#permissions", SelectionList).select_all() + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert app.is_running is True + assert app.result is None + await pilot.press("escape") + await pilot.pause() + + assert app.result is None + + +async def test_form_requires_permission_stays_open(): + app = ApiKeyForm(WORKSPACES, GRANTABLE, name="x", preselect_workspace_id=7) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert app.is_running is True + assert app.result is None + await pilot.press("escape") + await pilot.pause() + + assert app.result is None + + +async def test_form_cancel_returns_none(): + app = ApiKeyForm(WORKSPACES, GRANTABLE, name="x", preselect_workspace_id=7) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + + assert app.result is None + + +def _permission_values(app): + return [ + option.value for option in app.query_one("#permissions", SelectionList).options + ] + + +async def test_form_permissions_track_workspace(): + # ws-b grants only read:project; switching to it should drop create:project. + app = ApiKeyForm(WORKSPACES, GRANTABLE, name="x", preselect_workspace_id=7) + async with app.run_test() as pilot: + await pilot.pause() + assert sorted(_permission_values(app)) == ["create:project", "read:project"] + + app.query_one("#workspaces").action_next_button() + await pilot.pause() + values = _permission_values(app) + + assert values == ["read:project"] + + +# --------------------------------------------------------------------------- # +# ApiKeyPicker +# --------------------------------------------------------------------------- # + + +async def test_picker_confirms_selection(): + app = ApiKeyPicker([(5, "5 laptop"), (6, "6 ci")], preselect=0) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result == 5 + + +async def test_picker_cancel_returns_none(): + app = ApiKeyPicker([(5, "5 laptop")]) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + + assert app.result is None diff --git a/tests/test_hub_api_keys.py b/tests/test_hub_api_keys.py new file mode 100644 index 0000000..acbe58d --- /dev/null +++ b/tests/test_hub_api_keys.py @@ -0,0 +1,479 @@ +"""Tests for ``skore hub api-key`` (client + create/list/revoke commands). + +The client functions are exercised against an in-memory ``httpx.MockTransport`` +(no network). The command callbacks are exercised via ``CliRunner`` with the +``_client`` functions and ``require_login_token`` monkeypatched, plus a fake +Textual app for the interactive paths (mirroring the skills CLI tests). +""" + +from __future__ import annotations + +import json +from datetime import UTC +from types import SimpleNamespace + +import httpx +import pytest +import rich_click as click +from click.testing import CliRunner + +from skore_cli.hub import _api_keys, _client +from skore_cli.hub._client import ApiKeyInfo, Membership + + +def _transport(handler): + return httpx.MockTransport(handler) + + +# --------------------------------------------------------------------------- # +# _client: require_login_token gate +# --------------------------------------------------------------------------- # + + +def test_require_login_token_errors_without_token(monkeypatch): + monkeypatch.setattr( + _client, + "_auth", + lambda name: SimpleNamespace(fresh_token=lambda relogin=False: None), + ) + with pytest.raises(click.ClickException) as exc: + _client.require_login_token() + assert "not logged in" in str(exc.value) + + +def test_require_login_token_returns_access_token(monkeypatch): + monkeypatch.setattr( + _client, + "_auth", + lambda name: SimpleNamespace( + fresh_token=lambda relogin=False: {"access_token": "abc"} + ), + ) + assert _client.require_login_token() == "abc" + + +# --------------------------------------------------------------------------- # +# _client: HTTP calls via MockTransport +# --------------------------------------------------------------------------- # + + +def test_me_parses_id_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-a", + "role": "admin", + "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(7, "ws-a", frozenset({"read:project", "create:project"})) + ] + + +def test_create_sends_body_and_returns_secret(): + seen = {} + + def handler(request): + seen["path"] = request.url.path + seen["body"] = json.loads(request.content) + return httpx.Response(201, json={"api_key_id": 42, "api_key": "uid:secret"}) + + key_id, secret = _client.create_api_key( + "http://hub.test", + "tok", + "user-1", + name="laptop", + permissions=["read:project"], + workspace_id=7, + expires_at="2026-09-19T00:00:00+00:00", + transport=_transport(handler), + ) + assert (key_id, secret) == (42, "uid:secret") + assert seen["path"] == "/identity/users/user-1/api-keys" + assert seen["body"] == { + "name": "laptop", + "permissions": ["read:project"], + "workspace_id": 7, + "expires_at": "2026-09-19T00:00:00+00:00", + } + + +def test_create_omits_expires_at_when_never(): + seen = {} + + def handler(request): + seen["body"] = json.loads(request.content) + return httpx.Response(201, json={"api_key_id": 1, "api_key": "k"}) + + _client.create_api_key( + "http://hub.test", + "tok", + "u", + name="n", + permissions=["read:project"], + workspace_id=1, + expires_at=None, + transport=_transport(handler), + ) + assert "expires_at" not in seen["body"] + + +def test_list_api_keys_parses(): + def handler(request): + assert request.url.path == "/identity/users/u/api-keys" + return httpx.Response( + 200, + json=[ + { + "id": 1, + "name": "a", + "workspace_id": 7, + "created_at": "2026-01-01T00:00:00Z", + "expires_at": None, + } + ], + ) + + keys = _client.list_api_keys( + "http://hub.test", "tok", "u", transport=_transport(handler) + ) + assert keys == [ApiKeyInfo(1, "a", 7, "2026-01-01T00:00:00Z", None)] + + +def test_delete_api_key_ok(): + def handler(request): + assert request.method == "DELETE" + assert request.url.path == "/identity/users/u/api-keys/5" + return httpx.Response(204) + + _client.delete_api_key( + "http://hub.test", "tok", "u", 5, transport=_transport(handler) + ) + + +@pytest.mark.parametrize( + "code,snippet", + [ + (401, "skore hub login"), + (403, "not allowed"), + (404, "not found"), + (500, "hub request failed"), + ], +) +def test_error_mapping(code, snippet): + def handler(request): + return httpx.Response(code, json={"detail": "nope"}) + + with pytest.raises(click.ClickException) as exc: + _client.list_api_keys( + "http://hub.test", "tok", "u", transport=_transport(handler) + ) + assert snippet in str(exc.value) + + +# --------------------------------------------------------------------------- # +# helpers for command tests +# --------------------------------------------------------------------------- # + + +def _patch_session(monkeypatch, memberships, *, user_id="user-1"): + monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: url or "h") + monkeypatch.setattr(_client, "require_login_token", lambda: "tok") + monkeypatch.setattr(_client, "me", lambda uri, token: (user_id, memberships)) + + +_WS_A = Membership(7, "ws-a", frozenset({"read:project", "create:project"})) + + +# --------------------------------------------------------------------------- # +# create +# --------------------------------------------------------------------------- # + + +def test_create_requires_login(monkeypatch): + monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: "h") + + def boom(): + raise click.ClickException("not logged in; run `skore hub login` first.") + + monkeypatch.setattr(_client, "require_login_token", boom) + + result = CliRunner().invoke( + _api_keys.api_key, ["create", "-w", "ws-a", "--name", "x", "-p", "read:project"] + ) + assert result.exit_code != 0 + assert "not logged in" in result.output + + +def test_create_non_interactive_builds_payload(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) + captured = {} + + def fake_create( + uri, token, user_id, *, name, permissions, workspace_id, expires_at + ): + captured.update( + user_id=user_id, + name=name, + permissions=permissions, + workspace_id=workspace_id, + expires_at=expires_at, + ) + return 42, "uid:secret" + + monkeypatch.setattr(_client, "create_api_key", fake_create) + + result = CliRunner().invoke( + _api_keys.api_key, + [ + "create", + "--hub-url", + "http://hub.test", + "-w", + "ws-a", + "--name", + "laptop", + "-p", + "read:project", + "--validity", + "never", + ], + ) + assert result.exit_code == 0, result.output + assert "uid:secret" in result.output + assert captured == { + "user_id": "user-1", + "name": "laptop", + "permissions": ["read:project"], + "workspace_id": 7, + "expires_at": None, + } + + +def test_create_requires_workspace(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) + + result = CliRunner().invoke( + _api_keys.api_key, ["create", "--name", "x", "-p", "read:project"] + ) + assert result.exit_code != 0 + assert "--workspace" in result.output + + +def test_create_unknown_workspace_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) + + result = CliRunner().invoke( + _api_keys.api_key, + ["create", "-w", "nope", "--name", "x", "-p", "read:project"], + ) + assert result.exit_code != 0 + assert "unknown workspace" in result.output + + +def test_create_rejects_ungranted_permission(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) + + result = CliRunner().invoke( + _api_keys.api_key, + ["create", "-w", "ws-a", "--name", "x", "-p", "delete:project"], + ) + assert result.exit_code != 0 + assert "cannot grant delete:project" in result.output + + +def test_create_interactive_uses_form(monkeypatch): + from skore_cli.hub import app as hub_app + + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_api_keys, "_is_interactive", lambda: True) + + class _FakeForm: + def __init__(self, *a, **k): + self.result = hub_app.ApiKeyFormResult( + name="laptop", + workspace_id=7, + workspace_public_id="ws-a", + permissions=["read:project"], + validity="never", + ) + + def run(self): + return None + + monkeypatch.setattr(hub_app, "ApiKeyForm", _FakeForm) + + captured = {} + + def fake_create( + uri, token, user_id, *, name, permissions, workspace_id, expires_at + ): + captured.update(name=name, workspace_id=workspace_id, expires_at=expires_at) + return 7, "uid:secret" + + monkeypatch.setattr(_client, "create_api_key", fake_create) + + # No -w/-p flags => interactive form path. + result = CliRunner().invoke(_api_keys.api_key, ["create"]) + assert result.exit_code == 0, result.output + assert "uid:secret" in result.output + assert captured == {"name": "laptop", "workspace_id": 7, "expires_at": None} + + +def test_create_interactive_cancel(monkeypatch): + from skore_cli.hub import app as hub_app + + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_api_keys, "_is_interactive", lambda: True) + + class _FakeForm: + def __init__(self, *a, **k): + self.result = None + + def run(self): + return None + + monkeypatch.setattr(hub_app, "ApiKeyForm", _FakeForm) + monkeypatch.setattr( + _client, + "create_api_key", + lambda *a, **k: pytest.fail("create should not be called on cancel"), + ) + + result = CliRunner().invoke(_api_keys.api_key, ["create"]) + assert result.exit_code == 0, result.output + assert "Nothing created" in result.output + + +# --------------------------------------------------------------------------- # +# list +# --------------------------------------------------------------------------- # + + +def test_list_prints_table(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr( + _client, + "list_api_keys", + lambda uri, token, user_id: [ + ApiKeyInfo(1, "laptop", 7, "2026-01-01T00:00:00Z", None) + ], + ) + + result = CliRunner().invoke(_api_keys.api_key, ["list", "--hub-url", "http://h"]) + assert result.exit_code == 0, result.output + assert "laptop" in result.output + assert "ws-a" in result.output + assert "never" in result.output + + +def test_list_empty(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_client, "list_api_keys", lambda uri, token, user_id: []) + + result = CliRunner().invoke(_api_keys.api_key, ["list"]) + assert result.exit_code == 0, result.output + assert "No API keys" in result.output + + +# --------------------------------------------------------------------------- # +# revoke +# --------------------------------------------------------------------------- # + + +def test_revoke_by_id(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + deleted = {} + monkeypatch.setattr( + _client, + "delete_api_key", + lambda uri, token, user_id, api_key_id: deleted.update(id=api_key_id), + ) + + result = CliRunner().invoke( + _api_keys.api_key, ["revoke", "--hub-url", "http://h", "--id", "5", "--yes"] + ) + assert result.exit_code == 0, result.output + assert deleted["id"] == 5 + assert "revoked API key 5" in result.output + + +def test_revoke_non_interactive_without_id_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) + monkeypatch.setattr( + _client, + "list_api_keys", + lambda uri, token, user_id: [ApiKeyInfo(5, "laptop", 7, None, None)], + ) + + result = CliRunner().invoke(_api_keys.api_key, ["revoke"]) + assert result.exit_code != 0 + assert "--id" in result.output + + +def test_revoke_interactive_picker(monkeypatch): + from skore_cli.hub import app as hub_app + + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_api_keys, "_is_interactive", lambda: True) + monkeypatch.setattr( + _client, + "list_api_keys", + lambda uri, token, user_id: [ApiKeyInfo(5, "laptop", 7, None, None)], + ) + + class _FakePicker: + def __init__(self, *a, **k): + self.result = 5 + + def run(self): + return None + + monkeypatch.setattr(hub_app, "ApiKeyPicker", _FakePicker) + deleted = {} + monkeypatch.setattr( + _client, + "delete_api_key", + lambda uri, token, user_id, api_key_id: deleted.update(id=api_key_id), + ) + + result = CliRunner().invoke(_api_keys.api_key, ["revoke", "--yes"]) + assert result.exit_code == 0, result.output + assert deleted["id"] == 5 + + +# --------------------------------------------------------------------------- # +# _expires_at +# --------------------------------------------------------------------------- # + + +def test_expires_at_never_is_none(): + assert _api_keys._expires_at("never") is None + + +def test_expires_at_months_is_future_iso(): + from datetime import datetime + + value = _api_keys._expires_at("3") + parsed = datetime.fromisoformat(value) + assert parsed > datetime.now(UTC) From 424ad02feda381c5bfdb38ba9ad534d60685c7a4 Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Sat, 20 Jun 2026 11:01:39 +0200 Subject: [PATCH 3/7] _ --- src/skore_cli/hub/_agent_providers.py | 416 +++++++++++++ src/skore_cli/hub/_api_keys.py | 4 +- src/skore_cli/hub/_client.py | 265 ++++++++- src/skore_cli/hub/_commands.py | 18 +- src/skore_cli/hub/_workspaces.py | 187 ++++++ src/skore_cli/hub/app/__init__.py | 17 +- src/skore_cli/hub/app/_form.py | 31 +- src/skore_cli/hub/app/_provider_form.py | 265 +++++++++ tests/test_hub_agent_provider_form.py | 117 ++++ tests/test_hub_agent_providers.py | 746 ++++++++++++++++++++++++ tests/test_hub_api_key_form.py | 8 +- tests/test_hub_api_keys.py | 2 +- tests/test_hub_workspaces.py | 457 +++++++++++++++ 13 files changed, 2504 insertions(+), 29 deletions(-) create mode 100644 src/skore_cli/hub/_agent_providers.py create mode 100644 src/skore_cli/hub/_workspaces.py create mode 100644 src/skore_cli/hub/app/_provider_form.py create mode 100644 tests/test_hub_agent_provider_form.py create mode 100644 tests/test_hub_agent_providers.py create mode 100644 tests/test_hub_workspaces.py diff --git a/src/skore_cli/hub/_agent_providers.py b/src/skore_cli/hub/_agent_providers.py new file mode 100644 index 0000000..8757150 --- /dev/null +++ b/src/skore_cli/hub/_agent_providers.py @@ -0,0 +1,416 @@ +"""The ``skore hub agent-provider`` group: ``add``/``list``/``activate``/``remove``. + +Manage a workspace's agent LLM provider configuration, mirroring the hub UI. +Every command requires a prior ``skore hub login`` (a stored OAuth token) and is +scoped to a single workspace (resolved from ``-w/--workspace`` or, in a +terminal, an interactive picker). Heavy ``httpx``/``textual`` imports stay +deferred inside the callbacks. +""" + +from __future__ import annotations + +import rich_click as click + +from skore_cli._style import console +from skore_cli.hub import _client +from skore_cli.hub._api_keys import ( + _HUB_URL_OPTION, + _is_interactive, + _resolve_session, +) +from skore_cli.hub._client import AGENT_PROVIDERS + +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli hub agent-provider": [ + {"name": "Manage", "commands": ["add", "list", "activate", "remove"]}, + ], +} + + +@click.group("agent-provider") +def agent_provider() -> None: + """Add, list, activate and remove a workspace's agent LLM providers.""" + + +def _resolve_target_workspace( + memberships: list[_client.Membership], + workspace: str | None, + *, + interactive: bool, +) -> _client.Membership: + """Resolve the single workspace to act on. + + A ``-w/--workspace`` flag wins (validated against memberships). Otherwise a + lone membership is used directly; with several, an interactive picker is + shown when possible, else a usage error lists the available public ids. + """ + if not memberships: + raise click.ClickException( + "you are not a member of any hub workspace; create or join one first." + ) + + by_public = {m.public_id: m for m in memberships} + if workspace is not None: + if workspace not in by_public: + raise click.UsageError( + f"unknown workspace '{workspace}'; you belong to: " + f"{', '.join(by_public) or 'none'}." + ) + return by_public[workspace] + + if len(memberships) == 1: + return memberships[0] + + if interactive: + from skore_cli.hub.app import IdPicker + + labels = [(m.workspace_id, m.public_id) for m in memberships] + app = IdPicker(labels, title="Choose the workspace.") + app.run() + if app.result is None: + raise click.Abort() + return next(m for m in memberships if m.workspace_id == app.result) + + raise click.UsageError( + f"pass --workspace (one of: {', '.join(by_public) or 'none'})." + ) + + +def _model_or_auto(provider: _client.ProviderEntry) -> str: + return provider.selected_model or "auto" + + +def _secrets_summary(provider: _client.ProviderEntry) -> str: + flags = [] + if provider.anthropic_api_key_set: + flags.append("anthropic-key") + if provider.bedrock_external_id_set: + flags.append("bedrock-external-id") + if provider.aws_access_key_id_set: + flags.append("aws-access-key-id") + if provider.aws_secret_access_key_set: + flags.append("aws-secret-access-key") + return ", ".join(flags) or "-" + + +def _build_payload( + *, + name: str, + provider: str, + model: str | None, + anthropic_api_key: str | None, + aws_region: str | None, + bedrock_role_arn: str | None, + bedrock_external_id: str | None, + aws_access_key_id: str | None, + aws_secret_access_key: str | None, +) -> dict[str, object]: + """Build the create payload, mirroring the hub UI (drops irrelevant fields).""" + payload: dict[str, object] = {"name": name, "provider": provider} + if provider == "skore": + return payload + payload["selected_model"] = model + if provider == "anthropic": + payload["anthropic_api_key"] = anthropic_api_key + return payload + # bedrock: AWS fields are optional. + payload.update( + { + key: value + for key, value in ( + ("aws_region", aws_region), + ("bedrock_role_arn", bedrock_role_arn), + ("bedrock_external_id", bedrock_external_id), + ("aws_access_key_id", aws_access_key_id), + ("aws_secret_access_key", aws_secret_access_key), + ) + if value + } + ) + return payload + + +@agent_provider.command("add") +@_HUB_URL_OPTION +@click.option( + "--workspace", "-w", default=None, help="Hub workspace (public id) to act on." +) +@click.option("--name", default=None, help="A label for the provider.") +@click.option( + "--provider", + type=click.Choice(AGENT_PROVIDERS), + default=None, + help="Provider type to register.", +) +@click.option("--model", default=None, help="Model to use (for anthropic/bedrock).") +@click.option("--anthropic-api-key", default=None, help="Anthropic API key.") +@click.option("--aws-region", default=None, help="AWS region (bedrock).") +@click.option("--bedrock-role-arn", default=None, help="Bedrock role ARN.") +@click.option("--bedrock-external-id", default=None, help="Bedrock external id.") +@click.option("--aws-access-key-id", default=None, help="AWS access key id (bedrock).") +@click.option( + "--aws-secret-access-key", default=None, help="AWS secret access key (bedrock)." +) +@click.option( + "--activate/--no-activate", + default=False, + help="Activate the provider right after adding it.", +) +@click.option( + "--yes", + is_flag=True, + default=False, + help="Skip the interactive form even in a terminal (use the flags as given).", +) +def add( + hub_url: str | None, + workspace: str | None, + name: str | None, + provider: str | None, + model: str | None, + anthropic_api_key: str | None, + aws_region: str | None, + bedrock_role_arn: str | None, + bedrock_external_id: str | None, + aws_access_key_id: str | None, + aws_secret_access_key: str | None, + activate: bool, + yes: bool, +) -> None: + """Add an agent LLM provider to a workspace. + + In a terminal, omitting ``--provider`` (or a field it requires) opens an + interactive, provider-adaptive form. Non-interactively (or with ``--yes``), + ``--workspace`` (unless you belong to one), ``--name`` and ``--provider`` are + required; bring-your-own providers also need ``--model`` (and, for + ``anthropic``, ``--anthropic-api-key``). + """ + uri, token, _user_id, memberships = _resolve_session(hub_url) + interactive = _is_interactive() + membership = _resolve_target_workspace( + memberships, workspace, interactive=interactive + ) + providers = _client.agent_providers(uri, token, membership.workspace_id) + + def _missing_required() -> bool: + if provider is None: + return True + if provider != "skore" and not model: + return True + return provider == "anthropic" and not anthropic_api_key + + if interactive and not yes and _missing_required(): + from skore_cli.hub.app import AgentProviderForm + + has_active = any(p.is_active for p in providers.providers) + app = AgentProviderForm( + providers.available_models, + encryption_configured=providers.encryption_configured, + name=name or "", + activate_default=activate or not has_active, + ) + app.run() + if app.result is None: + console.print("Nothing added.") + return + result = app.result + chosen_name = result.name + chosen_provider = result.provider + model = result.selected_model + anthropic_api_key = result.anthropic_api_key + aws_region = result.aws_region + bedrock_role_arn = result.bedrock_role_arn + bedrock_external_id = result.bedrock_external_id + aws_access_key_id = result.aws_access_key_id + aws_secret_access_key = result.aws_secret_access_key + activate = result.activate + else: + if provider is None: + raise click.UsageError( + f"pass --provider (one of: {', '.join(AGENT_PROVIDERS)})." + ) + if not name: + raise click.UsageError("pass --name for the provider.") + if provider != "skore": + if not providers.encryption_configured: + raise click.ClickException( + f"the '{membership.public_id}' workspace has no encryption " + "configured; bring-your-own providers are unavailable. Use " + "--provider skore or configure encryption in the hub." + ) + available = providers.available_models.get(provider, []) + if not model: + raise click.UsageError( + f"pass --model for '{provider}' (one of: " + f"{', '.join(available) or 'none'})." + ) + if model not in available: + raise click.UsageError( + f"unknown model '{model}' for '{provider}'; available: " + f"{', '.join(available) or 'none'}." + ) + if provider == "anthropic" and not anthropic_api_key: + raise click.UsageError( + "pass --anthropic-api-key for the anthropic provider." + ) + chosen_name = name + chosen_provider = provider + + payload = _build_payload( + name=chosen_name, + provider=chosen_provider, + model=model, + anthropic_api_key=anthropic_api_key, + aws_region=aws_region, + bedrock_role_arn=bedrock_role_arn, + bedrock_external_id=bedrock_external_id, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + ) + created = _client.create_agent_provider( + uri, token, membership.workspace_id, payload=payload + ) + if activate: + _client.activate_agent_provider(uri, token, membership.workspace_id, created.id) + + console.print( + f"[skore.ok]+ added provider[/] " + f"[skore.muted](id {created.id}, workspace {membership.public_id})[/]" + ) + console.print(f" name : {created.name}") + console.print(f" provider : {created.provider}") + console.print(f" model : {_model_or_auto(created)}") + console.print(f" active : {'yes' if activate else 'no'}") + + +@agent_provider.command("list") +@_HUB_URL_OPTION +@click.option( + "--workspace", "-w", default=None, help="Hub workspace (public id) to act on." +) +def list_providers(hub_url: str | None, workspace: str | None) -> None: + """List a workspace's agent LLM providers (secrets are never shown).""" + uri, token, _user_id, memberships = _resolve_session(hub_url) + membership = _resolve_target_workspace( + memberships, workspace, interactive=_is_interactive() + ) + providers = _client.agent_providers(uri, token, membership.workspace_id) + + if not providers.providers: + console.print("[skore.muted]No agent providers.[/]") + else: + from rich.table import Table + + table = Table(box=None, pad_edge=False) + for column in ("id", "name", "provider", "model", "active", "secrets"): + table.add_column(column) + for entry in providers.providers: + table.add_row( + str(entry.id), + entry.name, + entry.provider, + _model_or_auto(entry), + "yes" if entry.is_active else "-", + _secrets_summary(entry), + ) + console.print(table) + + if not providers.encryption_configured: + console.print( + "[skore.muted]Encryption is not configured for this workspace; " + "bring-your-own (anthropic/bedrock) providers are unavailable.[/]" + ) + + +def _pick_provider(providers: list[_client.ProviderEntry], *, title: str) -> int | None: + from skore_cli.hub.app import IdPicker + + labels = [ + ( + entry.id, + f"{entry.id} {entry.name} ({entry.provider}/" + f"{_model_or_auto(entry)})" + (" *active" if entry.is_active else ""), + ) + for entry in providers + ] + app = IdPicker(labels, title=title) + app.run() + return app.result + + +@agent_provider.command("activate") +@_HUB_URL_OPTION +@click.option( + "--workspace", "-w", default=None, help="Hub workspace (public id) to act on." +) +@click.option("--id", "config_id", type=int, default=None, help="Provider id.") +def activate(hub_url: str | None, workspace: str | None, config_id: int | None) -> None: + """Activate one provider for the workspace (deactivates the others).""" + uri, token, _user_id, memberships = _resolve_session(hub_url) + interactive = _is_interactive() + membership = _resolve_target_workspace( + memberships, workspace, interactive=interactive + ) + + if config_id is None: + providers = _client.agent_providers(uri, token, membership.workspace_id) + if not providers.providers: + console.print("[skore.muted]No agent providers to activate.[/]") + return + if not interactive: + raise click.UsageError( + "pass --id to activate non-interactively (ids: " + f"{', '.join(str(p.id) for p in providers.providers)})." + ) + config_id = _pick_provider( + providers.providers, title="Choose the provider to activate." + ) + if config_id is None: + console.print("Nothing activated.") + return + + _client.activate_agent_provider(uri, token, membership.workspace_id, config_id) + console.print(f"[skore.ok]+[/] activated provider {config_id}.") + + +@agent_provider.command("remove") +@_HUB_URL_OPTION +@click.option( + "--workspace", "-w", default=None, help="Hub workspace (public id) to act on." +) +@click.option("--id", "config_id", type=int, default=None, help="Provider id.") +@click.option( + "--yes", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def remove( + hub_url: str | None, workspace: str | None, config_id: int | None, yes: bool +) -> None: + """Remove a provider from the workspace, or pick one interactively.""" + uri, token, _user_id, memberships = _resolve_session(hub_url) + interactive = _is_interactive() + membership = _resolve_target_workspace( + memberships, workspace, interactive=interactive + ) + + if config_id is None: + providers = _client.agent_providers(uri, token, membership.workspace_id) + if not providers.providers: + console.print("[skore.muted]No agent providers to remove.[/]") + return + if not interactive: + raise click.UsageError( + "pass --id to remove non-interactively (ids: " + f"{', '.join(str(p.id) for p in providers.providers)})." + ) + config_id = _pick_provider( + providers.providers, title="Choose the provider to remove." + ) + if config_id is None: + console.print("Nothing removed.") + return + + if not yes: + click.confirm(f"Remove provider {config_id}?", abort=True) + _client.delete_agent_provider(uri, token, membership.workspace_id, config_id) + console.print(f"[skore.ok]-[/] removed provider {config_id}.") diff --git a/src/skore_cli/hub/_api_keys.py b/src/skore_cli/hub/_api_keys.py index cb9be3f..0f463f5 100644 --- a/src/skore_cli/hub/_api_keys.py +++ b/src/skore_cli/hub/_api_keys.py @@ -282,7 +282,7 @@ def revoke(hub_url: str | None, api_key_id: int | None, yes: bool) -> None: "pass --id to revoke non-interactively (ids: " f"{', '.join(str(key.id) for key in keys)})." ) - from skore_cli.hub.app import ApiKeyPicker + from skore_cli.hub.app import IdPicker labels = [ ( @@ -292,7 +292,7 @@ def revoke(hub_url: str | None, api_key_id: int | None, yes: bool) -> None: ) for key in keys ] - app = ApiKeyPicker(labels) + app = IdPicker(labels, title="Choose the API key to revoke.") app.run() if app.result is None: console.print("Nothing revoked.") diff --git a/src/skore_cli/hub/_client.py b/src/skore_cli/hub/_client.py index f14e104..a99c535 100644 --- a/src/skore_cli/hub/_client.py +++ b/src/skore_cli/hub/_client.py @@ -1,13 +1,14 @@ -"""Thin HTTP client for the hub identity API used by ``skore hub api-key``. +"""Thin HTTP client for the hub API used by ``skore hub`` management commands. -Pure, testable functions over the hub's ``/identity`` endpoints. ``httpx`` is -imported lazily inside the calls so building the CLI stays cheap. All management -calls authenticate with the stored interactive login token as a bearer. +Pure, testable functions over the hub's ``/identity`` and ``/agent`` endpoints. +``httpx`` is imported lazily inside the calls so building the CLI stays cheap. +All management calls authenticate with the stored interactive login token as a +bearer. The current user's profile (``GET /identity/users/me``) is the single source of truth for both the workspaces the user belongs to (``workspace_id`` + ``public_id``) and the permissions grantable in each of them, mirroring how the -hub UI gates the create form. +hub UI gates the forms. """ from __future__ import annotations @@ -54,6 +55,56 @@ class ApiKeyInfo: expires_at: str | None +# The agent provider types the hub supports, kept as local literals. +AGENT_PROVIDERS = ["skore", "anthropic", "bedrock"] + + +@dataclass(frozen=True) +class ProviderEntry: + """A workspace's registered agent provider (secrets are masked as ``*_set``).""" + + id: int + name: str + is_active: bool + provider: str + selected_model: str | None + aws_region: str | None + bedrock_role_arn: str | None + anthropic_api_key_set: bool + bedrock_external_id_set: bool + aws_access_key_id_set: bool + aws_secret_access_key_set: bool + + +@dataclass(frozen=True) +class AgentProviders: + """The workspace's providers plus the UI metadata the create form needs.""" + + providers: list[ProviderEntry] + available_models: dict[str, list[str]] + encryption_configured: bool + + +@dataclass(frozen=True) +class MemberInfo: + """A workspace member (no email/name is exposed by the hub, only the id).""" + + user_id: str + role: str + invited_by: str | None + + +@dataclass(frozen=True) +class WorkspaceInfo: + """A workspace the user can act on, with its members when fetched in detail.""" + + id: int + public_id: str + is_public: bool + created_at: str | None + members: list[MemberInfo] | None + + def require_login_token() -> str: """Return a fresh OAuth bearer token, or error telling the user to log in. @@ -177,3 +228,207 @@ def delete_api_key( 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=f"revoking API key {api_key_id}") + + +def _provider_entry(item: dict[str, Any]) -> ProviderEntry: + return ProviderEntry( + id=item["id"], + name=item["name"], + is_active=bool(item.get("is_active")), + provider=item["provider"], + selected_model=item.get("selected_model"), + aws_region=item.get("aws_region"), + bedrock_role_arn=item.get("bedrock_role_arn"), + anthropic_api_key_set=bool(item.get("anthropic_api_key_set")), + bedrock_external_id_set=bool(item.get("bedrock_external_id_set")), + aws_access_key_id_set=bool(item.get("aws_access_key_id_set")), + aws_secret_access_key_set=bool(item.get("aws_secret_access_key_set")), + ) + + +def agent_providers( + hub_url: str, token: str, workspace_id: int, *, transport: Any = None +) -> AgentProviders: + """Return a workspace's agent providers plus create-form metadata.""" + with _client(hub_url, token, transport) as client: + response = client.get(f"/agent/workspaces/{workspace_id}/providers") + _raise_for(response, context="listing agent providers") + data = response.json() + return AgentProviders( + providers=[_provider_entry(item) for item in data.get("providers") or []], + available_models={ + key: list(value) + for key, value in (data.get("available_models") or {}).items() + }, + encryption_configured=bool(data.get("encryption_configured")), + ) + + +def create_agent_provider( + hub_url: str, + token: str, + workspace_id: int, + *, + payload: dict[str, Any], + transport: Any = None, +) -> ProviderEntry: + """Register a new agent provider; return the created (masked) entry.""" + with _client(hub_url, token, transport) as client: + response = client.post( + f"/agent/workspaces/{workspace_id}/providers", json=payload + ) + _raise_for( + response, + context="adding the provider (needs workspace owner/admin)", + ) + return _provider_entry(response.json()) + + +def activate_agent_provider( + hub_url: str, + token: str, + workspace_id: int, + config_id: int, + *, + transport: Any = None, +) -> None: + """Activate one provider for the workspace (deactivates the others).""" + with _client(hub_url, token, transport) as client: + response = client.post( + f"/agent/workspaces/{workspace_id}/providers/{config_id}/activate" + ) + _raise_for( + response, + context=f"activating provider {config_id} (needs workspace owner/admin)", + ) + + +def delete_agent_provider( + hub_url: str, + token: str, + workspace_id: int, + config_id: int, + *, + transport: Any = None, +) -> None: + """Remove a provider from the workspace.""" + with _client(hub_url, token, transport) as client: + response = client.delete( + f"/agent/workspaces/{workspace_id}/providers/{config_id}" + ) + _raise_for( + response, + context=f"removing provider {config_id} (needs workspace owner/admin)", + ) + + +def _workspace_info(item: dict[str, Any]) -> WorkspaceInfo: + members = item.get("members") + return WorkspaceInfo( + id=int(item["id"]), + public_id=item["public_id"], + is_public=bool(item.get("is_public")), + created_at=item.get("created_at"), + members=( + [ + MemberInfo( + user_id=str(member["user_id"]), + role=member.get("role") or "", + invited_by=member.get("invited_by"), + ) + for member in members + ] + if members is not None + else None + ), + ) + + +def list_workspaces( + hub_url: str, token: str, *, transport: Any = None +) -> list[WorkspaceInfo]: + """Return every workspace the user belongs to (following the cursor).""" + workspaces: list[WorkspaceInfo] = [] + cursor: int | None = None + with _client(hub_url, token, transport) as client: + while True: + params: dict[str, Any] = {"limit": 100} + if cursor is not None: + params["cursor"] = cursor + response = client.get("/identity/workspaces", params=params) + _raise_for(response, context="listing your workspaces") + data = response.json() + workspaces.extend(_workspace_info(item) for item in data.get("items") or []) + cursor = data.get("next_cursor") + if cursor is None: + break + return workspaces + + +def get_workspace( + hub_url: str, token: str, workspace_id: int, *, transport: Any = None +) -> WorkspaceInfo: + """Return a single workspace (with its members) by internal id.""" + with _client(hub_url, token, transport) as client: + response = client.get(f"/identity/workspaces/{workspace_id}") + _raise_for(response, context="fetching the workspace") + return _workspace_info(response.json()) + + +def create_workspace( + hub_url: str, token: str, *, public_id: str, transport: Any = None +) -> int: + """Create a workspace; return its internal id. + + The hub slugifies ``public_id`` and auto-suffixes it when taken, so the + stored public id may differ from the requested one (re-fetch to confirm). + """ + with _client(hub_url, token, transport) as client: + response = client.post("/identity/workspaces", json={"public_id": public_id}) + _raise_for(response, context="creating the workspace") + return int(response.json()["id"]) + + +def check_public_id( + hub_url: str, token: str, public_id: str, *, transport: Any = None +) -> tuple[bool, str | None]: + """Return ``(available, suggested_slug)`` for a candidate workspace public id.""" + with _client(hub_url, token, transport) as client: + response = client.get( + "/identity/workspaces/public-id-availability", + params={"public_id": public_id}, + ) + _raise_for(response, context="checking workspace availability") + data = response.json() + return bool(data.get("available")), data.get("suggested_slug") + + +def update_workspace( + hub_url: str, + token: str, + workspace_id: int, + *, + public_id: str, + transport: Any = None, +) -> None: + """Rename a workspace (its ``public_id``); needs workspace owner/admin.""" + with _client(hub_url, token, transport) as client: + response = client.put( + f"/identity/workspaces/{workspace_id}", json={"public_id": public_id} + ) + _raise_for( + response, + context="renaming the workspace (needs workspace owner/admin)", + ) + + +def delete_workspace( + hub_url: str, token: str, workspace_id: int, *, transport: Any = None +) -> None: + """Delete a workspace; owner only (hard delete on the hub).""" + with _client(hub_url, token, transport) as client: + response = client.delete(f"/identity/workspaces/{workspace_id}") + _raise_for( + response, + context="deleting the workspace (owner only)", + ) diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index 66405ff..1b82eb1 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -8,8 +8,10 @@ which is the only thing we persist (so a separate opencode process can use it). The ``api-key`` subgroup (``skore hub api-key``) mints, lists and revokes -workspace-scoped API keys against the hub, mirroring the hub UI. It requires a -prior ``skore hub login`` (a stored OAuth token). +workspace-scoped API keys against the hub, mirroring the hub UI. The +``agent-provider`` subgroup manages a workspace's agent LLM provider config, and +the ``workspace`` subgroup manages the lifecycle of the workspaces themselves. +All require a prior ``skore hub login`` (a stored OAuth token). Heavy ``skore`` imports are deferred into the command callbacks so building the CLI (and ``--help``) never imports the ``skore`` package. @@ -34,6 +36,8 @@ "cli hub": [ {"name": "Authentication", "commands": ["login", "logout", "status"]}, {"name": "API keys", "commands": ["api-key"]}, + {"name": "Agent", "commands": ["agent-provider"]}, + {"name": "Workspace", "commands": ["workspace"]}, ], } @@ -172,8 +176,14 @@ def status() -> None: ) -# The api-key subgroup is attached here; its heavy deps (httpx/textual) stay -# deferred inside its own command callbacks. +# The api-key/agent-provider/workspace subgroups are attached here; their heavy +# deps (httpx/textual) stay deferred inside their own command callbacks. +from skore_cli.hub._agent_providers import ( # noqa: E402 + agent_provider as _agent_provider_group, +) from skore_cli.hub._api_keys import api_key as _api_key_group # noqa: E402 +from skore_cli.hub._workspaces import workspace as _workspace_group # noqa: E402 hub.add_command(_api_key_group) +hub.add_command(_agent_provider_group) +hub.add_command(_workspace_group) diff --git a/src/skore_cli/hub/_workspaces.py b/src/skore_cli/hub/_workspaces.py new file mode 100644 index 0000000..d0b8d5c --- /dev/null +++ b/src/skore_cli/hub/_workspaces.py @@ -0,0 +1,187 @@ +"""The ``skore hub workspace`` group: list/show/create/rename/delete. + +Manage the lifecycle of the hub workspaces you belong to, mirroring the hub UI. +Every command requires a prior ``skore hub login`` (a stored OAuth token). +Commands that target one workspace take ``-w/--workspace`` (a public id) or, in +a terminal, fall back to an interactive picker. Heavy ``httpx``/``textual`` +imports stay deferred inside the callbacks. +""" + +from __future__ import annotations + +import rich_click as click + +from skore_cli._style import console +from skore_cli.hub import _client +from skore_cli.hub._agent_providers import _resolve_target_workspace +from skore_cli.hub._api_keys import ( + _HUB_URL_OPTION, + _is_interactive, + _resolve_session, +) + +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli hub workspace": [ + {"name": "Manage", "commands": ["list", "show", "create", "rename", "delete"]}, + ], +} + + +@click.group("workspace") +def workspace() -> None: + """List, show, create, rename and delete hub workspaces.""" + + +def _role_for(ws: _client.WorkspaceInfo, user_id: str) -> str: + for member in ws.members or []: + if member.user_id == user_id: + return member.role or "-" + return "-" + + +@workspace.command("list") +@_HUB_URL_OPTION +def list_workspaces(hub_url: str | None) -> None: + """List every workspace you belong to.""" + uri, token, user_id, _memberships = _resolve_session(hub_url) + workspaces = _client.list_workspaces(uri, token) + + if not workspaces: + console.print("[skore.muted]No workspaces.[/]") + return + + from rich.table import Table + + table = Table(box=None, pad_edge=False) + for column in ("id", "public_id", "public", "created", "members", "role"): + table.add_column(column) + for ws in workspaces: + table.add_row( + str(ws.id), + ws.public_id, + "yes" if ws.is_public else "-", + (ws.created_at or "-")[:19], + str(len(ws.members or [])), + _role_for(ws, user_id), + ) + console.print(table) + + +@workspace.command("show") +@_HUB_URL_OPTION +@click.option( + "--workspace", "-w", default=None, help="Hub workspace (public id) to show." +) +def show(hub_url: str | None, workspace: str | None) -> None: + """Show a workspace and its members.""" + uri, token, _user_id, memberships = _resolve_session(hub_url) + membership = _resolve_target_workspace( + memberships, workspace, interactive=_is_interactive() + ) + ws = _client.get_workspace(uri, token, membership.workspace_id) + + console.print(f"[skore.ok]workspace[/] [bold]{ws.public_id}[/]") + console.print(f" id : {ws.id}") + console.print(f" public : {'yes' if ws.is_public else 'no'}") + console.print(f" created : {(ws.created_at or '-')[:19]}") + + if not ws.members: + console.print("[skore.muted] no members.[/]") + return + + from rich.table import Table + + table = Table(box=None, pad_edge=False) + for column in ("user_id", "role", "invited_by"): + table.add_column(column) + for member in ws.members: + table.add_row(member.user_id, member.role or "-", member.invited_by or "-") + console.print(table) + + +@workspace.command("create") +@_HUB_URL_OPTION +@click.option("--public-id", default=None, help="Public id (slug) for the workspace.") +def create(hub_url: str | None, public_id: str | None) -> None: + """Create a new workspace (you become its owner).""" + uri, token, _user_id, _memberships = _resolve_session(hub_url) + + if public_id is None: + if not _is_interactive(): + raise click.UsageError("pass --public-id for the workspace.") + public_id = click.prompt("Workspace public id").strip() + if not public_id: + raise click.UsageError("the workspace public id must not be empty.") + + available, suggested = _client.check_public_id(uri, token, public_id) + if not available: + hint = f" try '{suggested}'." if suggested else "" + raise click.ClickException( + f"workspace public id '{public_id}' is not available.{hint}" + ) + + workspace_id = _client.create_workspace(uri, token, public_id=public_id) + # The hub may slugify/suffix the id; re-fetch to print the stored value. + created = _client.get_workspace(uri, token, workspace_id) + console.print( + f"[skore.ok]+ created workspace[/] " + f"[skore.muted](id {created.id})[/] [bold]{created.public_id}[/]" + ) + + +@workspace.command("rename") +@_HUB_URL_OPTION +@click.option( + "--workspace", "-w", default=None, help="Hub workspace (public id) to rename." +) +@click.option("--new-public-id", default=None, help="The new public id (slug).") +def rename( + hub_url: str | None, workspace: str | None, new_public_id: str | None +) -> None: + """Rename a workspace (change its public id); needs owner/admin.""" + uri, token, _user_id, memberships = _resolve_session(hub_url) + membership = _resolve_target_workspace( + memberships, workspace, interactive=_is_interactive() + ) + + if new_public_id is None: + if not _is_interactive(): + raise click.UsageError("pass --new-public-id .") + new_public_id = click.prompt("New public id").strip() + if not new_public_id: + raise click.UsageError("the new public id must not be empty.") + + _client.update_workspace( + uri, token, membership.workspace_id, public_id=new_public_id + ) + # The hub may slugify the id; re-fetch to print the stored value. + renamed = _client.get_workspace(uri, token, membership.workspace_id) + console.print( + f"[skore.ok]+[/] renamed workspace [skore.muted](id {renamed.id})[/] " + f"to [bold]{renamed.public_id}[/]" + ) + + +@workspace.command("delete") +@_HUB_URL_OPTION +@click.option( + "--workspace", "-w", default=None, help="Hub workspace (public id) to delete." +) +@click.option( + "--yes", is_flag=True, default=False, help="Skip the confirmation prompt." +) +def delete(hub_url: str | None, workspace: str | None, yes: bool) -> None: + """Delete a workspace; owner only and irreversible.""" + uri, token, _user_id, memberships = _resolve_session(hub_url) + membership = _resolve_target_workspace( + memberships, workspace, interactive=_is_interactive() + ) + + if not yes: + click.confirm( + f"Delete workspace '{membership.public_id}'? This is irreversible.", + abort=True, + ) + _client.delete_workspace(uri, token, membership.workspace_id) + console.print(f"[skore.ok]-[/] deleted workspace {membership.public_id}.") diff --git a/src/skore_cli/hub/app/__init__.py b/src/skore_cli/hub/app/__init__.py index 02af699..b0a4859 100644 --- a/src/skore_cli/hub/app/__init__.py +++ b/src/skore_cli/hub/app/__init__.py @@ -1,10 +1,21 @@ -"""Textual applications backing the interactive ``skore hub api-key`` commands.""" +"""Textual applications backing the interactive ``skore hub`` commands.""" from skore_cli.hub.app._form import ( VALIDITY_CHOICES, ApiKeyForm, ApiKeyFormResult, - ApiKeyPicker, + IdPicker, +) +from skore_cli.hub.app._provider_form import ( + AgentProviderForm, + AgentProviderFormResult, ) -__all__ = ["VALIDITY_CHOICES", "ApiKeyForm", "ApiKeyFormResult", "ApiKeyPicker"] +__all__ = [ + "VALIDITY_CHOICES", + "AgentProviderForm", + "AgentProviderFormResult", + "ApiKeyForm", + "ApiKeyFormResult", + "IdPicker", +] diff --git a/src/skore_cli/hub/app/_form.py b/src/skore_cli/hub/app/_form.py index c7cecda..2ce3785 100644 --- a/src/skore_cli/hub/app/_form.py +++ b/src/skore_cli/hub/app/_form.py @@ -1,9 +1,10 @@ """Textual apps backing the interactive ``skore hub api-key`` commands. ``ApiKeyForm`` is a single-screen form (name, workspace, permissions, validity) -mirroring the hub UI's create modal; ``ApiKeyPicker`` is a single-select picker -used by ``revoke``. Both follow the package convention: set ``self.result`` then -``exit()``; the caller reads ``app.result`` after ``app.run()``. +mirroring the hub UI's create modal; ``IdPicker`` is a generic single-select +picker over ``(id, label)`` rows. Both follow the package convention: set +``self.result`` then ``exit()``; the caller reads ``app.result`` after +``app.run()``. """ from __future__ import annotations @@ -211,14 +212,17 @@ def action_cancel(self) -> None: self.exit() -_PICKER_INTRO = ( - "Choose the API key to revoke.\n" +_PICKER_KEYS = ( "[reverse] ↑/↓ [/] choose [reverse] enter [/] confirm [reverse] esc [/] cancel" ) -class ApiKeyPicker(App[int | None]): - """Pick a single API key id to revoke.""" +class IdPicker(App[int | None]): + """Single-select picker over ``(id, label)`` rows; returns the chosen id. + + Used wherever a command needs the user to pick one of several hub objects + (an API key to revoke, a provider to activate/remove, a workspace). + """ CSS = """ Screen { @@ -243,16 +247,23 @@ class ApiKeyPicker(App[int | None]): Binding("escape", "cancel", "Cancel"), ] - def __init__(self, keys: list[tuple[int, str]], *, preselect: int = 0) -> None: + def __init__( + self, + keys: list[tuple[int, str]], + *, + title: str = "Choose an item.", + preselect: int = 0, + ) -> None: super().__init__() self._keys = keys + self._title = title self._preselect = preselect self.result: int | None = None def compose(self) -> ComposeResult: yield Header() with VerticalScroll(id="picker"): - yield Label(_PICKER_INTRO, classes="picker-intro") + yield Label(f"{self._title}\n{_PICKER_KEYS}", classes="picker-intro") with AutoRadioSet(id="keys"): for index, (_, label) in enumerate(self._keys): yield RadioButton(label, value=index == self._preselect) @@ -264,7 +275,7 @@ def on_mount(self) -> None: def action_confirm(self) -> None: index = self.query_one("#keys", AutoRadioSet).pressed_index if index < 0: - self.notify("Select a key.", severity="warning") + self.notify("Select an item.", severity="warning") return self.result = self._keys[index][0] self.exit() diff --git a/src/skore_cli/hub/app/_provider_form.py b/src/skore_cli/hub/app/_provider_form.py new file mode 100644 index 0000000..6ab6d62 --- /dev/null +++ b/src/skore_cli/hub/app/_provider_form.py @@ -0,0 +1,265 @@ +"""Textual app backing the interactive ``skore hub agent-provider add`` command. + +``AgentProviderForm`` is a single-screen, provider-adaptive form mirroring the +hub UI's agent provider modal: picking ``skore`` needs nothing else, while the +bring-your-own ``anthropic``/``bedrock`` providers reveal a model picker and the +relevant secret fields (only offered when the workspace has encryption +configured and the hub advertises models for them). It follows the package +convention: set ``self.result`` then ``exit()``; the caller reads ``app.result`` +after ``app.run()``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Vertical, VerticalScroll +from textual.widgets import ( + Checkbox, + Footer, + Header, + Input, + Label, + RadioButton, +) + +from skore_cli.skills.app._widgets import AutoRadioSet + +_PROVIDER_ORDER = ["skore", "anthropic", "bedrock"] + +_INTRO = ( + "Add an agent LLM provider for the workspace.\n" + "Pick a provider; bring-your-own providers need a model (and secrets).\n" + "[reverse] tab [/] next field [reverse] ↑/↓ [/] choose " + "[reverse] enter [/] add [reverse] esc [/] cancel" +) + + +@dataclass(frozen=True) +class AgentProviderFormResult: + """The choices captured by :class:`AgentProviderForm`.""" + + name: str + provider: str + selected_model: str | None + anthropic_api_key: str | None + aws_region: str | None + bedrock_role_arn: str | None + bedrock_external_id: str | None + aws_access_key_id: str | None + aws_secret_access_key: str | None + activate: bool + + +class AgentProviderForm(App[AgentProviderFormResult | None]): + """Interactive, provider-adaptive form to register an agent provider.""" + + CSS = """ + Screen { + align: center middle; + } + #form { + width: 90%; + height: 90%; + } + .form-intro { + margin: 1 1; + color: $text-muted; + } + .field-label { + margin: 1 1 0 1; + text-style: bold; + } + .field-hint { + margin: 0 1; + color: $text-muted; + } + Input { + margin: 0 1; + width: 100%; + } + AutoRadioSet { + margin: 0 1; + width: 100%; + } + """ + + BINDINGS = [ + Binding("enter", "confirm", "Add", priority=True), + Binding("escape", "cancel", "Cancel"), + Binding("tab", "focus_next", "Next", show=False), + Binding("shift+tab", "focus_previous", "Previous", show=False), + ] + + def __init__( + self, + available_models: dict[str, list[str]], + *, + encryption_configured: bool, + name: str = "", + activate_default: bool = True, + ) -> None: + super().__init__() + self._available_models = available_models + self._encryption_configured = encryption_configured + self._name = name + self._activate_default = activate_default + self._selectable = { + provider: self._is_selectable(provider) for provider in _PROVIDER_ORDER + } + self._initial_provider = next( + (p for p in _PROVIDER_ORDER if self._selectable[p]), "skore" + ) + self.result: AgentProviderFormResult | None = None + + def _is_selectable(self, provider: str) -> bool: + if provider == "skore": + return True + return self._encryption_configured and bool( + self._available_models.get(provider) + ) + + def compose(self) -> ComposeResult: + yield Header() + with VerticalScroll(id="form"): + yield Label(_INTRO, classes="form-intro") + + yield Label("Name", classes="field-label") + yield Input(value=self._name, placeholder="e.g. team-anthropic", id="name") + + yield Label("Provider", classes="field-label") + with AutoRadioSet(id="provider"): + for provider in _PROVIDER_ORDER: + yield RadioButton( + provider, + value=provider == self._initial_provider, + disabled=not self._selectable[provider], + id=f"provider-{provider}", + ) + + with Vertical(id="skore-fields"): + yield Label( + "Uses skore's managed LLM -- no extra configuration needed.", + classes="field-hint", + ) + + with Vertical(id="anthropic-fields"): + yield Label("Model", classes="field-label") + with AutoRadioSet(id="anthropic-model"): + for index, model in enumerate( + self._available_models.get("anthropic", []) + ): + yield RadioButton(model, value=index == 0) + yield Label("Anthropic API key", classes="field-label") + yield Input(password=True, id="anthropic-api-key") + + with Vertical(id="bedrock-fields"): + yield Label("Model", classes="field-label") + with AutoRadioSet(id="bedrock-model"): + for index, model in enumerate( + self._available_models.get("bedrock", []) + ): + yield RadioButton(model, value=index == 0) + yield Label("AWS region", classes="field-label") + yield Input(id="aws-region", placeholder="e.g. us-east-1") + yield Label("Bedrock role ARN", classes="field-label") + yield Input(id="bedrock-role-arn") + yield Label("Bedrock external id", classes="field-label") + yield Input(id="bedrock-external-id") + yield Label("AWS access key id", classes="field-label") + yield Input(id="aws-access-key-id") + yield Label("AWS secret access key", classes="field-label") + yield Input(password=True, id="aws-secret-access-key") + + yield Checkbox("Activate now", value=self._activate_default, id="activate") + yield Footer() + + def on_mount(self) -> None: + index = _PROVIDER_ORDER.index(self._initial_provider) + self.query_one("#provider", AutoRadioSet).select_index(index) + self._show_fields(self._initial_provider) + self.query_one("#name", Input).focus() + + def _current_provider(self) -> str: + index = self.query_one("#provider", AutoRadioSet).pressed_index + if index < 0: + return self._initial_provider + return _PROVIDER_ORDER[index] + + def _show_fields(self, provider: str) -> None: + for name in _PROVIDER_ORDER: + self.query_one(f"#{name}-fields", Vertical).display = name == provider + + def on_radio_set_changed(self, event: AutoRadioSet.Changed) -> None: + if event.radio_set.id == "provider": + self._show_fields(self._current_provider()) + + def _selected_model(self, provider: str) -> str | None: + radio = self.query_one(f"#{provider}-model", AutoRadioSet) + index = radio.pressed_index + models = self._available_models.get(provider, []) + if 0 <= index < len(models): + return models[index] + return None + + def action_confirm(self) -> None: + name = self.query_one("#name", Input).value.strip() + if not name: + self.notify("Enter a name for the provider.", severity="warning") + return + provider = self._current_provider() + + selected_model: str | None = None + anthropic_api_key: str | None = None + aws_region: str | None = None + bedrock_role_arn: str | None = None + bedrock_external_id: str | None = None + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None + + if provider != "skore": + selected_model = self._selected_model(provider) + if not selected_model: + self.notify("Select a model.", severity="warning") + return + if provider == "anthropic": + anthropic_api_key = ( + self.query_one("#anthropic-api-key", Input).value.strip() or None + ) + if not anthropic_api_key: + self.notify("Enter the Anthropic API key.", severity="warning") + return + if provider == "bedrock": + aws_region = self.query_one("#aws-region", Input).value.strip() or None + bedrock_role_arn = ( + self.query_one("#bedrock-role-arn", Input).value.strip() or None + ) + bedrock_external_id = ( + self.query_one("#bedrock-external-id", Input).value.strip() or None + ) + aws_access_key_id = ( + self.query_one("#aws-access-key-id", Input).value.strip() or None + ) + aws_secret_access_key = ( + self.query_one("#aws-secret-access-key", Input).value.strip() or None + ) + + self.result = AgentProviderFormResult( + name=name, + provider=provider, + selected_model=selected_model, + anthropic_api_key=anthropic_api_key, + aws_region=aws_region, + bedrock_role_arn=bedrock_role_arn, + bedrock_external_id=bedrock_external_id, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + activate=self.query_one("#activate", Checkbox).value, + ) + self.exit() + + def action_cancel(self) -> None: + self.result = None + self.exit() diff --git a/tests/test_hub_agent_provider_form.py b/tests/test_hub_agent_provider_form.py new file mode 100644 index 0000000..962c35b --- /dev/null +++ b/tests/test_hub_agent_provider_form.py @@ -0,0 +1,117 @@ +"""Textual pilot tests for the interactive ``skore hub agent-provider add`` form.""" + +from __future__ import annotations + +from textual.widgets import Input + +from skore_cli.hub.app import AgentProviderForm + +MODELS = {"anthropic": ["claude-x"], "bedrock": ["nova"]} + + +def _form(**kwargs): + kwargs.setdefault("encryption_configured", True) + kwargs.setdefault("name", "team") + return AgentProviderForm(MODELS, **kwargs) + + +async def test_provider_switch_toggles_field_groups(): + app = _form() + async with app.run_test() as pilot: + await pilot.pause() + assert app.query_one("#skore-fields").display is True + assert app.query_one("#anthropic-fields").display is False + + app.query_one("#provider").action_next_button() + await pilot.pause() + assert app.query_one("#skore-fields").display is False + assert app.query_one("#anthropic-fields").display is True + assert app.query_one("#bedrock-fields").display is False + + +async def test_skore_confirm_returns_result(): + app = _form() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result is not None + assert app.result.provider == "skore" + assert app.result.selected_model is None + assert app.result.activate is True + + +async def test_anthropic_requires_key_then_confirms(): + app = _form() + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#provider").action_next_button() + await pilot.pause() + + await pilot.press("enter") + await pilot.pause() + assert app.is_running is True + assert app.result is None + + app.query_one("#anthropic-api-key", Input).value = "secret" + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result is not None + assert app.result.provider == "anthropic" + assert app.result.selected_model == "claude-x" + assert app.result.anthropic_api_key == "secret" + + +async def test_bedrock_confirms_with_optional_fields(): + app = _form() + async with app.run_test() as pilot: + await pilot.pause() + provider = app.query_one("#provider") + provider.action_next_button() + provider.action_next_button() + await pilot.pause() + assert app.query_one("#bedrock-fields").display is True + + app.query_one("#aws-region", Input).value = "us-east-1" + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result is not None + assert app.result.provider == "bedrock" + assert app.result.selected_model == "nova" + assert app.result.aws_region == "us-east-1" + assert app.result.bedrock_role_arn is None + + +async def test_activate_default_off_reflected(): + app = _form(activate_default=False) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + assert app.result is not None + assert app.result.activate is False + + +async def test_escape_returns_none(): + app = _form() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + + assert app.result is None + + +async def test_byo_disabled_without_encryption(): + app = _form(encryption_configured=False) + async with app.run_test() as pilot: + await pilot.pause() + assert app.query_one("#provider-anthropic").disabled is True + assert app.query_one("#provider-bedrock").disabled is True + assert app.query_one("#provider-skore").disabled is False diff --git a/tests/test_hub_agent_providers.py b/tests/test_hub_agent_providers.py new file mode 100644 index 0000000..9af1b2f --- /dev/null +++ b/tests/test_hub_agent_providers.py @@ -0,0 +1,746 @@ +"""Tests for ``skore hub agent-provider`` (client + add/list/activate/remove). + +The client functions are exercised against an in-memory ``httpx.MockTransport`` +(no network). The command callbacks are exercised via ``CliRunner`` with the +``_client`` functions and the session helpers monkeypatched, plus fake Textual +apps for the interactive paths (mirroring the api-key tests). +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import rich_click as click +from click.testing import CliRunner + +from skore_cli.hub import _agent_providers, _api_keys, _client +from skore_cli.hub._client import AgentProviders, Membership, ProviderEntry + + +def _transport(handler): + return httpx.MockTransport(handler) + + +def _entry(**overrides): + base = { + "id": 11, + "name": "team", + "is_active": False, + "provider": "skore", + "selected_model": None, + "aws_region": None, + "bedrock_role_arn": None, + "anthropic_api_key_set": False, + "bedrock_external_id_set": False, + "aws_access_key_id_set": False, + "aws_secret_access_key_set": False, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- # +# _client: HTTP calls via MockTransport +# --------------------------------------------------------------------------- # + + +def test_agent_providers_parses(): + def handler(request): + assert request.url.path == "/agent/workspaces/7/providers" + assert request.headers["Authorization"] == "Bearer tok" + return httpx.Response( + 200, + json={ + "providers": [ + _entry( + id=1, + name="byo", + is_active=True, + provider="anthropic", + selected_model="claude-x", + anthropic_api_key_set=True, + ) + ], + "available_models": {"anthropic": ["claude-x"], "bedrock": ["nova"]}, + "encryption_configured": True, + }, + ) + + result = _client.agent_providers( + "http://hub.test", "tok", 7, transport=_transport(handler) + ) + assert result == AgentProviders( + providers=[ + ProviderEntry( + id=1, + name="byo", + is_active=True, + provider="anthropic", + selected_model="claude-x", + aws_region=None, + bedrock_role_arn=None, + anthropic_api_key_set=True, + bedrock_external_id_set=False, + aws_access_key_id_set=False, + aws_secret_access_key_set=False, + ) + ], + available_models={"anthropic": ["claude-x"], "bedrock": ["nova"]}, + encryption_configured=True, + ) + + +def test_create_agent_provider_posts_body_and_returns_entry(): + seen = {} + + def handler(request): + seen["path"] = request.url.path + seen["body"] = json.loads(request.content) + return httpx.Response( + 201, + json=_entry(id=42, name="team", provider="anthropic", selected_model="cx"), + ) + + entry = _client.create_agent_provider( + "http://hub.test", + "tok", + 7, + payload={"name": "team", "provider": "anthropic", "selected_model": "cx"}, + transport=_transport(handler), + ) + assert entry.id == 42 + assert seen["path"] == "/agent/workspaces/7/providers" + assert seen["body"] == { + "name": "team", + "provider": "anthropic", + "selected_model": "cx", + } + + +def test_activate_agent_provider_hits_path(): + def handler(request): + assert request.method == "POST" + assert request.url.path == "/agent/workspaces/7/providers/3/activate" + return httpx.Response(204) + + _client.activate_agent_provider( + "http://hub.test", "tok", 7, 3, transport=_transport(handler) + ) + + +def test_delete_agent_provider_hits_path(): + def handler(request): + assert request.method == "DELETE" + assert request.url.path == "/agent/workspaces/7/providers/3" + return httpx.Response(204) + + _client.delete_agent_provider( + "http://hub.test", "tok", 7, 3, transport=_transport(handler) + ) + + +def test_create_403_maps_to_owner_admin_message(): + def handler(request): + return httpx.Response(403, json={"detail": "forbidden"}) + + with pytest.raises(click.ClickException) as exc: + _client.create_agent_provider( + "http://hub.test", "tok", 7, payload={}, transport=_transport(handler) + ) + assert "owner/admin" in str(exc.value) + + +def test_create_400_surfaces_detail(): + def handler(request): + return httpx.Response(400, json={"detail": "encryption not configured"}) + + with pytest.raises(click.ClickException) as exc: + _client.create_agent_provider( + "http://hub.test", "tok", 7, payload={}, transport=_transport(handler) + ) + assert "encryption not configured" in str(exc.value) + + +# --------------------------------------------------------------------------- # +# helpers for command tests +# --------------------------------------------------------------------------- # + + +def _patch_session(monkeypatch, memberships, *, user_id="user-1"): + monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: url or "h") + monkeypatch.setattr(_client, "require_login_token", lambda: "tok") + monkeypatch.setattr(_client, "me", lambda uri, token: (user_id, memberships)) + + +def _patch_providers( + monkeypatch, *, providers=None, available_models=None, encryption=False +): + value = AgentProviders( + providers=providers or [], + available_models=available_models or {}, + encryption_configured=encryption, + ) + monkeypatch.setattr(_client, "agent_providers", lambda uri, token, ws_id: value) + return value + + +_WS_A = Membership(7, "ws-a", frozenset()) +_WS_B = Membership(8, "ws-b", frozenset()) + + +# --------------------------------------------------------------------------- # +# add +# --------------------------------------------------------------------------- # + + +def test_add_requires_login(monkeypatch): + monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: "h") + + def boom(): + raise click.ClickException("not logged in; run `skore hub login` first.") + + monkeypatch.setattr(_client, "require_login_token", boom) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + ["add", "-w", "ws-a", "--name", "x", "--provider", "skore"], + ) + assert result.exit_code != 0 + assert "not logged in" in result.output + + +def test_add_unknown_workspace_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers(monkeypatch) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + ["add", "-w", "nope", "--name", "x", "--provider", "skore"], + ) + assert result.exit_code != 0 + assert "unknown workspace" in result.output + + +def test_add_multi_workspace_non_interactive_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A, _WS_B]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers(monkeypatch) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + ["add", "--name", "x", "--provider", "skore"], + ) + assert result.exit_code != 0 + assert "--workspace" in result.output + + +def test_add_skore_builds_name_only_payload(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers(monkeypatch) + captured = {} + + def fake_create(uri, token, ws_id, *, payload): + captured.update(ws_id=ws_id, payload=payload) + return ProviderEntry( + 11, "team", False, "skore", None, None, None, False, False, False, False + ) + + monkeypatch.setattr(_client, "create_agent_provider", fake_create) + monkeypatch.setattr( + _client, + "activate_agent_provider", + lambda *a, **k: pytest.fail("should not activate"), + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + ["add", "--name", "team", "--provider", "skore"], + ) + assert result.exit_code == 0, result.output + assert captured["ws_id"] == 7 + assert captured["payload"] == {"name": "team", "provider": "skore"} + + +def test_add_anthropic_payload_and_activate(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers( + monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=True + ) + captured = {} + + def fake_create(uri, token, ws_id, *, payload): + captured["payload"] = payload + return ProviderEntry( + 12, + "team", + False, + "anthropic", + "claude-x", + None, + None, + True, + False, + False, + False, + ) + + activated = {} + monkeypatch.setattr(_client, "create_agent_provider", fake_create) + monkeypatch.setattr( + _client, + "activate_agent_provider", + lambda uri, token, ws_id, cid: activated.update(ws_id=ws_id, id=cid), + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + [ + "add", + "-w", + "ws-a", + "--name", + "team", + "--provider", + "anthropic", + "--model", + "claude-x", + "--anthropic-api-key", + "secret", + "--activate", + ], + ) + assert result.exit_code == 0, result.output + assert captured["payload"] == { + "name": "team", + "provider": "anthropic", + "selected_model": "claude-x", + "anthropic_api_key": "secret", + } + assert activated == {"ws_id": 7, "id": 12} + + +def test_add_bedrock_optional_fields(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers( + monkeypatch, available_models={"bedrock": ["nova"]}, encryption=True + ) + captured = {} + + def fake_create(uri, token, ws_id, *, payload): + captured["payload"] = payload + return ProviderEntry( + 13, + "team", + False, + "bedrock", + "nova", + "us-east-1", + None, + False, + False, + False, + False, + ) + + monkeypatch.setattr(_client, "create_agent_provider", fake_create) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + [ + "add", + "-w", + "ws-a", + "--name", + "team", + "--provider", + "bedrock", + "--model", + "nova", + "--aws-region", + "us-east-1", + "--bedrock-role-arn", + "arn:aws:iam::1:role/x", + ], + ) + assert result.exit_code == 0, result.output + assert captured["payload"] == { + "name": "team", + "provider": "bedrock", + "selected_model": "nova", + "aws_region": "us-east-1", + "bedrock_role_arn": "arn:aws:iam::1:role/x", + } + + +def test_add_byo_without_encryption_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers( + monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=False + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + [ + "add", + "-w", + "ws-a", + "--name", + "team", + "--provider", + "anthropic", + "--model", + "claude-x", + "--anthropic-api-key", + "secret", + ], + ) + assert result.exit_code != 0 + assert "encryption" in result.output + + +def test_add_model_not_available_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers( + monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=True + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + [ + "add", + "-w", + "ws-a", + "--name", + "team", + "--provider", + "anthropic", + "--model", + "nope", + "--anthropic-api-key", + "secret", + ], + ) + assert result.exit_code != 0 + assert "unknown model" in result.output + + +def test_add_anthropic_missing_key_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers( + monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=True + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + [ + "add", + "-w", + "ws-a", + "--name", + "team", + "--provider", + "anthropic", + "--model", + "claude-x", + "--yes", + ], + ) + assert result.exit_code != 0 + assert "anthropic-api-key" in result.output + + +def test_add_interactive_uses_form(monkeypatch): + from skore_cli.hub import app as hub_app + + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: True) + _patch_providers( + monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=True + ) + + class _FakeForm: + def __init__(self, *a, **k): + self.result = hub_app.AgentProviderFormResult( + name="team", + provider="anthropic", + selected_model="claude-x", + anthropic_api_key="secret", + aws_region=None, + bedrock_role_arn=None, + bedrock_external_id=None, + aws_access_key_id=None, + aws_secret_access_key=None, + activate=True, + ) + + def run(self): + return None + + monkeypatch.setattr(hub_app, "AgentProviderForm", _FakeForm) + + captured = {} + + def fake_create(uri, token, ws_id, *, payload): + captured["payload"] = payload + return ProviderEntry( + 14, + "team", + False, + "anthropic", + "claude-x", + None, + None, + True, + False, + False, + False, + ) + + activated = {} + monkeypatch.setattr(_client, "create_agent_provider", fake_create) + monkeypatch.setattr( + _client, + "activate_agent_provider", + lambda uri, token, ws_id, cid: activated.update(id=cid), + ) + + result = CliRunner().invoke(_agent_providers.agent_provider, ["add", "-w", "ws-a"]) + assert result.exit_code == 0, result.output + assert captured["payload"] == { + "name": "team", + "provider": "anthropic", + "selected_model": "claude-x", + "anthropic_api_key": "secret", + } + assert activated == {"id": 14} + + +def test_add_interactive_cancel(monkeypatch): + from skore_cli.hub import app as hub_app + + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: True) + _patch_providers(monkeypatch) + + class _FakeForm: + def __init__(self, *a, **k): + self.result = None + + def run(self): + return None + + monkeypatch.setattr(hub_app, "AgentProviderForm", _FakeForm) + monkeypatch.setattr( + _client, + "create_agent_provider", + lambda *a, **k: pytest.fail("should not create on cancel"), + ) + + result = CliRunner().invoke(_agent_providers.agent_provider, ["add", "-w", "ws-a"]) + assert result.exit_code == 0, result.output + assert "Nothing added" in result.output + + +# --------------------------------------------------------------------------- # +# list +# --------------------------------------------------------------------------- # + + +def test_list_prints_table(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + _patch_providers( + monkeypatch, + providers=[ + ProviderEntry( + 1, + "byo", + True, + "anthropic", + "claude-x", + None, + None, + True, + False, + False, + False, + ) + ], + encryption=True, + ) + + result = CliRunner().invoke(_agent_providers.agent_provider, ["list", "-w", "ws-a"]) + assert result.exit_code == 0, result.output + assert "byo" in result.output + assert "anthropic" in result.output + assert "claude-x" in result.output + + +def test_list_empty_with_encryption_note(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + _patch_providers(monkeypatch, encryption=False) + + result = CliRunner().invoke(_agent_providers.agent_provider, ["list", "-w", "ws-a"]) + assert result.exit_code == 0, result.output + assert "No agent providers" in result.output + assert "Encryption is not configured" in result.output + + +# --------------------------------------------------------------------------- # +# activate +# --------------------------------------------------------------------------- # + + +def test_activate_by_id(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + activated = {} + monkeypatch.setattr( + _client, + "activate_agent_provider", + lambda uri, token, ws_id, cid: activated.update(id=cid), + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, ["activate", "-w", "ws-a", "--id", "5"] + ) + assert result.exit_code == 0, result.output + assert activated == {"id": 5} + assert "activated provider 5" in result.output + + +def test_activate_non_interactive_without_id_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) + _patch_providers( + monkeypatch, + providers=[ + ProviderEntry( + 5, "x", False, "skore", None, None, None, False, False, False, False + ) + ], + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, ["activate", "-w", "ws-a"] + ) + assert result.exit_code != 0 + assert "--id" in result.output + + +def test_activate_interactive_picker(monkeypatch): + from skore_cli.hub import app as hub_app + + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: True) + _patch_providers( + monkeypatch, + providers=[ + ProviderEntry( + 5, "x", False, "skore", None, None, None, False, False, False, False + ) + ], + ) + + class _FakePicker: + def __init__(self, *a, **k): + self.result = 5 + + def run(self): + return None + + monkeypatch.setattr(hub_app, "IdPicker", _FakePicker) + activated = {} + monkeypatch.setattr( + _client, + "activate_agent_provider", + lambda uri, token, ws_id, cid: activated.update(id=cid), + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, ["activate", "-w", "ws-a"] + ) + assert result.exit_code == 0, result.output + assert activated == {"id": 5} + + +# --------------------------------------------------------------------------- # +# remove +# --------------------------------------------------------------------------- # + + +def test_remove_by_id_yes(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + deleted = {} + monkeypatch.setattr( + _client, + "delete_agent_provider", + lambda uri, token, ws_id, cid: deleted.update(id=cid), + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + ["remove", "-w", "ws-a", "--id", "5", "--yes"], + ) + assert result.exit_code == 0, result.output + assert deleted == {"id": 5} + assert "removed provider 5" in result.output + + +def test_remove_interactive_picker(monkeypatch): + from skore_cli.hub import app as hub_app + + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: True) + _patch_providers( + monkeypatch, + providers=[ + ProviderEntry( + 6, "x", False, "skore", None, None, None, False, False, False, False + ) + ], + ) + + class _FakePicker: + def __init__(self, *a, **k): + self.result = 6 + + def run(self): + return None + + monkeypatch.setattr(hub_app, "IdPicker", _FakePicker) + deleted = {} + monkeypatch.setattr( + _client, + "delete_agent_provider", + lambda uri, token, ws_id, cid: deleted.update(id=cid), + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, ["remove", "-w", "ws-a", "--yes"] + ) + assert result.exit_code == 0, result.output + assert deleted == {"id": 6} + + +def test_remove_confirm_abort(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr( + _client, + "delete_agent_provider", + lambda *a, **k: pytest.fail("should not delete on abort"), + ) + + result = CliRunner().invoke( + _agent_providers.agent_provider, + ["remove", "-w", "ws-a", "--id", "5"], + input="n\n", + ) + assert result.exit_code != 0 diff --git a/tests/test_hub_api_key_form.py b/tests/test_hub_api_key_form.py index db1dc66..86b7ae7 100644 --- a/tests/test_hub_api_key_form.py +++ b/tests/test_hub_api_key_form.py @@ -4,7 +4,7 @@ from textual.widgets import SelectionList -from skore_cli.hub.app import ApiKeyForm, ApiKeyPicker +from skore_cli.hub.app import ApiKeyForm, IdPicker WORKSPACES = [(7, "ws-a"), (8, "ws-b")] GRANTABLE = {7: ["create:project", "read:project"], 8: ["read:project"]} @@ -95,12 +95,12 @@ async def test_form_permissions_track_workspace(): # --------------------------------------------------------------------------- # -# ApiKeyPicker +# IdPicker # --------------------------------------------------------------------------- # async def test_picker_confirms_selection(): - app = ApiKeyPicker([(5, "5 laptop"), (6, "6 ci")], preselect=0) + app = IdPicker([(5, "5 laptop"), (6, "6 ci")], preselect=0) async with app.run_test() as pilot: await pilot.pause() await pilot.press("enter") @@ -110,7 +110,7 @@ async def test_picker_confirms_selection(): async def test_picker_cancel_returns_none(): - app = ApiKeyPicker([(5, "5 laptop")]) + app = IdPicker([(5, "5 laptop")]) async with app.run_test() as pilot: await pilot.pause() await pilot.press("escape") diff --git a/tests/test_hub_api_keys.py b/tests/test_hub_api_keys.py index acbe58d..616979c 100644 --- a/tests/test_hub_api_keys.py +++ b/tests/test_hub_api_keys.py @@ -449,7 +449,7 @@ def __init__(self, *a, **k): def run(self): return None - monkeypatch.setattr(hub_app, "ApiKeyPicker", _FakePicker) + monkeypatch.setattr(hub_app, "IdPicker", _FakePicker) deleted = {} monkeypatch.setattr( _client, diff --git a/tests/test_hub_workspaces.py b/tests/test_hub_workspaces.py new file mode 100644 index 0000000..ce18eae --- /dev/null +++ b/tests/test_hub_workspaces.py @@ -0,0 +1,457 @@ +"""Tests for ``skore hub workspace`` (client + list/show/create/rename/delete). + +The client functions are exercised against an in-memory ``httpx.MockTransport`` +(no network). The command callbacks are exercised via ``CliRunner`` with the +``_client`` functions and the session helpers monkeypatched, plus a fake +``IdPicker`` for the interactive selection path (mirroring the api-key and +agent-provider tests). +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import rich_click as click +from click.testing import CliRunner + +from skore_cli.hub import _api_keys, _client, _workspaces +from skore_cli.hub._client import MemberInfo, Membership, WorkspaceInfo + + +def _transport(handler): + return httpx.MockTransport(handler) + + +def _ws(**overrides): + base = { + "id": 7, + "public_id": "ws-a", + "is_public": False, + "created_at": "2026-01-01T00:00:00Z", + "members": [{"user_id": "user-1", "role": "owner", "invited_by": None}], + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- # +# _client: HTTP calls via MockTransport +# --------------------------------------------------------------------------- # + + +def test_list_workspaces_parses_and_paginates(): + seen = {"calls": 0} + + def handler(request): + assert request.url.path == "/identity/workspaces" + assert request.headers["Authorization"] == "Bearer tok" + seen["calls"] += 1 + cursor = request.url.params.get("cursor") + if cursor is None: + return httpx.Response( + 200, json={"items": [_ws(id=7, public_id="ws-a")], "next_cursor": 5} + ) + assert cursor == "5" + return httpx.Response( + 200, json={"items": [_ws(id=8, public_id="ws-b")], "next_cursor": None} + ) + + result = _client.list_workspaces( + "http://hub.test", "tok", transport=_transport(handler) + ) + assert seen["calls"] == 2 + assert [w.id for w in result] == [7, 8] + assert result[0].members == [MemberInfo("user-1", "owner", None)] + + +def test_get_workspace_parses_members(): + def handler(request): + assert request.url.path == "/identity/workspaces/7" + return httpx.Response( + 200, + json=_ws( + members=[ + {"user_id": "user-1", "role": "owner", "invited_by": None}, + {"user_id": "user-2", "role": "reader", "invited_by": "user-1"}, + ] + ), + ) + + ws = _client.get_workspace( + "http://hub.test", "tok", 7, transport=_transport(handler) + ) + assert ws.id == 7 + assert ws.members == [ + MemberInfo("user-1", "owner", None), + MemberInfo("user-2", "reader", "user-1"), + ] + + +def test_create_workspace_posts_body_and_returns_id(): + seen = {} + + def handler(request): + seen["path"] = request.url.path + seen["body"] = json.loads(request.content) + return httpx.Response(201, json={"id": 42}) + + workspace_id = _client.create_workspace( + "http://hub.test", "tok", public_id="my-ws", transport=_transport(handler) + ) + assert workspace_id == 42 + assert seen["path"] == "/identity/workspaces" + assert seen["body"] == {"public_id": "my-ws"} + + +def test_check_public_id_parses(): + def handler(request): + assert request.url.path == "/identity/workspaces/public-id-availability" + assert request.url.params.get("public_id") == "my-ws" + return httpx.Response( + 200, json={"available": False, "suggested_slug": "my-ws-1"} + ) + + available, suggested = _client.check_public_id( + "http://hub.test", "tok", "my-ws", transport=_transport(handler) + ) + assert (available, suggested) == (False, "my-ws-1") + + +def test_update_workspace_puts_body(): + seen = {} + + def handler(request): + assert request.method == "PUT" + seen["path"] = request.url.path + seen["body"] = json.loads(request.content) + return httpx.Response(204) + + _client.update_workspace( + "http://hub.test", "tok", 7, public_id="new-ws", transport=_transport(handler) + ) + assert seen["path"] == "/identity/workspaces/7" + assert seen["body"] == {"public_id": "new-ws"} + + +def test_delete_workspace_hits_path(): + def handler(request): + assert request.method == "DELETE" + assert request.url.path == "/identity/workspaces/7" + return httpx.Response(204) + + _client.delete_workspace("http://hub.test", "tok", 7, transport=_transport(handler)) + + +def test_delete_403_maps_owner_only(): + def handler(request): + return httpx.Response(403, json={"detail": "forbidden"}) + + with pytest.raises(click.ClickException) as exc: + _client.delete_workspace( + "http://hub.test", "tok", 7, transport=_transport(handler) + ) + assert "owner only" in str(exc.value) + + +def test_get_404_maps_not_found(): + def handler(request): + return httpx.Response(404, json={"detail": "nope"}) + + with pytest.raises(click.ClickException) as exc: + _client.get_workspace( + "http://hub.test", "tok", 7, transport=_transport(handler) + ) + assert "not found" in str(exc.value) + + +# --------------------------------------------------------------------------- # +# helpers for command tests +# --------------------------------------------------------------------------- # + + +def _patch_session(monkeypatch, memberships, *, user_id="user-1"): + monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: url or "h") + monkeypatch.setattr(_client, "require_login_token", lambda: "tok") + monkeypatch.setattr(_client, "me", lambda uri, token: (user_id, memberships)) + + +_WS_A = Membership(7, "ws-a", frozenset()) +_WS_B = Membership(8, "ws-b", frozenset()) + + +# --------------------------------------------------------------------------- # +# list +# --------------------------------------------------------------------------- # + + +def test_list_requires_login(monkeypatch): + monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: "h") + + def boom(): + raise click.ClickException("not logged in; run `skore hub login` first.") + + monkeypatch.setattr(_client, "require_login_token", boom) + + result = CliRunner().invoke(_workspaces.workspace, ["list"]) + assert result.exit_code != 0 + assert "not logged in" in result.output + + +def test_list_prints_table(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr( + _client, + "list_workspaces", + lambda uri, token: [ + WorkspaceInfo( + 7, + "ws-a", + False, + "2026-01-01T00:00:00Z", + [MemberInfo("user-1", "owner", None)], + ) + ], + ) + + result = CliRunner().invoke( + _workspaces.workspace, ["list", "--hub-url", "http://h"] + ) + assert result.exit_code == 0, result.output + assert "ws-a" in result.output + assert "owner" in result.output + + +def test_list_empty(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_client, "list_workspaces", lambda uri, token: []) + + result = CliRunner().invoke(_workspaces.workspace, ["list"]) + assert result.exit_code == 0, result.output + assert "No workspaces" in result.output + + +# --------------------------------------------------------------------------- # +# show +# --------------------------------------------------------------------------- # + + +def test_show_prints_members(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + monkeypatch.setattr( + _client, + "get_workspace", + lambda uri, token, ws_id: WorkspaceInfo( + 7, + "ws-a", + False, + "2026-01-01T00:00:00Z", + [MemberInfo("user-1", "owner", None)], + ), + ) + + result = CliRunner().invoke(_workspaces.workspace, ["show", "-w", "ws-a"]) + assert result.exit_code == 0, result.output + assert "ws-a" in result.output + assert "user-1" in result.output + assert "owner" in result.output + + +def test_show_unknown_workspace_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + + result = CliRunner().invoke(_workspaces.workspace, ["show", "-w", "nope"]) + assert result.exit_code != 0 + assert "unknown workspace" in result.output + + +# --------------------------------------------------------------------------- # +# create +# --------------------------------------------------------------------------- # + + +def test_create_with_public_id(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + monkeypatch.setattr( + _client, "check_public_id", lambda uri, token, pid: (True, None) + ) + captured = {} + + def fake_create(uri, token, *, public_id): + captured["public_id"] = public_id + return 42 + + monkeypatch.setattr(_client, "create_workspace", fake_create) + monkeypatch.setattr( + _client, + "get_workspace", + lambda uri, token, ws_id: WorkspaceInfo(42, "my-ws", False, None, None), + ) + + result = CliRunner().invoke( + _workspaces.workspace, ["create", "--public-id", "my-ws"] + ) + assert result.exit_code == 0, result.output + assert captured["public_id"] == "my-ws" + assert "created workspace" in result.output + assert "my-ws" in result.output + + +def test_create_taken_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + monkeypatch.setattr( + _client, "check_public_id", lambda uri, token, pid: (False, "my-ws-1") + ) + monkeypatch.setattr( + _client, + "create_workspace", + lambda *a, **k: pytest.fail("should not create when taken"), + ) + + result = CliRunner().invoke( + _workspaces.workspace, ["create", "--public-id", "my-ws"] + ) + assert result.exit_code != 0 + assert "not available" in result.output + assert "my-ws-1" in result.output + + +def test_create_interactive_prompt(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: True) + monkeypatch.setattr( + _client, "check_public_id", lambda uri, token, pid: (True, None) + ) + captured = {} + + def fake_create(uri, token, *, public_id): + captured["public_id"] = public_id + return 42 + + monkeypatch.setattr(_client, "create_workspace", fake_create) + monkeypatch.setattr( + _client, + "get_workspace", + lambda uri, token, ws_id: WorkspaceInfo(42, "prompted", False, None, None), + ) + + result = CliRunner().invoke(_workspaces.workspace, ["create"], input="prompted\n") + assert result.exit_code == 0, result.output + assert captured["public_id"] == "prompted" + + +def test_create_non_interactive_missing_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + + result = CliRunner().invoke(_workspaces.workspace, ["create"]) + assert result.exit_code != 0 + assert "--public-id" in result.output + + +# --------------------------------------------------------------------------- # +# rename +# --------------------------------------------------------------------------- # + + +def test_rename_with_flags(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + captured = {} + + def fake_update(uri, token, ws_id, *, public_id): + captured.update(ws_id=ws_id, public_id=public_id) + + monkeypatch.setattr(_client, "update_workspace", fake_update) + monkeypatch.setattr( + _client, + "get_workspace", + lambda uri, token, ws_id: WorkspaceInfo(7, "new-ws", False, None, None), + ) + + result = CliRunner().invoke( + _workspaces.workspace, + ["rename", "-w", "ws-a", "--new-public-id", "new-ws"], + ) + assert result.exit_code == 0, result.output + assert captured == {"ws_id": 7, "public_id": "new-ws"} + assert "new-ws" in result.output + + +def test_rename_non_interactive_missing_new_errors(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + + result = CliRunner().invoke(_workspaces.workspace, ["rename", "-w", "ws-a"]) + assert result.exit_code != 0 + assert "--new-public-id" in result.output + + +# --------------------------------------------------------------------------- # +# delete +# --------------------------------------------------------------------------- # + + +def test_delete_yes(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + deleted = {} + monkeypatch.setattr( + _client, + "delete_workspace", + lambda uri, token, ws_id: deleted.update(ws_id=ws_id), + ) + + result = CliRunner().invoke( + _workspaces.workspace, ["delete", "-w", "ws-a", "--yes"] + ) + assert result.exit_code == 0, result.output + assert deleted == {"ws_id": 7} + assert "deleted workspace ws-a" in result.output + + +def test_delete_confirm_abort(monkeypatch): + _patch_session(monkeypatch, [_WS_A]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) + monkeypatch.setattr( + _client, + "delete_workspace", + lambda *a, **k: pytest.fail("should not delete on abort"), + ) + + result = CliRunner().invoke( + _workspaces.workspace, ["delete", "-w", "ws-a"], input="n\n" + ) + assert result.exit_code != 0 + + +def test_delete_interactive_picker(monkeypatch): + from skore_cli.hub import app as hub_app + + _patch_session(monkeypatch, [_WS_A, _WS_B]) + monkeypatch.setattr(_workspaces, "_is_interactive", lambda: True) + + class _FakePicker: + def __init__(self, *a, **k): + self.result = 8 + + def run(self): + return None + + monkeypatch.setattr(hub_app, "IdPicker", _FakePicker) + deleted = {} + monkeypatch.setattr( + _client, + "delete_workspace", + lambda uri, token, ws_id: deleted.update(ws_id=ws_id), + ) + + result = CliRunner().invoke(_workspaces.workspace, ["delete", "--yes"]) + assert result.exit_code == 0, result.output + assert deleted == {"ws_id": 8} + assert "deleted workspace ws-b" in result.output From e4229909fdd046b7ba975ede935ef51efcf669ee Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Wed, 1 Jul 2026 18:44:20 +0200 Subject: [PATCH 4/7] auto configure harness --- .gitignore | 3 + README.md | 21 +- pyproject.toml | 11 +- src/skore_cli/__init__.py | 24 +- src/skore_cli/_hub_auth.py | 72 + src/skore_cli/_skore.py | 2 +- src/skore_cli/agent/__init__.py | 24 +- src/skore_cli/agent/_commands.py | 296 ++- src/skore_cli/agent/_harnesses.py | 637 ++----- src/skore_cli/agent/_skore_file.py | 69 + src/skore_cli/agent/app/_picker.py | 65 +- src/skore_cli/agent/mcp/__init__.py | 22 - src/skore_cli/agent/mcp/_a2a_client.py | 302 --- src/skore_cli/agent/mcp/_commands.py | 246 --- src/skore_cli/agent/mcp/_executor.py | 206 -- src/skore_cli/agent/mcp/_handlers.py | 245 --- src/skore_cli/agent/mcp/_hosts.py | 250 --- src/skore_cli/agent/mcp/_jobs.py | 259 --- src/skore_cli/agent/mcp/_server.py | 131 -- src/skore_cli/agent/model/__init__.py | 10 - src/skore_cli/agent/model/_commands.py | 374 ---- src/skore_cli/app/__init__.py | 5 + src/skore_cli/app/_help.py | 67 + src/skore_cli/hub/_agent_providers.py | 7 +- src/skore_cli/hub/_api_keys.py | 7 +- src/skore_cli/hub/_client.py | 10 +- src/skore_cli/hub/_commands.py | 110 +- src/skore_cli/hub/_workspaces.py | 7 +- src/skore_cli/hub/app/_form.py | 41 +- src/skore_cli/hub/app/_provider_form.py | 36 +- src/skore_cli/skills/app/_find.py | 22 +- src/skore_cli/skills/app/_install.py | 22 +- src/skore_cli/skills/app/_manage.py | 17 +- tests/test_agent_commands.py | 243 +-- tests/test_agent_mcp.py | 914 --------- tests/test_agent_workspace.py | 133 -- tests/test_harnesses.py | 361 +--- tests/test_hub.py | 267 +-- tests/test_hub_api_key_form.py | 13 + tests/test_hub_api_keys.py | 16 +- tests/test_hub_auth.py | 62 + tests/test_pickers.py | 21 +- uv.lock | 2293 +++++++++++++++++++++++ 43 files changed, 3548 insertions(+), 4395 deletions(-) create mode 100644 src/skore_cli/_hub_auth.py create mode 100644 src/skore_cli/agent/_skore_file.py delete mode 100644 src/skore_cli/agent/mcp/__init__.py delete mode 100644 src/skore_cli/agent/mcp/_a2a_client.py delete mode 100644 src/skore_cli/agent/mcp/_commands.py delete mode 100644 src/skore_cli/agent/mcp/_executor.py delete mode 100644 src/skore_cli/agent/mcp/_handlers.py delete mode 100644 src/skore_cli/agent/mcp/_hosts.py delete mode 100644 src/skore_cli/agent/mcp/_jobs.py delete mode 100644 src/skore_cli/agent/mcp/_server.py delete mode 100644 src/skore_cli/agent/model/__init__.py delete mode 100644 src/skore_cli/agent/model/_commands.py create mode 100644 src/skore_cli/app/__init__.py create mode 100644 src/skore_cli/app/_help.py delete mode 100644 tests/test_agent_mcp.py delete mode 100644 tests/test_agent_workspace.py create mode 100644 tests/test_hub_auth.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 4b3eeef..7377a61 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ pixi.lock .idea/ .vscode/ .DS_Store + +# Skore agent credentials (project-local) +.skore diff --git a/README.md b/README.md index b7dbc63..c68d1c0 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,8 @@ terminal. pip install skore-cli ``` -The base install is batteries-included: it bundles the `hub`, `agent`, and -`agent mcp` features (so it pulls in `skore`, `pyyaml`, and the `mcp` SDK). No -extras are required. +The base install is batteries-included: it bundles the `hub` and `agent` +features (so it pulls in `skore`). No extras are required. ## Usage @@ -32,18 +31,18 @@ skore skills remove # remove installed skills Skills are installed into the current project by default; pass `--global`/`-g` to target the user directory, and `--agent`/`-a` to select specific agents. -It also exposes a `hub` group (authenticate with a Skore Hub instance), an -`agent` group (wire a workspace to the Skore Hub agent for any harness), and -`agent mcp` (a local, harness-agnostic MCP relay that delegates ML tasks to the -hub agent). After a `skore hub login`, the relay lets your harness's assistant -delegate a task to the Skore agent, streams the agent's activity back, runs the -workspace actions it requests, and relays its user questions to you: +It also exposes a `hub` group (authenticate with a Skore Hub instance) and an +`agent` command (authenticate, configure and launch Claude Code, OpenCode or Pi +against the Skore Hub agent): ```bash -skore agent mcp install --host cursor # register the relay with a host -skore agent mcp serve # the stdio relay the host launches +skore hub login +skore agent ``` +After the first run, project credentials live in `.skore` (gitignored) and the +command reuses them on later runs. + ## License MIT diff --git a/pyproject.toml b/pyproject.toml index ffdcfc6..3a43b80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,13 +33,8 @@ dependencies = [ "rich-click>=1.9", "textual", # The `hub` and `agent` commands reuse skore's authentication machinery; the - # Continue and Claude Code harness writers need PyYAML to (de)serialize - # configs; the `agent mcp` command runs a local MCP server (the `mcp` SDK) - # that drives a headless opencode process via subprocess; the `hub api-key` - # commands call the hub identity API directly with httpx. + # `hub api-key` commands call the hub identity API directly with httpx. "skore[hub]", - "pyyaml", - "mcp>=1.2", "httpx", ] @@ -169,5 +164,7 @@ ignore_missing_imports = true module = [ "rich_click.*", "textual.*", - "mcp.*", ] + +[tool.ty.environment] +python = ".venv" diff --git a/src/skore_cli/__init__.py b/src/skore_cli/__init__.py index 164cbd8..aa59a2a 100644 --- a/src/skore_cli/__init__.py +++ b/src/skore_cli/__init__.py @@ -15,10 +15,28 @@ from skore_cli.skills import skills -@click.group() +click.rich_click.COMMAND_GROUPS = { + **getattr(click.rich_click, "COMMAND_GROUPS", {}), + "cli": [ + {"name": "Agent", "commands": ["agent"]}, + {"name": "Hub", "commands": ["hub"]}, + {"name": "Skills", "commands": ["skills"]}, + ], +} + + +@click.group(invoke_without_command=True) +@click.pass_context @click.version_option(package_name="skore-cli") -def cli() -> None: - """Skore command-line interface.""" +def cli(ctx) -> None: + """Skore command-line interface. + + Use ``skore agent`` to connect a project to the Skore Hub agent, + ``skore hub`` to authenticate and manage workspaces, and + ``skore skills`` to install probabl-skills locally. + """ + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) cli.add_command(skills) diff --git a/src/skore_cli/_hub_auth.py b/src/skore_cli/_hub_auth.py new file mode 100644 index 0000000..5bd5ada --- /dev/null +++ b/src/skore_cli/_hub_auth.py @@ -0,0 +1,72 @@ +"""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 hub login` or `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 index a079200..81410d2 100644 --- a/src/skore_cli/_skore.py +++ b/src/skore_cli/_skore.py @@ -41,7 +41,7 @@ def resolve_hub_uri( 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). This keeps ``hub login``, - ``agent init`` and ``agent mcp serve`` all pointing at the same hub. + ``agent`` and ``skore hub login`` all pointing at the same hub. ``auth_fn`` defaults to :func:`auth` but is injectable so each command can pass the ``_auth`` accessor that its tests monkeypatch. diff --git a/src/skore_cli/agent/__init__.py b/src/skore_cli/agent/__init__.py index 913dc59..5c73582 100644 --- a/src/skore_cli/agent/__init__.py +++ b/src/skore_cli/agent/__init__.py @@ -1,24 +1,10 @@ -"""The ``skore agent`` command group to wire a workspace to the Skore Hub agent. +"""The ``skore agent`` command to connect a project to the Skore Hub agent. -Architecture B (IP-isolated): an agent harness runs locally and is pointed at -``skore-hub`` as an OpenAI-compatible model provider. A PydanticAI agent on the -hub owns the orchestration and loads the probabl-skills *server-side*; the harness -only executes the tool calls the hub emits. Skills are therefore **not** installed -locally, keeping the orchestration IP on the hub. +The command authenticates with the hub, stores workspace credentials in a local +``.skore`` file, writes the harness configuration, and launches Claude, +OpenCode or Pi when installed. -The transport is a plain OpenAI-compatible model endpoint, so the agent is -harness-agnostic. ``skore agent model install`` configures a specific harness for -you: pass ``--harness `` to do it non-interactively, or omit it in a -terminal to pick one interactively (mirroring ``skore skills``). A ``generic`` -fallback just prints the connection values for any other OpenAI-compatible client. - -The ``mcp`` subgroup (``skore agent mcp``) offers an alternative: instead of -pointing the harness *at* the hub model, it runs a local MCP relay so the -harness's own assistant can *delegate* a task to the Skore Hub agent (which -streams its activity back, requests workspace actions, and asks the user -questions through the relay). Same IP boundary; different integration shape. - -Heavy ``skore`` (and ``textual``) imports are deferred into the command callbacks +Heavy ``skore`` (and ``textual``) imports are deferred into the command callback so building the CLI (and ``--help``) never imports them. """ diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index 2854b57..865fd10 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -1,40 +1,278 @@ -"""The ``skore agent`` click group: wires the integration subgroups. +"""The ``skore agent`` command: authenticate, configure and launch a harness.""" -The hub agent can be consumed two ways, each its own subgroup: +from __future__ import annotations -- ``skore agent model`` -- the agent is served behind an OpenAI-compatible - endpoint and a local harness is pointed at it (agent-as-model). -- ``skore agent mcp`` -- a local MCP relay lets the harness's own assistant - delegate ML tasks to the hub agent (agent-as-tool). +import sys +from pathlib import Path -Both subgroups keep their heavy ``skore``/``textual``/``mcp`` imports deferred -inside their command callbacks so building the CLI (and ``--help``) stays cheap. -""" +import rich_click as click -from __future__ import annotations +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._harnesses import ( + DEFAULT_MODEL_ID, + HARNESSES, + HARNESS_NAMES, + HarnessContext, + detect_harnesses, + launch_harness, +) +from skore_cli.agent._skore_file import SkoreConfig, ensure_gitignore_entry +from skore_cli.hub import _client -import rich_click as click +PROJECT_PERMISSIONS = ( + "create:project", + "read:project", + "update:project", + "delete:project", +) + + +def _is_interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _pick_workspace( + memberships: list[_client.Membership], +) -> _client.Membership: + """Launch the Textual workspace picker and return the chosen membership.""" + from skore_cli.agent.app import WorkspacePicker + + rows = [(membership.public_id, membership.public_id) for membership in memberships] + app = WorkspacePicker(rows) + app.run() + if app.result is None: + raise click.Abort() + return next(m for m in memberships if m.public_id == app.result) + + +def _pick_harness(workspace: Path) -> str: + """Launch the Textual harness picker among installed harnesses.""" + from skore_cli.agent.app import HarnessPicker + + detected = detect_harnesses(workspace) + if not detected: + raise click.ClickException( + "no supported harness found on PATH. Install one of: " + f"{', '.join(HARNESS_NAMES)}." + ) + rows = [ + (name, HARNESSES[name].label, True) + for name in HARNESSES + if name in detected + ] + app = HarnessPicker(rows, preselect=0) + app.run() + if app.result is None: + raise click.Abort() + 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 _ensure_login(hub_url: str, *, timeout: int) -> str: + """Return a bearer token, running interactive login when needed.""" + return ensure_login(timeout=timeout) + + +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: + 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(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 + + +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 not _is_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." + ) + return membership + + +@click.command() +@click.option( + "--workspace", + "-w", + default=".", + type=click.Path(file_okay=False, path_type=Path), + help="Project directory to configure (default: current directory).", +) +@click.option( + "--hub-url", + 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, like `skore hub login`." + ), +) +@click.option( + "--harness", + "-H", + "harness_name", + type=click.Choice(HARNESS_NAMES), + default=None, + help="Harness to use non-interactively (omit to pick among installed ones).", +) +@click.option( + "--model-id", + default=DEFAULT_MODEL_ID, + show_default=True, + help="Model id advertised by the hub.", +) +@click.option( + "--login-timeout", + default=600, + show_default=True, + help="Seconds to wait for interactive device login.", +) +def agent( + workspace: Path, + hub_url: str | None, + harness_name: str | None, + model_id: str, + login_timeout: int, +) -> None: + """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. + + Supported harnesses: Claude, OpenCode and Pi (must be on ``PATH``). + """ + workspace = workspace.resolve() + if not workspace.is_dir(): + raise click.ClickException(f"workspace does not exist: {workspace}") + + config = SkoreConfig.load(workspace) + + 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 + ) + 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) + 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) -click.rich_click.COMMAND_GROUPS = { - **getattr(click.rich_click, "COMMAND_GROUPS", {}), - "cli agent": [ - {"name": "Integrations", "commands": ["model", "mcp"]}, - ], -} + if config is None or not config.api_key: + if harness_name is None: + if not _is_interactive(): + raise click.UsageError( + "pass --harness to create an API key non-interactively." + ) + 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 + 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), + ) + config_path = config.save(workspace) + ensure_gitignore_entry(workspace) + console.print(f"[skore.ok]+[/] saved [skore.path]{config_path}[/]") -@click.group(invoke_without_command=True) -@click.pass_context -def agent(ctx) -> None: - """Connect this workspace to the Skore Hub agent (any harness).""" - if ctx.invoked_subcommand is None: - click.echo(ctx.get_help()) + if harness_name is None: + if not _is_interactive(): + raise click.UsageError( + f"pass --harness (one of: {', '.join(HARNESS_NAMES)})." + ) + harness_name = _pick_harness(workspace) + harness = HARNESSES[harness_name] + if not harness.detect(workspace): + raise click.ClickException( + f"{harness.label} is not installed or not on PATH." + ) -# The integration subgroups. Imported here so they attach to the agent group; -# their heavy deps stay deferred inside their own callbacks. -from skore_cli.agent.mcp import mcp as _mcp_group # noqa: E402 -from skore_cli.agent.model import model as _model_group # noqa: E402 + if config.harness != harness_name: + config = SkoreConfig( + hub_url=config.hub_url, + workspace=config.workspace, + workspace_id=config.workspace_id, + api_key=config.api_key, + harness=harness_name, + ) + config.save(workspace) -agent.add_command(_model_group) -agent.add_command(_mcp_group) + console.print( + f"Configuring [skore.skill]{harness.label}[/] in [skore.path]{workspace}[/]" + ) + harness.configure( + HarnessContext( + workspace=workspace, + hub_url=config.hub_url, + api_key=config.api_key, + model_id=model_id, + ) + ) + launch_harness(harness_name, workspace, model_id=model_id) diff --git a/src/skore_cli/agent/_harnesses.py b/src/skore_cli/agent/_harnesses.py index fcba396..63c7780 100644 --- a/src/skore_cli/agent/_harnesses.py +++ b/src/skore_cli/agent/_harnesses.py @@ -1,14 +1,7 @@ """Harness registry, detection and per-harness configuration writers. -Each :class:`Harness` knows how to (best-effort) *detect* whether it is present and -how to *configure* it so it talks to the Skore Hub agent's OpenAI-compatible -endpoint. The writers deliberately differ because harnesses are configured very -differently (project file vs. global file vs. env vars vs. a translation proxy). - -Credentials stay user-managed: the API key is never written to a file, only -*referenced* (``{env:...}`` for opencode, ``$SKORE_HUB_API_KEY`` in env files, -``${{ env.SKORE_HUB_API_KEY }}`` for Continue). Interactive device-flow tokens are -refreshed on the fly and emitted as a bearer. +Supported harnesses: Claude, OpenCode and Pi. Each writer mirrors the +copy-pastable setup snippets from the Skore Hub agent-setup UI. """ from __future__ import annotations @@ -21,508 +14,164 @@ from pathlib import Path from typing import Any -from skore_cli._skore import auth as _auth from skore_cli._style import console -API_KEY_ENV = "SKORE_HUB_API_KEY" -DEFAULT_HUB_URL = "http://127.0.0.1:8000" DEFAULT_MODEL_ID = "skore-agent" - -# Marker recording what `skore agent init` configured, so `status` is trivial. -MARKER_FILENAME = ".skore-agent.json" - -# Visible connection file written by the generic harness (paste-friendly). -GENERIC_CONFIG_FILENAME = "skore-agent.json" - -# Header naming the hub workspace the agent is attached to (a workspace public id). -WORKSPACE_HEADER = "X-Skore-Workspace" - -# opencode provider name: no dots (avoids the opencode option-dropping bug #5674). -OPENCODE_PROVIDER_KEY = "skore" OPENCODE_SCHEMA = "https://opencode.ai/config.json" -SESSION_HEADER = "X-Skore-Session-Id" -SESSION_PLUGIN_FILENAME = "skore-session.js" -SESSION_PLUGIN_SOURCE = f"""\ -// Auto-generated by `skore agent init`. Forwards opencode's session id to the -// Skore Hub agent so the hub can resume the matching conversation history. -export const SkoreSession = async () => ({{ - "chat.headers": async (input, output) => {{ - const id = input?.provider?.info?.id ?? input?.provider?.id; - if (id === "{OPENCODE_PROVIDER_KEY}" && input?.sessionID) {{ - output.headers["{SESSION_HEADER}"] = input.sessionID; - }} - }}, -}}); -""" - - -def base_url(hub_url: str) -> str: - """Return the OpenAI-compatible base URL the harness should point at.""" - return f"{hub_url.rstrip('/')}/v1" +OPENCODE_PROVIDER_KEY = "skore" @dataclass(frozen=True) -class Credential: - """A resolved hub credential. - - ``kind`` is ``"api_key"`` (user-managed via ``SKORE_HUB_API_KEY``, never - embedded), ``"bearer"`` (an interactive device-flow access token, embedded as - a literal) or ``"none"``. - """ - - kind: str - token: str = "" - - -def resolve_credential() -> Credential: - """Resolve the hub credential, mirroring the Python authentication. - - Prefers the user-managed API key (referenced, never stored); otherwise - refreshes the persisted interactive token (relaunching the device login if the - refresh token is dead) and returns it as a bearer. - """ - if os.environ.get(API_KEY_ENV): - return Credential("api_key") - - token = _auth("token").fresh_token(relogin=True) - if token and token.get("access_token"): - return Credential("bearer", token["access_token"]) - return Credential("none") - - -def header_pair(cred: Credential) -> tuple[str, str] | None: - """Return the ``(name, value)`` auth header, opencode/generic ``{env:}`` style.""" - if cred.kind == "api_key": - return ("X-API-Key", f"{{env:{API_KEY_ENV}}}") - if cred.kind == "bearer": - return ("Authorization", f"Bearer {cred.token}") - return None - - -def workspace_headers(ctx: ConfigureContext) -> dict[str, str]: - """Return the workspace-attachment header, if a workspace slug is set. - - API-key credentials are already bound to a workspace server-side, so the - header is only meaningful (and only written) for the interactive bearer path. - """ - if ctx.hub_workspace: - return {WORKSPACE_HEADER: ctx.hub_workspace} - return {} - - -def fetch_workspaces(hub_url: str, cred: Credential) -> list[tuple[str, str]]: - """List the workspaces the user can attach to, as ``(public_id, name)`` pairs. - - Calls the hub ``GET /identity/workspaces`` with the resolved credential. Used - only by the interactive bearer picker; API-key auth needs no selection. - """ - import httpx - - if cred.kind == "api_key": - token = os.environ.get(API_KEY_ENV, "") - headers = {"X-API-Key": token} - elif cred.kind == "bearer": - headers = {"Authorization": f"Bearer {cred.token}"} - else: - headers = {} - - url = f"{hub_url.rstrip('/')}/identity/workspaces" - response = httpx.get(url, headers=headers, timeout=30, follow_redirects=True) - response.raise_for_status() - payload = response.json() - items = payload.get("items", payload) if isinstance(payload, dict) else payload - - workspaces: list[tuple[str, str]] = [] - for item in items or []: - public_id = item.get("public_id") or item.get("slug") - if not public_id: - continue - workspaces.append((public_id, item.get("name") or public_id)) - return workspaces - - -@dataclass -class ConfigureContext: +class HarnessContext: """Inputs shared by every harness writer.""" workspace: Path hub_url: str - model_id: str - cred: Credential - # Hub workspace slug (public id) the agent is attached to; only written for - # the bearer path (API keys are already workspace-bound server-side). - hub_workspace: str | None = None - # opencode-only: - write_session_plugin: bool = True - # generic-only: - write_file: bool = True + api_key: str + model_id: str = DEFAULT_MODEL_ID @property def base_url(self) -> str: - return base_url(self.hub_url) - - -def _no_credential_note() -> None: - console.print( - f" [skore.muted](no credential resolved -- set {API_KEY_ENV} or run " - "`skore hub login`, then re-run init)[/]" - ) + return f"{self.hub_url.rstrip('/')}/v1" -def _warn_workspace_header_unsupported(ctx: ConfigureContext) -> None: - """Warn when a bearer setup cannot carry the workspace header. - - This harness cannot send custom headers, so an interactive-login (bearer) - token cannot be scoped to a workspace and the hub now rejects unscoped agent - calls. A workspace-scoped API key carries the binding intrinsically. - """ - if ctx.cred.kind == "bearer": - console.print( - f"[yellow] This harness cannot send the {WORKSPACE_HEADER} header, so " - "an interactive-login token cannot be scoped to a workspace and the " - f"hub will reject the call. Use a workspace-scoped API key " - f"({API_KEY_ENV}) instead.[/]" - ) - - -# --------------------------------------------------------------------------- # -# Writers -# --------------------------------------------------------------------------- # -def _configure_opencode(ctx: ConfigureContext) -> dict[str, Any]: - """Write/merge ``opencode.json`` + the session-binding plugin.""" +def _configure_opencode(ctx: HarnessContext) -> dict[str, Any]: + """Write ``opencode.json`` with the Skore Hub provider.""" config_path = ctx.workspace / "opencode.json" - config: dict = {} - if config_path.exists(): - try: - config = json.loads(config_path.read_text() or "{}") - except json.JSONDecodeError: - console.print( - "[yellow] existing opencode.json is invalid JSON; backing it up.[/]" - ) - config_path.rename(config_path.with_suffix(".json.bak")) - config = {} - - options: dict = {"baseURL": ctx.base_url} - headers: dict[str, str] = {} - pair = header_pair(ctx.cred) - if pair: - headers[pair[0]] = pair[1] - headers.update(workspace_headers(ctx)) - if headers: - options["headers"] = headers - - config.setdefault("$schema", OPENCODE_SCHEMA) - config.setdefault("provider", {}) - config["provider"][OPENCODE_PROVIDER_KEY] = { - "npm": "@ai-sdk/openai-compatible", - "name": "Skore Hub Agent", - "options": options, - "models": {ctx.model_id: {"name": "Skore Agent"}}, + config: dict[str, Any] = { + "$schema": OPENCODE_SCHEMA, + "model": f"{OPENCODE_PROVIDER_KEY}/{ctx.model_id}", + "provider": { + OPENCODE_PROVIDER_KEY: { + "npm": "@ai-sdk/openai-compatible", + "name": "Skore Hub", + "options": { + "baseURL": ctx.base_url, + "apiKey": ctx.api_key, + }, + "models": {ctx.model_id: {"name": "Skore Agent"}}, + } + }, } config_path.write_text(json.dumps(config, indent=2) + "\n") console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") + return {"config_path": str(config_path)} - session_plugin = False - if ctx.write_session_plugin: - plugin_path = ctx.workspace / ".opencode" / "plugin" / SESSION_PLUGIN_FILENAME - plugin_path.parent.mkdir(parents=True, exist_ok=True) - plugin_path.write_text(SESSION_PLUGIN_SOURCE) - session_plugin = True - console.print( - f"[skore.ok]+[/] wrote [skore.path]{plugin_path}[/] " - "[skore.muted](binds the hub session to the opencode conversation)[/]" - ) - - console.print( - "\nNext steps:\n" - f" 1. Run [skore.skill]opencode[/] in [skore.path]{ctx.workspace}[/]\n" - f" 2. Pick the '[skore.skill]{ctx.model_id}[/]' model and start iterating." - ) - if ctx.cred.kind == "api_key": - console.print( - f" [skore.muted](export {API_KEY_ENV} in opencode's shell so the " - "{env:...} reference resolves)[/]" - ) - return {"config_path": str(config_path), "session_plugin": session_plugin} - - -def _configure_copilot(ctx: ConfigureContext) -> dict[str, Any]: - """Write a sourceable env file for the GitHub Copilot CLI (BYOK).""" - env_path = ctx.workspace / "skore-agent.env" - if ctx.cred.kind == "bearer": - api_key_line = f'export COPILOT_PROVIDER_API_KEY="{ctx.cred.token}"' - else: - api_key_line = f'export COPILOT_PROVIDER_API_KEY="${API_KEY_ENV}"' - lines = [ - "# Source this before running `copilot`: source skore-agent.env", - f'export COPILOT_PROVIDER_BASE_URL="{ctx.base_url}"', - 'export COPILOT_PROVIDER_TYPE="openai"', - f'export COPILOT_MODEL="{ctx.model_id}"', - api_key_line, - "", - ] - env_path.write_text("\n".join(lines)) - console.print(f"[skore.ok]+[/] wrote [skore.path]{env_path}[/]") - _warn_workspace_header_unsupported(ctx) - console.print( - "\nNext steps:\n" - f" 1. [skore.skill]source {env_path.name}[/] " - f"in [skore.path]{ctx.workspace}[/]" - ) - if ctx.cred.kind == "api_key": - console.print(f" [skore.muted](with {API_KEY_ENV} exported)[/]") - console.print(" 2. Run [skore.skill]copilot[/] and start iterating.") - return {"env_file": str(env_path)} +def _configure_claude(ctx: HarnessContext) -> dict[str, Any]: + """Write ``.claude/settings.local.json`` for Claude.""" + config_dir = ctx.workspace / ".claude" + config_dir.mkdir(parents=True, exist_ok=True) + config_path = config_dir / "settings.local.json" + payload = { + "env": { + "ANTHROPIC_BASE_URL": ctx.hub_url.rstrip("/"), + "ANTHROPIC_AUTH_TOKEN": ctx.api_key, + "ANTHROPIC_MODEL": ctx.model_id, + } + } + config_path.write_text(json.dumps(payload, indent=2) + "\n") + console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") + return {"config_path": str(config_path)} -def _configure_continue(ctx: ConfigureContext) -> dict[str, Any]: - """Merge a model block into the global ``~/.continue/config.yaml``.""" - try: - import yaml - except ImportError as error: # pragma: no cover - exercised via the CLI - raise _yaml_missing() from error - config_dir = Path.home() / ".continue" +def _configure_pi(ctx: HarnessContext) -> dict[str, Any]: + """Write ``.pi/agent/models.json`` for Pi.""" + config_dir = ctx.workspace / ".pi" / "agent" config_dir.mkdir(parents=True, exist_ok=True) - config_path = config_dir / "config.yaml" - - config: dict = {} - if config_path.exists(): - try: - config = yaml.safe_load(config_path.read_text()) or {} - except yaml.YAMLError: - console.print( - "[yellow] existing config.yaml is invalid YAML; backing it up.[/]" - ) - config_path.rename(config_path.with_suffix(".yaml.bak")) - config = {} - else: - backup = config_path.with_suffix(".yaml.bak") - backup.write_text(config_path.read_text()) - console.print(f"[skore.muted] backed up existing config to {backup}[/]") - - config.setdefault("name", "My Config") - config.setdefault("version", "0.0.1") - config.setdefault("schema", "v1") - - pair = header_pair(ctx.cred) - if ctx.cred.kind == "api_key": - headers = {"X-API-Key": f"${{{{ env.{API_KEY_ENV} }}}}"} - elif pair: - headers = {pair[0]: pair[1]} - else: - headers = {} - headers.update(workspace_headers(ctx)) - - model_block = { - "name": "Skore Hub Agent", - "provider": "openai", - "model": ctx.model_id, - "apiBase": ctx.base_url, - "roles": ["chat", "edit", "apply"], - "capabilities": ["tool_use"], - } - if headers: - model_block["requestOptions"] = {"headers": headers} - - models = [m for m in config.get("models", []) if m.get("name") != "Skore Hub Agent"] - models.append(model_block) - config["models"] = models - - config_path.write_text(yaml.safe_dump(config, sort_keys=False)) - console.print(f"[skore.ok]+[/] updated [skore.path]{config_path}[/]") - console.print( - "\nNext steps:\n" - " 1. Reload the Continue extension in your IDE.\n" - " 2. Pick the '[skore.skill]Skore Hub Agent[/]' model in Continue." - ) - if ctx.cred.kind == "api_key": - console.print( - f" [skore.muted](Continue resolves ${{{{ env.{API_KEY_ENV} }}}}; " - f"export {API_KEY_ENV} in the IDE's environment)[/]" - ) - return {"config_path": str(config_path), "scope": "global"} - - -def _configure_cline(ctx: ConfigureContext) -> dict[str, Any]: - """Best-effort: write the workspace ``.vscode/settings.json`` provider keys. - - Cline stores the API key in VS Code's secret storage (not settings.json) and - its setting keys vary by version, so we write the provider/base-URL/model and - ask the user to paste the key in the Cline panel. - """ - settings_path = ctx.workspace / ".vscode" / "settings.json" - settings_path.parent.mkdir(parents=True, exist_ok=True) - - settings: dict = {} - if settings_path.exists(): - try: - settings = json.loads(settings_path.read_text() or "{}") - except json.JSONDecodeError: - console.print( - "[yellow] existing settings.json is invalid JSON; backing it up.[/]" - ) - settings_path.rename(settings_path.with_suffix(".json.bak")) - settings = {} - - settings["cline.apiProvider"] = "openai-compatible" - settings["cline.openAiBaseUrl"] = ctx.base_url - settings["cline.apiModelId"] = ctx.model_id - settings_path.write_text(json.dumps(settings, indent=2) + "\n") - console.print(f"[skore.ok]+[/] wrote [skore.path]{settings_path}[/]") - _warn_workspace_header_unsupported(ctx) - console.print( - "\nNext steps:\n" - " 1. Open the Cline panel in VS Code (gear icon) and confirm provider " - "[skore.skill]OpenAI Compatible[/], base URL and model.\n" - " 2. Paste your hub API key in the [bold]API Key[/] field " - "[skore.muted](Cline stores secrets in VS Code, not settings.json).[/]\n" - "[skore.muted] Setting keys vary by Cline version; if base URL/model did " - "not pre-fill, set them in the panel.[/]" - ) - return {"settings_path": str(settings_path), "scope": "vscode"} - - -def _configure_claude_code(ctx: ConfigureContext) -> dict[str, Any]: - """Experimental: generate a LiteLLM proxy config + env for Claude Code. - - Claude Code speaks the Anthropic wire format, so it needs a proxy translating - Anthropic <-> our OpenAI ``/v1``. We generate the LiteLLM config and the env - pointing Claude Code at it; the user runs the proxy. - """ - try: - import yaml - except ImportError as error: # pragma: no cover - exercised via the CLI - raise _yaml_missing() from error - - proxy_path = ctx.workspace / "litellm.skore.yaml" - env_path = ctx.workspace / "skore-agent.env" - - pair = header_pair(ctx.cred) - if ctx.cred.kind == "api_key": - headers = {"X-API-Key": f"os.environ/{API_KEY_ENV}"} - elif pair: - headers = {pair[0]: pair[1]} - else: - headers = {} - headers.update(workspace_headers(ctx)) - - litellm_params: dict = { - "model": f"openai/{ctx.model_id}", - "api_base": ctx.base_url, - "api_key": "skore-unused", + config_path = config_dir / "models.json" + payload = { + "providers": { + "skore": { + "baseUrl": ctx.base_url, + "api": "openai-completions", + "apiKey": ctx.api_key, + "authHeader": True, + "compat": { + "supportsDeveloperRole": False, + "supportsReasoningEffort": False, + }, + "models": [ + { + "id": ctx.model_id, + "name": "Skore Agent", + "reasoning": True, + "input": ["text"], + "contextWindow": 200000, + "maxTokens": 8192, + } + ], + } + } } - if headers: - litellm_params["headers"] = headers + config_path.write_text(json.dumps(payload, indent=2) + "\n") + console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") + return {"config_path": str(config_path)} - proxy_config = { - "model_list": [{"model_name": ctx.model_id, "litellm_params": litellm_params}] - } - proxy_path.write_text(yaml.safe_dump(proxy_config, sort_keys=False)) - env_path.write_text( - "# Source this before running `claude`: source skore-agent.env\n" - 'export ANTHROPIC_BASE_URL="http://127.0.0.1:4000"\n' - 'export ANTHROPIC_AUTH_TOKEN="skore-litellm"\n' - ) - console.print(f"[skore.ok]+[/] wrote [skore.path]{proxy_path}[/]") - console.print(f"[skore.ok]+[/] wrote [skore.path]{env_path}[/]") - console.print( - "\n[yellow]Claude Code support is experimental[/] (needs an " - "Anthropic<->OpenAI proxy).\n" - "Next steps:\n" - " 1. [skore.skill]pip install 'litellm\\[proxy]'[/]\n" - f" 2. [skore.skill]litellm --config {proxy_path.name}[/] " - "[skore.muted](serves on :4000)[/]\n" - f" 3. [skore.skill]source {env_path.name}[/]" - ) - if ctx.cred.kind == "api_key": - console.print(f" [skore.muted](with {API_KEY_ENV} exported)[/]") - console.print(" 4. Run [skore.skill]claude[/] and pick the model.") - return {"proxy_config": str(proxy_path), "env_file": str(env_path)} - - -def _configure_generic(ctx: ConfigureContext) -> dict[str, Any]: - """Print (and optionally write) connection values for any OpenAI client.""" - console.print( - "\nConnection values for any OpenAI-compatible harness:\n" - f" base URL : [skore.path]{ctx.base_url}[/]\n" - f" model id : [skore.skill]{ctx.model_id}[/]" - ) - pair = header_pair(ctx.cred) - headers: dict[str, str] = {} - if pair: - headers[pair[0]] = pair[1] - headers.update(workspace_headers(ctx)) - if pair: - console.print(f" header : {pair[0]}: {pair[1]}") - else: - _no_credential_note() - for name, value in workspace_headers(ctx).items(): - console.print(f" header : {name}: {value}") - - extra: dict[str, Any] = {} - if ctx.write_file: - out_path = ctx.workspace / GENERIC_CONFIG_FILENAME - payload = { - "baseURL": ctx.base_url, - "model": ctx.model_id, - "headers": headers, - } - out_path.write_text(json.dumps(payload, indent=2) + "\n") - console.print(f"\n[skore.ok]+[/] wrote [skore.path]{out_path}[/]") - extra["config_file"] = str(out_path) - console.print( - "\n[skore.muted]Paste these into your harness's OpenAI-compatible provider " - "settings. The hub loads probabl-skills server-side (IP isolation).[/]" - ) - return extra +def _detect_opencode(_workspace: Path) -> bool: + return shutil.which("opencode") is not None -def _yaml_missing(): - import rich_click as click +def _detect_claude(_workspace: Path) -> bool: + return shutil.which("claude") is not None - return click.ClickException( - "this harness needs PyYAML (install it with `pip install " - "skore-cli` or `pip install pyyaml`)." - ) +def _detect_pi(_workspace: Path) -> bool: + return shutil.which("pi") is not None -# --------------------------------------------------------------------------- # -# Detection -# --------------------------------------------------------------------------- # -def _detect_opencode(workspace: Path) -> bool: - if (workspace / "opencode.json").exists(): - return True - return shutil.which("opencode") is not None + +def launch_harness( + name: str, workspace: Path, *, model_id: str = DEFAULT_MODEL_ID +) -> None: + """Launch the named harness in ``workspace``.""" + harness = HARNESSES[name] + if not harness.detect(workspace): + raise RuntimeError(f"{harness.label} is not installed or not on PATH.") + console.print(f"[skore.ok]Launching[/] [skore.skill]{harness.label}[/] ...") + _LAUNCHERS[name](workspace, model_id=model_id) -def _detect_copilot(workspace: Path) -> bool: - return shutil.which("copilot") is not None +def _launch_opencode(workspace: Path, *, model_id: str) -> None: + _exec_harness( + "opencode", + ["opencode", "-m", f"{OPENCODE_PROVIDER_KEY}/{model_id}"], + ) -def _detect_continue(workspace: Path) -> bool: - return (Path.home() / ".continue").exists() +def _launch_claude(workspace: Path, *, model_id: str) -> None: + env = os.environ.copy() + settings_path = workspace / ".claude" / "settings.local.json" + if settings_path.is_file(): + settings = json.loads(settings_path.read_text() or "{}") + env.update(settings.get("env", {})) + _exec_harness("claude", ["claude"], env=env) -def _detect_cline(workspace: Path) -> bool: - if (workspace / ".vscode").exists(): - return True - ext_dir = Path.home() / ".vscode" / "extensions" - return ext_dir.is_dir() and any(ext_dir.glob("saoudrizwan.claude-dev*")) +def _launch_pi(workspace: Path, *, model_id: str) -> None: + env = os.environ.copy() + env["PI_CODING_AGENT_DIR"] = str(workspace / ".pi" / "agent") + _exec_harness( + "pi", + ["pi", "--provider", OPENCODE_PROVIDER_KEY, "--model", model_id], + env=env, + ) -def _detect_claude_code(workspace: Path) -> bool: - return (Path.home() / ".claude").exists() or shutil.which("claude") is not None +def _exec_harness(name: str, argv: list[str], *, env: dict[str, str] | None = None) -> None: + executable = shutil.which(argv[0]) + if executable is None: + raise RuntimeError(f"{name} is not installed or not on PATH.") + os.execve(executable, argv, env or os.environ) -def _detect_never(workspace: Path) -> bool: - return False +_LAUNCHERS = { + "claude": _launch_claude, + "opencode": _launch_opencode, + "pi": _launch_pi, +} -# --------------------------------------------------------------------------- # -# Registry -# --------------------------------------------------------------------------- # @dataclass(frozen=True) class Harness: """A configurable agent harness.""" @@ -530,48 +179,28 @@ class Harness: name: str label: str detect: Callable[[Path], bool] - configure: Callable[[ConfigureContext], dict[str, Any]] - session_binding: str = "fallback" # "plugin" | "fallback" - extras: tuple[str, ...] = field(default=()) + configure: Callable[[HarnessContext], dict[str, Any]] + extras: tuple[str, ...] = field(default_factory=tuple) HARNESSES: dict[str, Harness] = { + "claude": Harness( + "claude", + "Claude", + _detect_claude, + _configure_claude, + ), "opencode": Harness( "opencode", - "opencode - project config + session binding (turnkey)", + "OpenCode", _detect_opencode, _configure_opencode, - session_binding="plugin", - ), - "copilot": Harness( - "copilot", - "GitHub Copilot CLI - sourceable env file", - _detect_copilot, - _configure_copilot, - ), - "continue": Harness( - "continue", - "Continue - global ~/.continue/config.yaml", - _detect_continue, - _configure_continue, - ), - "cline": Harness( - "cline", - "Cline - VS Code settings (best-effort; paste key in panel)", - _detect_cline, - _configure_cline, - ), - "claude-code": Harness( - "claude-code", - "Claude Code - LiteLLM proxy config (experimental)", - _detect_claude_code, - _configure_claude_code, ), - "generic": Harness( - "generic", - "generic - print values for any OpenAI-compatible client", - _detect_never, - _configure_generic, + "pi": Harness( + "pi", + "Pi", + _detect_pi, + _configure_pi, ), } @@ -579,9 +208,5 @@ class Harness: def detect_harnesses(workspace: Path) -> list[str]: - """Return the names of harnesses that look present, in registry order.""" - return [ - name - for name, harness in HARNESSES.items() - if name != "generic" and harness.detect(workspace) - ] + """Return harness names that look installed, in registry order.""" + return [name for name, harness in HARNESSES.items() if harness.detect(workspace)] diff --git a/src/skore_cli/agent/_skore_file.py b/src/skore_cli/agent/_skore_file.py new file mode 100644 index 0000000..51f077e --- /dev/null +++ b/src/skore_cli/agent/_skore_file.py @@ -0,0 +1,69 @@ +"""Read and write the project-local ``.skore`` agent configuration file.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path + +SKORE_FILENAME = ".skore" + + +@dataclass +class SkoreConfig: + """Persisted Skore agent settings for a project.""" + + hub_url: str + workspace: str + workspace_id: int + api_key: str + harness: str | None = None + + @classmethod + def load(cls, path: Path) -> SkoreConfig | None: + """Load ``.skore`` from ``path`` when present and valid.""" + file_path = path / SKORE_FILENAME + if not file_path.is_file(): + return None + try: + data = json.loads(file_path.read_text() or "{}") + except json.JSONDecodeError: + return 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: + return None + harness = data.get("harness") + if harness == "claude-code": + harness = "claude" + return cls( + hub_url=hub_url, + workspace=workspace, + workspace_id=int(workspace_id), + api_key=api_key, + harness=harness, + ) + + def save(self, path: Path) -> Path: + """Write this config to ``path/.skore`` and return the file path.""" + file_path = path / SKORE_FILENAME + payload = {key: value for key, value in asdict(self).items() if value is not None} + file_path.write_text(json.dumps(payload, indent=2) + "\n") + return file_path + + +def ensure_gitignore_entry(workspace: Path, entry: str = SKORE_FILENAME) -> None: + """Append ``entry`` to the project ``.gitignore`` when missing.""" + gitignore = workspace / ".gitignore" + if gitignore.is_file(): + lines = gitignore.read_text().splitlines() + if any(line.strip() == entry for line in lines): + return + if lines and lines[-1] != "": + lines.append("") + lines.append(entry) + gitignore.write_text("\n".join(lines) + "\n") + return + gitignore.write_text(f"{entry}\n") diff --git a/src/skore_cli/agent/app/_picker.py b/src/skore_cli/agent/app/_picker.py index 3789b68..e883f53 100644 --- a/src/skore_cli/agent/app/_picker.py +++ b/src/skore_cli/agent/app/_picker.py @@ -1,4 +1,4 @@ -"""The single-select picker backing interactive ``skore agent init``.""" +"""The single-select picker backing interactive ``skore agent``.""" from __future__ import annotations @@ -7,14 +7,52 @@ from textual.containers import Vertical from textual.widgets import Footer, Header, Label, RadioButton +from skore_cli.app._help import HELP_BINDING, HelpScreen from skore_cli.skills.app._widgets import AutoRadioSet -_INTRO = ( - "Choose the agent harness to configure.\n" - "Detected harnesses are marked and pre-selected.\n" - "[reverse] ↑/↓ [/] choose [reverse] Enter [/] confirm" +_HARNESS_INTRO = ( + "Choose the agent harness to launch.\n" + "Only harnesses installed on PATH are listed.\n" + "[reverse] ↑/↓ [/] choose [reverse] Enter [/] confirm [reverse] ? [/] help" ) +_HARNESS_HELP = """\ +Pick the local coding agent to configure and launch. + +Supported harnesses: + • Claude — writes .claude/settings.local.json + • OpenCode — writes opencode.json + • Pi — writes .pi/agent/models.json + +Skore stores your hub credentials in .skore and selects the +skore-agent model when the harness starts. + +Keys: + ↑/↓ move selection + Enter confirm + Esc cancel + ? show this help +""" + +_WORKSPACE_INTRO = ( + "Choose the Skore Hub workspace to attach the agent to.\n" + "The agent uses this workspace's LLM provider configuration.\n" + "[reverse] ↑/↓ [/] choose [reverse] Enter [/] confirm [reverse] ? [/] help" +) + +_WORKSPACE_HELP = """\ +Pick the hub workspace this project should use. + +Skore creates a workspace-scoped API key and saves it in +.skore together with the workspace id. + +Keys: + ↑/↓ move selection + Enter confirm + Esc cancel + ? show this help +""" + class HarnessPicker(App[str | None]): """Pick a single harness name from a radio set.""" @@ -40,6 +78,7 @@ class HarnessPicker(App[str | None]): BINDINGS = [ Binding("enter", "confirm", "Confirm", priority=True), Binding("escape", "cancel", "Cancel"), + HELP_BINDING, ] def __init__( @@ -56,7 +95,7 @@ def __init__( def compose(self) -> ComposeResult: yield Header() with Vertical(id="picker"): - yield Label(_INTRO, classes="picker-intro") + yield Label(_HARNESS_INTRO, classes="picker-intro") with AutoRadioSet(id="harnesses"): for index, (_, label, detected) in enumerate(self._harnesses): text = f"{label} (detected)" if detected else label @@ -66,6 +105,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one("#harnesses", AutoRadioSet).select_index(self._preselect) + def action_show_help(self) -> None: + self.push_screen(HelpScreen("Choose a harness", _HARNESS_HELP)) + def action_confirm(self) -> None: index = self.query_one("#harnesses", AutoRadioSet).pressed_index if index < 0: @@ -79,13 +121,6 @@ def action_cancel(self) -> None: self.exit() -_WORKSPACE_INTRO = ( - "Choose the Skore Hub workspace to attach the agent to.\n" - "The agent uses this workspace's LLM provider configuration.\n" - "[reverse] ↑/↓ [/] choose [reverse] Enter [/] confirm" -) - - class WorkspacePicker(App[str | None]): """Pick a single workspace public id from a radio set.""" @@ -110,6 +145,7 @@ class WorkspacePicker(App[str | None]): BINDINGS = [ Binding("enter", "confirm", "Confirm", priority=True), Binding("escape", "cancel", "Cancel"), + HELP_BINDING, ] def __init__( @@ -136,6 +172,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one("#workspaces", AutoRadioSet).select_index(self._preselect) + def action_show_help(self) -> None: + self.push_screen(HelpScreen("Choose a workspace", _WORKSPACE_HELP)) + def action_confirm(self) -> None: index = self.query_one("#workspaces", AutoRadioSet).pressed_index if index < 0: diff --git a/src/skore_cli/agent/mcp/__init__.py b/src/skore_cli/agent/mcp/__init__.py deleted file mode 100644 index cbc04b4..0000000 --- a/src/skore_cli/agent/mcp/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""The ``skore agent mcp`` command group: the delegation relay. - -Exposes a local, harness-agnostic MCP relay (``skore agent mcp serve``) that lets -the user's outer LLM delegate a machine-learning task to the Skore Hub agent -after a Skore Hub login, through a single blocking ``skore_ml_run`` tool (no -polling). Internally the relay is an A2A client: it opens one durable task on the -hub and consumes a pushed SSE event stream. The hub agent (its own model + -skills) runs the orchestration server-side; whenever it defers a tool call, the -relay executes it locally (file/shell ops) or puts a skill-gate question to the -user via MCP elicitation, and streams the result back. The orchestration IP stays -on the hub. - -``skore agent mcp install`` writes the per-host MCP configuration so the relay -launches the same way across every supported host. - -Heavy imports (the ``mcp`` SDK, ``httpx``) are deferred into the command -callbacks so building the CLI (and ``--help``) never imports them. -""" - -from skore_cli.agent.mcp._commands import mcp - -__all__ = ["mcp"] diff --git a/src/skore_cli/agent/mcp/_a2a_client.py b/src/skore_cli/agent/mcp/_a2a_client.py deleted file mode 100644 index 97f534f..0000000 --- a/src/skore_cli/agent/mcp/_a2a_client.py +++ /dev/null @@ -1,302 +0,0 @@ -"""A2A client: drive the hub delegation task over a durable, streamed connection. - -The relay no longer polls. It opens ONE task on the hub's A2A endpoint -(``POST /v1/a2a``, method ``message/stream``) and consumes a pushed SSE event -stream. When the agent defers tool calls, the hub emits an ``input-required`` -event; the relay executes them locally (see :mod:`_handlers`) and posts the -results back with ``message/send`` -- the same open task resumes and streams the -next events. A dropped connection is transparently resumed with -``tasks/resubscribe`` from the last received event ``seq`` (the hub replays the -gap), so no events are lost and nothing is polled. - -The SSE ``data:`` payload is the compact task-event dict produced by the hub task -manager; :func:`iter_sse` is factored out (pure) for unit testing. -""" - -from __future__ import annotations - -import json -import logging -import os -from collections.abc import AsyncIterator -from dataclasses import dataclass -from pathlib import Path - -from skore_cli.agent._harnesses import ( - API_KEY_ENV, - WORKSPACE_HEADER, - Credential, - base_url, -) - -logger = logging.getLogger("skore_cli.agent.mcp") - -# Task states that end the stream (mirror hub.agent.tasks.TERMINAL_STATES). -TERMINAL_STATES = {"completed", "failed", "canceled"} - - -class A2AClientError(RuntimeError): - """Raised when the hub A2A endpoint returns an error response.""" - - -def _auth_headers(cred: Credential) -> dict[str, str]: - """Return the real auth header for an HTTP call (not the ``{env:}`` form).""" - if cred.kind == "api_key": - return {"X-API-Key": os.environ.get(API_KEY_ENV, "")} - if cred.kind == "bearer": - return {"Authorization": f"Bearer {cred.token}"} - return {} - - -def _error_message(status_code: int, body: bytes | str) -> str: - """Extract an actionable message from an error response body.""" - text = body.decode() if isinstance(body, bytes) else body - try: - payload = json.loads(text) - except (ValueError, json.JSONDecodeError): - return f"hub returned HTTP {status_code}: {text[:300]}" - if isinstance(payload, dict): - error = payload.get("error") - if isinstance(error, dict) and error.get("message"): - return str(error["message"]) - return f"hub returned HTTP {status_code}." - - -async def iter_sse(lines: AsyncIterator[str]) -> AsyncIterator[dict]: - """Parse an SSE line stream into event dicts (one per ``data:`` frame).""" - data: list[str] = [] - async for line in lines: - if line == "": - if data: - raw = "\n".join(data) - data = [] - try: - yield json.loads(raw) - except json.JSONDecodeError: - continue - continue - if line.startswith(":"): - continue - if line.startswith("data:"): - data.append(line[5:].lstrip()) - if data: - try: - yield json.loads("\n".join(data)) - except json.JSONDecodeError: - return - - -class A2AClient: - """Drive a single hub delegation task over a resumable SSE stream.""" - - def __init__( - self, - *, - hub_url: str, - cred: Credential, - hub_workspace: str | None, - tools: list[dict], - timeout: float = 600.0, - ) -> None: - self._base = base_url(hub_url) - self._endpoint = self._base + "/a2a" - self._cred = cred - self._hub_workspace = hub_workspace - self._tools = tools - self._timeout = timeout - # Live task state, tracked from the event stream. - self.task_id = "" - self.state = "submitted" - self.result = "" - self.error = "" - self._last_seq = -1 - - def _headers(self, *, sse: bool) -> dict[str, str]: - headers = {"Content-Type": "application/json"} - headers.update(_auth_headers(self._cred)) - if self._hub_workspace: - headers[WORKSPACE_HEADER] = self._hub_workspace - if sse: - headers["Accept"] = "text/event-stream" - return headers - - def _stream_payload(self, task_text: str) -> dict: - return { - "jsonrpc": "2.0", - "id": 1, - "method": "message/stream", - "params": { - "message": { - "role": "user", - "parts": [{"kind": "text", "text": task_text}], - "metadata": {"tools": self._tools}, - } - }, - } - - def _resubscribe_payload(self) -> dict: - return { - "jsonrpc": "2.0", - "id": 1, - "method": "tasks/resubscribe", - "params": {"id": self.task_id, "cursor": self._last_seq + 1}, - } - - def _track(self, event: dict) -> None: - """Update the live task state from a streamed event.""" - seq = event.get("seq") - if isinstance(seq, int): - self._last_seq = max(self._last_seq, seq) - if not self.task_id: - task_id = event.get("task_id") - if isinstance(task_id, str): - self.task_id = task_id - kind = event.get("kind") - if kind == "status": - self.state = event.get("state", self.state) - elif kind == "result": - self.state = "completed" - self.result = event.get("text", "") - elif kind == "error": - self.state = "failed" - self.error = event.get("message", "") - - @staticmethod - def _is_terminal(event: dict) -> bool: - if event.get("kind") in ("result", "error"): - return True - return event.get("kind") == "status" and event.get("state") in TERMINAL_STATES - - async def events(self, task_text: str) -> AsyncIterator[dict]: - """Yield the task's events, auto-resuming a dropped stream. - - Starts the task with ``message/stream`` and yields each event. On a - transport error (or a stream that closes before a terminal event), it - reconnects with ``tasks/resubscribe`` from the last seen ``seq`` until a - terminal event arrives. - """ - import httpx - - payload = self._stream_payload(task_text) - while True: - try: - async with ( - httpx.AsyncClient(timeout=self._timeout) as client, - client.stream( - "POST", - self._endpoint, - json=payload, - headers=self._headers(sse=True), - follow_redirects=True, - ) as response, - ): - if response.status_code >= 400: - body = await response.aread() - raise A2AClientError(_error_message(response.status_code, body)) - async for event in iter_sse(response.aiter_lines()): - self._track(event) - yield event - if self._is_terminal(event): - return - except httpx.HTTPError as exc: - if self.state in TERMINAL_STATES: - return - logger.warning( - "a2a stream dropped (%s); resubscribing from seq %d", - exc, - self._last_seq + 1, - ) - if self.state in TERMINAL_STATES: - return - if not self.task_id: - # Never received the task id: nothing to resume against. - raise A2AClientError( - "the hub connection failed before the task was created" - ) - payload = self._resubscribe_payload() - - async def send_results(self, results: dict[str, str]) -> None: - """Post the locally-executed tool results back to the waiting task.""" - import httpx - - payload = { - "jsonrpc": "2.0", - "id": 1, - "method": "message/send", - "params": { - "message": { - "taskId": self.task_id, - "role": "user", - "parts": [{"kind": "data", "data": {"tool_results": results}}], - } - }, - } - async with httpx.AsyncClient(timeout=self._timeout) as client: - response = await client.post( - self._endpoint, - json=payload, - headers=self._headers(sse=False), - follow_redirects=True, - ) - if response.status_code >= 400: - raise A2AClientError(_error_message(response.status_code, response.text)) - - async def cancel(self) -> None: - """Best-effort cancel of the task (e.g. when the MCP call is aborted).""" - if not self.task_id: - return - import httpx - - payload = { - "jsonrpc": "2.0", - "id": 1, - "method": "tasks/cancel", - "params": {"id": self.task_id}, - } - try: - async with httpx.AsyncClient(timeout=self._timeout) as client: - await client.post( - self._endpoint, - json=payload, - headers=self._headers(sse=False), - follow_redirects=True, - ) - except Exception: # noqa: BLE001 - cancellation is best-effort - logger.debug("a2a cancel failed for task %s", self.task_id) - - async def fetch_template(self, skill: str, path: str) -> str: - """Fetch a raw skill template over the non-LLM data-plane channel.""" - import httpx - - url = f"{self._base}/skills/{skill}/template" - async with httpx.AsyncClient(timeout=self._timeout) as client: - response = await client.get( - url, - params={"path": path}, - headers=self._headers(sse=False), - follow_redirects=True, - ) - if response.status_code >= 400: - raise A2AClientError(_error_message(response.status_code, response.text)) - return response.text - - -@dataclass -class RelayConfig: - """Resolved hub connection used to build per-task A2A clients.""" - - default_workspace: Path - hub_url: str - hub_workspace: str | None - cred: Credential - - def make_client(self) -> A2AClient: - """Build an :class:`A2AClient` advertising the canonical local toolset.""" - from skore_cli.agent.mcp._jobs import CANONICAL_TOOLS - - return A2AClient( - hub_url=self.hub_url, - cred=self.cred, - hub_workspace=self.hub_workspace, - tools=CANONICAL_TOOLS, - ) diff --git a/src/skore_cli/agent/mcp/_commands.py b/src/skore_cli/agent/mcp/_commands.py deleted file mode 100644 index e20a871..0000000 --- a/src/skore_cli/agent/mcp/_commands.py +++ /dev/null @@ -1,246 +0,0 @@ -"""The ``skore agent mcp`` click group: ``serve``, ``install`` and ``status``. - -``serve`` runs the local stdio delegation relay (the outer LLM's bridge to the -Skore Hub agent); ``install`` registers that ``serve`` command with a specific -MCP host; ``status`` reports where the relay is registered. ``serve``/``install`` -resolve the hub URL the same way as ``skore hub login`` and gate on a Skore Hub -credential; ``status`` is read-only and never refreshes credentials. -""" - -from __future__ import annotations - -import logging -import os -import sys -from pathlib import Path - -import rich_click as click - -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._harnesses import ( - API_KEY_ENV, - DEFAULT_MODEL_ID, - Credential, - resolve_credential, -) -from skore_cli.agent.mcp._hosts import HOST_NAMES - -click.rich_click.COMMAND_GROUPS = { - **getattr(click.rich_click, "COMMAND_GROUPS", {}), - "cli agent mcp": [ - {"name": "Delegation relay", "commands": ["serve", "install", "status"]}, - ], -} - - -@click.group() -def mcp() -> None: - """Delegate ML tasks to the Skore Hub agent from any MCP host.""" - - -def _resolve_serve_workspace(cred: Credential, hub_workspace: str | None) -> str | None: - """Resolve the hub workspace to send with relay requests. - - API keys are already workspace-bound server-side (nothing is sent). For the - interactive (bearer) path the workspace must be explicit, since the hub - rejects agent calls that resolve to no workspace. - """ - if cred.kind != "bearer": - return None - if hub_workspace: - return hub_workspace - raise click.UsageError( - "pass --hub-workspace to scope the agent for interactive-login " - "(bearer) auth, or use a workspace-scoped API key (SKORE_HUB_API_KEY)." - ) - - -def _print_credential(cred: Credential) -> None: - if cred.kind == "api_key": - console.print(f"Using the API key from [bold]{API_KEY_ENV}[/].") - elif cred.kind == "bearer": - console.print("Using your interactive [bold]skore hub login[/] token.") - else: - console.print( - f"[yellow]No credential found. Set {API_KEY_ENV} or run " - "`skore hub login` before launching the host.[/]" - ) - - -@mcp.command("serve") -@click.option( - "--workspace", - "-w", - default=".", - type=click.Path(file_okay=False, path_type=Path), - help="Workspace directory the agent acts in (default: current directory).", -) -@click.option( - "--hub-url", - 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, like `skore hub login`." - ), -) -@click.option( - "--hub-workspace", - default=None, - help=( - "Hub workspace (public id) to scope the agent to. Required for " - "interactive-login (bearer) auth; ignored with a workspace-scoped API key." - ), -) -@click.option( - "--model-id", - default=DEFAULT_MODEL_ID, - help="Model id advertised by the hub (default: skore-agent).", -) -def serve( - workspace: Path, - hub_url: str | None, - hub_workspace: str | None, - model_id: str, -) -> None: - """Run the local stdio MCP relay bridging the outer LLM to the Skore agent. - - This is the command MCP hosts launch (see ``skore agent mcp install``). It - speaks the MCP stdio protocol on stdout, so all diagnostics go to stderr. - """ - # stdout is the MCP transport: route logging to stderr only. - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(name)s: %(message)s")) - mcp_logger = logging.getLogger("skore_cli.agent.mcp") - mcp_logger.addHandler(handler) - mcp_logger.setLevel(logging.INFO) - - workspace = workspace.resolve() - if not workspace.is_dir(): - raise click.ClickException(f"workspace does not exist: {workspace}") - - cred = resolve_credential() - if cred.kind == "none": - raise click.ClickException( - f"no hub credential found. Set {API_KEY_ENV} (recommended) or run " - "`skore hub login`, then start `skore agent mcp serve` again." - ) - attached = _resolve_serve_workspace(cred, hub_workspace) - hub_url = resolve_hub_uri(hub_url, _auth) - - from skore_cli.agent.mcp._a2a_client import RelayConfig - from skore_cli.agent.mcp._server import build_mcp_server - - config = RelayConfig( - default_workspace=workspace, - hub_url=hub_url, - hub_workspace=attached, - cred=cred, - ) - server = build_mcp_server(config) - server.run() - - -@mcp.command("install") -@click.option( - "--host", - "-H", - "host_name", - type=click.Choice(HOST_NAMES), - default="generic", - help="MCP host to configure (default: generic, which prints the command).", -) -@click.option( - "--workspace", - "-w", - default=".", - type=click.Path(file_okay=False, path_type=Path), - help="Workspace directory to configure (default: current directory).", -) -@click.option( - "--hub-url", - 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. Baked into the registered " - "`serve` command." - ), -) -@click.option( - "--hub-workspace", - default=None, - help=( - "Hub workspace (public id) baked into the registered `serve` command. " - "Required for interactive-login (bearer) auth; ignored with an API key." - ), -) -def install( - host_name: str, - workspace: Path, - hub_url: str | None, - hub_workspace: str | None, -) -> None: - """Register ``skore agent mcp serve`` with an MCP host.""" - from skore_cli.agent.mcp._hosts import HOSTS, InstallContext - - workspace = workspace.resolve() - if not workspace.is_dir(): - raise click.ClickException(f"workspace does not exist: {workspace}") - - cred = resolve_credential() - _print_credential(cred) - - hub_url = resolve_hub_uri(hub_url, _auth) - attached = _resolve_serve_workspace(cred, hub_workspace) - - host = HOSTS[host_name] - console.print(f"Registering the Skore relay for [skore.skill]{host.name}[/].") - host.configure( - InstallContext(workspace=workspace, hub_url=hub_url, hub_workspace=attached) - ) - - -@mcp.command("status") -@click.option( - "--workspace", - "-w", - default=".", - type=click.Path(file_okay=False, path_type=Path), - help="Workspace directory to inspect (default: current directory).", -) -def status(workspace: Path) -> None: - """Show where the delegation relay is registered for this workspace. - - Read-only and non-interactive: it never refreshes credentials. It reports the - resolved hub URL (from the env/default), whether an API key is set, and which - MCP hosts have ``skore-ml`` registered (with the baked ``serve`` args). - """ - from skore_cli.agent.mcp._hosts import installed - - workspace = workspace.resolve() - - hub_url = resolve_hub_uri(None, _auth) - api_key_note = ( - f"[skore.ok]set[/] (from {API_KEY_ENV})" - if os.environ.get(API_KEY_ENV) - else f"[skore.muted]not set[/] (run `skore hub login` or set {API_KEY_ENV})" - ) - - console.print(f"workspace : [skore.path]{workspace}[/]") - console.print(f"hub URL : [skore.path]{hub_url}[/]") - console.print(f"API key : {api_key_note}") - console.print("hosts :") - - for host in installed(workspace): - if host.present: - baked = " ".join(host.serve_args or []) or "(defaults)" - console.print( - f" [skore.ok]+[/] [skore.skill]{host.name}[/] " - f"[skore.muted]{host.config_path}[/]\n" - f" args: [skore.path]{baked}[/]" - ) - else: - console.print( - f" [skore.muted]- {host.name} (not registered; {host.config_path})[/]" - ) diff --git a/src/skore_cli/agent/mcp/_executor.py b/src/skore_cli/agent/mcp/_executor.py deleted file mode 100644 index 2ce86dc..0000000 --- a/src/skore_cli/agent/mcp/_executor.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Local, workspace-confined execution of the relay's data-plane tools. - -The hub agent's file/command tool calls are executed here (in the user's -workspace) instead of being relayed to the outer LLM. Each operation returns an -:class:`ExecResult` carrying: - -* ``output`` -- the full result fed back to the hub agent as the tool result - (e.g. a file's content), so the agent keeps reasoning with real data; -* ``summary`` -- a compact one-line description surfaced to the outer LLM as - ``activity`` (e.g. ``wrote tests/test_x.py (42 lines)``), so bulk bytes never - enter the outer model's context. - -All paths are confined to the workspace: absolute paths and ``..`` escapes are -rejected. Blocking file IO runs in a thread and shell commands run as a -subprocess, so the asyncio event loop driving the jobs is never blocked. -""" - -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -from pathlib import Path - -# Cap how much command output we feed back to the hub (keeps hub input bounded); -# the middle of very long output is elided. -_MAX_OUTPUT_CHARS = 20_000 -# Default wall-clock budget for a shell command. -_DEFAULT_TIMEOUT = 600.0 - - -class ExecError(RuntimeError): - """Raised when a data-plane operation cannot be performed safely.""" - - -@dataclass -class ExecResult: - """The outcome of one data-plane operation.""" - - output: str - summary: str - - -def _safe_path(workspace: Path, path: str) -> Path: - """Resolve ``path`` strictly inside ``workspace``. - - Rejects absolute paths and any ``..`` traversal that would escape the - workspace root. - """ - if not isinstance(path, str) or not path.strip(): - raise ExecError("a non-empty path is required") - candidate = Path(path) - if candidate.is_absolute(): - raise ExecError(f"absolute paths are not allowed: {path!r}") - root = workspace.resolve() - target = (root / candidate).resolve() - if target != root and root not in target.parents: - raise ExecError(f"path escapes the workspace: {path!r}") - return target - - -def _rel(workspace: Path, target: Path) -> str: - """Best-effort workspace-relative display path.""" - try: - return target.relative_to(workspace.resolve()).as_posix() - except ValueError: - return target.as_posix() - - -def _truncate(text: str, limit: int = _MAX_OUTPUT_CHARS) -> str: - """Elide the middle of an over-long string, keeping head and tail.""" - if len(text) <= limit: - return text - head = limit // 2 - tail = limit - head - elided = len(text) - limit - return f"{text[:head]}\n... [{elided} chars elided] ...\n{text[-tail:]}" - - -def _read_file(workspace: Path, path: str) -> ExecResult: - target = _safe_path(workspace, path) - if not target.is_file(): - raise ExecError(f"file not found: {path!r}") - content = target.read_text() - lines = content.count("\n") + (1 if content and not content.endswith("\n") else 0) - return ExecResult( - output=content, - summary=f"read {_rel(workspace, target)} ({lines} lines)", - ) - - -def _write_file(workspace: Path, path: str, content: str) -> ExecResult: - target = _safe_path(workspace, path) - target.parent.mkdir(parents=True, exist_ok=True) - text = content if isinstance(content, str) else str(content) - target.write_text(text) - lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0) - summary = f"wrote {_rel(workspace, target)} ({lines} lines)" - return ExecResult(output=summary, summary=summary) - - -def _edit_file(workspace: Path, path: str, old: str, new: str) -> ExecResult: - target = _safe_path(workspace, path) - if not target.is_file(): - raise ExecError(f"file not found: {path!r}") - if not isinstance(old, str) or old == "": - raise ExecError("`old` must be a non-empty string") - content = target.read_text() - occurrences = content.count(old) - if occurrences == 0: - raise ExecError(f"`old` text not found in {path!r}") - if occurrences > 1: - raise ExecError( - f"`old` text is ambiguous in {path!r} ({occurrences} matches); " - "include more surrounding context to make it unique" - ) - updated = content.replace(old, new if isinstance(new, str) else str(new), 1) - target.write_text(updated) - summary = f"edited {_rel(workspace, target)}" - return ExecResult(output=summary, summary=summary) - - -def _list_dir(workspace: Path, path: str = ".") -> ExecResult: - target = _safe_path(workspace, path or ".") - if not target.is_dir(): - raise ExecError(f"directory not found: {path!r}") - entries = [ - child.name + ("/" if child.is_dir() else "") - for child in sorted(target.iterdir()) - ] - listing = "\n".join(entries) - return ExecResult( - output=listing, - summary=f"listed {_rel(workspace, target)} ({len(entries)} entries)", - ) - - -async def read_file(workspace: Path, path: str) -> ExecResult: - """Read a workspace text file.""" - return await asyncio.to_thread(_read_file, workspace, path) - - -async def write_file(workspace: Path, path: str, content: str) -> ExecResult: - """Create or overwrite a workspace file.""" - return await asyncio.to_thread(_write_file, workspace, path, content) - - -async def edit_file(workspace: Path, path: str, old: str, new: str) -> ExecResult: - """Replace an exact, unique text span in a workspace file.""" - return await asyncio.to_thread(_edit_file, workspace, path, old, new) - - -async def list_dir(workspace: Path, path: str = ".") -> ExecResult: - """List a workspace directory.""" - return await asyncio.to_thread(_list_dir, workspace, path) - - -async def run_bash( - workspace: Path, command: str, timeout: float = _DEFAULT_TIMEOUT -) -> ExecResult: - """Run a shell command in the workspace, returning exit code + output.""" - if not isinstance(command, str) or not command.strip(): - raise ExecError("a non-empty command is required") - root = workspace.resolve() - proc = await asyncio.create_subprocess_shell( - command, - cwd=str(root), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - try: - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) - except TimeoutError: - proc.kill() - await proc.wait() - raise ExecError( - f"command timed out after {timeout:.0f}s: {command!r}" - ) from None - text = (stdout or b"").decode("utf-8", errors="replace") - code = proc.returncode if proc.returncode is not None else -1 - lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0) - output = f"exit code: {code}\n{_truncate(text)}" - return ExecResult( - output=output, - summary=f"ran `{command}` -> exit {code} ({lines} lines)", - ) - - -def materialize( - workspace: Path, dest_path: str, template_text: str, substitutions: object -) -> ExecResult: - """Write a fetched template to ``dest_path`` after literal substitutions. - - ``substitutions`` is a ``{find: replace}`` map applied as plain string - replacements (the agent supplies the placeholders it knows from the skill), - so the large template body never passes through any LLM. - """ - text = template_text - if isinstance(substitutions, dict): - for find, replace in substitutions.items(): - text = text.replace(str(find), str(replace)) - target = _safe_path(workspace, dest_path) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(text) - lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0) - summary = f"materialized {_rel(workspace, target)} ({lines} lines)" - return ExecResult(output=summary, summary=summary) diff --git a/src/skore_cli/agent/mcp/_handlers.py b/src/skore_cli/agent/mcp/_handlers.py deleted file mode 100644 index 7a5d0f1..0000000 --- a/src/skore_cli/agent/mcp/_handlers.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Resolve the hub agent's deferred tool calls on the user's machine. - -When the hub task streams an ``input-required`` event, it carries the agent's -deferred tool calls. This module classifies each call by its (canonical) name and -produces the result string fed back to the hub: - -* data-plane (``read_file``/``write_file``/``edit_file``/``list_dir``/ - ``materialize_template``) -- executed locally via :mod:`_executor`; only a - compact summary is surfaced to the outer LLM (``ctx.info``), never the bytes; -* ``run_bash`` -- the user approves it inline via MCP elicitation, then the relay - runs it; -* ``ask_user`` -- a skill gate: the questions are put to the human inline via MCP - elicitation (the outer model never answers them). - -Elicitation is the only path for human decisions (the no-poll design assumes an -elicitation-capable host). If the host lacks elicitation, gate calls fail with a -clear, actionable error rather than silently letting the outer model decide. -""" - -from __future__ import annotations - -import json -import logging -import re - -from pydantic import BaseModel, Field, create_model - -from skore_cli.agent.mcp import _executor -from skore_cli.agent.mcp._jobs import ( - ASK_USER_TOOL, - MATERIALIZE_TOOL, - RELAY_TOOLS, - RUN_BASH_TOOL, - _command_text, - _normalize_questions, -) - -logger = logging.getLogger("skore_cli.agent.mcp") - - -class ElicitationUnsupportedError(RuntimeError): - """Raised when a human gate needs elicitation but the host lacks it.""" - - -class _ConfirmSchema(BaseModel): - """Elicitation schema for inline shell-command approval.""" - - approve: bool = Field(description="Approve running the shell command?") - - -def _sanitize_field_name(qid: str, index: int, used: set[str]) -> str: - """Turn a question id (e.g. ``G-PKG-NAME``) into a unique valid field name.""" - safe = re.sub(r"\W", "_", qid).strip("_") - if not safe or safe[0].isdigit(): - safe = f"q_{safe}".rstrip("_") or f"q{index + 1}" - base = safe - counter = 2 - while safe in used: - safe = f"{base}_{counter}" - counter += 1 - return safe - - -def _build_questions_form( - questions: list[dict], -) -> tuple[type[BaseModel], dict[str, str]]: - """Build an elicitation schema (one field per question) + a field->id map. - - Elicitation schemas only allow primitive field types, so every field is a - ``str``: single-choice questions carry their ``options`` as a JSON-schema - ``enum`` (rendered as a dropdown by capable hosts), and multi-select degrades - to a free string whose description lists the options. - """ - fields: dict[str, tuple[type, object]] = {} - field_to_id: dict[str, str] = {} - used: set[str] = set() - for index, question in enumerate(questions): - qid = question.get("id") or f"q{index + 1}" - name = _sanitize_field_name(qid, index, used) - used.add(name) - field_to_id[name] = qid - - prompt = question.get("prompt") or "Your input is required to continue." - options = question.get("options") or [] - multiple = bool(question.get("multiple")) - default = question.get("default") - - description = prompt - field_kwargs: dict[str, object] = {} - if options and not multiple: - field_kwargs["json_schema_extra"] = {"enum": list(options)} - elif options and multiple: - joined = ", ".join(str(o) for o in options) - description = ( - f"{prompt} (choose one or more, comma-separated, from: {joined})" - ) - field_kwargs["description"] = description - - if isinstance(default, str) and default: - field_info = Field(default=default, **field_kwargs) - else: - field_info = Field(..., **field_kwargs) - fields[name] = (str, field_info) - - model = create_model("SkoreQuestions", **fields) # type: ignore[call-overload] - return model, field_to_id - - -async def _answer_questions(ctx: object, args: object) -> str: - """Put a skill gate's questions to the human inline; return JSON answers. - - Returns a JSON ``{question_id: answer}`` map. A decline/cancel yields an empty - map (the hub agent may then apply its defaults). Raises - :class:`ElicitationUnsupportedError` if the host cannot elicit. - """ - questions = _normalize_questions(args) - form, field_to_id = _build_questions_form(questions) - count = len(questions) - noun = "question" if count == 1 else "questions" - try: - result = await ctx.elicit( # type: ignore[attr-defined] - message=f"The Skore agent needs your input on {count} {noun} to continue.", - schema=form, - ) - except Exception as exc: # noqa: BLE001 - host may not support elicitation - raise ElicitationUnsupportedError( - "this host does not support MCP elicitation, which is required to " - "relay the Skore agent's skill-gate questions to you. Use an " - "elicitation-capable MCP host." - ) from exc - action = getattr(result, "action", None) - data = getattr(result, "data", None) - if action == "accept" and data is not None: - answers = { - qid: str(value) - for name, qid in field_to_id.items() - if (value := getattr(data, name, None)) is not None - } - return json.dumps(answers) - return json.dumps({}) - - -async def _confirm_and_run(ctx: object, workspace, args: object) -> str: - """Ask the user to approve a shell command inline, then run it if approved.""" - command = _command_text(args) - if not command: - return "no command provided" - try: - result = await ctx.elicit( # type: ignore[attr-defined] - message=f"The Skore agent wants to run: {command}", schema=_ConfirmSchema - ) - except Exception: # noqa: BLE001 - host may not support elicitation - return "command declined (host cannot confirm shell commands)" - action = getattr(result, "action", None) - data = getattr(result, "data", None) - approved = action == "accept" and bool(getattr(data, "approve", False)) - if not approved: - return "command declined by the user" - try: - exec_result = await _executor.run_bash(workspace, command) - except Exception as exc: # noqa: BLE001 - surfaced to the hub as tool output - message = f"run_bash failed: {exc}" - await _info(ctx, message) - return message - await _info(ctx, exec_result.summary) - return exec_result.output - - -async def _run_data_plane(ctx: object, client: object, workspace, name, args) -> str: - """Execute a data-plane tool locally; emit a compact summary; return output.""" - parsed = args - if isinstance(args, str): - try: - parsed = json.loads(args or "{}") - except json.JSONDecodeError: - parsed = {} - if not isinstance(parsed, dict): - parsed = {} - try: - if name == "read_file": - result = await _executor.read_file(workspace, parsed.get("path", "")) - elif name == "write_file": - result = await _executor.write_file( - workspace, parsed.get("path", ""), parsed.get("content", "") - ) - elif name == "edit_file": - result = await _executor.edit_file( - workspace, - parsed.get("path", ""), - parsed.get("old", ""), - parsed.get("new", ""), - ) - elif name == "list_dir": - result = await _executor.list_dir(workspace, parsed.get("path", ".")) - elif name == MATERIALIZE_TOOL: - template = await client.fetch_template( # type: ignore[attr-defined] - parsed.get("skill", ""), parsed.get("template_path", "") - ) - result = _executor.materialize( - workspace, - parsed.get("dest_path", ""), - template, - parsed.get("substitutions"), - ) - else: - message = f"unknown tool {name!r}" - await _info(ctx, message) - return message - except Exception as exc: # noqa: BLE001 - surfaced to the hub as tool output - message = f"{name} failed: {exc}" - await _info(ctx, message) - return message - await _info(ctx, result.summary) - return result.output - - -async def _info(ctx: object, message: str) -> None: - """Best-effort progress line to the host (never fatal).""" - try: - await ctx.info(message) # type: ignore[attr-defined] - except Exception: # noqa: BLE001 - host may not support notifications - logger.debug("ctx.info failed: %s", message) - - -async def resolve_tool_calls( - ctx: object, client: object, workspace, tool_calls: list[dict] -) -> dict[str, str]: - """Execute/relay each deferred tool call; return a ``{call_id: output}`` map.""" - results: dict[str, str] = {} - for call in tool_calls: - call_id = call.get("id", "") - fn = call.get("function") or {} - name = fn.get("name", "") - args = fn.get("arguments", "") - if name == ASK_USER_TOOL: - results[call_id] = await _answer_questions(ctx, args) - elif name == RUN_BASH_TOOL: - results[call_id] = await _confirm_and_run(ctx, workspace, args) - elif name in RELAY_TOOLS or name == MATERIALIZE_TOOL: - results[call_id] = await _run_data_plane(ctx, client, workspace, name, args) - else: - message = f"unknown tool {name!r}" - await _info(ctx, message) - results[call_id] = message - return results diff --git a/src/skore_cli/agent/mcp/_hosts.py b/src/skore_cli/agent/mcp/_hosts.py deleted file mode 100644 index 7f6e69b..0000000 --- a/src/skore_cli/agent/mcp/_hosts.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Per-host writers for ``skore agent mcp install``. - -``skore agent mcp install --host `` writes the configuration a given MCP -host needs to launch ``skore agent mcp serve`` over stdio. Hosts store MCP server -definitions differently (a project ``mcp.json``, an ``opencode.json`` block, a -global ``config.toml``), so each writer is bespoke. The resolved hub URL and -workspace are baked into the registered ``serve`` command so the relay points at -the same hub the user logged into. -""" - -from __future__ import annotations - -import json -import tomllib -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from skore_cli._style import console - -# The MCP server id every host registers (the relay tool namespace). -SERVER_NAME = "skore-ml" - -# The CLI subcommand the host launches over stdio. -LAUNCH_ARGS = ["agent", "mcp", "serve"] - -# The executable the host invokes (the installed `skore` entry point). -EXECUTABLE = "skore" - - -@dataclass -class InstallContext: - """Inputs for a host writer.""" - - workspace: Path - hub_url: str | None = None - hub_workspace: str | None = None - - -def _serve_args(ctx: InstallContext) -> list[str]: - """Build the ``serve`` argv, baking in the resolved hub URL/workspace.""" - args = list(LAUNCH_ARGS) - if ctx.hub_url: - args += ["--hub-url", ctx.hub_url] - if ctx.hub_workspace: - args += ["--hub-workspace", ctx.hub_workspace] - return args - - -def _server_block_command(ctx: InstallContext) -> dict[str, Any]: - """Return the standard ``{command, args}`` MCP server block.""" - return {"command": EXECUTABLE, "args": _serve_args(ctx)} - - -def _load_json(path: Path) -> dict: - if not path.exists(): - return {} - try: - return json.loads(path.read_text() or "{}") - except json.JSONDecodeError: - path.rename(path.with_suffix(path.suffix + ".bak")) - return {} - - -def _write_json(path: Path, data: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2) + "\n") - - -def _write_mcp_servers_json(path: Path, ctx: InstallContext) -> dict[str, Any]: - """Merge a ``mcpServers`` entry into a JSON config (Cursor / Claude Code).""" - config = _load_json(path) - servers = config.setdefault("mcpServers", {}) - servers[SERVER_NAME] = _server_block_command(ctx) - _write_json(path, config) - return {"config": str(path)} - - -def _configure_cursor(ctx: InstallContext) -> dict[str, Any]: - return _write_mcp_servers_json(ctx.workspace / ".cursor" / "mcp.json", ctx) - - -def _configure_claude_code(ctx: InstallContext) -> dict[str, Any]: - return _write_mcp_servers_json(ctx.workspace / ".mcp.json", ctx) - - -def _configure_opencode(ctx: InstallContext) -> dict[str, Any]: - """Write an ``mcp`` block into ``opencode.json`` (local stdio server).""" - path = ctx.workspace / "opencode.json" - config = _load_json(path) - config.setdefault("$schema", "https://opencode.ai/config.json") - mcp = config.setdefault("mcp", {}) - mcp[SERVER_NAME] = { - "type": "local", - "command": [EXECUTABLE, *_serve_args(ctx)], - "enabled": True, - } - _write_json(path, config) - return {"config": str(path)} - - -def _configure_codex(ctx: InstallContext) -> dict[str, Any]: - """Append an idempotent ``[mcp_servers.skore-ml]`` block to ~/.codex/config.toml.""" - config_path = Path.home() / ".codex" / "config.toml" - config_path.parent.mkdir(parents=True, exist_ok=True) - section = f"[mcp_servers.{SERVER_NAME}]" - existing = config_path.read_text() if config_path.exists() else "" - if section in existing: - return {"config": str(config_path)} - - args = _serve_args(ctx) - args_toml = ", ".join(json.dumps(arg) for arg in args) - block = f'\n{section}\ncommand = "{EXECUTABLE}"\nargs = [{args_toml}]\n' - with config_path.open("a") as handle: - handle.write(block) - return {"config": str(config_path)} - - -def _configure_generic(ctx: InstallContext) -> dict[str, Any]: - """Print the launch command for any other MCP host to register manually.""" - command = " ".join([EXECUTABLE, *_serve_args(ctx)]) - console.print( - "Register this stdio MCP server with your host (command):\n" - f" [skore.path]{command}[/]" - ) - return {"command": command} - - -@dataclass -class Host: - """A supported MCP host and its config writer.""" - - name: str - label: str - writer: Callable[[InstallContext], dict[str, Any]] - - def configure(self, ctx: InstallContext) -> dict[str, Any]: - return self.writer(ctx) - - -HOSTS: dict[str, Host] = { - "cursor": Host("cursor", "Cursor - .cursor/mcp.json", _configure_cursor), - "claude-code": Host( - "claude-code", "Claude Code - .mcp.json", _configure_claude_code - ), - "opencode": Host( - "opencode", "opencode - opencode.json (mcp block)", _configure_opencode - ), - "codex": Host("codex", "Codex - ~/.codex/config.toml", _configure_codex), - "generic": Host( - "generic", "generic - prints the launch command", _configure_generic - ), -} - -HOST_NAMES = list(HOSTS) - - -@dataclass(frozen=True) -class Installed: - """A host's detected ``skore-ml`` relay registration.""" - - name: str - label: str - config_path: Path - present: bool - serve_args: list[str] | None = None - - -def _codex_config_path() -> Path: - return Path.home() / ".codex" / "config.toml" - - -def _json_server_args(path: Path, *, key: str) -> list[str] | None: - """Return the ``skore-ml`` server's args from a ``{key: {...}}`` JSON config. - - Used for the hosts that store a ``{command, args}`` block (Cursor / Claude - Code, both under ``mcpServers``). Returns ``None`` when the entry is absent. - """ - config = _load_json(path) - entry = (config.get(key) or {}).get(SERVER_NAME) - if not isinstance(entry, dict): - return None - args = entry.get("args") - return list(args) if isinstance(args, list) else [] - - -def _opencode_server_args(path: Path) -> list[str] | None: - """Return the ``skore-ml`` args from ``opencode.json`` (``mcp`` block). - - opencode stores the launch command as a single ``command`` list - ``[EXECUTABLE, *args]``; drop the executable to mirror the other hosts. - """ - config = _load_json(path) - entry = (config.get("mcp") or {}).get(SERVER_NAME) - if not isinstance(entry, dict): - return None - command = entry.get("command") - if isinstance(command, list) and command: - return [str(part) for part in command[1:]] - return [] - - -def _codex_server_args(path: Path) -> list[str] | None: - """Return the ``skore-ml`` args from ``~/.codex/config.toml``.""" - if not path.exists(): - return None - try: - config = tomllib.loads(path.read_text()) - except (OSError, tomllib.TOMLDecodeError): - return None - entry = (config.get("mcp_servers") or {}).get(SERVER_NAME) - if not isinstance(entry, dict): - return None - args = entry.get("args") - return list(args) if isinstance(args, list) else [] - - -def installed(workspace: Path) -> list[Installed]: - """Detect which hosts have the ``skore-ml`` relay registered. - - Read-only: inspects each host's known config location (project files under - ``workspace`` for Cursor/Claude Code/opencode, the global - ``~/.codex/config.toml`` for Codex) and reports the baked ``serve`` args. - The ``generic`` host has no file, so it is skipped. - """ - cursor = workspace / ".cursor" / "mcp.json" - claude = workspace / ".mcp.json" - opencode = workspace / "opencode.json" - codex = _codex_config_path() - - probes: list[tuple[str, Path, list[str] | None]] = [ - ("cursor", cursor, _json_server_args(cursor, key="mcpServers")), - ("claude-code", claude, _json_server_args(claude, key="mcpServers")), - ("opencode", opencode, _opencode_server_args(opencode)), - ("codex", codex, _codex_server_args(codex)), - ] - - results: list[Installed] = [] - for name, path, args in probes: - results.append( - Installed( - name=name, - label=HOSTS[name].label, - config_path=path, - present=args is not None, - serve_args=args, - ) - ) - return results diff --git a/src/skore_cli/agent/mcp/_jobs.py b/src/skore_cli/agent/mcp/_jobs.py deleted file mode 100644 index 86d4bb5..0000000 --- a/src/skore_cli/agent/mcp/_jobs.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Canonical delegation toolset + question/command normalization helpers. - -The delegation *loop* now lives on the hub (see ``hub.agent.tasks``); the relay is -a thin A2A client (:mod:`_a2a_client`) that executes the agent's deferred tool -calls locally (:mod:`_handlers`). This module holds the harness-agnostic pieces -shared by that client: - -* :data:`CANONICAL_TOOLS` -- the fixed tool set advertised to the hub agent (the - hub mirrors them as its deferred external toolset); -* :func:`_normalize_questions` / :func:`_command_text` -- coerce an ``ask_user`` / - ``run_bash`` call's arguments into the structured shapes the relay needs. -""" - -from __future__ import annotations - -import json -import re - -# The reserved tool requiring explicit user approval before the relay runs it. -RUN_BASH_TOOL = "run_bash" -# Tools the relay executes locally (data plane); their bulk output never reaches -# the outer LLM, only a compact activity summary does. -RELAY_TOOLS = {"read_file", "write_file", "edit_file", "list_dir"} -MATERIALIZE_TOOL = "materialize_template" - -# The reserved tool the hub agent calls to pause for a human decision (a skill -# gate). The relay routes it to the user instead of executing it. -ASK_USER_TOOL = "ask_user" - -# A fixed, harness-agnostic tool set advertised to the hub agent. The outer LLM -# maps these to whatever native tools it has; the hub never sees (or depends on) -# a specific harness's tool naming. -CANONICAL_TOOLS: list[dict] = [ - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a UTF-8 text file from the workspace.", - "parameters": { - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "write_file", - "description": "Create or overwrite a workspace file with new content.", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "content": {"type": "string"}, - }, - "required": ["path", "content"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "edit_file", - "description": "Replace an exact text span in a workspace file.", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "old": {"type": "string"}, - "new": {"type": "string"}, - }, - "required": ["path", "old", "new"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_dir", - "description": "List the entries of a workspace directory.", - "parameters": { - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "run_bash", - "description": "Run a shell command in the workspace and return output.", - "parameters": { - "type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"], - }, - }, - }, - { - "type": "function", - "function": { - "name": MATERIALIZE_TOOL, - "description": ( - "Deliver a bundled skill template to the workspace VERBATIM. " - "Prefer this over read_file/write_file when a skill instructs you " - "to copy a template and substitute placeholders: the template body " - "is fetched and written locally without passing through the " - "transcript. `template_path` is the skill-relative template path " - "(e.g. templates/experiment.py); `dest_path` is where to write it " - "in the workspace; `substitutions` is a map of the exact " - "placeholder text to its replacement (e.g. " - '{"{{PKG_NAME}}": "digichem"}).' - ), - "parameters": { - "type": "object", - "properties": { - "skill": {"type": "string"}, - "template_path": {"type": "string"}, - "dest_path": {"type": "string"}, - "substitutions": {"type": "object"}, - }, - "required": ["skill", "template_path", "dest_path"], - }, - }, - }, - { - "type": "function", - "function": { - "name": ASK_USER_TOOL, - "description": ( - "Ask the human user one or more questions at a skill gate and wait " - "for THEIR answers (the relay surfaces them to the human; they are " - "never answered by the outer model). Pass ONE entry per gate/" - "decision in `questions` (never concatenate several gates into a " - "single prompt). Set each `id` to the gate code (e.g. G-PKG-NAME), " - "and fill `options`/`default` from the gate definition when known. " - "Answers come back keyed by `id`." - ), - "parameters": { - "type": "object", - "properties": { - "questions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "prompt": {"type": "string"}, - "options": { - "type": "array", - "items": {"type": "string"}, - }, - "multiple": {"type": "boolean"}, - "default": {"type": "string"}, - }, - "required": ["prompt"], - }, - } - }, - "required": ["questions"], - }, - }, - }, -] - - -# A gate code like ``G-PKG-NAME`` used to identify a question deterministically. -_GATE_CODE = re.compile(r"\bG-[A-Z0-9][A-Z0-9-]*\b") -# Enumerated-list item markers, e.g. ``1.``/``2)`` at line start or inline. -_ENUM_MARKER = re.compile(r"(?:^|\s)\d+[.)]\s+") -# A gate code anchored at the start of a chunk (used as a fallback split point). -_GATE_CODE_SPLIT = re.compile(r"(?=\bG-[A-Z0-9][A-Z0-9-]*\b)") - - -def _coerce_question(raw: object, index: int) -> dict: - """Coerce one question item into the full ``{id, prompt, options, ...}`` shape.""" - if isinstance(raw, str): - raw = {"prompt": raw} - if not isinstance(raw, dict): - raw = {"prompt": str(raw)} - prompt = "" - for key in ("prompt", "question", "message", "text"): - value = raw.get(key) - if isinstance(value, str) and value.strip(): - prompt = value.strip() - break - raw_options = raw.get("options") - options = [str(o) for o in raw_options] if isinstance(raw_options, list) else [] - qid = raw.get("id") - if not isinstance(qid, str) or not qid.strip(): - match = _GATE_CODE.search(prompt) - qid = match.group(0) if match else f"q{index + 1}" - default = raw.get("default") - return { - "id": qid, - "prompt": prompt or "The Skore agent needs your input to continue.", - "options": options, - "multiple": bool(raw.get("multiple", False)), - "default": default if isinstance(default, str) else None, - } - - -def _split_questions(text: str) -> list[dict]: - """Fallback: split a single enumerated blob into separate questions. - - Used when the agent crams several gates into one free-text string instead of - the structured ``questions`` array. Splits on numbered markers (``1.``/``2)``) - and derives each id from a leading gate code when present. - """ - text = text.strip() - if not text: - return [_coerce_question("", 0)] - chunks = [c.strip() for c in _ENUM_MARKER.split(text) if c.strip()] - if len(chunks) <= 1: - # No numbered markers: fall back to splitting on gate codes if several - # appear, otherwise treat the whole blob as one question. - if len(_GATE_CODE.findall(text)) > 1: - chunks = [c.strip() for c in _GATE_CODE_SPLIT.split(text) if c.strip()] - else: - return [_coerce_question(text, 0)] - return [_coerce_question(chunk, i) for i, chunk in enumerate(chunks)] - - -def _command_text(args: object) -> str: - """Pull the shell command out of a ``run_bash`` call's arguments.""" - if isinstance(args, str): - try: - args = json.loads(args or "{}") - except json.JSONDecodeError: - return args.strip() - if isinstance(args, dict): - value = args.get("command") - if isinstance(value, str): - return value.strip() - return "" - - -def _normalize_questions(args: object) -> list[dict]: - """Normalize an ``ask_user`` call's arguments into a list of structured questions. - - Accepts the structured ``questions`` array, a legacy single ``question``/ - ``prompt`` string (split heuristically), or empty args. - """ - if isinstance(args, str): - try: - args = json.loads(args or "{}") - except json.JSONDecodeError: - return _split_questions(args) - if isinstance(args, dict): - questions = args.get("questions") - if isinstance(questions, list) and questions: - return [_coerce_question(item, i) for i, item in enumerate(questions)] - for key in ("question", "prompt", "message", "text"): - value = args.get(key) - if isinstance(value, str) and value.strip(): - return _split_questions(value) - return [_coerce_question("", 0)] diff --git a/src/skore_cli/agent/mcp/_server.py b/src/skore_cli/agent/mcp/_server.py deleted file mode 100644 index 50a8238..0000000 --- a/src/skore_cli/agent/mcp/_server.py +++ /dev/null @@ -1,131 +0,0 @@ -"""The FastMCP relay backing ``skore agent mcp serve`` (no-poll, A2A-backed). - -The user's outer LLM delegates a machine-learning task to the Skore Hub agent -through a SINGLE blocking tool: - -* ``skore_ml_run(task)`` -- delegate a task and return only when it finishes. - -There is no polling. Internally the relay opens one durable A2A task on the hub -(:mod:`_a2a_client`) and consumes a pushed SSE event stream. The hub agent (its -own model + skills) drives the orchestration server-side; whenever it defers tool -calls, the relay executes them locally (:mod:`_handlers`) -- file/shell ops on the -real workspace, and skill-gate questions put to the human via MCP elicitation -- -and posts the results back, all within the one blocking call. Progress is streamed -to the user via ``ctx.info``/``ctx.report_progress`` (so the outer model is not -re-invoked just to relay activity), and the bulk of file/command output never -enters the outer LLM's context. - -The server speaks MCP over stdio, so nothing here may write to stdout; logging -goes to stderr via the module logger. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging -from pathlib import Path - -from mcp.server.fastmcp import Context, FastMCP - -from skore_cli.agent.mcp._a2a_client import RelayConfig -from skore_cli.agent.mcp._handlers import ( - ElicitationUnsupportedError, - resolve_tool_calls, -) - -logger = logging.getLogger("skore_cli.agent.mcp") - -_SERVER_INSTRUCTIONS = """\ -These tools delegate machine-learning engineering to the Skore agent -- a -specialized senior ML engineer for scikit-learn / tabular workflows (data -exploration, pipelines, cross-validation, metrics, model selection, experiment -tracking) that works in the current workspace. - -Call skore_ml_run(task=) once and wait for it to return. It is a single -blocking call: the Skore agent runs the whole task server-side, the relay -performs any file/shell operations on the workspace itself, and any skill-gate -question is put to YOU/the user directly (via an interactive prompt) -- you never -read or write files and you never answer the agent's questions on the user's -behalf. The agent's progress is streamed to the user as it happens. When the call -returns, present the result; quote it rather than rephrasing. Let the Skore agent -decide the ML methodology; do not invent steps of your own. -""" - -_RUN_DESCRIPTION = """\ -Delegate a machine-learning engineering task to the Skore agent and return its -final result. This is a SINGLE blocking call -- there is no polling: it returns -only when the task is done (or fails). The relay performs all workspace file and -shell operations itself and surfaces any skill-gate question to the user -interactively, so you never read/write files and never answer those questions -yourself. `task` is the goal in natural language; `workspace` optionally overrides -the directory the agent acts in (defaults to the served workspace). -""" - - -async def _emit_progress(ctx: Context, text: str, step: int) -> None: - """Stream an activity line to the user without re-invoking the outer model.""" - try: - await ctx.info(text) - except Exception: # noqa: BLE001 - host may not support notifications - logger.debug("ctx.info failed") - # Keeps the blocking call alive on hosts that reset tool timeouts on progress; - # harmless when the host ignores progress. - with contextlib.suppress(Exception): - await ctx.report_progress(progress=step, total=None, message=text[:120]) - - -async def drive_task(ctx: Context, client: object, task: str, workspace: Path) -> str: - """Drive one delegation task to completion over the A2A stream (no polling). - - Consumes the hub's pushed events; on ``input-required`` it executes the - deferred tool calls locally (and elicits gates), posts the results back, and - keeps streaming until a terminal event. Returns the final result text. - """ - await _emit_progress(ctx, f"delegating to the Skore agent: {task[:80]}", 0) - step = 0 - try: - async for event in client.events(task): # type: ignore[attr-defined] - kind = event.get("kind") - if kind == "activity": - step += 1 - await _emit_progress(ctx, event.get("text", ""), step) - elif kind == "input-required": - results = await resolve_tool_calls( - ctx, client, workspace, event.get("tool_calls") or [] - ) - await client.send_results(results) # type: ignore[attr-defined] - elif kind == "result": - return event.get("text", "") or ( - "The Skore agent finished with no output." - ) - elif kind == "error": - return ( - f"The Skore agent failed: {event.get('message', 'unknown error')}" - ) - # Stream ended without an explicit result/error event. - if client.state == "completed": # type: ignore[attr-defined] - return client.result or "The Skore agent finished with no output." - if client.state == "failed": # type: ignore[attr-defined] - return f"The Skore agent failed: {client.error or 'unknown error'}" - return f"The Skore agent stopped (state={client.state})." - except ElicitationUnsupportedError as exc: - await client.cancel() # type: ignore[attr-defined] - return f"Cannot complete the task: {exc}" - except asyncio.CancelledError: - await client.cancel() # type: ignore[attr-defined] - raise - - -def build_mcp_server(config: RelayConfig) -> FastMCP: - """Build the FastMCP relay exposing the single ``skore_ml_run`` tool.""" - server = FastMCP("skore-ml", instructions=_SERVER_INSTRUCTIONS) - - @server.tool(name="skore_ml_run", description=_RUN_DESCRIPTION) - async def skore_ml_run( - task: str, ctx: Context, workspace: str | None = None - ) -> str: - target = Path(workspace).resolve() if workspace else config.default_workspace - return await drive_task(ctx, config.make_client(), task, target) - - return server diff --git a/src/skore_cli/agent/model/__init__.py b/src/skore_cli/agent/model/__init__.py deleted file mode 100644 index 810ac2e..0000000 --- a/src/skore_cli/agent/model/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""The ``skore agent model`` command group (agent-as-model integration). - -Exposes the Skore Hub agent as an OpenAI-compatible model: ``install`` wires a -local harness to the hub endpoint and ``status`` reports that wiring. Heavy -``skore``/``textual`` imports stay deferred inside the command callbacks. -""" - -from skore_cli.agent.model._commands import model - -__all__ = ["model"] diff --git a/src/skore_cli/agent/model/_commands.py b/src/skore_cli/agent/model/_commands.py deleted file mode 100644 index 8888fbc..0000000 --- a/src/skore_cli/agent/model/_commands.py +++ /dev/null @@ -1,374 +0,0 @@ -"""The ``skore agent model`` click group: ``install`` and ``status``. - -This is the "agent-as-model" integration: the Skore Hub agent is served behind -an OpenAI-compatible endpoint and a local harness is pointed at it. ``install`` -configures a harness (and records a ``.skore-agent.json`` marker); ``status`` -reports that wiring. -""" - -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path - -import rich_click as click - -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._harnesses import ( - API_KEY_ENV, - DEFAULT_MODEL_ID, - HARNESS_NAMES, - HARNESSES, - MARKER_FILENAME, - ConfigureContext, - Credential, - base_url, - detect_harnesses, - fetch_workspaces, - resolve_credential, -) - -click.rich_click.COMMAND_GROUPS = { - **getattr(click.rich_click, "COMMAND_GROUPS", {}), - "cli agent model": [ - {"name": "OpenAI-compatible endpoint", "commands": ["install", "status"]}, - ], -} - - -@click.group() -def model() -> None: - """Use the Skore Hub agent as your harness's model (OpenAI-compatible).""" - - -def _is_interactive() -> bool: - return sys.stdin.isatty() and sys.stdout.isatty() - - -def _print_credential(cred: Credential) -> None: - if cred.kind == "api_key": - console.print( - f"Using the API key from [bold]{API_KEY_ENV}[/] " - "[skore.muted](never written to a file; only referenced).[/]" - ) - elif cred.kind == "bearer": - console.print( - "Using your interactive [bold]skore hub login[/] token " - "[skore.muted](refreshed automatically).[/]" - ) - else: - console.print( - f"[yellow]No credential found. Set {API_KEY_ENV} (recommended) or run " - "`skore hub login` for interactive authentication, then re-run.[/]" - ) - - -def _install_skills(workspace: Path, install: bool) -> None: - if not install: - return - console.print( - "[yellow]Note: installing skills locally is OFF by default for IP " - "isolation. The hub agent loads skills server-side; local skills are not " - "needed.[/]" - ) - console.print( - "Installing probabl-skills into the workspace (--skills override) ..." - ) - try: - result = subprocess.run( - [sys.executable, "-m", "skore_cli", "skills", "install", "--all"], - cwd=workspace, - check=False, - capture_output=True, - text=True, - ) - except OSError as error: - console.print(f"[yellow] could not run skills install: {error}[/]") - return - if result.returncode != 0: - console.print( - "[yellow] skills install failed (continuing); run " - "`skore skills install --all` manually.\n " - f"{(result.stderr or '').strip()}[/]" - ) - else: - console.print("[skore.ok] skills installed.[/]") - - -def _pick_harness(workspace: Path) -> str | None: - """Launch the Textual picker (textual imported lazily) and return a name.""" - from skore_cli.agent.app import HarnessPicker - - detected = set(detect_harnesses(workspace)) - rows = [ - (name, harness.label, name in detected) for name, harness in HARNESSES.items() - ] - preselect = next((i for i, row in enumerate(rows) if row[2]), 0) - - app = HarnessPicker(rows, preselect=preselect) - app.run() - return app.result - - -def _pick_workspace(workspaces: list[tuple[str, str]]) -> str | None: - """Launch the Textual workspace picker and return the chosen public id.""" - from skore_cli.agent.app import WorkspacePicker - - app = WorkspacePicker(workspaces) - app.run() - return app.result - - -def _resolve_hub_workspace( - cred: Credential, hub_url: str, hub_workspace: str | None -) -> str | None: - """Resolve the hub workspace the agent should attach to. - - API keys are already workspace-bound server-side, so nothing is attached for - them. For the interactive (bearer) path the workspace must be explicit: from - ``--hub-workspace`` or an interactive picker; otherwise this errors, because - the hub now rejects agent calls that resolve to no workspace. - """ - if cred.kind == "api_key": - if hub_workspace: - console.print( - "[yellow]Ignoring --hub-workspace: the workspace is fixed by your " - f"API key ({API_KEY_ENV}).[/]" - ) - else: - console.print( - f"[skore.muted]Workspace is fixed by your API key ({API_KEY_ENV}).[/]" - ) - return None - - if cred.kind != "bearer": - # No credential: nothing can be attached; install already warns about this. - return None - - if hub_workspace: - return hub_workspace - - try: - workspaces = fetch_workspaces(hub_url, cred) - except Exception as error: # noqa: BLE001 - surfaced as a friendly CLI error - raise click.ClickException( - f"could not list your hub workspaces ({error}); pass " - "--hub-workspace to attach one explicitly." - ) from error - - if not workspaces: - raise click.ClickException( - "you are not a member of any hub workspace; create or join one, then retry." - ) - - if not _is_interactive(): - raise click.UsageError( - "pass --hub-workspace to attach a workspace non-interactively " - f"(one of: {', '.join(public_id for public_id, _ in workspaces)})." - ) - - chosen = _pick_workspace(workspaces) - if chosen is None: - raise click.ClickException("no workspace selected.") - return chosen - - -@model.command("install") -@click.option( - "--workspace", - "-w", - default=".", - type=click.Path(file_okay=False, path_type=Path), - help="Workspace directory to configure (default: current directory).", -) -@click.option( - "--harness", - "-H", - "harness_name", - type=click.Choice(HARNESS_NAMES), - default=None, - help="Harness to configure non-interactively (omit to pick interactively).", -) -@click.option( - "--hub-url", - 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, like `skore hub login`." - ), -) -@click.option( - "--hub-workspace", - default=None, - help=( - "Hub workspace (public id) to attach the agent to. Required for " - "interactive-login (bearer) auth in non-interactive mode; ignored when a " - "workspace-scoped API key is used." - ), -) -@click.option( - "--model-id", - default=DEFAULT_MODEL_ID, - help="Model id advertised by the hub (default: skore-agent).", -) -@click.option( - "--skills/--no-skills", - "install_skills", - default=False, - help="Install probabl-skills locally (default: off; the hub serves skills).", -) -@click.option( - "--session-plugin/--no-session-plugin", - "write_session_plugin", - default=True, - help="opencode only: write the session-binding plugin (default: on).", -) -@click.option( - "--config-file/--no-config-file", - "write_file", - default=True, - help="generic only: also write skore-agent.json (default: on).", -) -def install( - workspace: Path, - harness_name: str | None, - hub_url: str | None, - hub_workspace: str | None, - model_id: str, - install_skills: bool, - write_session_plugin: bool, - write_file: bool, -) -> None: - """Configure an agent harness to talk to the Skore Hub agent. - - Pass ``--harness `` to configure a specific harness non-interactively; - omit it in a terminal to pick one interactively (like ``skore skills``). The - ``generic`` harness just prints the connection values for any other - OpenAI-compatible client. - """ - workspace = workspace.resolve() - if not workspace.is_dir(): - raise click.ClickException(f"workspace does not exist: {workspace}") - - if harness_name is None: - if _is_interactive(): - harness_name = _pick_harness(workspace) - if harness_name is None: - console.print("Nothing selected.") - return - else: - raise click.UsageError( - "Specify --harness to configure non-interactively " - f"(one of: {', '.join(HARNESS_NAMES)})." - ) - - harness = HARNESSES[harness_name] - console.print( - f"Configuring [skore.skill]{harness.name}[/] in: [skore.path]{workspace}[/]" - ) - - cred = resolve_credential() - _print_credential(cred) - - # Resolve the hub address the same way `skore hub login` does (explicit - # --hub-url, else SKORE_HUB_URI, else the public hub). - hub_url = resolve_hub_uri(hub_url, _auth) - - attached_workspace = _resolve_hub_workspace(cred, hub_url, hub_workspace) - if attached_workspace: - console.print( - f"Attaching to hub workspace [skore.skill]{attached_workspace}[/]." - ) - - _install_skills(workspace, install_skills) - - ctx = ConfigureContext( - workspace=workspace, - hub_url=hub_url, - model_id=model_id, - cred=cred, - hub_workspace=attached_workspace, - write_session_plugin=write_session_plugin, - write_file=write_file, - ) - extra = harness.configure(ctx) - - marker = { - "harness": harness.name, - "hub_url": hub_url, - "base_url": base_url(hub_url), - "hub_workspace": attached_workspace, - "model_id": model_id, - "auth": cred.kind, - "session_binding": harness.session_binding, - **extra, - } - marker_path = workspace / MARKER_FILENAME - marker_path.write_text(json.dumps(marker, indent=2) + "\n") - console.print(f"\n[skore.muted]Recorded the setup in {marker_path}.[/]") - - -@model.command("status") -@click.option( - "--workspace", - "-w", - default=".", - type=click.Path(file_okay=False, path_type=Path), - help="Workspace directory to inspect (default: current directory).", -) -def status(workspace: Path) -> None: - """Show the agent wiring for this workspace.""" - workspace = workspace.resolve() - marker_path = workspace / MARKER_FILENAME - if not marker_path.exists(): - raise click.ClickException( - f"no {MARKER_FILENAME} in {workspace}; " - "run `skore agent model install` first." - ) - - marker = json.loads(marker_path.read_text() or "{}") - harness_name = marker.get("harness", "?") - base = marker.get("base_url", "?") - model_id = marker.get("model_id", "?") - auth = marker.get("auth", "none") - binding = marker.get("session_binding", "fallback") - hub_workspace = marker.get("hub_workspace") - - skills_dir = workspace / ".agents" / "skills" - n_skills = len(list(skills_dir.iterdir())) if skills_dir.is_dir() else 0 - skills_note = ( - f"{n_skills} local (override)" if n_skills else "served by hub (none local)" - ) - - auth_note = { - "api_key": f"[skore.ok]API key[/] (from {API_KEY_ENV})", - "bearer": "[skore.ok]interactive token[/]", - "none": "[skore.muted]none[/]", - }.get(auth, auth) - - session_note = ( - "[skore.ok]bound[/] via the X-Skore-Session-Id header" - if binding == "plugin" - else "[skore.muted]via the OpenAI 'user' field, else content-hash fallback[/]" - ) - - workspace_note = ( - f"[skore.skill]{hub_workspace}[/]" - if hub_workspace - else "[skore.muted]bound by API key (no header)[/]" - if auth == "api_key" - else "[skore.muted]none[/]" - ) - - console.print(f"workspace : [skore.path]{workspace}[/]") - console.print(f"harness : [skore.skill]{harness_name}[/]") - console.print(f"hub URL : [skore.path]{base}[/]") - console.print(f"hub ws : {workspace_note}") - console.print(f"model : {model_id}") - console.print(f"auth : {auth_note}") - console.print(f"skills : {skills_note}") - console.print(f"session : {session_note}") diff --git a/src/skore_cli/app/__init__.py b/src/skore_cli/app/__init__.py new file mode 100644 index 0000000..57a1ebc --- /dev/null +++ b/src/skore_cli/app/__init__.py @@ -0,0 +1,5 @@ +"""Shared Textual UI pieces for interactive CLI screens.""" + +from skore_cli.app._help import HelpInput, HelpScreen + +__all__ = ["HelpInput", "HelpScreen"] diff --git a/src/skore_cli/app/_help.py b/src/skore_cli/app/_help.py new file mode 100644 index 0000000..815514e --- /dev/null +++ b/src/skore_cli/app/_help.py @@ -0,0 +1,67 @@ +"""Modal help overlay for interactive CLI screens.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.events import Key +from textual.screen import ModalScreen +from textual.widgets import Footer, Input, Label, Static + +HELP_BINDING = Binding("question_mark", "show_help", "Help", priority=True) + + +class HelpInput(Input): + """Input that opens the app help screen instead of inserting ``?``.""" + + async def _on_key(self, event: Key) -> None: + if event.key == "question_mark": + self.app.action_show_help() + event.prevent_default() + event.stop() + return + await super()._on_key(event) + + +class HelpScreen(ModalScreen[None]): + """A dismissible help panel opened with ``?``.""" + + DEFAULT_CSS = """ + HelpScreen { + align: center middle; + } + #help-panel { + width: 80%; + max-width: 90; + padding: 1 2; + border: thick $accent; + background: $surface; + } + .help-title { + text-style: bold; + margin-bottom: 1; + } + .help-body { + color: $text-muted; + } + """ + + BINDINGS = [ + Binding("escape", "dismiss", "Close"), + Binding("question_mark", "dismiss", "Close"), + ] + + def __init__(self, title: str, body: str) -> None: + super().__init__() + self._title = title + self._body = body + + def compose(self) -> ComposeResult: + with Vertical(id="help-panel"): + yield Label(self._title, classes="help-title") + yield Static(self._body, classes="help-body") + yield Footer() + + def action_dismiss(self) -> None: + self.dismiss() diff --git a/src/skore_cli/hub/_agent_providers.py b/src/skore_cli/hub/_agent_providers.py index 8757150..219312b 100644 --- a/src/skore_cli/hub/_agent_providers.py +++ b/src/skore_cli/hub/_agent_providers.py @@ -28,9 +28,12 @@ } -@click.group("agent-provider") -def agent_provider() -> None: +@click.group("agent-provider", invoke_without_command=True) +@click.pass_context +def agent_provider(ctx) -> None: """Add, list, activate and remove a workspace's agent LLM providers.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) def _resolve_target_workspace( diff --git a/src/skore_cli/hub/_api_keys.py b/src/skore_cli/hub/_api_keys.py index 0f463f5..fc6214c 100644 --- a/src/skore_cli/hub/_api_keys.py +++ b/src/skore_cli/hub/_api_keys.py @@ -34,9 +34,12 @@ } -@click.group("api-key") -def api_key() -> None: +@click.group("api-key", invoke_without_command=True) +@click.pass_context +def api_key(ctx) -> None: """Create, list and revoke workspace-scoped hub API keys.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) def _is_interactive() -> bool: diff --git a/src/skore_cli/hub/_client.py b/src/skore_cli/hub/_client.py index a99c535..3b0d348 100644 --- a/src/skore_cli/hub/_client.py +++ b/src/skore_cli/hub/_client.py @@ -18,7 +18,7 @@ import rich_click as click -from skore_cli._skore import auth as _auth +from skore_cli._hub_auth import ensure_login # The grantable permissions, kept as local literals so ``--help`` never imports # the (heavy) ``skore``/hub packages. Mirrors hub ``Permission`` enum values. @@ -111,13 +111,7 @@ def require_login_token() -> str: This gates ``skore hub api-key`` behind a prior ``skore hub login``: an ``SKORE_HUB_API_KEY`` alone is intentionally not sufficient to mint new keys. """ - token = _auth("token").fresh_token(relogin=False) - if not token or not token.get("access_token"): - raise click.ClickException( - "not logged in; run `skore hub login` first. API keys are minted with " - "your interactive login, not an existing SKORE_HUB_API_KEY." - ) - return token["access_token"] + return ensure_login() def _client(hub_url: str, token: str, transport: Any = None): diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py index 1b82eb1..4f89481 100644 --- a/src/skore_cli/hub/_commands.py +++ b/src/skore_cli/hub/_commands.py @@ -1,17 +1,11 @@ """The ``skore hub`` command group to authenticate with a Skore Hub instance. -Auth mirrors the existing Python authentication: +Auth mirrors ``skore``'s in-process authentication: * an **API key** is user-managed through the ``SKORE_HUB_API_KEY`` environment - variable -- we never store it; opencode reads it from the environment; -* otherwise an interactive **device-flow** login obtains a (short-lived) token, - which is the only thing we persist (so a separate opencode process can use it). - -The ``api-key`` subgroup (``skore hub api-key``) mints, lists and revokes -workspace-scoped API keys against the hub, mirroring the hub UI. The -``agent-provider`` subgroup manages a workspace's agent LLM provider config, and -the ``workspace`` subgroup manages the lifecycle of the workspaces themselves. -All require a prior ``skore hub login`` (a stored OAuth token). + variable; +* otherwise ``skore.login()`` runs the interactive device flow and keeps the + token in memory for the current process. Heavy ``skore`` imports are deferred into the command callbacks so building the CLI (and ``--help``) never imports the ``skore`` package. @@ -23,14 +17,11 @@ import rich_click as click +from skore_cli._hub_auth import API_KEY_ENV, auth_kind, clear_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 -# Mirrors ``skore._plugins.hub.authentication`` env var name; kept as a local -# literal so showing help never imports the (heavy) ``skore`` package. -API_KEY_ENV = "SKORE_HUB_API_KEY" - click.rich_click.COMMAND_GROUPS = { **getattr(click.rich_click, "COMMAND_GROUPS", {}), "cli hub": [ @@ -70,7 +61,7 @@ def login(hub_url: str | None, timeout: int) -> None: With an API key (``SKORE_HUB_API_KEY``) there is nothing to do: it is user-managed and read from the environment. Without one, run the interactive - device flow and persist the resulting token locally. + device flow for this process. """ uri = resolve_hub_uri(hub_url, _auth) @@ -78,106 +69,55 @@ def login(hub_url: str | None, timeout: int) -> None: console.print( f"[skore.ok]Using the API key from[/] [bold]{API_KEY_ENV}[/] " f"[skore.ok]for {uri}.[/]\n" - " [skore.muted]Nothing is stored; opencode reads the key from the " + " [skore.muted]Nothing is stored; the key is read from the " "environment.[/]" ) return - # No API key: fall back to the interactive OAuth device flow and persist the - # token (the only thing we manage -- mirrors the Python `Token` device flow). - store = _auth("store") - - console.print(f"Logging in to [skore.path]{uri}[/] via interactive device auth.") - access_token, refresh_token, expires_at = _auth("token").interactive_device_login( - timeout=timeout - ) - - saved = store.save( - { - "uri": uri, - "access_token": access_token, - "refresh_token": refresh_token, - "expires_at": expires_at, - } - ) - console.print( - f"[skore.ok]+[/] logged in to [skore.path]{uri}[/] (interactive)\n" - f" [skore.muted]token -> {saved}[/]\n" - "[yellow]Note: this token is short-lived; re-run `skore hub login` when it " - f"expires. For a durable setup, prefer an API key via {API_KEY_ENV}.[/]" - ) + _auth("login").login(timeout=timeout) @hub.command("logout") def logout() -> None: - """Revoke the interactive token on the hub and remove it locally. + """Clear the in-process interactive session. The API key (``SKORE_HUB_API_KEY``) is user-managed and not revoked here. """ - store = _auth("store") - - token = store.load() - if token and token.get("access_token"): - post_oauth_logout = _auth("token").post_oauth_logout - - try: - post_oauth_logout(token["access_token"], token.get("refresh_token")) - console.print("[skore.ok]+[/] revoked the token on the hub") - except Exception as error: # noqa: BLE001 - best-effort revoke; still clear - console.print( - f"[yellow]Could not revoke the token on the hub ({error}); " - "removing it locally anyway.[/]" - ) - - removed = store.clear() - if removed: - console.print(f"[skore.ok]-[/] removed stored token ([skore.path]{removed}[/])") - console.print( - " [skore.muted]An existing opencode.json still holds the now-revoked " - "bearer; re-run `skore hub login` then `skore agent init` to " - "reconnect.[/]" - ) - elif os.environ.get(API_KEY_ENV): + if clear_login(): + console.print("[skore.ok]-[/] cleared the interactive session.") + return + + if os.environ.get(API_KEY_ENV): console.print( - f"No stored token. The API key from [bold]{API_KEY_ENV}[/] is " + f"No interactive session. The API key from [bold]{API_KEY_ENV}[/] is " "user-managed; unset it yourself to stop using it." ) else: - console.print("No stored token to remove.") + console.print("No interactive session to clear.") @hub.command("status") def status() -> None: - """Show how this machine will authenticate to the hub.""" - store = _auth("store") - token_expired = _auth("token")._token_expired - - has_env_key = bool(os.environ.get(API_KEY_ENV)) - token = store.load() + """Show how this process will authenticate to the hub.""" + kind = auth_kind() console.print(f"hub URI : [skore.path]{_auth('uri').URI()}[/]") - if has_env_key: + if os.environ.get(API_KEY_ENV): console.print(f"API key (env): [skore.ok]set[/] ({API_KEY_ENV})") else: console.print("API key (env): [skore.muted]not set[/]") - if token and token.get("access_token"): - expires_at = token.get("expires_at", "?") - if token_expired(token.get("expires_at")): - console.print(f"token : stored, [yellow]expired[/] ({expires_at})") - else: - console.print(f"token : stored, [skore.ok]valid[/] ({expires_at})") - console.print(f"token path : [skore.path]{store.path()}[/]") - else: - console.print("token : [skore.muted]none[/]") - if not has_env_key and not (token and token.get("access_token")): + if kind == "bearer": + console.print("session : [skore.ok]interactive token[/] (in memory)") + elif kind == "api_key": + console.print("session : [skore.ok]API key[/] (from environment)") + else: + console.print("session : [skore.muted]none[/]") raise click.ClickException( "Not authenticated. Set SKORE_HUB_API_KEY or run `skore hub login`." ) -# The api-key/agent-provider/workspace subgroups are attached here; their heavy -# deps (httpx/textual) stay deferred inside their own command callbacks. from skore_cli.hub._agent_providers import ( # noqa: E402 agent_provider as _agent_provider_group, ) diff --git a/src/skore_cli/hub/_workspaces.py b/src/skore_cli/hub/_workspaces.py index d0b8d5c..2fa2eac 100644 --- a/src/skore_cli/hub/_workspaces.py +++ b/src/skore_cli/hub/_workspaces.py @@ -28,9 +28,12 @@ } -@click.group("workspace") -def workspace() -> None: +@click.group("workspace", invoke_without_command=True) +@click.pass_context +def workspace(ctx) -> None: """List, show, create, rename and delete hub workspaces.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) def _role_for(ws: _client.WorkspaceInfo, user_id: str) -> str: diff --git a/src/skore_cli/hub/app/_form.py b/src/skore_cli/hub/app/_form.py index 2ce3785..eb0444c 100644 --- a/src/skore_cli/hub/app/_form.py +++ b/src/skore_cli/hub/app/_form.py @@ -24,6 +24,7 @@ ) from textual.widgets.selection_list import Selection +from skore_cli.app._help import HELP_BINDING, HelpInput, HelpScreen from skore_cli.skills.app._widgets import AutoRadioSet # (value, label) validity choices, mirroring the hub UI (default: 3 months). @@ -39,9 +40,24 @@ "Create a workspace-scoped API key.\n" "Pick the workspace, the permissions to grant, and a validity.\n" "[reverse] tab [/] next field [reverse] ↑/↓ space [/] choose " - "[reverse] enter [/] create [reverse] esc [/] cancel" + "[reverse] enter [/] create [reverse] esc [/] cancel [reverse] ? [/] help" ) +_API_KEY_HELP = """\ +Create a workspace-scoped API key for the hub. + +Fill in a name, pick the workspace, grant permissions, +and choose how long the key stays valid. The secret is +shown only once after creation. + +Keys: + Tab next field + ↑/↓ Space choose options + Enter create key + Esc cancel + ? show this help +""" + @dataclass(frozen=True) class ApiKeyFormResult: @@ -97,6 +113,7 @@ class ApiKeyForm(App[ApiKeyFormResult | None]): Binding("escape", "cancel", "Cancel"), Binding("tab", "focus_next", "Next", show=False), Binding("shift+tab", "focus_previous", "Previous", show=False), + HELP_BINDING, ] def __init__( @@ -134,7 +151,7 @@ def compose(self) -> ComposeResult: yield Label(_INTRO, classes="form-intro") yield Label("Name", classes="field-label") - yield Input(value=self._name, placeholder="e.g. laptop", id="name") + yield HelpInput(value=self._name, placeholder="e.g. laptop", id="name") yield Label("Workspace", classes="field-label") with AutoRadioSet(id="workspaces"): @@ -156,6 +173,9 @@ def on_mount(self) -> None: self._populate_permissions(self._initial_permissions) self.query_one("#name", Input).focus() + def action_show_help(self) -> None: + self.push_screen(HelpScreen("Create an API key", _API_KEY_HELP)) + def _current_workspace_id(self) -> int: index = self.query_one("#workspaces", AutoRadioSet).pressed_index index = index if index >= 0 else self._workspace_index @@ -213,9 +233,20 @@ def action_cancel(self) -> None: _PICKER_KEYS = ( - "[reverse] ↑/↓ [/] choose [reverse] enter [/] confirm [reverse] esc [/] cancel" + "[reverse] ↑/↓ [/] choose [reverse] enter [/] confirm " + "[reverse] esc [/] cancel [reverse] ? [/] help" ) +_ID_PICKER_HELP = """\ +Select one item from the list. + +Keys: + ↑/↓ move selection + Enter confirm + Esc cancel + ? show this help +""" + class IdPicker(App[int | None]): """Single-select picker over ``(id, label)`` rows; returns the chosen id. @@ -245,6 +276,7 @@ class IdPicker(App[int | None]): BINDINGS = [ Binding("enter", "confirm", "Confirm", priority=True), Binding("escape", "cancel", "Cancel"), + HELP_BINDING, ] def __init__( @@ -260,6 +292,9 @@ def __init__( self._preselect = preselect self.result: int | None = None + def action_show_help(self) -> None: + self.push_screen(HelpScreen(self._title, _ID_PICKER_HELP)) + def compose(self) -> ComposeResult: yield Header() with VerticalScroll(id="picker"): diff --git a/src/skore_cli/hub/app/_provider_form.py b/src/skore_cli/hub/app/_provider_form.py index 6ab6d62..3325662 100644 --- a/src/skore_cli/hub/app/_provider_form.py +++ b/src/skore_cli/hub/app/_provider_form.py @@ -25,6 +25,7 @@ RadioButton, ) +from skore_cli.app._help import HELP_BINDING, HelpInput, HelpScreen from skore_cli.skills.app._widgets import AutoRadioSet _PROVIDER_ORDER = ["skore", "anthropic", "bedrock"] @@ -33,9 +34,24 @@ "Add an agent LLM provider for the workspace.\n" "Pick a provider; bring-your-own providers need a model (and secrets).\n" "[reverse] tab [/] next field [reverse] ↑/↓ [/] choose " - "[reverse] enter [/] add [reverse] esc [/] cancel" + "[reverse] enter [/] add [reverse] esc [/] cancel [reverse] ? [/] help" ) +_PROVIDER_HELP = """\ +Register the LLM provider that powers the hub agent. + +Choose Skore's managed provider or bring your own Anthropic +or Bedrock credentials. Optional fields appear based on the +provider you pick. + +Keys: + Tab next field + ↑/↓ choose options + Enter add provider + Esc cancel + ? show this help +""" + @dataclass(frozen=True) class AgentProviderFormResult: @@ -91,6 +107,7 @@ class AgentProviderForm(App[AgentProviderFormResult | None]): Binding("escape", "cancel", "Cancel"), Binding("tab", "focus_next", "Next", show=False), Binding("shift+tab", "focus_previous", "Previous", show=False), + HELP_BINDING, ] def __init__( @@ -127,7 +144,7 @@ def compose(self) -> ComposeResult: yield Label(_INTRO, classes="form-intro") yield Label("Name", classes="field-label") - yield Input(value=self._name, placeholder="e.g. team-anthropic", id="name") + yield HelpInput(value=self._name, placeholder="e.g. team-anthropic", id="name") yield Label("Provider", classes="field-label") with AutoRadioSet(id="provider"): @@ -153,7 +170,7 @@ def compose(self) -> ComposeResult: ): yield RadioButton(model, value=index == 0) yield Label("Anthropic API key", classes="field-label") - yield Input(password=True, id="anthropic-api-key") + yield HelpInput(password=True, id="anthropic-api-key") with Vertical(id="bedrock-fields"): yield Label("Model", classes="field-label") @@ -163,15 +180,15 @@ def compose(self) -> ComposeResult: ): yield RadioButton(model, value=index == 0) yield Label("AWS region", classes="field-label") - yield Input(id="aws-region", placeholder="e.g. us-east-1") + yield HelpInput(id="aws-region", placeholder="e.g. us-east-1") yield Label("Bedrock role ARN", classes="field-label") - yield Input(id="bedrock-role-arn") + yield HelpInput(id="bedrock-role-arn") yield Label("Bedrock external id", classes="field-label") - yield Input(id="bedrock-external-id") + yield HelpInput(id="bedrock-external-id") yield Label("AWS access key id", classes="field-label") - yield Input(id="aws-access-key-id") + yield HelpInput(id="aws-access-key-id") yield Label("AWS secret access key", classes="field-label") - yield Input(password=True, id="aws-secret-access-key") + yield HelpInput(password=True, id="aws-secret-access-key") yield Checkbox("Activate now", value=self._activate_default, id="activate") yield Footer() @@ -182,6 +199,9 @@ def on_mount(self) -> None: self._show_fields(self._initial_provider) self.query_one("#name", Input).focus() + def action_show_help(self) -> None: + self.push_screen(HelpScreen("Add an agent provider", _PROVIDER_HELP)) + def _current_provider(self) -> str: index = self.query_one("#provider", AutoRadioSet).pressed_index if index < 0: diff --git a/src/skore_cli/skills/app/_find.py b/src/skore_cli/skills/app/_find.py index 3bd69af..ef91393 100644 --- a/src/skore_cli/skills/app/_find.py +++ b/src/skore_cli/skills/app/_find.py @@ -6,6 +6,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding +from skore_cli.app._help import HELP_BINDING, HelpScreen from textual.widgets import Footer, Header, SelectionList from skore_cli.skills.app._widgets import SkillSelection @@ -14,9 +15,24 @@ "Browse the catalog and pick the workflows and individual skills you want " "to preview.\n" "[reverse] ↑/↓ [/] move [reverse] Space [/] (de)select " - "[reverse] Tab [/] switch lists [reverse] Enter [/] show details" + "[reverse] Tab [/] switch lists [reverse] Enter [/] show details " + "[reverse] ? [/] help" ) +_FIND_HELP = """\ +Browse probabl-skills and pick entries to preview. + +Workflows bundle related skills. Use Tab to move between +the workflow list and the skill list. + +Keys: + ↑/↓ Space move and (de)select + Tab switch lists + Enter show details + Esc cancel + ? show this help +""" + class ProbablSkillsFinder(App[None]): """A single-step selector to preview catalog entries. @@ -40,6 +56,7 @@ class ProbablSkillsFinder(App[None]): Binding("tab", "focus_next", "Switch list", show=True), Binding("shift+tab", "focus_previous", "Switch list", show=True), Binding("escape", "cancel", "Cancel"), + HELP_BINDING, ] def __init__(self, catalog: dict[str, Any]) -> None: @@ -55,6 +72,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one("#sel-workflows", SelectionList).focus() + def action_show_help(self) -> None: + self.push_screen(HelpScreen("Find skills", _FIND_HELP)) + def action_confirm(self) -> None: selected = self.query_one(SkillSelection).selected_ids() if not selected: diff --git a/src/skore_cli/skills/app/_install.py b/src/skore_cli/skills/app/_install.py index ff718ca..27179a6 100644 --- a/src/skore_cli/skills/app/_install.py +++ b/src/skore_cli/skills/app/_install.py @@ -16,6 +16,7 @@ TabPane, ) +from skore_cli.app._help import HELP_BINDING, HelpScreen from skore_cli.skills._agents import AGENT_NAMES, DEFAULT_AGENT from skore_cli.skills.app._widgets import AutoRadioSet, SkillSelection @@ -37,9 +38,24 @@ "Project (local) installs into the current repository only.\n" "User (global) installs into your home directory so every project can use " "the skills.\n" - "[reverse] ↑/↓ [/] choose [reverse] Enter [/] confirm" + "[reverse] ↑/↓ [/] choose [reverse] Enter [/] confirm [reverse] ? [/] help" ) +_INSTALL_HELP = """\ +Install probabl-skills in three steps: + +1. Pick workflows and/or individual skills +2. Choose the target agent directory +3. Choose project-local or global scope + +Keys: + ↑/↓ Space move and (de)select + Tab switch fields or wizard tabs + Enter confirm step + Esc cancel + ? show this help +""" + class ProbablSkillsInstaller(App[None]): """A tabbed wizard to pick skills, target agents and the install scope.""" @@ -67,6 +83,7 @@ class ProbablSkillsInstaller(App[None]): Binding("tab", "focus_next", "Switch list", show=True), Binding("shift+tab", "focus_previous", "Switch list", show=True), Binding("escape", "cancel", "Cancel"), + HELP_BINDING, ] def __init__( @@ -110,6 +127,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one("#sel-workflows", SelectionList).focus() + def action_show_help(self) -> None: + self.push_screen(HelpScreen("Install skills", _INSTALL_HELP)) + def _selected_ids(self) -> list[str]: return self.query_one(SkillSelection).selected_ids() diff --git a/src/skore_cli/skills/app/_manage.py b/src/skore_cli/skills/app/_manage.py index 5c54803..967bb4d 100644 --- a/src/skore_cli/skills/app/_manage.py +++ b/src/skore_cli/skills/app/_manage.py @@ -6,14 +6,25 @@ from textual.binding import Binding from textual.containers import Vertical from textual.widgets import Footer, Header, Label, SelectionList +from skore_cli.app._help import HELP_BINDING, HelpScreen from textual.widgets.selection_list import Selection _INTRO = ( "Select the installed skills to manage.\n" "[reverse] ↑/↓ [/] move [reverse] Space [/] (de)select " - "[reverse] Enter [/] confirm" + "[reverse] Enter [/] confirm [reverse] ? [/] help" ) +_MANAGE_HELP = """\ +Select installed skills to update or remove. + +Keys: + ↑/↓ Space move and (de)select + Enter confirm + Esc cancel + ? show this help +""" + class InstalledSkillsPicker(App[list[str] | None]): """Pick installed skill ids from a single selection list.""" @@ -41,6 +52,7 @@ class InstalledSkillsPicker(App[list[str] | None]): BINDINGS = [ Binding("enter", "confirm", "Confirm", priority=True), Binding("escape", "cancel", "Cancel"), + HELP_BINDING, ] def __init__(self, skill_ids: list[str], *, title: str) -> None: @@ -65,6 +77,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.query_one("#sel-installed", SelectionList).focus() + def action_show_help(self) -> None: + self.push_screen(HelpScreen(self._title, _MANAGE_HELP)) + def action_confirm(self) -> None: selected = list(self.query_one("#sel-installed", SelectionList).selected) if not selected: diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index 8b52021..3184fa0 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -1,163 +1,168 @@ -"""Tests for ``skore agent model`` commands (status, install guards, skills). - -These complement ``test_agent_workspace.py`` (which already covers the opencode -writer, the install marker and ``_resolve_hub_workspace``) without duplicating it. -""" +"""Tests for the ``skore agent`` command and ``.skore`` persistence.""" from __future__ import annotations import json -from types import SimpleNamespace from click.testing import CliRunner -from skore_cli.agent._harnesses import MARKER_FILENAME, Credential -from skore_cli.agent.model import _commands -from skore_cli.agent.model._commands import install, status - - -def _write_marker(directory, **overrides): - marker = { - "harness": "opencode", - "hub_url": "http://hub.test", - "base_url": "http://hub.test/v1", - "hub_workspace": "ws-1", - "model_id": "skore-agent", - "auth": "bearer", - "session_binding": "plugin", - } - marker.update(overrides) - (directory / MARKER_FILENAME).write_text(json.dumps(marker) + "\n") - return marker - - -# --------------------------------------------------------------------------- # -# status -# --------------------------------------------------------------------------- # - - -def test_status_missing_marker_errors(tmp_path): - result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) - - assert result.exit_code != 0 - assert "run `skore agent model install`" in result.output - - -def test_status_prints_marker_fields(tmp_path): - _write_marker(tmp_path) - - result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) +from skore_cli.agent import _commands, _harnesses +from skore_cli.agent._commands import agent +from skore_cli.agent._harnesses import HARNESSES, HarnessContext, DEFAULT_MODEL_ID +from skore_cli.agent._skore_file import SKORE_FILENAME, SkoreConfig, ensure_gitignore_entry +from skore_cli.hub import _client - assert result.exit_code == 0, result.output - assert "opencode" in result.output - assert "http://hub.test/v1" in result.output - assert "skore-agent" in result.output - assert "ws-1" in result.output +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), + ) -def test_status_counts_local_skills(tmp_path): - _write_marker(tmp_path) - skills_dir = tmp_path / ".agents" / "skills" - skills_dir.mkdir(parents=True) - (skills_dir / "alpha").mkdir() - (skills_dir / "beta").mkdir() - result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) +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) + (directory / SKORE_FILENAME).write_text(json.dumps(payload) + "\n") + return payload + + +def test_skore_config_round_trip(tmp_path): + config = SkoreConfig( + hub_url="http://hub.test", + workspace="ws-1", + workspace_id=1, + api_key="secret", + harness="pi", + ) + path = config.save(tmp_path) + loaded = SkoreConfig.load(tmp_path) + assert path.name == SKORE_FILENAME + assert loaded == config - assert result.exit_code == 0, result.output - assert "2 local" in result.output +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 -def test_status_no_local_skills_note(tmp_path): - _write_marker(tmp_path) - result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) +def test_skore_config_load_normalizes_legacy_claude_code_harness(tmp_path): + _write_skore(tmp_path, harness="claude-code") + loaded = SkoreConfig.load(tmp_path) + assert loaded is not None + assert loaded.harness == "claude" - assert "served by hub" in result.output +def test_ensure_gitignore_appends_entry(tmp_path): + ensure_gitignore_entry(tmp_path) + assert (tmp_path / ".gitignore").read_text().strip() == ".skore" -# --------------------------------------------------------------------------- # -# install guards -# --------------------------------------------------------------------------- # + ensure_gitignore_entry(tmp_path) + assert (tmp_path / ".gitignore").read_text().count(".skore") == 1 -def test_install_non_interactive_without_harness_errors(tmp_path, monkeypatch): - monkeypatch.setattr(_commands, "_is_interactive", lambda: False) +def test_opencode_writer_embeds_api_key(tmp_path): + HARNESSES["opencode"].configure( + HarnessContext( + workspace=tmp_path, + hub_url="http://hub.test", + api_key="secret-key", + ) + ) + config = json.loads((tmp_path / "opencode.json").read_text()) + provider = config["provider"]["skore"] + assert config["model"] == "skore/skore-agent" + assert provider["options"]["baseURL"] == "http://hub.test/v1" + assert provider["options"]["apiKey"] == "secret-key" - result = CliRunner().invoke(install, ["--workspace", str(tmp_path), "--no-skills"]) +def test_agent_nonexistent_workspace_errors(tmp_path): + missing = tmp_path / "missing" + result = CliRunner().invoke(agent, ["--workspace", str(missing)]) assert result.exit_code != 0 - assert "Specify --harness" in result.output + assert "workspace does not exist" in result.output -def test_install_nonexistent_workspace_errors(tmp_path): - missing = tmp_path / "does-not-exist" +def test_agent_uses_existing_skore_config(tmp_path, monkeypatch): + _write_skore(tmp_path) + monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test") + monkeypatch.setattr(_commands, "detect_harnesses", lambda workspace: ["opencode"]) + launched: list[str] = [] + monkeypatch.setattr( + _commands, + "launch_harness", + lambda name, workspace, model_id=DEFAULT_MODEL_ID: launched.append(name), + ) result = CliRunner().invoke( - install, ["--workspace", str(missing), "--harness", "generic", "--no-skills"] + agent, + ["--workspace", str(tmp_path), "--harness", "opencode"], ) - assert result.exit_code != 0 - assert "workspace does not exist" in result.output - - -# --------------------------------------------------------------------------- # -# install happy path (generic harness) -# --------------------------------------------------------------------------- # + assert result.exit_code == 0, result.output + assert launched == ["opencode"] + assert json.loads((tmp_path / "opencode.json").read_text())["provider"]["skore"] -def test_install_generic_happy_path(tmp_path, monkeypatch): - monkeypatch.setattr(_commands, "resolve_credential", lambda: Credential("api_key")) - # Resolve the hub URL without importing the (absent) skore package. - monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: url) +def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): + monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test") + 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, "detect_harnesses", lambda workspace: ["opencode"]) + monkeypatch.setattr( + _commands, "launch_harness", lambda name, workspace, model_id=DEFAULT_MODEL_ID: None + ) result = CliRunner().invoke( - install, - [ - "--workspace", - str(tmp_path), - "--harness", - "generic", - "--hub-url", - "http://hub.test", - "--no-skills", - ], + agent, + ["--workspace", str(tmp_path), "--harness", "opencode"], ) assert result.exit_code == 0, result.output - marker = json.loads((tmp_path / MARKER_FILENAME).read_text()) - assert marker["harness"] == "generic" - assert marker["auth"] == "api_key" - assert marker["hub_url"] == "http://hub.test" - assert (tmp_path / "skore-agent.json").is_file() - - -# --------------------------------------------------------------------------- # -# _install_skills -# --------------------------------------------------------------------------- # + saved = json.loads((tmp_path / SKORE_FILENAME).read_text()) + assert saved["api_key"] == "new-secret" + assert saved["workspace"] == "ws-1" + assert (tmp_path / ".gitignore").read_text().strip().endswith(".skore") -def test_install_skills_off_skips_subprocess(tmp_path, monkeypatch): - called = [] +def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): + monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test") + monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( - _commands.subprocess, "run", lambda *a, **k: called.append((a, k)) + _commands._client, + "me", + lambda hub_url, token: ("user-1", [_membership()]), ) + monkeypatch.setattr(_commands, "_is_interactive", lambda: False) - _commands._install_skills(tmp_path, install=False) - - assert called == [] - - -def test_install_skills_on_invokes_subprocess(tmp_path, monkeypatch): - called = [] - - def fake_run(argv, **kwargs): - called.append(argv) - return SimpleNamespace(returncode=0, stderr="") + result = CliRunner().invoke(agent, ["--workspace", str(tmp_path)]) - monkeypatch.setattr(_commands.subprocess, "run", fake_run) + assert result.exit_code != 0 + assert "pass --harness" in result.output - _commands._install_skills(tmp_path, install=True) - assert len(called) == 1 - assert called[0][1:] == ["-m", "skore_cli", "skills", "install", "--all"] +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" diff --git a/tests/test_agent_mcp.py b/tests/test_agent_mcp.py deleted file mode 100644 index 0f2b875..0000000 --- a/tests/test_agent_mcp.py +++ /dev/null @@ -1,914 +0,0 @@ -"""Tests for the ``skore agent mcp`` delegation relay (no-poll, A2A-backed). - -The relay opens ONE durable task on the hub's A2A endpoint and consumes a pushed -SSE event stream; it executes the agent's deferred tool calls locally and posts -results back, exposing a single blocking ``skore_ml_run`` tool to the outer LLM. -Tests use fakes (no network): a fake A2A client for the relay loop, a fake MCP -context for elicitation, and a fake ``httpx`` for the client's reconnect logic. -The MCP-SDK-dependent server build is guarded with ``importorskip``. -""" - -from __future__ import annotations - -import asyncio -import json -from types import SimpleNamespace - -import pytest -import rich_click as click -from click.testing import CliRunner - -from skore_cli.agent._harnesses import Credential -from skore_cli.agent.mcp import _a2a_client, _handlers, _hosts, _jobs -from skore_cli.agent.mcp import _commands as mcp_commands -from skore_cli.agent.mcp._a2a_client import RelayConfig -from skore_cli.agent.mcp._commands import ( - _resolve_serve_workspace, - install, - serve, - status, -) -from skore_cli.agent.mcp._jobs import ASK_USER_TOOL, CANONICAL_TOOLS - -# --------------------------------------------------------------------------- # -# fakes -# --------------------------------------------------------------------------- # - - -class _FakeContext: - """A minimal MCP Context fake exposing info(), report_progress() and elicit().""" - - def __init__( - self, - *, - action: str = "accept", - values: dict[str, str] | None = None, - approve: bool = True, - raise_on_elicit: bool = False, - ) -> None: - self.action = action - self.values = values or {} - self.approve = approve - self.raise_on_elicit = raise_on_elicit - self.infos: list[str] = [] - self.elicited: list[str] = [] - - async def info(self, message: str) -> None: - self.infos.append(message) - - async def report_progress(self, **_kwargs) -> None: - return None - - async def elicit(self, message: str, schema): - self.elicited.append(message) - if self.raise_on_elicit: - raise RuntimeError("host does not support elicitation") - if self.action != "accept": - return SimpleNamespace(action=self.action, data=None) - fields = list(schema.model_fields) - if fields == ["approve"]: - return SimpleNamespace( - action="accept", data=SimpleNamespace(approve=self.approve) - ) - data = {name: self.values.get(name, "x") for name in fields} - return SimpleNamespace(action="accept", data=SimpleNamespace(**data)) - - -class _FakeClient: - """A scripted A2A client yielding fixed events; records posted results.""" - - def __init__(self, events: list[dict], *, template: str = "") -> None: - self._events = events - self.sent: list[dict] = [] - self.cancelled = False - self.task_id = "task_fake" - self.state = "submitted" - self.result = "" - self.error = "" - self.template = template - self.template_calls: list[tuple[str, str]] = [] - - async def events(self, task_text: str): - for event in self._events: - kind = event.get("kind") - if kind == "status": - self.state = event.get("state", self.state) - elif kind == "result": - self.state = "completed" - self.result = event.get("text", "") - elif kind == "error": - self.state = "failed" - self.error = event.get("message", "") - yield event - - async def send_results(self, results: dict[str, str]) -> None: - self.sent.append(results) - - async def cancel(self) -> None: - self.cancelled = True - - async def fetch_template(self, skill: str, path: str) -> str: - self.template_calls.append((skill, path)) - return self.template - - -def _tool_call(call_id, name, args): - return { - "id": call_id, - "type": "function", - "function": {"name": name, "arguments": json.dumps(args)}, - } - - -def _config(tmp_path): - return RelayConfig( - default_workspace=tmp_path, - hub_url="http://hub.test", - hub_workspace=None, - cred=Credential("api_key"), - ) - - -# --------------------------------------------------------------------------- # -# client transport: auth, headers, payloads -# --------------------------------------------------------------------------- # - - -def test_auth_headers_api_key(monkeypatch): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - assert _a2a_client._auth_headers(Credential("api_key")) == { - "X-API-Key": "uid:secret" - } - - -def test_auth_headers_bearer(): - assert _a2a_client._auth_headers(Credential("bearer", "tok")) == { - "Authorization": "Bearer tok" - } - - -def test_auth_headers_none(): - assert _a2a_client._auth_headers(Credential("none")) == {} - - -def test_client_headers_include_workspace_and_accept(): - client = _a2a_client.A2AClient( - hub_url="http://hub.test", - cred=Credential("bearer", "tok"), - hub_workspace="ws-1", - tools=[], - ) - sse = client._headers(sse=True) - assert sse["X-Skore-Workspace"] == "ws-1" - assert sse["Authorization"] == "Bearer tok" - assert sse["Accept"] == "text/event-stream" - # The non-streaming POST omits the SSE Accept header. - assert "Accept" not in client._headers(sse=False) - - -def test_endpoint_targets_v1_a2a(): - client = _a2a_client.A2AClient( - hub_url="http://hub.test/", - cred=Credential("none"), - hub_workspace=None, - tools=[], - ) - assert client._endpoint == "http://hub.test/v1/a2a" - - -def test_stream_payload_carries_task_and_tools(): - tools = [{"type": "function", "function": {"name": "read_file"}}] - client = _a2a_client.A2AClient( - hub_url="http://hub.test", - cred=Credential("none"), - hub_workspace=None, - tools=tools, - ) - payload = client._stream_payload("explore the data") - assert payload["method"] == "message/stream" - message = payload["params"]["message"] - assert message["parts"][0]["text"] == "explore the data" - assert message["metadata"]["tools"] == tools - - -def test_resubscribe_payload_resumes_after_last_seq(): - client = _a2a_client.A2AClient( - hub_url="http://hub.test", cred=Credential("none"), hub_workspace=None, tools=[] - ) - client.task_id = "task_1" - client._last_seq = 4 - payload = client._resubscribe_payload() - assert payload["method"] == "tasks/resubscribe" - assert payload["params"] == {"id": "task_1", "cursor": 5} - - -def test_track_updates_state_task_id_and_seq(): - client = _a2a_client.A2AClient( - hub_url="http://hub.test", cred=Credential("none"), hub_workspace=None, tools=[] - ) - client._track({"seq": 0, "task_id": "task_9", "kind": "status", "state": "working"}) - assert client.task_id == "task_9" - assert client.state == "working" - assert client._last_seq == 0 - client._track({"seq": 3, "kind": "result", "text": "done"}) - assert client.state == "completed" - assert client.result == "done" - assert client._last_seq == 3 - - -def test_is_terminal_detects_end_events(): - assert _a2a_client.A2AClient._is_terminal({"kind": "result"}) - assert _a2a_client.A2AClient._is_terminal({"kind": "error"}) - assert _a2a_client.A2AClient._is_terminal({"kind": "status", "state": "canceled"}) - assert not _a2a_client.A2AClient._is_terminal( - {"kind": "status", "state": "working"} - ) - assert not _a2a_client.A2AClient._is_terminal({"kind": "activity", "text": "x"}) - - -# --------------------------------------------------------------------------- # -# SSE parsing -# --------------------------------------------------------------------------- # - - -def test_iter_sse_parses_frames(): - async def _lines(): - for line in [ - "id: 0", - 'data: {"seq": 0, "kind": "status", "state": "working"}', - "", - ": keep-alive comment", - "id: 1", - 'data: {"seq": 1, "kind": "result", "text": "hi"}', - "", - ]: - yield line - - async def _collect(): - return [event async for event in _a2a_client.iter_sse(_lines())] - - events = asyncio.run(_collect()) - assert [e["kind"] for e in events] == ["status", "result"] - assert events[1]["text"] == "hi" - - -# --------------------------------------------------------------------------- # -# client events(): reconnect via resubscribe on a dropped stream -# --------------------------------------------------------------------------- # - - -class _FakeStream: - def __init__(self, lines, status=200): - self._lines = lines - self.status_code = status - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - async def aread(self): - return b"{}" - - async def aiter_lines(self): - for line in self._lines: - if isinstance(line, Exception): - raise line - yield line - - -def _sse(seq, payload): - return [f"id: {seq}", f"data: {json.dumps(payload)}", ""] - - -def test_events_resubscribes_after_drop(monkeypatch): - import httpx - - # First connection: emits seq 0/1 then drops mid-stream. The reconnect must - # resume from seq 2 and complete. - scripts = [ - [ - *_sse( - 0, {"seq": 0, "task_id": "task_z", "kind": "status", "state": "working"} - ), - *_sse(1, {"seq": 1, "kind": "activity", "text": "step"}), - httpx.ReadError("connection reset"), - ], - [*_sse(2, {"seq": 2, "kind": "result", "text": "finished"})], - ] - seen_payloads: list[dict] = [] - - class _FakeAsyncClient: - def __init__(self, *a, **k): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - def stream(self, method, url, *, json=None, **k): - seen_payloads.append(json) - return _FakeStream(scripts.pop(0)) - - monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) - - client = _a2a_client.A2AClient( - hub_url="http://hub.test", cred=Credential("none"), hub_workspace=None, tools=[] - ) - - async def _collect(): - return [event async for event in client.events("do ml")] - - events = asyncio.run(_collect()) - assert [e["kind"] for e in events] == ["status", "activity", "result"] - assert client.result == "finished" - # The second request was a resubscribe resuming from the last seen seq + 1. - assert seen_payloads[0]["method"] == "message/stream" - assert seen_payloads[1]["method"] == "tasks/resubscribe" - assert seen_payloads[1]["params"] == {"id": "task_z", "cursor": 2} - - -def test_events_raises_on_http_error(monkeypatch): - import httpx - - class _FakeAsyncClient: - def __init__(self, *a, **k): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - def stream(self, method, url, **k): - return _FakeStream([], status=403) - - monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient) - client = _a2a_client.A2AClient( - hub_url="http://hub.test", cred=Credential("none"), hub_workspace=None, tools=[] - ) - - async def _collect(): - return [event async for event in client.events("x")] - - with pytest.raises(_a2a_client.A2AClientError): - asyncio.run(_collect()) - - -# --------------------------------------------------------------------------- # -# canonical toolset (shared schema) -# --------------------------------------------------------------------------- # - - -def test_canonical_tools_include_ask_user(): - names = [t["function"]["name"] for t in CANONICAL_TOOLS] - assert ASK_USER_TOOL in names - assert {"read_file", "write_file", "run_bash", "materialize_template"} <= set(names) - - -def test_ask_user_tool_advertises_questions_array(): - ask = next(t for t in CANONICAL_TOOLS if t["function"]["name"] == ASK_USER_TOOL) - params = ask["function"]["parameters"] - assert params["required"] == ["questions"] - questions = params["properties"]["questions"] - assert questions["type"] == "array" - item_props = questions["items"]["properties"] - assert {"id", "prompt", "options", "multiple", "default"} <= set(item_props) - - -def test_normalize_questions_structured_passthrough(): - args = { - "questions": [ - {"id": "G-A", "prompt": "Pick env", "options": ["pixi", "uv"]}, - {"prompt": "Tabular lib?", "default": "pandas"}, - ] - } - qs = _jobs._normalize_questions(args) - assert [q["id"] for q in qs] == ["G-A", "q2"] - assert qs[0]["options"] == ["pixi", "uv"] - assert qs[1]["prompt"] == "Tabular lib?" - assert qs[1]["default"] == "pandas" - - -def test_normalize_questions_legacy_single_string(): - qs = _jobs._normalize_questions('{"question": "Which CV?"}') - assert len(qs) == 1 - assert qs[0]["prompt"] == "Which CV?" - assert _jobs._normalize_questions("{}")[0]["prompt"] - - -def test_normalize_questions_splits_enumerated_blob(): - text = "1. G-PKG-NAME what package name? 2. G-ENV-MGR which environment manager?" - qs = _jobs._normalize_questions({"question": text}) - assert [q["id"] for q in qs] == ["G-PKG-NAME", "G-ENV-MGR"] - - -# --------------------------------------------------------------------------- # -# elicitation form -# --------------------------------------------------------------------------- # - - -def test_build_questions_form_options_default_and_sanitized_ids(): - questions = [ - { - "id": "G-PKG-NAME", - "prompt": "Package name?", - "options": [], - "multiple": False, - "default": "digichem", - }, - { - "id": "G-TABULAR", - "prompt": "Tabular lib?", - "options": ["pandas", "polars"], - "multiple": False, - "default": None, - }, - { - "id": "G-EXTRAS", - "prompt": "Extras?", - "options": ["a", "b", "c"], - "multiple": True, - "default": None, - }, - ] - model, field_to_id = _handlers._build_questions_form(questions) - schema = model.model_json_schema() - props = schema["properties"] - - assert field_to_id == { - "G_PKG_NAME": "G-PKG-NAME", - "G_TABULAR": "G-TABULAR", - "G_EXTRAS": "G-EXTRAS", - } - assert props["G_PKG_NAME"]["default"] == "digichem" - assert "G_PKG_NAME" not in schema.get("required", []) - assert props["G_TABULAR"]["enum"] == ["pandas", "polars"] - assert "G_TABULAR" in schema["required"] - assert props["G_EXTRAS"]["type"] == "string" - assert "enum" not in props["G_EXTRAS"] - assert "comma-separated" in props["G_EXTRAS"]["description"] - - -# --------------------------------------------------------------------------- # -# tool-call resolution (data plane + gates) -# --------------------------------------------------------------------------- # - - -def test_resolve_write_file_executes_and_summarizes(tmp_path): - ctx = _FakeContext() - client = _FakeClient([]) - calls = [_tool_call("c1", "write_file", {"path": "out/foo.txt", "content": "hi\n"})] - results = asyncio.run(_handlers.resolve_tool_calls(ctx, client, tmp_path, calls)) - assert (tmp_path / "out" / "foo.txt").read_text() == "hi\n" - assert "wrote out/foo.txt" in results["c1"] - assert any("wrote out/foo.txt" in m for m in ctx.infos) - - -def test_resolve_read_file_returns_content(tmp_path): - (tmp_path / "data.txt").write_text("hello\nworld\n") - ctx = _FakeContext() - calls = [_tool_call("c1", "read_file", {"path": "data.txt"})] - results = asyncio.run( - _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) - ) - assert results["c1"] == "hello\nworld\n" - - -def test_resolve_read_file_error_is_surfaced(tmp_path): - ctx = _FakeContext() - calls = [_tool_call("c1", "read_file", {"path": "missing.txt"})] - results = asyncio.run( - _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) - ) - assert "read_file failed" in results["c1"] - - -def test_resolve_materialize_fetches_template_and_substitutes(tmp_path): - ctx = _FakeContext() - client = _FakeClient([], template="name = ''\n") - calls = [ - _tool_call( - "m1", - "materialize_template", - { - "skill": "demo", - "template_path": "templates/exp.py", - "dest_path": "exp.py", - "substitutions": {"": "churn"}, - }, - ) - ] - results = asyncio.run(_handlers.resolve_tool_calls(ctx, client, tmp_path, calls)) - assert (tmp_path / "exp.py").read_text() == "name = 'churn'\n" - assert client.template_calls == [("demo", "templates/exp.py")] - assert "materialized exp.py" in results["m1"] - - -def test_resolve_run_bash_approve_runs(tmp_path): - ctx = _FakeContext(approve=True) - calls = [_tool_call("c1", "run_bash", {"command": "echo hi"})] - results = asyncio.run( - _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) - ) - assert "exit code: 0" in results["c1"] - assert "hi" in results["c1"] - assert ctx.elicited # the user was asked to confirm - - -def test_resolve_run_bash_decline_does_not_run(tmp_path): - ctx = _FakeContext(approve=False) - calls = [_tool_call("c1", "run_bash", {"command": "rm -rf /"})] - results = asyncio.run( - _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) - ) - assert "declined" in results["c1"] - - -def test_resolve_ask_user_elicits_and_keys_answers(tmp_path): - ctx = _FakeContext(action="accept", values={"G_TGT": "churn"}) - calls = [ - _tool_call( - "a1", ASK_USER_TOOL, {"questions": [{"id": "G-TGT", "prompt": "Target?"}]} - ) - ] - results = asyncio.run( - _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) - ) - assert json.loads(results["a1"]) == {"G-TGT": "churn"} - assert ctx.elicited - - -def test_resolve_ask_user_decline_returns_empty(tmp_path): - ctx = _FakeContext(action="decline") - calls = [ - _tool_call( - "a1", ASK_USER_TOOL, {"questions": [{"id": "G-TGT", "prompt": "Target?"}]} - ) - ] - results = asyncio.run( - _handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls) - ) - assert json.loads(results["a1"]) == {} - - -def test_resolve_ask_user_unsupported_elicitation_raises(tmp_path): - ctx = _FakeContext(raise_on_elicit=True) - calls = [ - _tool_call( - "a1", ASK_USER_TOOL, {"questions": [{"id": "G-TGT", "prompt": "Target?"}]} - ) - ] - with pytest.raises(_handlers.ElicitationUnsupportedError): - asyncio.run(_handlers.resolve_tool_calls(ctx, _FakeClient([]), tmp_path, calls)) - - -# --------------------------------------------------------------------------- # -# drive_task: the blocking relay loop -# --------------------------------------------------------------------------- # - - -def test_drive_task_executes_tools_and_returns_result(tmp_path): - from skore_cli.agent.mcp._server import drive_task - - events = [ - {"seq": 0, "task_id": "t", "kind": "status", "state": "working"}, - { - "seq": 1, - "kind": "input-required", - "tool_calls": [ - _tool_call("w1", "write_file", {"path": "a.txt", "content": "x"}), - _tool_call( - "a1", ASK_USER_TOOL, {"questions": [{"id": "G-A", "prompt": "?"}]} - ), - ], - }, - {"seq": 2, "kind": "activity", "text": "scaffolding"}, - {"seq": 3, "kind": "result", "text": "All done."}, - ] - client = _FakeClient(events) - ctx = _FakeContext(values={"G_A": "pick"}) - - result = asyncio.run(drive_task(ctx, client, "build a model", tmp_path)) - assert result == "All done." - # The write executed locally; the gate was elicited; both fed back to the hub. - assert (tmp_path / "a.txt").read_text() == "x" - assert client.sent == [{"w1": "wrote a.txt (1 lines)", "a1": '{"G-A": "pick"}'}] - assert any("scaffolding" in m for m in ctx.infos) - - -def test_drive_task_surfaces_error(tmp_path): - from skore_cli.agent.mcp._server import drive_task - - events = [ - {"seq": 0, "kind": "status", "state": "working"}, - {"seq": 1, "kind": "error", "message": "workspace not found"}, - ] - result = asyncio.run(drive_task(_FakeContext(), _FakeClient(events), "x", tmp_path)) - assert "workspace not found" in result - - -def test_drive_task_unsupported_elicitation_cancels(tmp_path): - from skore_cli.agent.mcp._server import drive_task - - events = [ - {"seq": 0, "kind": "status", "state": "working"}, - { - "seq": 1, - "kind": "input-required", - "tool_calls": [ - _tool_call( - "a1", ASK_USER_TOOL, {"questions": [{"id": "G-A", "prompt": "?"}]} - ) - ], - }, - ] - client = _FakeClient(events) - ctx = _FakeContext(raise_on_elicit=True) - result = asyncio.run(drive_task(ctx, client, "x", tmp_path)) - assert "does not support MCP elicitation" in result - assert client.cancelled - - -# --------------------------------------------------------------------------- # -# host writers -# --------------------------------------------------------------------------- # - - -def test_serve_args_embeds_hub_url_and_workspace(tmp_path): - ctx = _hosts.InstallContext( - workspace=tmp_path, hub_url="http://hub.test", hub_workspace="ws-1" - ) - assert _hosts._serve_args(ctx) == [ - "agent", - "mcp", - "serve", - "--hub-url", - "http://hub.test", - "--hub-workspace", - "ws-1", - ] - - -def test_install_cursor_writes_mcp_json(tmp_path): - _hosts.HOSTS["cursor"].configure(_hosts.InstallContext(workspace=tmp_path)) - config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) - server = config["mcpServers"]["skore-ml"] - assert server["command"] == "skore" - assert server["args"] == ["agent", "mcp", "serve"] - - -def test_install_cursor_embeds_flags(tmp_path): - ctx = _hosts.InstallContext( - workspace=tmp_path, hub_url="http://hub.test", hub_workspace="ws-1" - ) - _hosts.HOSTS["cursor"].configure(ctx) - config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) - args = config["mcpServers"]["skore-ml"]["args"] - assert args[-4:] == ["--hub-url", "http://hub.test", "--hub-workspace", "ws-1"] - - -def test_install_claude_code_writes_mcp_json(tmp_path): - _hosts.HOSTS["claude-code"].configure(_hosts.InstallContext(workspace=tmp_path)) - config = json.loads((tmp_path / ".mcp.json").read_text()) - assert config["mcpServers"]["skore-ml"]["command"] == "skore" - - -def test_install_opencode_writes_mcp_block(tmp_path): - _hosts.HOSTS["opencode"].configure(_hosts.InstallContext(workspace=tmp_path)) - config = json.loads((tmp_path / "opencode.json").read_text()) - entry = config["mcp"]["skore-ml"] - assert entry["type"] == "local" - assert entry["command"] == ["skore", "agent", "mcp", "serve"] - assert entry["enabled"] is True - - -def test_install_codex_appends_idempotent_block(tmp_path, monkeypatch): - monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) - _hosts.HOSTS["codex"].configure(_hosts.InstallContext(workspace=tmp_path)) - config_path = tmp_path / ".codex" / "config.toml" - text = config_path.read_text() - assert "[mcp_servers.skore-ml]" in text - assert 'command = "skore"' in text - _hosts.HOSTS["codex"].configure(_hosts.InstallContext(workspace=tmp_path)) - assert config_path.read_text().count("[mcp_servers.skore-ml]") == 1 - - -def test_install_generic_prints_command(tmp_path, monkeypatch): - printed: list[str] = [] - monkeypatch.setattr( - _hosts, - "console", - SimpleNamespace(print=lambda *a, **k: printed.append(" ".join(map(str, a)))), - ) - _hosts.HOSTS["generic"].configure(_hosts.InstallContext(workspace=tmp_path)) - assert any("skore agent mcp serve" in line for line in printed) - - -def test_install_backs_up_invalid_json(tmp_path): - (tmp_path / ".cursor").mkdir() - (tmp_path / ".cursor" / "mcp.json").write_text("{ not json") - _hosts.HOSTS["cursor"].configure(_hosts.InstallContext(workspace=tmp_path)) - assert (tmp_path / ".cursor" / "mcp.json.bak").is_file() - config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) - assert config["mcpServers"]["skore-ml"] - - -# --------------------------------------------------------------------------- # -# install command (CliRunner) -# --------------------------------------------------------------------------- # - - -def test_install_command_writes_config(tmp_path, monkeypatch): - monkeypatch.setattr(mcp_commands, "resolve_credential", lambda: Credential("none")) - monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") - result = CliRunner().invoke( - install, ["--host", "cursor", "--workspace", str(tmp_path)] - ) - assert result.exit_code == 0, result.output - assert (tmp_path / ".cursor" / "mcp.json").is_file() - - -def test_install_command_nonexistent_workspace_errors(tmp_path): - missing = tmp_path / "nope" - result = CliRunner().invoke( - install, ["--host", "cursor", "--workspace", str(missing)] - ) - assert result.exit_code != 0 - assert "workspace does not exist" in result.output - - -def test_install_command_bearer_embeds_workspace_and_url(tmp_path, monkeypatch): - monkeypatch.setattr( - mcp_commands, "resolve_credential", lambda: Credential("bearer", "tok") - ) - monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") - result = CliRunner().invoke( - install, - ["--host", "cursor", "--workspace", str(tmp_path), "--hub-workspace", "ws-1"], - ) - assert result.exit_code == 0, result.output - args = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())["mcpServers"][ - "skore-ml" - ]["args"] - assert args == [ - "agent", - "mcp", - "serve", - "--hub-url", - "http://hub.test", - "--hub-workspace", - "ws-1", - ] - - -def test_install_command_api_key_ignores_hub_workspace(tmp_path, monkeypatch): - monkeypatch.setattr( - mcp_commands, "resolve_credential", lambda: Credential("api_key") - ) - monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") - result = CliRunner().invoke( - install, - ["--host", "cursor", "--workspace", str(tmp_path), "--hub-workspace", "ws-1"], - ) - assert result.exit_code == 0, result.output - args = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())["mcpServers"][ - "skore-ml" - ]["args"] - assert args == ["agent", "mcp", "serve", "--hub-url", "http://hub.test"] - - -# --------------------------------------------------------------------------- # -# installed() detection + status command -# --------------------------------------------------------------------------- # - - -def test_installed_reports_registered_host(tmp_path, monkeypatch): - # Point codex's global config at the temp dir so nothing on disk leaks in. - monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) - ctx = _hosts.InstallContext( - workspace=tmp_path, hub_url="http://hub.test", hub_workspace="ws-1" - ) - _hosts.HOSTS["cursor"].configure(ctx) - - by_name = {row.name: row for row in _hosts.installed(tmp_path)} - cursor = by_name["cursor"] - assert cursor.present is True - assert cursor.serve_args[-4:] == [ - "--hub-url", - "http://hub.test", - "--hub-workspace", - "ws-1", - ] - # opencode was never configured here. - assert by_name["opencode"].present is False - assert by_name["opencode"].serve_args is None - - -def test_installed_reads_opencode_and_codex(tmp_path, monkeypatch): - monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) - _hosts.HOSTS["opencode"].configure(_hosts.InstallContext(workspace=tmp_path)) - _hosts.HOSTS["codex"].configure(_hosts.InstallContext(workspace=tmp_path)) - - by_name = {row.name: row for row in _hosts.installed(tmp_path)} - # opencode stores [EXECUTABLE, *args]; the executable is dropped. - assert by_name["opencode"].serve_args == ["agent", "mcp", "serve"] - assert by_name["codex"].present is True - assert by_name["codex"].serve_args == ["agent", "mcp", "serve"] - - -def test_status_command_reports_registered(tmp_path, monkeypatch): - monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) - monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") - monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) - _hosts.HOSTS["cursor"].configure( - _hosts.InstallContext(workspace=tmp_path, hub_url="http://hub.test") - ) - - result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) - - assert result.exit_code == 0, result.output - assert "http://hub.test" in result.output - assert "cursor" in result.output - assert "not set" in result.output - - -def test_status_command_nothing_installed(tmp_path, monkeypatch): - monkeypatch.setattr(_hosts.Path, "home", classmethod(lambda cls: tmp_path)) - monkeypatch.setattr(mcp_commands, "resolve_hub_uri", lambda *_: "http://hub.test") - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - - result = CliRunner().invoke(status, ["--workspace", str(tmp_path)]) - - assert result.exit_code == 0, result.output - assert "not registered" in result.output - assert "set" in result.output - - -# --------------------------------------------------------------------------- # -# serve guards -# --------------------------------------------------------------------------- # - - -def test_resolve_serve_workspace_api_key_returns_none(): - assert _resolve_serve_workspace(Credential("api_key"), "ws-1") is None - - -def test_resolve_serve_workspace_bearer_uses_flag(): - assert _resolve_serve_workspace(Credential("bearer", "t"), "ws-1") == "ws-1" - - -def test_resolve_serve_workspace_bearer_without_flag_errors(): - with pytest.raises(click.UsageError): - _resolve_serve_workspace(Credential("bearer", "t"), None) - - -def test_serve_nonexistent_workspace_errors(tmp_path): - missing = tmp_path / "nope" - result = CliRunner().invoke(serve, ["--workspace", str(missing)]) - assert result.exit_code != 0 - assert "workspace does not exist" in result.output - - -def test_serve_no_credential_errors(tmp_path, monkeypatch): - monkeypatch.setattr(mcp_commands, "resolve_credential", lambda: Credential("none")) - result = CliRunner().invoke(serve, ["--workspace", str(tmp_path)]) - assert result.exit_code != 0 - assert "no hub credential" in result.output - - -# --------------------------------------------------------------------------- # -# executor path confinement -# --------------------------------------------------------------------------- # - - -def test_executor_confines_paths_to_workspace(tmp_path): - from skore_cli.agent.mcp import _executor - - with pytest.raises(_executor.ExecError): - _executor._safe_path(tmp_path, "../escape.txt") - with pytest.raises(_executor.ExecError): - _executor._safe_path(tmp_path, "/etc/passwd") - resolved = _executor._safe_path(tmp_path, "sub/dir/file.txt") - assert str(resolved).startswith(str(tmp_path.resolve())) - - -# --------------------------------------------------------------------------- # -# MCP server build (requires the `mcp` SDK) -# --------------------------------------------------------------------------- # - - -def test_build_mcp_server_exposes_single_run_tool(tmp_path): - pytest.importorskip("mcp") - from skore_cli.agent.mcp._server import build_mcp_server - - server = build_mcp_server(_config(tmp_path)) - - async def _names(): - return sorted(t.name for t in await server.list_tools()) - - assert asyncio.run(_names()) == ["skore_ml_run"] diff --git a/tests/test_agent_workspace.py b/tests/test_agent_workspace.py deleted file mode 100644 index 07de71e..0000000 --- a/tests/test_agent_workspace.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Tests for attaching the agent to a hub workspace (`skore agent model install`).""" - -from __future__ import annotations - -import json - -import pytest -import rich_click as click -from click.testing import CliRunner - -from skore_cli.agent._harnesses import ( - HARNESSES, - WORKSPACE_HEADER, - ConfigureContext, - Credential, -) -from skore_cli.agent.model import _commands -from skore_cli.agent.model._commands import install - - -def _opencode_headers(workspace, cred, hub_workspace): - ctx = ConfigureContext( - workspace=workspace, - hub_url="http://hub.test", - model_id="skore-agent", - cred=cred, - hub_workspace=hub_workspace, - write_session_plugin=False, - ) - HARNESSES["opencode"].configure(ctx) - config = json.loads((workspace / "opencode.json").read_text()) - return config["provider"]["skore"]["options"].get("headers", {}) - - -def test_opencode_writes_workspace_header_for_bearer(tmp_path): - headers = _opencode_headers( - tmp_path, Credential("bearer", "tok"), hub_workspace="ws-x" - ) - assert headers[WORKSPACE_HEADER] == "ws-x" - assert headers["Authorization"] == "Bearer tok" - - -def test_opencode_no_workspace_header_for_api_key(tmp_path, monkeypatch): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - # API keys are workspace-bound server-side: no header is written. - headers = _opencode_headers(tmp_path, Credential("api_key"), hub_workspace=None) - assert WORKSPACE_HEADER not in headers - assert headers["X-API-Key"] == "{env:SKORE_HUB_API_KEY}" - - -def test_install_records_attached_workspace_in_marker(tmp_path, monkeypatch): - monkeypatch.setattr( - _commands, "resolve_credential", lambda: Credential("bearer", "tok") - ) - # Resolve the hub URL without importing the (absent) skore package. - monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: url) - result = CliRunner().invoke( - install, - [ - "--workspace", - str(tmp_path), - "--harness", - "opencode", - "--hub-url", - "http://hub.test", - "--hub-workspace", - "ws-x", - "--no-session-plugin", - "--no-skills", - ], - ) - assert result.exit_code == 0, result.output - marker = json.loads((tmp_path / ".skore-agent.json").read_text()) - assert marker["hub_workspace"] == "ws-x" - - -# --------------------------------------------------------------------------- # -# _resolve_hub_workspace -# --------------------------------------------------------------------------- # - - -def test_resolve_api_key_ignores_workspace(): - # API key path resolves to no attached slug (bound server-side). - assert ( - _commands._resolve_hub_workspace( - Credential("api_key"), "http://hub.test", "ws-x" - ) - is None - ) - - -def test_resolve_bearer_uses_flag(): - assert ( - _commands._resolve_hub_workspace( - Credential("bearer", "tok"), "http://hub.test", "ws-1" - ) - == "ws-1" - ) - - -def test_resolve_bearer_non_interactive_without_flag_errors(monkeypatch): - monkeypatch.setattr( - _commands, "fetch_workspaces", lambda hub_url, cred: [("ws-1", "ws-1")] - ) - monkeypatch.setattr(_commands, "_is_interactive", lambda: False) - with pytest.raises(click.UsageError): - _commands._resolve_hub_workspace( - Credential("bearer", "tok"), "http://hub.test", None - ) - - -def test_resolve_bearer_no_workspaces_errors(monkeypatch): - monkeypatch.setattr(_commands, "fetch_workspaces", lambda hub_url, cred: []) - with pytest.raises(click.ClickException): - _commands._resolve_hub_workspace( - Credential("bearer", "tok"), "http://hub.test", None - ) - - -def test_resolve_bearer_interactive_uses_picker(monkeypatch): - monkeypatch.setattr( - _commands, - "fetch_workspaces", - lambda hub_url, cred: [("ws-1", "ws-1"), ("ws-2", "ws-2")], - ) - monkeypatch.setattr(_commands, "_is_interactive", lambda: True) - monkeypatch.setattr(_commands, "_pick_workspace", lambda workspaces: "ws-2") - assert ( - _commands._resolve_hub_workspace( - Credential("bearer", "tok"), "http://hub.test", None - ) - == "ws-2" - ) diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index 70050c8..945c4aa 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -1,339 +1,118 @@ -"""Tests for the harness registry, helpers, detection and config writers.""" +"""Tests for the harness registry, detection and config writers.""" from __future__ import annotations import json -import sys -from pathlib import Path -from types import SimpleNamespace import pytest from skore_cli.agent import _harnesses from skore_cli.agent._harnesses import ( - API_KEY_ENV, HARNESSES, - WORKSPACE_HEADER, - ConfigureContext, - Credential, - base_url, + HarnessContext, detect_harnesses, - fetch_workspaces, - header_pair, - resolve_credential, - workspace_headers, ) -@pytest.fixture -def cap(monkeypatch): - """Capture raw console markup emitted by the harness module.""" - messages: list[str] = [] - monkeypatch.setattr( - _harnesses, "console", SimpleNamespace(print=lambda *a, **k: messages.append( - " ".join(str(x) for x in a) - )) - ) - return messages - - -@pytest.fixture -def no_api_key(monkeypatch): - monkeypatch.delenv(API_KEY_ENV, raising=False) - - -def _ctx(workspace, cred, **kwargs): - return ConfigureContext( +def _ctx(workspace, **kwargs): + return HarnessContext( workspace=workspace, hub_url="http://hub.test", - model_id="skore-agent", - cred=cred, + api_key="secret-key", **kwargs, ) -# --------------------------------------------------------------------------- # -# Pure helpers -# --------------------------------------------------------------------------- # - - -def test_base_url_strips_and_appends_v1(): - assert base_url("http://hub.test/") == "http://hub.test/v1" - assert base_url("http://hub.test") == "http://hub.test/v1" - - -def test_header_pair_api_key(): - assert header_pair(Credential("api_key")) == ("X-API-Key", f"{{env:{API_KEY_ENV}}}") - - -def test_header_pair_bearer(): - assert header_pair(Credential("bearer", "tok")) == ("Authorization", "Bearer tok") - - -def test_header_pair_none(): - assert header_pair(Credential("none")) is None - - -def test_workspace_headers_set_and_unset(tmp_path): - with_ws = _ctx(tmp_path, Credential("bearer", "t"), hub_workspace="ws-1") - without_ws = _ctx(tmp_path, Credential("bearer", "t"), hub_workspace=None) - assert workspace_headers(with_ws) == {WORKSPACE_HEADER: "ws-1"} - assert workspace_headers(without_ws) == {} - - -# --------------------------------------------------------------------------- # -# resolve_credential -# --------------------------------------------------------------------------- # - - -def test_resolve_credential_api_key(monkeypatch, no_api_key): - monkeypatch.setenv(API_KEY_ENV, "uid:secret") - assert resolve_credential() == Credential("api_key") - - -def test_resolve_credential_bearer(monkeypatch, no_api_key): - monkeypatch.setattr( - _harnesses, - "_auth", - lambda name: SimpleNamespace( - fresh_token=lambda relogin: {"access_token": "tok"} - ), - ) - assert resolve_credential() == Credential("bearer", "tok") - - -def test_resolve_credential_none(monkeypatch, no_api_key): +def test_detect_opencode_by_binary(tmp_path, monkeypatch): monkeypatch.setattr( - _harnesses, - "_auth", - lambda name: SimpleNamespace(fresh_token=lambda relogin: None), + _harnesses.shutil, + "which", + lambda name: "/usr/bin/opencode" if name == "opencode" else None, ) - assert resolve_credential() == Credential("none") - - -# --------------------------------------------------------------------------- # -# Detection -# --------------------------------------------------------------------------- # - - -def test_detect_opencode_by_file(tmp_path, monkeypatch): - monkeypatch.setattr(_harnesses.shutil, "which", lambda name: None) - (tmp_path / "opencode.json").write_text("{}") - assert _harnesses._detect_opencode(tmp_path) is True - - -def test_detect_opencode_by_binary(tmp_path, monkeypatch): - monkeypatch.setattr(_harnesses.shutil, "which", lambda name: "/usr/bin/opencode") assert _harnesses._detect_opencode(tmp_path) is True + assert detect_harnesses(tmp_path) == ["opencode"] -def test_detect_continue_by_home(workspace): - assert _harnesses._detect_continue(workspace.project) is False - (workspace.home / ".continue").mkdir() - assert _harnesses._detect_continue(workspace.project) is True - - -def test_detect_harnesses_excludes_generic(tmp_path, monkeypatch, workspace): - monkeypatch.setattr(_harnesses.shutil, "which", lambda name: None) - (workspace.project / "opencode.json").write_text("{}") - detected = detect_harnesses(workspace.project) - assert "opencode" in detected - assert "generic" not in detected - - -# --------------------------------------------------------------------------- # -# Writers -# --------------------------------------------------------------------------- # - - -def _opencode_config(workspace): - return json.loads((workspace / "opencode.json").read_text()) - - -def test_opencode_api_key_uses_env_reference(tmp_path, cap, monkeypatch, no_api_key): - monkeypatch.setenv(API_KEY_ENV, "uid:secret") - extra = HARNESSES["opencode"].configure( - _ctx(tmp_path, Credential("api_key"), write_session_plugin=False) - ) - headers = _opencode_config(tmp_path)["provider"]["skore"]["options"]["headers"] - assert headers["X-API-Key"] == f"{{env:{API_KEY_ENV}}}" - assert extra["session_plugin"] is False - - -def test_opencode_bearer_writes_session_plugin(tmp_path, cap): - extra = HARNESSES["opencode"].configure( - _ctx( - tmp_path, - Credential("bearer", "tok"), - hub_workspace="ws-1", - write_session_plugin=True, - ) +def test_detect_claude_by_binary(tmp_path, monkeypatch): + monkeypatch.setattr( + _harnesses.shutil, + "which", + lambda name: "/usr/bin/claude" if name == "claude" else None, ) - assert extra["session_plugin"] is True - plugin = tmp_path / ".opencode" / "plugin" / "skore-session.js" - assert plugin.is_file() - headers = _opencode_config(tmp_path)["provider"]["skore"]["options"]["headers"] - assert headers["Authorization"] == "Bearer tok" - assert headers[WORKSPACE_HEADER] == "ws-1" + assert detect_harnesses(tmp_path) == ["claude"] -def test_opencode_backs_up_invalid_json(tmp_path, cap): - (tmp_path / "opencode.json").write_text("{ not json") - HARNESSES["opencode"].configure( - _ctx(tmp_path, Credential("none"), write_session_plugin=False) +def test_detect_pi_by_binary(tmp_path, monkeypatch): + monkeypatch.setattr( + _harnesses.shutil, + "which", + lambda name: "/usr/bin/pi" if name == "pi" else None, ) - assert (tmp_path / "opencode.json.bak").is_file() - assert _opencode_config(tmp_path)["provider"]["skore"]["npm"] + assert detect_harnesses(tmp_path) == ["pi"] -def test_copilot_bearer_embeds_token(tmp_path, cap): - extra = HARNESSES["copilot"].configure(_ctx(tmp_path, Credential("bearer", "tok"))) - content = Path(extra["env_file"]).read_text() - assert 'COPILOT_PROVIDER_API_KEY="tok"' in content - assert 'COPILOT_PROVIDER_BASE_URL="http://hub.test/v1"' in content - - -def test_copilot_api_key_references_env(tmp_path, cap, monkeypatch, no_api_key): - monkeypatch.setenv(API_KEY_ENV, "uid:secret") - extra = HARNESSES["copilot"].configure(_ctx(tmp_path, Credential("api_key"))) - content = Path(extra["env_file"]).read_text() - assert f'COPILOT_PROVIDER_API_KEY="${API_KEY_ENV}"' in content - - -def test_cline_writes_settings(tmp_path, cap): - extra = HARNESSES["cline"].configure(_ctx(tmp_path, Credential("api_key"))) - settings = json.loads(Path(extra["settings_path"]).read_text()) - assert settings["cline.apiProvider"] == "openai-compatible" - assert settings["cline.openAiBaseUrl"] == "http://hub.test/v1" - assert settings["cline.apiModelId"] == "skore-agent" - +def test_detect_harnesses_excludes_missing(tmp_path, monkeypatch): + monkeypatch.setattr(_harnesses.shutil, "which", lambda name: None) + assert detect_harnesses(tmp_path) == [] -def test_cline_backs_up_invalid_json(tmp_path, cap): - settings_dir = tmp_path / ".vscode" - settings_dir.mkdir() - (settings_dir / "settings.json").write_text("{ bad") - HARNESSES["cline"].configure(_ctx(tmp_path, Credential("none"))) - assert (settings_dir / "settings.json.bak").is_file() +def test_opencode_config_matches_hub_ui(tmp_path): + HARNESSES["opencode"].configure(_ctx(tmp_path)) + config = json.loads((tmp_path / "opencode.json").read_text()) + assert config["$schema"] == "https://opencode.ai/config.json" + assert config["model"] == "skore/skore-agent" + assert config["provider"]["skore"]["name"] == "Skore Hub" + assert config["provider"]["skore"]["models"]["skore-agent"]["name"] == "Skore Agent" -def test_generic_writes_file_with_headers(tmp_path, cap): - extra = HARNESSES["generic"].configure( - _ctx(tmp_path, Credential("bearer", "tok"), hub_workspace="ws-1") - ) - payload = json.loads(Path(extra["config_file"]).read_text()) - assert payload["baseURL"] == "http://hub.test/v1" - assert payload["headers"]["Authorization"] == "Bearer tok" - assert payload["headers"][WORKSPACE_HEADER] == "ws-1" +def test_pi_config_matches_hub_ui(tmp_path): + HARNESSES["pi"].configure(_ctx(tmp_path)) + config = json.loads((tmp_path / ".pi" / "agent" / "models.json").read_text()) + model = config["providers"]["skore"]["models"][0] + assert model["id"] == "skore-agent" + assert model["contextWindow"] == 200000 -def test_generic_no_file_when_disabled(tmp_path, cap): - extra = HARNESSES["generic"].configure( - _ctx(tmp_path, Credential("api_key"), write_file=False) - ) - assert "config_file" not in extra - assert not (tmp_path / "skore-agent.json").exists() +def test_launch_opencode_passes_model_flag(tmp_path, monkeypatch): + captured: dict[str, list[str]] = {} -def test_generic_none_credential_note(tmp_path, cap): - HARNESSES["generic"].configure(_ctx(tmp_path, Credential("none"), write_file=False)) - assert any("no credential resolved" in m for m in cap) + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv - -def test_continue_merges_model_block(workspace, cap): - extra = HARNESSES["continue"].configure( - _ctx(workspace.project, Credential("bearer", "tok"), hub_workspace="ws-1") + monkeypatch.setattr( + _harnesses.shutil, + "which", + lambda cmd: "/usr/bin/opencode" if cmd == "opencode" else None, ) - import yaml + monkeypatch.setattr(_harnesses, "_exec_harness", fake_exec) + _harnesses.launch_harness("opencode", tmp_path, model_id="skore-agent") + assert captured["argv"] == ["opencode", "-m", "skore/skore-agent"] - config = yaml.safe_load(Path(extra["config_path"]).read_text()) - blocks = [m for m in config["models"] if m["name"] == "Skore Hub Agent"] - assert len(blocks) == 1 - assert blocks[0]["apiBase"] == "http://hub.test/v1" - headers = blocks[0]["requestOptions"]["headers"] - assert headers["Authorization"] == "Bearer tok" - assert headers[WORKSPACE_HEADER] == "ws-1" +def test_launch_pi_passes_provider_and_model(tmp_path, monkeypatch): + captured: dict[str, object] = {} -def test_continue_backs_up_existing(workspace, cap): - config_dir = workspace.home / ".continue" - config_dir.mkdir() - (config_dir / "config.yaml").write_text("name: Existing\nmodels: []\n") - HARNESSES["continue"].configure(_ctx(workspace.project, Credential("api_key"))) - assert (config_dir / "config.yaml.bak").is_file() + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv + captured["env"] = env - -def test_claude_code_writes_proxy_and_env(workspace, cap): - extra = HARNESSES["claude-code"].configure( - _ctx(workspace.project, Credential("bearer", "tok")) + monkeypatch.setattr( + _harnesses.shutil, + "which", + lambda cmd: "/usr/bin/pi" if cmd == "pi" else None, ) - import yaml - - proxy = yaml.safe_load(Path(extra["proxy_config"]).read_text()) - entry = proxy["model_list"][0] - assert entry["model_name"] == "skore-agent" - assert entry["litellm_params"]["api_base"] == "http://hub.test/v1" - env_text = Path(extra["env_file"]).read_text() - assert "ANTHROPIC_BASE_URL" in env_text - - -# --------------------------------------------------------------------------- # -# fetch_workspaces (httpx injected as a fake module) -# --------------------------------------------------------------------------- # - - -class _FakeResponse: - def __init__(self, payload): - self._payload = payload - - def raise_for_status(self): - return None - - def json(self): - return self._payload - + monkeypatch.setattr(_harnesses, "_exec_harness", fake_exec) + _harnesses.launch_harness("pi", tmp_path, model_id="skore-agent") + assert captured["argv"] == ["pi", "--provider", "skore", "--model", "skore-agent"] + assert "PI_CODING_AGENT_DIR" in captured["env"] -def _fake_httpx(payload, captured): - def get(url, headers=None, timeout=None, follow_redirects=None): - captured["url"] = url - captured["headers"] = headers - return _FakeResponse(payload) - return SimpleNamespace(get=get) - - -def test_fetch_workspaces_api_key_header(monkeypatch, no_api_key): - monkeypatch.setenv(API_KEY_ENV, "uid:secret") - captured: dict = {} - monkeypatch.setitem( - sys.modules, "httpx", _fake_httpx({"items": []}, captured) +def test_launch_errors_when_binary_missing(tmp_path, monkeypatch): + monkeypatch.setattr( + _harnesses.shutil, + "which", + lambda name: "/usr/bin/opencode" if name == "opencode" else None, ) - fetch_workspaces("http://hub.test", Credential("api_key")) - assert captured["headers"] == {"X-API-Key": "uid:secret"} - assert captured["url"] == "http://hub.test/identity/workspaces" - - -def test_fetch_workspaces_bearer_header(monkeypatch, no_api_key): - captured: dict = {} - monkeypatch.setitem(sys.modules, "httpx", _fake_httpx([], captured)) - fetch_workspaces("http://hub.test", Credential("bearer", "tok")) - assert captured["headers"] == {"Authorization": "Bearer tok"} - - -def test_fetch_workspaces_parses_items_and_fallbacks(monkeypatch, no_api_key): - payload = { - "items": [ - {"public_id": "ws-1", "name": "First"}, - {"slug": "ws-2"}, - {"name": "no-id"}, - ] - } - monkeypatch.setitem(sys.modules, "httpx", _fake_httpx(payload, {})) - result = fetch_workspaces("http://hub.test", Credential("bearer", "tok")) - assert result == [("ws-1", "First"), ("ws-2", "ws-2")] - - -def test_fetch_workspaces_accepts_bare_list(monkeypatch, no_api_key): - payload = [{"public_id": "ws-1", "name": "First"}] - monkeypatch.setitem(sys.modules, "httpx", _fake_httpx(payload, {})) - result = fetch_workspaces("http://hub.test", Credential("bearer", "tok")) - assert result == [("ws-1", "First")] + monkeypatch.setattr(_harnesses.os, "execve", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))) + with pytest.raises(RuntimeError): + _harnesses.launch_harness("opencode", tmp_path) diff --git a/tests/test_hub.py b/tests/test_hub.py index 5307c18..aaf0002 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -1,10 +1,4 @@ -"""Tests for the ``skore hub`` command group (login/logout/status). - -The hub commands reach into the optional ``skore`` package through the module -level ``_auth`` accessor. ``skore`` is not installed in the test environment, so -every test swaps ``_auth`` for an in-memory fake -- this keeps the tests fast and -hermetic while exercising the real command logic. -""" +"""Tests for the ``skore hub`` command group (login/logout/status).""" from __future__ import annotations @@ -13,239 +7,162 @@ import pytest from click.testing import CliRunner -# The commands live in ``skore_cli.hub._commands``; import that module so -# ``monkeypatch.setattr(hub, "_auth", ...)`` targets the namespace the command -# callbacks actually resolve ``_auth`` from. +from skore_cli import _hub_auth from skore_cli.hub import _commands as hub hub_cli = hub.hub -class _Recorder: - def __init__(self, token): - self.token = dict(token) if token else None - self.saved = None - self.cleared = False - self.logout_args = None - self.interactive_called = False - self.logout_error = None - - -def _make_auth(recorder: _Recorder, *, uri="http://hub.test", expired=False): - def interactive_device_login(*, timeout=600): - recorder.interactive_called = True - return ("acc", "ref", "2099-01-01T00:00:00+00:00") - - def post_oauth_logout(access_token, refresh_token=None): - recorder.logout_args = (access_token, refresh_token) - if recorder.logout_error is not None: - raise recorder.logout_error - - def save(token): - recorder.saved = token - return "/tmp/hub.json" - - def clear(): - if recorder.token: - recorder.cleared = True - recorder.token = None - return "/tmp/hub.json" - return None - - store = SimpleNamespace( - load=lambda: recorder.token, - save=save, - clear=clear, - path=lambda: "/tmp/hub.json", - ) - token_mod = SimpleNamespace( - interactive_device_login=interactive_device_login, - post_oauth_logout=post_oauth_logout, - _token_expired=lambda expires_at: expired, - ) - uri_mod = SimpleNamespace(URI=lambda: uri) - - modules = {"uri": uri_mod, "store": store, "token": token_mod} - return lambda name: modules[name] +class _TokenCreds: + def __init__(self, token: str = "acc"): + self._token = token + def __call__(self): + return {"Authorization": f"Bearer {self._token}"} -@pytest.fixture -def no_api_key(monkeypatch): - monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) - monkeypatch.delenv("SKORE_HUB_URI", raising=False) +def _make_login(*, credentials=None, uri="http://hub.test"): + login_mod = SimpleNamespace(credentials=credentials, login_calls=0) -# --------------------------------------------------------------------------- # -# status -# --------------------------------------------------------------------------- # + def login(*, timeout=600): + login_mod.login_calls += 1 + login_mod.credentials = _TokenCreds() + login_mod.login = login + uri_mod = SimpleNamespace(URI=lambda: uri) + return lambda name: login_mod if name == "login" else uri_mod -def test_status_with_api_key(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - monkeypatch.setattr(hub, "_auth", _make_auth(_Recorder(None))) - - result = CliRunner().invoke(hub_cli, ["status"]) - assert result.exit_code == 0, result.output - assert "set" in result.output +def _patch_auth(monkeypatch, auth_fn): + monkeypatch.setattr(_hub_auth, "_auth", auth_fn) + monkeypatch.setattr(hub, "_auth", auth_fn) -def test_status_with_valid_token(monkeypatch, no_api_key): - recorder = _Recorder({"access_token": "acc", "expires_at": "2099-01-01"}) - monkeypatch.setattr(hub, "_auth", _make_auth(recorder, expired=False)) +@pytest.fixture +def no_api_key(monkeypatch): + monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) + monkeypatch.delenv("SKORE_HUB_URI", raising=False) - result = CliRunner().invoke(hub_cli, ["status"]) - assert result.exit_code == 0, result.output - assert "valid" in result.output +def test_status_with_api_key(monkeypatch, no_api_key): + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + _patch_auth(monkeypatch, _make_login()) + result = CliRunner().invoke(hub_cli, ["status"]) -def test_status_with_expired_token(monkeypatch, no_api_key): - recorder = _Recorder({"access_token": "acc", "expires_at": "2000-01-01"}) - monkeypatch.setattr(hub, "_auth", _make_auth(recorder, expired=True)) + assert result.exit_code == 0, result.output + assert "API key" in result.output - result = CliRunner().invoke(hub_cli, ["status"]) - assert result.exit_code == 0, result.output - assert "expired" in result.output +def test_status_with_interactive_session(monkeypatch, no_api_key): + _patch_auth(monkeypatch, _make_login(credentials=_TokenCreds())) + result = CliRunner().invoke(hub_cli, ["status"]) -def test_status_unauthenticated_errors(monkeypatch, no_api_key): - monkeypatch.setattr(hub, "_auth", _make_auth(_Recorder(None))) + assert result.exit_code == 0, result.output + assert "interactive token" in result.output - result = CliRunner().invoke(hub_cli, ["status"]) - assert result.exit_code != 0 - assert "Not authenticated" in result.output +def test_status_unauthenticated_errors(monkeypatch, no_api_key): + _patch_auth(monkeypatch, _make_login()) + result = CliRunner().invoke(hub_cli, ["status"]) -# --------------------------------------------------------------------------- # -# login -# --------------------------------------------------------------------------- # + assert result.exit_code != 0 + assert "Not authenticated" in result.output def test_login_with_api_key_does_nothing(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - recorder = _Recorder(None) - monkeypatch.setattr(hub, "_auth", _make_auth(recorder)) + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + auth = _make_login() + _patch_auth(monkeypatch, auth) - result = CliRunner().invoke(hub_cli, ["login"]) + result = CliRunner().invoke(hub_cli, ["login"]) - assert result.exit_code == 0, result.output - assert recorder.interactive_called is False - assert recorder.saved is None + assert result.exit_code == 0, result.output + assert auth("login").login_calls == 0 def test_login_without_key_runs_device_flow(monkeypatch, no_api_key): - recorder = _Recorder(None) - monkeypatch.setattr(hub, "_auth", _make_auth(recorder)) + auth = _make_login() + _patch_auth(monkeypatch, auth) - result = CliRunner().invoke(hub_cli, ["login"]) + result = CliRunner().invoke(hub_cli, ["login"]) - assert result.exit_code == 0, result.output - assert recorder.interactive_called is True - assert recorder.saved == { - "uri": "http://hub.test", - "access_token": "acc", - "refresh_token": "ref", - "expires_at": "2099-01-01T00:00:00+00:00", - } + assert result.exit_code == 0, result.output + assert auth("login").login_calls == 1 + assert auth("login").credentials is not None def test_login_hub_url_sets_env(monkeypatch, no_api_key): - recorder = _Recorder(None) - # URI() echoes the env var the command is expected to set from --hub-url. - monkeypatch.setattr(hub, "_auth", _make_auth(recorder, uri="http://127.0.0.1:9999")) - - result = CliRunner().invoke( - hub_cli, ["login", "--hub-url", "http://127.0.0.1:9999"] - ) - - assert result.exit_code == 0, result.output - import os + _patch_auth(monkeypatch, _make_login(uri="http://127.0.0.1:9999")) - assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:9999" + result = CliRunner().invoke( + hub_cli, ["login", "--hub-url", "http://127.0.0.1:9999"] + ) + assert result.exit_code == 0, result.output + import os -# --------------------------------------------------------------------------- # -# resolve_hub_uri (shared by hub login, agent init, agent mcp serve) -# --------------------------------------------------------------------------- # + assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:9999" def test_resolve_hub_uri_seeds_env_and_resolves(no_api_key): - import os + import os - from skore_cli import _skore + from skore_cli import _skore - seen = {} + seen = {} - def fake_auth(name): - seen["name"] = name - return SimpleNamespace(URI=lambda: "http://resolved") + def fake_auth(name): + seen["name"] = name + return SimpleNamespace(URI=lambda: "http://resolved") - uri = _skore.resolve_hub_uri("http://127.0.0.1:8000", fake_auth) + uri = _skore.resolve_hub_uri("http://127.0.0.1:8000", fake_auth) - assert seen["name"] == "uri" - assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:8000" - assert uri == "http://resolved" + assert seen["name"] == "uri" + assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:8000" + assert uri == "http://resolved" def test_resolve_hub_uri_without_url_leaves_env(no_api_key): - import os - - from skore_cli import _skore - - uri = _skore.resolve_hub_uri( - None, lambda name: SimpleNamespace(URI=lambda: "http://default") - ) - - assert "SKORE_HUB_URI" not in os.environ - assert uri == "http://default" - - -# --------------------------------------------------------------------------- # -# logout -# --------------------------------------------------------------------------- # - + import os -def test_logout_revokes_and_clears(monkeypatch, no_api_key): - recorder = _Recorder({"access_token": "acc", "refresh_token": "ref"}) - monkeypatch.setattr(hub, "_auth", _make_auth(recorder)) + from skore_cli import _skore - result = CliRunner().invoke(hub_cli, ["logout"]) + uri = _skore.resolve_hub_uri( + None, lambda name: SimpleNamespace(URI=lambda: "http://default") + ) - assert result.exit_code == 0, result.output - assert recorder.logout_args == ("acc", "ref") - assert recorder.cleared is True - assert "revoked the token" in result.output + assert "SKORE_HUB_URI" not in os.environ + assert uri == "http://default" -def test_logout_still_clears_when_revoke_fails(monkeypatch, no_api_key): - recorder = _Recorder({"access_token": "acc", "refresh_token": "ref"}) - recorder.logout_error = RuntimeError("network down") - monkeypatch.setattr(hub, "_auth", _make_auth(recorder)) +def test_logout_clears_session(monkeypatch, no_api_key): + auth = _make_login(credentials=_TokenCreds()) + _patch_auth(monkeypatch, auth) - result = CliRunner().invoke(hub_cli, ["logout"]) + result = CliRunner().invoke(hub_cli, ["logout"]) - assert result.exit_code == 0, result.output - assert recorder.cleared is True - assert "Could not revoke" in result.output + assert result.exit_code == 0, result.output + assert auth("login").credentials is None + assert "cleared" in result.output -def test_logout_no_token_with_api_key(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - monkeypatch.setattr(hub, "_auth", _make_auth(_Recorder(None))) +def test_logout_no_session_with_api_key(monkeypatch, no_api_key): + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + _patch_auth(monkeypatch, _make_login()) - result = CliRunner().invoke(hub_cli, ["logout"]) + result = CliRunner().invoke(hub_cli, ["logout"]) - assert result.exit_code == 0, result.output - assert "user-managed" in result.output + assert result.exit_code == 0, result.output + assert "user-managed" in result.output -def test_logout_no_token_at_all(monkeypatch, no_api_key): - monkeypatch.setattr(hub, "_auth", _make_auth(_Recorder(None))) +def test_logout_no_session_at_all(monkeypatch, no_api_key): + _patch_auth(monkeypatch, _make_login()) - result = CliRunner().invoke(hub_cli, ["logout"]) + result = CliRunner().invoke(hub_cli, ["logout"]) - assert result.exit_code == 0, result.output - assert "No stored token to remove" in result.output + assert result.exit_code == 0, result.output + assert "No interactive session" in result.output diff --git a/tests/test_hub_api_key_form.py b/tests/test_hub_api_key_form.py index 86b7ae7..624e8a4 100644 --- a/tests/test_hub_api_key_form.py +++ b/tests/test_hub_api_key_form.py @@ -4,6 +4,7 @@ from textual.widgets import SelectionList +from skore_cli.app._help import HelpScreen from skore_cli.hub.app import ApiKeyForm, IdPicker WORKSPACES = [(7, "ws-a"), (8, "ws-b")] @@ -74,6 +75,18 @@ async def test_form_cancel_returns_none(): assert app.result is None +async def test_form_help_screen(): + app = ApiKeyForm(WORKSPACES, GRANTABLE, name="x", preselect_workspace_id=7) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("?") + await pilot.pause() + assert isinstance(app.screen, HelpScreen) + await pilot.press("escape") + await pilot.pause() + assert app.is_running is True + + def _permission_values(app): return [ option.value for option in app.query_one("#permissions", SelectionList).options diff --git a/tests/test_hub_api_keys.py b/tests/test_hub_api_keys.py index 616979c..84deb15 100644 --- a/tests/test_hub_api_keys.py +++ b/tests/test_hub_api_keys.py @@ -31,24 +31,16 @@ def _transport(handler): def test_require_login_token_errors_without_token(monkeypatch): - monkeypatch.setattr( - _client, - "_auth", - lambda name: SimpleNamespace(fresh_token=lambda relogin=False: None), - ) + monkeypatch.setattr(_client, "ensure_login", lambda: (_ for _ in ()).throw( + click.ClickException("not logged in") + )) with pytest.raises(click.ClickException) as exc: _client.require_login_token() assert "not logged in" in str(exc.value) def test_require_login_token_returns_access_token(monkeypatch): - monkeypatch.setattr( - _client, - "_auth", - lambda name: SimpleNamespace( - fresh_token=lambda relogin=False: {"access_token": "abc"} - ), - ) + monkeypatch.setattr(_client, "ensure_login", lambda: "abc") assert _client.require_login_token() == "abc" diff --git a/tests/test_hub_auth.py b/tests/test_hub_auth.py new file mode 100644 index 0000000..c993d6d --- /dev/null +++ b/tests/test_hub_auth.py @@ -0,0 +1,62 @@ +"""Tests for ``_hub_auth`` and hub client 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() diff --git a/tests/test_pickers.py b/tests/test_pickers.py index 5cbb919..1d14d6e 100644 --- a/tests/test_pickers.py +++ b/tests/test_pickers.py @@ -3,11 +3,12 @@ from __future__ import annotations from skore_cli.agent.app import HarnessPicker, WorkspacePicker +from skore_cli.app._help import HelpScreen HARNESSES = [ - ("opencode", "opencode", True), - ("copilot", "GitHub Copilot CLI", False), - ("generic", "generic", False), + ("opencode", "OpenCode", True), + ("claude", "Claude", False), + ("pi", "Pi", False), ] WORKSPACES = [("ws-1", "First"), ("ws-2", "Second")] @@ -35,7 +36,7 @@ async def test_harness_picker_honors_preselect(): await pilot.press("enter") await pilot.pause() - assert app.result == "copilot" + assert app.result == "claude" async def test_harness_picker_cancel_returns_none(): @@ -48,6 +49,18 @@ async def test_harness_picker_cancel_returns_none(): assert app.result is None +async def test_harness_picker_help_screen(): + app = HarnessPicker(HARNESSES, preselect=0) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("?") + await pilot.pause() + assert isinstance(app.screen, HelpScreen) + await pilot.press("escape") + await pilot.pause() + assert app.is_running is True + + # --------------------------------------------------------------------------- # # WorkspacePicker # --------------------------------------------------------------------------- # diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..99f667f --- /dev/null +++ b/uv.lock @@ -0,0 +1,2293 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a2/78f662f1b18968531f67d3fcde1b7ea8496920bacd4f16ddb5b79d112e46/matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef", size = 9436261, upload-time = "2026-06-12T02:27:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/5e/92/044f1de43901310202f4c79acf4f141be53b2ca8d8380e2fcefb3d523a75/matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233", size = 9264669, upload-time = "2026-06-12T02:27:37.413Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/f0b4f9ba7ec14a7af8151f3ad71ecfe3561e6ba38cfab1db3681ba4ca112/matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45", size = 10021076, upload-time = "2026-06-12T02:27:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/4d679c6dcd594a156542080ac907ddccf7b09ca11655c4b28eca8e9ee5da/matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055", size = 10828999, upload-time = "2026-06-12T02:27:42.433Z" }, + { url = "https://files.pythonhosted.org/packages/07/74/0a3683802037d8cd013144d77c247219b47f2aabace6fdde74faa12bacf7/matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3", size = 10913103, upload-time = "2026-06-12T02:27:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/970fcbf381e82ec66fdf5da8ea76e2e9240f61a24011ce9fd1d42c37ac2d/matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4", size = 9310945, upload-time = "2026-06-12T02:27:46.867Z" }, + { url = "https://files.pythonhosted.org/packages/14/4e/6e7cfed23611265ded53806852343b5c59339e506e84c474a9b5afc3b249/matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7", size = 8999304, upload-time = "2026-06-12T02:27:48.798Z" }, + { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, + { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, + { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, + { url = "https://files.pythonhosted.org/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79", size = 9452137, upload-time = "2026-06-12T02:28:37.93Z" }, + { url = "https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3", size = 9281514, upload-time = "2026-06-12T02:28:40.028Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9", size = 10843005, upload-time = "2026-06-12T02:28:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430", size = 11127459, upload-time = "2026-06-12T02:28:44.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba", size = 10925160, upload-time = "2026-06-12T02:28:46.564Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2", size = 9485186, upload-time = "2026-06-12T02:28:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d", size = 9160349, upload-time = "2026-06-12T02:28:51.382Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847", size = 9500921, upload-time = "2026-06-12T02:28:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e", size = 9332190, upload-time = "2026-06-12T02:28:55.623Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0", size = 10854181, upload-time = "2026-06-12T02:28:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb", size = 11137715, upload-time = "2026-06-12T02:29:00.555Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9", size = 10939427, upload-time = "2026-06-12T02:29:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6", size = 9535809, upload-time = "2026-06-12T02:29:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159", size = 9209226, upload-time = "2026-06-12T02:29:07.033Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094, upload-time = "2026-06-12T02:29:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183, upload-time = "2026-06-12T02:29:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "narwhals" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/66ed1fc6e38a0c0f330627ec5c5d597990d6159b6712b82af0ad2c65f06c/narwhals-2.23.0.tar.gz", hash = "sha256:13e7ff5b4bb4a2f77b907c2e4d8a76e273dfc1323a3c997440a2f9fd26aed408", size = 656209, upload-time = "2026-07-01T11:21:53.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl", hash = "sha256:769e7b9ab102c93d8fa019f6b4cd1a657909b04a20bf6210e5a35aae06814ae9", size = 458938, upload-time = "2026-07-01T11:21:51.677Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, + { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "plotly" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/fd/d72c292d78aadb93d1a9bcd76bf3c678271040c7cf10abe5788b33040a39/plotly-6.8.0.tar.gz", hash = "sha256:e088e7ddc68d4f70e3d66659224727a45296d71d2b8284181862d3d8f1f0d88f", size = 6915161, upload-time = "2026-06-03T18:33:40.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl", hash = "sha256:13c5c4a0f70b74cab1913eda0de49b826df5931708eb6f9c3010040614700ec8", size = 9902055, upload-time = "2026-06-03T18:33:34.26Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-order" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/d8/82a5dc2aad3392f66c9741960fcbeeb45dd4d62eedb218aed6c52eea36eb/pytest_order-1.5.0.tar.gz", hash = "sha256:96acd7587b5a2855dcaa4a898288103d202894a61afd813adbc9b77aab04d90d", size = 54136, upload-time = "2026-06-13T05:39:41.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/5c/bd6a85b44beb8bd74c0e86ab699ef0e096e260dc90458b84fc54c28e4335/pytest_order-1.5.0-py3-none-any.whl", hash = "sha256:667ba2b7303fe2c529848663e0a41dfb0cc4d64f09f87272e4a9a1751efb52b2", size = 16634, upload-time = "2026-06-13T05:39:40.431Z" }, +] + +[[package]] +name = "pytest-randomly" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[package.optional-dependencies] +jupyter = [ + { name = "ipywidgets" }, +] + +[[package]] +name = "rich-click" +version = "1.9.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "skore" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "diskcache" }, + { name = "jinja2" }, + { name = "joblib" }, + { name = "matplotlib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, + { name = "platformdirs" }, + { name = "plotly" }, + { name = "rich" }, + { name = "scikit-learn" }, + { name = "seaborn" }, + { name = "skrub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/2b/365635fcf0bdd50f1913b34bd0968c7a189e1e75297bd1c35456b6e1bbba/skore-0.22.0.tar.gz", hash = "sha256:1601c9ef05a9ee48bcc1473c99fb8ca42242e332df2e155e8ecf4a17cb13e5e3", size = 242674, upload-time = "2026-06-23T13:41:19.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/73/f55c8481df3439378a93ff096c17ca3e098e72513f1cc1a696fed89d2f76/skore-0.22.0-py3-none-any.whl", hash = "sha256:0bc275ff07032a84dfc68714dd4ce9d8ad8fd88c60b78aa78a1df3c5f66344eb", size = 325269, upload-time = "2026-06-23T13:41:18.112Z" }, +] + +[package.optional-dependencies] +hub = [ + { name = "httpx" }, + { name = "orjson" }, + { name = "pydantic" }, + { name = "rich", extra = ["jupyter"] }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[[package]] +name = "skore-cli" +version = "0.0.1.dev0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "skore", extra = ["hub"] }, + { name = "textual" }, +] + +[package.optional-dependencies] +test = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-order" }, + { name = "pytest-randomly" }, + { name = "pytest-xdist" }, + { name = "xdoctest" }, +] + +[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'" }, + { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "pytest-order", marker = "extra == 'test'" }, + { name = "pytest-randomly", marker = "extra == 'test'" }, + { name = "pytest-xdist", marker = "extra == 'test'" }, + { name = "rich", specifier = ">=14.2" }, + { name = "rich-click", specifier = ">=1.9" }, + { name = "skore", extras = ["hub"] }, + { name = "textual" }, + { name = "xdoctest", marker = "extra == 'test'" }, +] +provides-extras = ["test"] + +[[package]] +name = "skrub" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "matplotlib" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, + { name = "pydot" }, + { name = "requests" }, + { name = "scikit-learn" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/69/2aa879d1aa7e697109860c2346efe8164531fa270fafa4ce1d22843157a2/skrub-0.9.0.tar.gz", hash = "sha256:c294f148ddf5dc1b3792c815fb9ea708338af6bda8eb47cfe04054bbb4a3b757", size = 3653206, upload-time = "2026-05-06T12:49:15.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/39/6cc76da6f09aeb079d45b66bd36fbd9a9dd5425ded656f4fff2d2b30c5b6/skrub-0.9.0-py3-none-any.whl", hash = "sha256:d8a3e6526cd4c5c001a512a77927a9a7652896e11800ceacacca6d8cacd52e82", size = 537407, upload-time = "2026-05-06T12:49:13.612Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "xdoctest" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/df/1b5751e3546967a4884cff6ea743b1e457d3f7c94fc35913f149a85734fc/xdoctest-1.3.2.tar.gz", hash = "sha256:bf6078c4fc0d60aafd8753fccdc435c95f26a640508e606d188d96f48359f0aa", size = 209923, upload-time = "2026-03-27T01:13:25.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/1d/6b9cf46122f2f07eab95c4af29ab5e29bd6674147a0b1e9a6d3929870af1/xdoctest-1.3.2-py3-none-any.whl", hash = "sha256:052118c8efb2b4cfb54485d328915b9e7b44da37c64b0998ca6aa21193dcb601", size = 146469, upload-time = "2026-03-27T01:13:23.417Z" }, +] From 5e5e7811946b5805c6355ce66f4d30f1a1b41941 Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Wed, 1 Jul 2026 20:41:05 +0200 Subject: [PATCH 5/7] run hooks --- pyproject.toml | 2 + src/skore_cli/__init__.py | 1 - src/skore_cli/agent/_commands.py | 10 +- src/skore_cli/agent/_harnesses.py | 4 +- src/skore_cli/agent/_skore_file.py | 4 +- src/skore_cli/app/_help.py | 12 +- src/skore_cli/hub/app/_form.py | 3 +- src/skore_cli/hub/app/_provider_form.py | 7 +- src/skore_cli/skills/app/_find.py | 2 +- src/skore_cli/skills/app/_manage.py | 3 +- tests/test_agent_commands.py | 31 +++-- tests/test_cli.py | 4 +- tests/test_harnesses.py | 6 +- tests/test_hub.py | 158 ++++++++++++------------ tests/test_hub_api_keys.py | 9 +- tests/test_hub_auth.py | 58 ++++----- 16 files changed, 174 insertions(+), 140 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6024c7b..11977f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -164,6 +164,8 @@ ignore_missing_imports = true module = [ "rich_click.*", "textual.*", + "httpx", + "httpx.*", ] [tool.ty.environment] diff --git a/src/skore_cli/__init__.py b/src/skore_cli/__init__.py index aa59a2a..bfa2cd5 100644 --- a/src/skore_cli/__init__.py +++ b/src/skore_cli/__init__.py @@ -14,7 +14,6 @@ from skore_cli.hub import hub from skore_cli.skills import skills - click.rich_click.COMMAND_GROUPS = { **getattr(click.rich_click, "COMMAND_GROUPS", {}), "cli": [ diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index 865fd10..f092575 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -13,8 +13,8 @@ from skore_cli._style import console from skore_cli.agent._harnesses import ( DEFAULT_MODEL_ID, - HARNESSES, HARNESS_NAMES, + HARNESSES, HarnessContext, detect_harnesses, launch_harness, @@ -59,9 +59,7 @@ def _pick_harness(workspace: Path) -> str: f"{', '.join(HARNESS_NAMES)}." ) rows = [ - (name, HARNESSES[name].label, True) - for name in HARNESSES - if name in detected + (name, HARNESSES[name].label, True) for name in HARNESSES if name in detected ] app = HarnessPicker(rows, preselect=0) app.run() @@ -250,9 +248,7 @@ def agent( harness = HARNESSES[harness_name] if not harness.detect(workspace): - raise click.ClickException( - f"{harness.label} is not installed or not on PATH." - ) + raise click.ClickException(f"{harness.label} is not installed or not on PATH.") if config.harness != harness_name: config = SkoreConfig( diff --git a/src/skore_cli/agent/_harnesses.py b/src/skore_cli/agent/_harnesses.py index 63c7780..69a940c 100644 --- a/src/skore_cli/agent/_harnesses.py +++ b/src/skore_cli/agent/_harnesses.py @@ -158,7 +158,9 @@ def _launch_pi(workspace: Path, *, model_id: str) -> None: ) -def _exec_harness(name: str, argv: list[str], *, env: dict[str, str] | None = None) -> None: +def _exec_harness( + name: str, argv: list[str], *, env: dict[str, str] | None = None +) -> None: executable = shutil.which(argv[0]) if executable is None: raise RuntimeError(f"{name} is not installed or not on PATH.") diff --git a/src/skore_cli/agent/_skore_file.py b/src/skore_cli/agent/_skore_file.py index 51f077e..4010d4a 100644 --- a/src/skore_cli/agent/_skore_file.py +++ b/src/skore_cli/agent/_skore_file.py @@ -49,7 +49,9 @@ def load(cls, path: Path) -> SkoreConfig | None: def save(self, path: Path) -> Path: """Write this config to ``path/.skore`` and return the file path.""" file_path = path / SKORE_FILENAME - payload = {key: value for key, value in asdict(self).items() if value is not None} + payload = { + key: value for key, value in asdict(self).items() if value is not None + } file_path.write_text(json.dumps(payload, indent=2) + "\n") return file_path diff --git a/src/skore_cli/app/_help.py b/src/skore_cli/app/_help.py index 815514e..acc2a5c 100644 --- a/src/skore_cli/app/_help.py +++ b/src/skore_cli/app/_help.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Protocol, cast + from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Vertical @@ -12,12 +14,16 @@ HELP_BINDING = Binding("question_mark", "show_help", "Help", priority=True) +class _HelpApp(Protocol): + def action_show_help(self) -> None: ... + + class HelpInput(Input): """Input that opens the app help screen instead of inserting ``?``.""" async def _on_key(self, event: Key) -> None: if event.key == "question_mark": - self.app.action_show_help() + cast(_HelpApp, self.app).action_show_help() event.prevent_default() event.stop() return @@ -63,5 +69,5 @@ def compose(self) -> ComposeResult: yield Static(self._body, classes="help-body") yield Footer() - def action_dismiss(self) -> None: - self.dismiss() + async def action_dismiss(self, result: None = None) -> None: + self.dismiss(result) diff --git a/src/skore_cli/hub/app/_form.py b/src/skore_cli/hub/app/_form.py index eb0444c..2257faa 100644 --- a/src/skore_cli/hub/app/_form.py +++ b/src/skore_cli/hub/app/_form.py @@ -20,6 +20,7 @@ Input, Label, RadioButton, + RadioSet, SelectionList, ) from textual.widgets.selection_list import Selection @@ -193,7 +194,7 @@ def _populate_permissions(self, preselect: list[str]) -> None: if options: permissions.add_options(options) - def on_radio_set_changed(self, event: AutoRadioSet.Changed) -> None: + def on_radio_set_changed(self, event: RadioSet.Changed) -> None: # Switching workspace changes which permissions are grantable; rebuild # the list (keeping any still-grantable selections). if event.radio_set.id == "workspaces": diff --git a/src/skore_cli/hub/app/_provider_form.py b/src/skore_cli/hub/app/_provider_form.py index 3325662..98ef7e9 100644 --- a/src/skore_cli/hub/app/_provider_form.py +++ b/src/skore_cli/hub/app/_provider_form.py @@ -23,6 +23,7 @@ Input, Label, RadioButton, + RadioSet, ) from skore_cli.app._help import HELP_BINDING, HelpInput, HelpScreen @@ -144,7 +145,9 @@ def compose(self) -> ComposeResult: yield Label(_INTRO, classes="form-intro") yield Label("Name", classes="field-label") - yield HelpInput(value=self._name, placeholder="e.g. team-anthropic", id="name") + yield HelpInput( + value=self._name, placeholder="e.g. team-anthropic", id="name" + ) yield Label("Provider", classes="field-label") with AutoRadioSet(id="provider"): @@ -212,7 +215,7 @@ def _show_fields(self, provider: str) -> None: for name in _PROVIDER_ORDER: self.query_one(f"#{name}-fields", Vertical).display = name == provider - def on_radio_set_changed(self, event: AutoRadioSet.Changed) -> None: + def on_radio_set_changed(self, event: RadioSet.Changed) -> None: if event.radio_set.id == "provider": self._show_fields(self._current_provider()) diff --git a/src/skore_cli/skills/app/_find.py b/src/skore_cli/skills/app/_find.py index ef91393..270f6e5 100644 --- a/src/skore_cli/skills/app/_find.py +++ b/src/skore_cli/skills/app/_find.py @@ -6,9 +6,9 @@ from textual.app import App, ComposeResult from textual.binding import Binding -from skore_cli.app._help import HELP_BINDING, HelpScreen from textual.widgets import Footer, Header, SelectionList +from skore_cli.app._help import HELP_BINDING, HelpScreen from skore_cli.skills.app._widgets import SkillSelection _FIND_INTRO = ( diff --git a/src/skore_cli/skills/app/_manage.py b/src/skore_cli/skills/app/_manage.py index 967bb4d..69ec692 100644 --- a/src/skore_cli/skills/app/_manage.py +++ b/src/skore_cli/skills/app/_manage.py @@ -6,9 +6,10 @@ from textual.binding import Binding from textual.containers import Vertical from textual.widgets import Footer, Header, Label, SelectionList -from skore_cli.app._help import HELP_BINDING, HelpScreen from textual.widgets.selection_list import Selection +from skore_cli.app._help import HELP_BINDING, HelpScreen + _INTRO = ( "Select the installed skills to manage.\n" "[reverse] ↑/↓ [/] move [reverse] Space [/] (de)select " diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index 3184fa0..41c8293 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -6,10 +6,14 @@ from click.testing import CliRunner -from skore_cli.agent import _commands, _harnesses +from skore_cli.agent import _commands from skore_cli.agent._commands import agent -from skore_cli.agent._harnesses import HARNESSES, HarnessContext, DEFAULT_MODEL_ID -from skore_cli.agent._skore_file import SKORE_FILENAME, SkoreConfig, ensure_gitignore_entry +from skore_cli.agent._harnesses import DEFAULT_MODEL_ID, HARNESSES, HarnessContext +from skore_cli.agent._skore_file import ( + SKORE_FILENAME, + SkoreConfig, + ensure_gitignore_entry, +) from skore_cli.hub import _client @@ -92,7 +96,9 @@ def test_agent_nonexistent_workspace_errors(tmp_path): def test_agent_uses_existing_skore_config(tmp_path, monkeypatch): _write_skore(tmp_path) - monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test") + monkeypatch.setattr( + _commands, "resolve_hub_uri", lambda url, *a, **k: url or "http://hub.test" + ) monkeypatch.setattr(_commands, "detect_harnesses", lambda workspace: ["opencode"]) launched: list[str] = [] monkeypatch.setattr( @@ -112,7 +118,9 @@ def test_agent_uses_existing_skore_config(tmp_path, monkeypatch): def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): - monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test") + monkeypatch.setattr( + _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + ) monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, @@ -131,7 +139,9 @@ def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): ) monkeypatch.setattr(_commands, "detect_harnesses", lambda workspace: ["opencode"]) monkeypatch.setattr( - _commands, "launch_harness", lambda name, workspace, model_id=DEFAULT_MODEL_ID: None + _commands, + "launch_harness", + lambda name, workspace, model_id=DEFAULT_MODEL_ID: None, ) result = CliRunner().invoke( @@ -147,7 +157,9 @@ def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): - monkeypatch.setattr(_commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test") + monkeypatch.setattr( + _commands, "resolve_hub_uri", lambda url, *a, **k: "http://hub.test" + ) monkeypatch.setattr(_commands, "_ensure_login", lambda hub_url, timeout: "tok") monkeypatch.setattr( _commands._client, @@ -165,4 +177,7 @@ def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): 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" + assert ( + _commands._resolve_api_key_name("opencode", ["opencode", "opencode-2"]) + == "opencode-3" + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 18b24a8..b913676 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -96,7 +96,7 @@ def made(): # pragma: no cover - never invoked ... # The loaded object is a factory returning the command. - _patch_entry_points(monkeypatch, [_FakeEntryPoint("made", lambda: (lambda: made))]) + _patch_entry_points(monkeypatch, [_FakeEntryPoint("made", lambda: lambda: made)]) group = _group() _plugins.load_plugins(group) @@ -120,7 +120,7 @@ def boom(): def test_load_plugins_skips_non_command(monkeypatch, capsys): _patch_entry_points( - monkeypatch, [_FakeEntryPoint("weird", lambda: (lambda: "not-a-command"))] + monkeypatch, [_FakeEntryPoint("weird", lambda: lambda: "not-a-command")] ) group = _group() diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index 945c4aa..5fb9c04 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -113,6 +113,10 @@ def test_launch_errors_when_binary_missing(tmp_path, monkeypatch): "which", lambda name: "/usr/bin/opencode" if name == "opencode" else None, ) - monkeypatch.setattr(_harnesses.os, "execve", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr( + _harnesses.os, + "execve", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")), + ) with pytest.raises(RuntimeError): _harnesses.launch_harness("opencode", tmp_path) diff --git a/tests/test_hub.py b/tests/test_hub.py index aaf0002..53ce90f 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -14,155 +14,155 @@ class _TokenCreds: - def __init__(self, token: str = "acc"): - self._token = token + def __init__(self, token: str = "acc"): + self._token = token - def __call__(self): - return {"Authorization": f"Bearer {self._token}"} + def __call__(self): + return {"Authorization": f"Bearer {self._token}"} def _make_login(*, credentials=None, uri="http://hub.test"): - login_mod = SimpleNamespace(credentials=credentials, login_calls=0) + login_mod = SimpleNamespace(credentials=credentials, login_calls=0) - def login(*, timeout=600): - login_mod.login_calls += 1 - login_mod.credentials = _TokenCreds() + def login(*, timeout=600): + login_mod.login_calls += 1 + login_mod.credentials = _TokenCreds() - login_mod.login = login - uri_mod = SimpleNamespace(URI=lambda: uri) - return lambda name: login_mod if name == "login" else uri_mod + login_mod.login = login + uri_mod = SimpleNamespace(URI=lambda: uri) + return lambda name: login_mod if name == "login" else uri_mod def _patch_auth(monkeypatch, auth_fn): - monkeypatch.setattr(_hub_auth, "_auth", auth_fn) - monkeypatch.setattr(hub, "_auth", auth_fn) + monkeypatch.setattr(_hub_auth, "_auth", auth_fn) + monkeypatch.setattr(hub, "_auth", auth_fn) @pytest.fixture def no_api_key(monkeypatch): - monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) - monkeypatch.delenv("SKORE_HUB_URI", raising=False) + monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) + monkeypatch.delenv("SKORE_HUB_URI", raising=False) def test_status_with_api_key(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - _patch_auth(monkeypatch, _make_login()) + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + _patch_auth(monkeypatch, _make_login()) - result = CliRunner().invoke(hub_cli, ["status"]) + result = CliRunner().invoke(hub_cli, ["status"]) - assert result.exit_code == 0, result.output - assert "API key" in result.output + assert result.exit_code == 0, result.output + assert "API key" in result.output def test_status_with_interactive_session(monkeypatch, no_api_key): - _patch_auth(monkeypatch, _make_login(credentials=_TokenCreds())) + _patch_auth(monkeypatch, _make_login(credentials=_TokenCreds())) - result = CliRunner().invoke(hub_cli, ["status"]) + result = CliRunner().invoke(hub_cli, ["status"]) - assert result.exit_code == 0, result.output - assert "interactive token" in result.output + assert result.exit_code == 0, result.output + assert "interactive token" in result.output def test_status_unauthenticated_errors(monkeypatch, no_api_key): - _patch_auth(monkeypatch, _make_login()) + _patch_auth(monkeypatch, _make_login()) - result = CliRunner().invoke(hub_cli, ["status"]) + result = CliRunner().invoke(hub_cli, ["status"]) - assert result.exit_code != 0 - assert "Not authenticated" in result.output + assert result.exit_code != 0 + assert "Not authenticated" in result.output def test_login_with_api_key_does_nothing(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - auth = _make_login() - _patch_auth(monkeypatch, auth) + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + auth = _make_login() + _patch_auth(monkeypatch, auth) - result = CliRunner().invoke(hub_cli, ["login"]) + result = CliRunner().invoke(hub_cli, ["login"]) - assert result.exit_code == 0, result.output - assert auth("login").login_calls == 0 + assert result.exit_code == 0, result.output + assert auth("login").login_calls == 0 def test_login_without_key_runs_device_flow(monkeypatch, no_api_key): - auth = _make_login() - _patch_auth(monkeypatch, auth) + auth = _make_login() + _patch_auth(monkeypatch, auth) - result = CliRunner().invoke(hub_cli, ["login"]) + result = CliRunner().invoke(hub_cli, ["login"]) - assert result.exit_code == 0, result.output - assert auth("login").login_calls == 1 - assert auth("login").credentials is not None + assert result.exit_code == 0, result.output + assert auth("login").login_calls == 1 + assert auth("login").credentials is not None def test_login_hub_url_sets_env(monkeypatch, no_api_key): - _patch_auth(monkeypatch, _make_login(uri="http://127.0.0.1:9999")) + _patch_auth(monkeypatch, _make_login(uri="http://127.0.0.1:9999")) - result = CliRunner().invoke( - hub_cli, ["login", "--hub-url", "http://127.0.0.1:9999"] - ) + result = CliRunner().invoke( + hub_cli, ["login", "--hub-url", "http://127.0.0.1:9999"] + ) - assert result.exit_code == 0, result.output - import os + assert result.exit_code == 0, result.output + import os - assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:9999" + assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:9999" def test_resolve_hub_uri_seeds_env_and_resolves(no_api_key): - import os + import os - from skore_cli import _skore + from skore_cli import _skore - seen = {} + seen = {} - def fake_auth(name): - seen["name"] = name - return SimpleNamespace(URI=lambda: "http://resolved") + def fake_auth(name): + seen["name"] = name + return SimpleNamespace(URI=lambda: "http://resolved") - uri = _skore.resolve_hub_uri("http://127.0.0.1:8000", fake_auth) + uri = _skore.resolve_hub_uri("http://127.0.0.1:8000", fake_auth) - assert seen["name"] == "uri" - assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:8000" - assert uri == "http://resolved" + assert seen["name"] == "uri" + assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:8000" + assert uri == "http://resolved" def test_resolve_hub_uri_without_url_leaves_env(no_api_key): - import os + import os - from skore_cli import _skore + from skore_cli import _skore - uri = _skore.resolve_hub_uri( - None, lambda name: SimpleNamespace(URI=lambda: "http://default") - ) + uri = _skore.resolve_hub_uri( + None, lambda name: SimpleNamespace(URI=lambda: "http://default") + ) - assert "SKORE_HUB_URI" not in os.environ - assert uri == "http://default" + assert "SKORE_HUB_URI" not in os.environ + assert uri == "http://default" def test_logout_clears_session(monkeypatch, no_api_key): - auth = _make_login(credentials=_TokenCreds()) - _patch_auth(monkeypatch, auth) + auth = _make_login(credentials=_TokenCreds()) + _patch_auth(monkeypatch, auth) - result = CliRunner().invoke(hub_cli, ["logout"]) + result = CliRunner().invoke(hub_cli, ["logout"]) - assert result.exit_code == 0, result.output - assert auth("login").credentials is None - assert "cleared" in result.output + assert result.exit_code == 0, result.output + assert auth("login").credentials is None + assert "cleared" in result.output def test_logout_no_session_with_api_key(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - _patch_auth(monkeypatch, _make_login()) + monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") + _patch_auth(monkeypatch, _make_login()) - result = CliRunner().invoke(hub_cli, ["logout"]) + result = CliRunner().invoke(hub_cli, ["logout"]) - assert result.exit_code == 0, result.output - assert "user-managed" in result.output + assert result.exit_code == 0, result.output + assert "user-managed" in result.output def test_logout_no_session_at_all(monkeypatch, no_api_key): - _patch_auth(monkeypatch, _make_login()) + _patch_auth(monkeypatch, _make_login()) - result = CliRunner().invoke(hub_cli, ["logout"]) + result = CliRunner().invoke(hub_cli, ["logout"]) - assert result.exit_code == 0, result.output - assert "No interactive session" in result.output + assert result.exit_code == 0, result.output + assert "No interactive session" in result.output diff --git a/tests/test_hub_api_keys.py b/tests/test_hub_api_keys.py index 84deb15..91066a2 100644 --- a/tests/test_hub_api_keys.py +++ b/tests/test_hub_api_keys.py @@ -10,7 +10,6 @@ import json from datetime import UTC -from types import SimpleNamespace import httpx import pytest @@ -31,9 +30,11 @@ def _transport(handler): def test_require_login_token_errors_without_token(monkeypatch): - monkeypatch.setattr(_client, "ensure_login", lambda: (_ for _ in ()).throw( - click.ClickException("not logged in") - )) + monkeypatch.setattr( + _client, + "ensure_login", + lambda: (_ for _ in ()).throw(click.ClickException("not logged in")), + ) with pytest.raises(click.ClickException) as exc: _client.require_login_token() assert "not logged in" in str(exc.value) diff --git a/tests/test_hub_auth.py b/tests/test_hub_auth.py index c993d6d..4b50162 100644 --- a/tests/test_hub_auth.py +++ b/tests/test_hub_auth.py @@ -11,52 +11,54 @@ class _TokenCreds: - def __init__(self, token: str = "acc"): - self._token = token + def __init__(self, token: str = "acc"): + self._token = token - def __call__(self): - return {"Authorization": f"Bearer {self._token}"} + def __call__(self): + return {"Authorization": f"Bearer {self._token}"} def _make_login(*, credentials=None): - login_mod = SimpleNamespace(credentials=credentials, login_calls=0) + login_mod = SimpleNamespace(credentials=credentials, login_calls=0) - def login(*, timeout=600): - login_mod.login_calls += 1 - login_mod.credentials = _TokenCreds() + def login(*, timeout=600): + login_mod.login_calls += 1 + login_mod.credentials = _TokenCreds() - login_mod.login = login - return lambda name: login_mod + 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" + 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" + 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" + 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 + 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() + 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() From 6c9bf53d852024344d8b5ac09a6b2415849df6eb Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Wed, 1 Jul 2026 20:45:42 +0200 Subject: [PATCH 6/7] fix tests --- tests/test_agent_commands.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index 41c8293..1d48e6f 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -3,10 +3,11 @@ from __future__ import annotations import json +import re from click.testing import CliRunner -from skore_cli.agent import _commands +from skore_cli.agent import _commands, _harnesses from skore_cli.agent._commands import agent from skore_cli.agent._harnesses import DEFAULT_MODEL_ID, HARNESSES, HarnessContext from skore_cli.agent._skore_file import ( @@ -16,6 +17,22 @@ ) from skore_cli.hub import _client +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + +def _plain_output(output: str) -> str: + """Strip ANSI codes from rich-click panels for stable assertions.""" + return _ANSI_ESCAPE.sub("", output) + + +def _mock_harness_on_path(monkeypatch, name: str) -> None: + """Pretend the harness binary is installed without relying on the real PATH.""" + monkeypatch.setattr( + _harnesses.shutil, + "which", + lambda cmd: f"/usr/bin/{cmd}" if cmd == name else None, + ) + def _membership(public_id: str = "ws-1", workspace_id: int = 1): return _client.Membership( @@ -96,10 +113,10 @@ def test_agent_nonexistent_workspace_errors(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" ) - monkeypatch.setattr(_commands, "detect_harnesses", lambda workspace: ["opencode"]) launched: list[str] = [] monkeypatch.setattr( _commands, @@ -118,6 +135,7 @@ 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" ) @@ -137,7 +155,6 @@ def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): "create_api_key", lambda *a, **k: (42, "new-secret"), ) - monkeypatch.setattr(_commands, "detect_harnesses", lambda workspace: ["opencode"]) monkeypatch.setattr( _commands, "launch_harness", @@ -171,7 +188,7 @@ def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): result = CliRunner().invoke(agent, ["--workspace", str(tmp_path)]) assert result.exit_code != 0 - assert "pass --harness" in result.output + assert "pass --harness" in _plain_output(result.output) def test_resolve_api_key_name_deduplicates(): From db2ce9082261ce4aab773090fdcb23e93c41e13b Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Thu, 2 Jul 2026 15:04:57 +0200 Subject: [PATCH 7/7] remove the skore hub subcommand --- README.md | 23 +- pyproject.toml | 4 +- src/skore_cli/__init__.py | 10 +- src/skore_cli/_hub_auth.py | 4 +- src/skore_cli/_skore.py | 15 +- src/skore_cli/agent/_client.py | 138 +++++ src/skore_cli/agent/_commands.py | 4 +- src/skore_cli/hub/__init__.py | 10 - src/skore_cli/hub/_agent_providers.py | 419 ------------- src/skore_cli/hub/_api_keys.py | 308 ---------- src/skore_cli/hub/_client.py | 428 -------------- src/skore_cli/hub/_commands.py | 129 ---- src/skore_cli/hub/_workspaces.py | 190 ------ src/skore_cli/hub/app/__init__.py | 21 - src/skore_cli/hub/app/_form.py | 321 ---------- src/skore_cli/hub/app/_provider_form.py | 288 --------- tests/test_agent_commands.py | 3 +- tests/test_cli.py | 2 +- tests/test_hub.py | 168 ------ tests/test_hub_agent_provider_form.py | 117 ---- tests/test_hub_agent_providers.py | 746 ------------------------ tests/test_hub_api_key_form.py | 132 ----- tests/test_hub_api_keys.py | 472 --------------- tests/test_hub_auth.py | 2 +- tests/test_hub_workspaces.py | 457 --------------- 25 files changed, 160 insertions(+), 4251 deletions(-) create mode 100644 src/skore_cli/agent/_client.py delete mode 100644 src/skore_cli/hub/__init__.py delete mode 100644 src/skore_cli/hub/_agent_providers.py delete mode 100644 src/skore_cli/hub/_api_keys.py delete mode 100644 src/skore_cli/hub/_client.py delete mode 100644 src/skore_cli/hub/_commands.py delete mode 100644 src/skore_cli/hub/_workspaces.py delete mode 100644 src/skore_cli/hub/app/__init__.py delete mode 100644 src/skore_cli/hub/app/_form.py delete mode 100644 src/skore_cli/hub/app/_provider_form.py delete mode 100644 tests/test_hub.py delete mode 100644 tests/test_hub_agent_provider_form.py delete mode 100644 tests/test_hub_agent_providers.py delete mode 100644 tests/test_hub_api_key_form.py delete mode 100644 tests/test_hub_api_keys.py delete mode 100644 tests/test_hub_workspaces.py diff --git a/README.md b/README.md index 2918003..a6db86d 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,10 @@ 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 two areas: - **skills** — discover, install and manage [Agent Skills](https://agentskills.io) from the [probabl-ai/skills](https://github.com/probabl-ai/skills) catalog -- **hub** — authenticate with a Skore Hub instance and manage workspaces, API - keys and agent providers - **agent** — connect a project to the Skore Hub agent, write harness config and launch a local coding agent @@ -17,8 +15,8 @@ Command-line interface for [skore](https://github.com/probabl-ai/skore). pip install skore-cli ``` -The base install is batteries-included: it bundles the `hub` and `agent` -features (so it pulls in `skore`). No extras are required. +The base install is batteries-included: it bundles the `agent` feature (so it +pulls in `skore`). No extras are required. ## Usage @@ -36,26 +34,13 @@ skore skills update # update installed skills skore skills remove # remove installed skills ``` -### Hub - -Authenticate with the hub via `skore hub login` (interactive device flow) or by -setting `SKORE_HUB_API_KEY`. Use `SKORE_HUB_URI` to point at a non-default hub. - -```bash -skore hub login -skore hub status -skore hub api-key create # workspace-scoped API keys -skore hub workspace list # workspaces -skore hub agent-provider list -``` - ### 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: **Claude**, **OpenCode** and **Pi** (must be on `PATH`). Later runs reuse `.skore` in the project directory -(gitignored). +(gitignored). Use `SKORE_HUB_URI` (or `--hub-url`) to point at a non-default hub. ```bash skore agent diff --git a/pyproject.toml b/pyproject.toml index 11977f3..d2d20fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ dependencies = [ "rich>=14.2", "rich-click>=1.9", "textual", - # The `hub` and `agent` commands reuse skore's authentication machinery; the - # `hub api-key` commands call the hub identity API directly with httpx. + # The `agent` command reuses skore's authentication machinery and calls the + # hub identity API directly with httpx. "skore[hub]", "httpx", ] diff --git a/src/skore_cli/__init__.py b/src/skore_cli/__init__.py index bfa2cd5..f3021b6 100644 --- a/src/skore_cli/__init__.py +++ b/src/skore_cli/__init__.py @@ -11,14 +11,12 @@ # 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 click.rich_click.COMMAND_GROUPS = { **getattr(click.rich_click, "COMMAND_GROUPS", {}), "cli": [ {"name": "Agent", "commands": ["agent"]}, - {"name": "Hub", "commands": ["hub"]}, {"name": "Skills", "commands": ["skills"]}, ], } @@ -30,8 +28,7 @@ def cli(ctx) -> None: """Skore command-line interface. - Use ``skore agent`` to connect a project to the Skore Hub agent, - ``skore hub`` to authenticate and manage workspaces, and + Use ``skore agent`` to connect a project to the Skore Hub agent, and ``skore skills`` to install probabl-skills locally. """ if ctx.invoked_subcommand is None: @@ -39,10 +36,9 @@ def cli(ctx) -> None: cli.add_command(skills) -cli.add_command(hub) cli.add_command(agent) # Commands contributed by other packages via the `skore_cli.plugins` entry-point -# group. Kept for third-party extensibility; the built-in `hub`/`agent` commands -# no longer go through it so the CLI never imports `skore` just to show help. +# 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) diff --git a/src/skore_cli/_hub_auth.py b/src/skore_cli/_hub_auth.py index 5bd5ada..7338842 100644 --- a/src/skore_cli/_hub_auth.py +++ b/src/skore_cli/_hub_auth.py @@ -57,9 +57,7 @@ def ensure_login(*, timeout: int = 600) -> str: token = bearer_token() if not token: - raise click.ClickException( - "not logged in; run `skore hub login` or `skore agent` again." - ) + raise click.ClickException("not logged in; run `skore agent` again.") return token diff --git a/src/skore_cli/_skore.py b/src/skore_cli/_skore.py index 81410d2..bfc844d 100644 --- a/src/skore_cli/_skore.py +++ b/src/skore_cli/_skore.py @@ -1,9 +1,9 @@ -"""Lazy access to the (heavy, optional) ``skore`` package for hub/agent commands. +"""Lazy access to the (heavy, optional) ``skore`` package for the agent command. -The ``hub`` and ``agent`` command groups reuse 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. +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 @@ -36,12 +36,11 @@ def auth(submodule: str) -> ModuleType: def resolve_hub_uri( hub_url: str | None, auth_fn: Callable[[str], ModuleType] = auth ) -> str: - """Resolve the hub base URL exactly like ``skore hub login``. + """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). This keeps ``hub login``, - ``agent`` and ``skore hub login`` all pointing at the same hub. + 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. diff --git a/src/skore_cli/agent/_client.py b/src/skore_cli/agent/_client.py new file mode 100644 index 0000000..17f1c18 --- /dev/null +++ b/src/skore_cli/agent/_client.py @@ -0,0 +1,138 @@ +"""Thin HTTP client for the hub API used by the ``skore agent`` command. + +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. +""" + +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() + ] diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index f092575..b1303db 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -11,6 +11,7 @@ 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._harnesses import ( DEFAULT_MODEL_ID, HARNESS_NAMES, @@ -20,7 +21,6 @@ launch_harness, ) from skore_cli.agent._skore_file import SkoreConfig, ensure_gitignore_entry -from skore_cli.hub import _client PROJECT_PERMISSIONS = ( "create:project", @@ -153,7 +153,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, like `skore hub login`." + f"{URI_ENV} env var or the public hub." ), ) @click.option( diff --git a/src/skore_cli/hub/__init__.py b/src/skore_cli/hub/__init__.py deleted file mode 100644 index e96f0e9..0000000 --- a/src/skore_cli/hub/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""The ``skore hub`` command group (authentication + workspace API keys). - -Re-exports the ``hub`` click group. Heavy ``skore``/``httpx``/``textual`` -imports stay deferred inside the command callbacks so building the CLI (and -``--help``) never imports them. -""" - -from skore_cli.hub._commands import hub - -__all__ = ["hub"] diff --git a/src/skore_cli/hub/_agent_providers.py b/src/skore_cli/hub/_agent_providers.py deleted file mode 100644 index 219312b..0000000 --- a/src/skore_cli/hub/_agent_providers.py +++ /dev/null @@ -1,419 +0,0 @@ -"""The ``skore hub agent-provider`` group: ``add``/``list``/``activate``/``remove``. - -Manage a workspace's agent LLM provider configuration, mirroring the hub UI. -Every command requires a prior ``skore hub login`` (a stored OAuth token) and is -scoped to a single workspace (resolved from ``-w/--workspace`` or, in a -terminal, an interactive picker). Heavy ``httpx``/``textual`` imports stay -deferred inside the callbacks. -""" - -from __future__ import annotations - -import rich_click as click - -from skore_cli._style import console -from skore_cli.hub import _client -from skore_cli.hub._api_keys import ( - _HUB_URL_OPTION, - _is_interactive, - _resolve_session, -) -from skore_cli.hub._client import AGENT_PROVIDERS - -click.rich_click.COMMAND_GROUPS = { - **getattr(click.rich_click, "COMMAND_GROUPS", {}), - "cli hub agent-provider": [ - {"name": "Manage", "commands": ["add", "list", "activate", "remove"]}, - ], -} - - -@click.group("agent-provider", invoke_without_command=True) -@click.pass_context -def agent_provider(ctx) -> None: - """Add, list, activate and remove a workspace's agent LLM providers.""" - if ctx.invoked_subcommand is None: - click.echo(ctx.get_help()) - - -def _resolve_target_workspace( - memberships: list[_client.Membership], - workspace: str | None, - *, - interactive: bool, -) -> _client.Membership: - """Resolve the single workspace to act on. - - A ``-w/--workspace`` flag wins (validated against memberships). Otherwise a - lone membership is used directly; with several, an interactive picker is - shown when possible, else a usage error lists the available public ids. - """ - if not memberships: - raise click.ClickException( - "you are not a member of any hub workspace; create or join one first." - ) - - by_public = {m.public_id: m for m in memberships} - if workspace is not None: - if workspace not in by_public: - raise click.UsageError( - f"unknown workspace '{workspace}'; you belong to: " - f"{', '.join(by_public) or 'none'}." - ) - return by_public[workspace] - - if len(memberships) == 1: - return memberships[0] - - if interactive: - from skore_cli.hub.app import IdPicker - - labels = [(m.workspace_id, m.public_id) for m in memberships] - app = IdPicker(labels, title="Choose the workspace.") - app.run() - if app.result is None: - raise click.Abort() - return next(m for m in memberships if m.workspace_id == app.result) - - raise click.UsageError( - f"pass --workspace (one of: {', '.join(by_public) or 'none'})." - ) - - -def _model_or_auto(provider: _client.ProviderEntry) -> str: - return provider.selected_model or "auto" - - -def _secrets_summary(provider: _client.ProviderEntry) -> str: - flags = [] - if provider.anthropic_api_key_set: - flags.append("anthropic-key") - if provider.bedrock_external_id_set: - flags.append("bedrock-external-id") - if provider.aws_access_key_id_set: - flags.append("aws-access-key-id") - if provider.aws_secret_access_key_set: - flags.append("aws-secret-access-key") - return ", ".join(flags) or "-" - - -def _build_payload( - *, - name: str, - provider: str, - model: str | None, - anthropic_api_key: str | None, - aws_region: str | None, - bedrock_role_arn: str | None, - bedrock_external_id: str | None, - aws_access_key_id: str | None, - aws_secret_access_key: str | None, -) -> dict[str, object]: - """Build the create payload, mirroring the hub UI (drops irrelevant fields).""" - payload: dict[str, object] = {"name": name, "provider": provider} - if provider == "skore": - return payload - payload["selected_model"] = model - if provider == "anthropic": - payload["anthropic_api_key"] = anthropic_api_key - return payload - # bedrock: AWS fields are optional. - payload.update( - { - key: value - for key, value in ( - ("aws_region", aws_region), - ("bedrock_role_arn", bedrock_role_arn), - ("bedrock_external_id", bedrock_external_id), - ("aws_access_key_id", aws_access_key_id), - ("aws_secret_access_key", aws_secret_access_key), - ) - if value - } - ) - return payload - - -@agent_provider.command("add") -@_HUB_URL_OPTION -@click.option( - "--workspace", "-w", default=None, help="Hub workspace (public id) to act on." -) -@click.option("--name", default=None, help="A label for the provider.") -@click.option( - "--provider", - type=click.Choice(AGENT_PROVIDERS), - default=None, - help="Provider type to register.", -) -@click.option("--model", default=None, help="Model to use (for anthropic/bedrock).") -@click.option("--anthropic-api-key", default=None, help="Anthropic API key.") -@click.option("--aws-region", default=None, help="AWS region (bedrock).") -@click.option("--bedrock-role-arn", default=None, help="Bedrock role ARN.") -@click.option("--bedrock-external-id", default=None, help="Bedrock external id.") -@click.option("--aws-access-key-id", default=None, help="AWS access key id (bedrock).") -@click.option( - "--aws-secret-access-key", default=None, help="AWS secret access key (bedrock)." -) -@click.option( - "--activate/--no-activate", - default=False, - help="Activate the provider right after adding it.", -) -@click.option( - "--yes", - is_flag=True, - default=False, - help="Skip the interactive form even in a terminal (use the flags as given).", -) -def add( - hub_url: str | None, - workspace: str | None, - name: str | None, - provider: str | None, - model: str | None, - anthropic_api_key: str | None, - aws_region: str | None, - bedrock_role_arn: str | None, - bedrock_external_id: str | None, - aws_access_key_id: str | None, - aws_secret_access_key: str | None, - activate: bool, - yes: bool, -) -> None: - """Add an agent LLM provider to a workspace. - - In a terminal, omitting ``--provider`` (or a field it requires) opens an - interactive, provider-adaptive form. Non-interactively (or with ``--yes``), - ``--workspace`` (unless you belong to one), ``--name`` and ``--provider`` are - required; bring-your-own providers also need ``--model`` (and, for - ``anthropic``, ``--anthropic-api-key``). - """ - uri, token, _user_id, memberships = _resolve_session(hub_url) - interactive = _is_interactive() - membership = _resolve_target_workspace( - memberships, workspace, interactive=interactive - ) - providers = _client.agent_providers(uri, token, membership.workspace_id) - - def _missing_required() -> bool: - if provider is None: - return True - if provider != "skore" and not model: - return True - return provider == "anthropic" and not anthropic_api_key - - if interactive and not yes and _missing_required(): - from skore_cli.hub.app import AgentProviderForm - - has_active = any(p.is_active for p in providers.providers) - app = AgentProviderForm( - providers.available_models, - encryption_configured=providers.encryption_configured, - name=name or "", - activate_default=activate or not has_active, - ) - app.run() - if app.result is None: - console.print("Nothing added.") - return - result = app.result - chosen_name = result.name - chosen_provider = result.provider - model = result.selected_model - anthropic_api_key = result.anthropic_api_key - aws_region = result.aws_region - bedrock_role_arn = result.bedrock_role_arn - bedrock_external_id = result.bedrock_external_id - aws_access_key_id = result.aws_access_key_id - aws_secret_access_key = result.aws_secret_access_key - activate = result.activate - else: - if provider is None: - raise click.UsageError( - f"pass --provider (one of: {', '.join(AGENT_PROVIDERS)})." - ) - if not name: - raise click.UsageError("pass --name for the provider.") - if provider != "skore": - if not providers.encryption_configured: - raise click.ClickException( - f"the '{membership.public_id}' workspace has no encryption " - "configured; bring-your-own providers are unavailable. Use " - "--provider skore or configure encryption in the hub." - ) - available = providers.available_models.get(provider, []) - if not model: - raise click.UsageError( - f"pass --model for '{provider}' (one of: " - f"{', '.join(available) or 'none'})." - ) - if model not in available: - raise click.UsageError( - f"unknown model '{model}' for '{provider}'; available: " - f"{', '.join(available) or 'none'}." - ) - if provider == "anthropic" and not anthropic_api_key: - raise click.UsageError( - "pass --anthropic-api-key for the anthropic provider." - ) - chosen_name = name - chosen_provider = provider - - payload = _build_payload( - name=chosen_name, - provider=chosen_provider, - model=model, - anthropic_api_key=anthropic_api_key, - aws_region=aws_region, - bedrock_role_arn=bedrock_role_arn, - bedrock_external_id=bedrock_external_id, - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - ) - created = _client.create_agent_provider( - uri, token, membership.workspace_id, payload=payload - ) - if activate: - _client.activate_agent_provider(uri, token, membership.workspace_id, created.id) - - console.print( - f"[skore.ok]+ added provider[/] " - f"[skore.muted](id {created.id}, workspace {membership.public_id})[/]" - ) - console.print(f" name : {created.name}") - console.print(f" provider : {created.provider}") - console.print(f" model : {_model_or_auto(created)}") - console.print(f" active : {'yes' if activate else 'no'}") - - -@agent_provider.command("list") -@_HUB_URL_OPTION -@click.option( - "--workspace", "-w", default=None, help="Hub workspace (public id) to act on." -) -def list_providers(hub_url: str | None, workspace: str | None) -> None: - """List a workspace's agent LLM providers (secrets are never shown).""" - uri, token, _user_id, memberships = _resolve_session(hub_url) - membership = _resolve_target_workspace( - memberships, workspace, interactive=_is_interactive() - ) - providers = _client.agent_providers(uri, token, membership.workspace_id) - - if not providers.providers: - console.print("[skore.muted]No agent providers.[/]") - else: - from rich.table import Table - - table = Table(box=None, pad_edge=False) - for column in ("id", "name", "provider", "model", "active", "secrets"): - table.add_column(column) - for entry in providers.providers: - table.add_row( - str(entry.id), - entry.name, - entry.provider, - _model_or_auto(entry), - "yes" if entry.is_active else "-", - _secrets_summary(entry), - ) - console.print(table) - - if not providers.encryption_configured: - console.print( - "[skore.muted]Encryption is not configured for this workspace; " - "bring-your-own (anthropic/bedrock) providers are unavailable.[/]" - ) - - -def _pick_provider(providers: list[_client.ProviderEntry], *, title: str) -> int | None: - from skore_cli.hub.app import IdPicker - - labels = [ - ( - entry.id, - f"{entry.id} {entry.name} ({entry.provider}/" - f"{_model_or_auto(entry)})" + (" *active" if entry.is_active else ""), - ) - for entry in providers - ] - app = IdPicker(labels, title=title) - app.run() - return app.result - - -@agent_provider.command("activate") -@_HUB_URL_OPTION -@click.option( - "--workspace", "-w", default=None, help="Hub workspace (public id) to act on." -) -@click.option("--id", "config_id", type=int, default=None, help="Provider id.") -def activate(hub_url: str | None, workspace: str | None, config_id: int | None) -> None: - """Activate one provider for the workspace (deactivates the others).""" - uri, token, _user_id, memberships = _resolve_session(hub_url) - interactive = _is_interactive() - membership = _resolve_target_workspace( - memberships, workspace, interactive=interactive - ) - - if config_id is None: - providers = _client.agent_providers(uri, token, membership.workspace_id) - if not providers.providers: - console.print("[skore.muted]No agent providers to activate.[/]") - return - if not interactive: - raise click.UsageError( - "pass --id to activate non-interactively (ids: " - f"{', '.join(str(p.id) for p in providers.providers)})." - ) - config_id = _pick_provider( - providers.providers, title="Choose the provider to activate." - ) - if config_id is None: - console.print("Nothing activated.") - return - - _client.activate_agent_provider(uri, token, membership.workspace_id, config_id) - console.print(f"[skore.ok]+[/] activated provider {config_id}.") - - -@agent_provider.command("remove") -@_HUB_URL_OPTION -@click.option( - "--workspace", "-w", default=None, help="Hub workspace (public id) to act on." -) -@click.option("--id", "config_id", type=int, default=None, help="Provider id.") -@click.option( - "--yes", is_flag=True, default=False, help="Skip the confirmation prompt." -) -def remove( - hub_url: str | None, workspace: str | None, config_id: int | None, yes: bool -) -> None: - """Remove a provider from the workspace, or pick one interactively.""" - uri, token, _user_id, memberships = _resolve_session(hub_url) - interactive = _is_interactive() - membership = _resolve_target_workspace( - memberships, workspace, interactive=interactive - ) - - if config_id is None: - providers = _client.agent_providers(uri, token, membership.workspace_id) - if not providers.providers: - console.print("[skore.muted]No agent providers to remove.[/]") - return - if not interactive: - raise click.UsageError( - "pass --id to remove non-interactively (ids: " - f"{', '.join(str(p.id) for p in providers.providers)})." - ) - config_id = _pick_provider( - providers.providers, title="Choose the provider to remove." - ) - if config_id is None: - console.print("Nothing removed.") - return - - if not yes: - click.confirm(f"Remove provider {config_id}?", abort=True) - _client.delete_agent_provider(uri, token, membership.workspace_id, config_id) - console.print(f"[skore.ok]-[/] removed provider {config_id}.") diff --git a/src/skore_cli/hub/_api_keys.py b/src/skore_cli/hub/_api_keys.py deleted file mode 100644 index fc6214c..0000000 --- a/src/skore_cli/hub/_api_keys.py +++ /dev/null @@ -1,308 +0,0 @@ -"""The ``skore hub api-key`` click group: ``create``, ``list`` and ``revoke``. - -Mint, list and revoke workspace-scoped hub API keys, mirroring the hub UI. Every -command requires a prior ``skore hub login`` (a stored OAuth token); the current -user profile (``/identity/users/me``) provides the workspaces and the -permissions grantable in each. Heavy ``httpx``/``textual`` imports stay deferred -inside the callbacks. -""" - -from __future__ import annotations - -import calendar -import sys -from datetime import UTC, datetime - -import rich_click as click - -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.hub import _client -from skore_cli.hub._client import PERMISSIONS - -# Local literal (avoids importing the heavy ``skore`` package just for help). -API_KEY_ENV = "SKORE_HUB_API_KEY" - -VALIDITY_VALUES = ["1", "3", "6", "never"] - -click.rich_click.COMMAND_GROUPS = { - **getattr(click.rich_click, "COMMAND_GROUPS", {}), - "cli hub api-key": [ - {"name": "Manage", "commands": ["create", "list", "revoke"]}, - ], -} - - -@click.group("api-key", invoke_without_command=True) -@click.pass_context -def api_key(ctx) -> None: - """Create, list and revoke workspace-scoped hub API keys.""" - if ctx.invoked_subcommand is None: - click.echo(ctx.get_help()) - - -def _is_interactive() -> bool: - return sys.stdin.isatty() and sys.stdout.isatty() - - -def _add_months(start: datetime, months: int) -> datetime: - """Add ``months`` to ``start``, clamping the day to the target month's end.""" - index = start.month - 1 + months - year = start.year + index // 12 - month = index % 12 + 1 - day = min(start.day, calendar.monthrange(year, month)[1]) - return start.replace(year=year, month=month, day=day) - - -def _expires_at(validity: str) -> str | None: - """Compute an ISO 8601 UTC expiry from a validity choice (``never`` -> None).""" - if validity == "never": - return None - return _add_months(datetime.now(UTC), int(validity)).isoformat() - - -def _resolve_session( - hub_url: str | None, -) -> tuple[str, str, str, list[_client.Membership]]: - """Resolve hub URL + login token + current user id + workspace memberships.""" - uri = resolve_hub_uri(hub_url, _auth) - token = _client.require_login_token() - user_id, memberships = _client.me(uri, token) - return uri, token, user_id, memberships - - -_HUB_URL_OPTION = click.option( - "--hub-url", - 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, like `skore hub login`." - ), -) - - -def _print_created( - secret: str, - api_key_id: int, - public_id: str, - permissions: list[str], - expires_at: str | None, -) -> None: - console.print( - f"\n[skore.ok]+ created API key[/] " - f"[skore.muted](id {api_key_id}, workspace {public_id})[/]" - ) - console.print("[yellow]Copy it now -- the hub shows the secret only once:[/]") - console.print(f"\n [bold]{secret}[/]\n") - console.print(f" permissions : {', '.join(permissions)}") - console.print(f" expires : {expires_at or 'never'}") - console.print( - "\n[skore.muted]Use it by exporting it in your shell:[/]\n" - f' export {API_KEY_ENV}="{secret}"' - ) - - -@api_key.command("create") -@_HUB_URL_OPTION -@click.option( - "--workspace", - "-w", - default=None, - help="Hub workspace (public id) to scope the key to.", -) -@click.option("--name", default=None, help="A label for the key.") -@click.option( - "--permission", - "-p", - "permissions", - multiple=True, - type=click.Choice(PERMISSIONS), - help="Permission to grant (repeatable). Must be grantable in the workspace.", -) -@click.option( - "--validity", - type=click.Choice(VALIDITY_VALUES), - default="3", - show_default=True, - help="Months until the key expires, or 'never'.", -) -@click.option( - "--yes", - is_flag=True, - default=False, - help="Skip the interactive form even in a terminal (use the flags as given).", -) -def create( - hub_url: str | None, - workspace: str | None, - name: str | None, - permissions: tuple[str, ...], - validity: str, - yes: bool, -) -> None: - """Create a workspace-scoped API key. - - In a terminal, omitting ``--workspace`` or ``--permission`` opens an - interactive form (prefilled with any flags). Non-interactively (or with - ``--yes``), ``--workspace``, ``--name`` and at least one ``--permission`` are - required. - """ - uri, token, user_id, memberships = _resolve_session(hub_url) - if not memberships: - raise click.ClickException( - "you are not a member of any hub workspace; create or join one first." - ) - - by_public = {m.public_id: m for m in memberships} - grantable = {m.workspace_id: sorted(m.permissions) for m in memberships} - - if _is_interactive() and not yes and (workspace is None or not permissions): - from skore_cli.hub.app import ApiKeyForm - - preselect = ( - by_public[workspace].workspace_id if workspace in by_public else None - ) - app = ApiKeyForm( - [(m.workspace_id, m.public_id) for m in memberships], - grantable, - name=name or "", - permissions=list(permissions), - validity=validity, - preselect_workspace_id=preselect, - ) - app.run() - if app.result is None: - console.print("Nothing created.") - return - result = app.result - chosen_name = result.name - workspace_id = result.workspace_id - public_id = result.workspace_public_id - perms = result.permissions - chosen_validity = result.validity - else: - if workspace is None: - raise click.UsageError( - "pass --workspace (one of: " - f"{', '.join(by_public) or 'none'})." - ) - if workspace not in by_public: - raise click.UsageError( - f"unknown workspace '{workspace}'; you belong to: " - f"{', '.join(by_public) or 'none'}." - ) - if not name: - raise click.UsageError("pass --name for the key.") - if not permissions: - raise click.UsageError( - f"pass at least one --permission (one of: {', '.join(PERMISSIONS)})." - ) - membership = by_public[workspace] - ungranted = [p for p in permissions if p not in membership.permissions] - if ungranted: - raise click.UsageError( - f"you cannot grant {', '.join(ungranted)} in '{workspace}'; " - f"grantable: {', '.join(sorted(membership.permissions)) or 'none'}." - ) - chosen_name = name - workspace_id = membership.workspace_id - public_id = membership.public_id - perms = list(permissions) - chosen_validity = validity - - expires_at = _expires_at(chosen_validity) - api_key_id, secret = _client.create_api_key( - uri, - token, - user_id, - name=chosen_name, - permissions=perms, - workspace_id=workspace_id, - expires_at=expires_at, - ) - _print_created(secret, api_key_id, public_id, perms, expires_at) - - -@api_key.command("list") -@_HUB_URL_OPTION -@click.option( - "--workspace", - "-w", - default=None, - help="Only show keys for this workspace (public id).", -) -def list_keys(hub_url: str | None, workspace: str | None) -> None: - """List your hub API keys (metadata only; secrets are never shown).""" - uri, token, user_id, memberships = _resolve_session(hub_url) - public_by_id = {m.workspace_id: m.public_id for m in memberships} - - keys = _client.list_api_keys(uri, token, user_id) - if workspace is not None: - wanted = {m.workspace_id for m in memberships if m.public_id == workspace} - keys = [key for key in keys if key.workspace_id in wanted] - - if not keys: - console.print("[skore.muted]No API keys.[/]") - return - - from rich.table import Table - - table = Table(box=None, pad_edge=False) - for column in ("id", "name", "workspace", "created", "expires"): - table.add_column(column) - for key in keys: - table.add_row( - str(key.id), - key.name or "-", - public_by_id.get(key.workspace_id, str(key.workspace_id)), - (key.created_at or "-")[:19], - (key.expires_at or "never")[:19] if key.expires_at else "never", - ) - console.print(table) - - -@api_key.command("revoke") -@_HUB_URL_OPTION -@click.option( - "--id", "api_key_id", type=int, default=None, help="API key id to revoke." -) -@click.option( - "--yes", is_flag=True, default=False, help="Skip the confirmation prompt." -) -def revoke(hub_url: str | None, api_key_id: int | None, yes: bool) -> None: - """Revoke (delete) an API key by id, or pick one interactively.""" - uri, token, user_id, memberships = _resolve_session(hub_url) - public_by_id = {m.workspace_id: m.public_id for m in memberships} - - if api_key_id is None: - keys = _client.list_api_keys(uri, token, user_id) - if not keys: - console.print("[skore.muted]No API keys to revoke.[/]") - return - if not _is_interactive(): - raise click.UsageError( - "pass --id to revoke non-interactively (ids: " - f"{', '.join(str(key.id) for key in keys)})." - ) - from skore_cli.hub.app import IdPicker - - labels = [ - ( - key.id, - f"{key.id} {key.name or '-'} " - f"({public_by_id.get(key.workspace_id, key.workspace_id)})", - ) - for key in keys - ] - app = IdPicker(labels, title="Choose the API key to revoke.") - app.run() - if app.result is None: - console.print("Nothing revoked.") - return - api_key_id = app.result - - if not yes: - click.confirm(f"Revoke API key {api_key_id}?", abort=True) - _client.delete_api_key(uri, token, user_id, api_key_id) - console.print(f"[skore.ok]-[/] revoked API key {api_key_id}.") diff --git a/src/skore_cli/hub/_client.py b/src/skore_cli/hub/_client.py deleted file mode 100644 index 3b0d348..0000000 --- a/src/skore_cli/hub/_client.py +++ /dev/null @@ -1,428 +0,0 @@ -"""Thin HTTP client for the hub API used by ``skore hub`` management commands. - -Pure, testable functions over the hub's ``/identity`` and ``/agent`` endpoints. -``httpx`` is imported lazily inside the calls so building the CLI stays cheap. -All management calls authenticate with the stored interactive login token as a -bearer. - -The current user's profile (``GET /identity/users/me``) is the single source of -truth for both the workspaces the user belongs to (``workspace_id`` + -``public_id``) and the permissions grantable in each of them, mirroring how the -hub UI gates the forms. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -import rich_click as click - -from skore_cli._hub_auth import ensure_login - -# The grantable permissions, kept as local literals so ``--help`` never imports -# the (heavy) ``skore``/hub packages. Mirrors hub ``Permission`` enum values. -PERMISSIONS = [ - "create:project", - "read:project", - "update:project", - "delete:project", - "create:invitation", - "read:invitation", - "delete:invitation", -] - -_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 - - -# The agent provider types the hub supports, kept as local literals. -AGENT_PROVIDERS = ["skore", "anthropic", "bedrock"] - - -@dataclass(frozen=True) -class ProviderEntry: - """A workspace's registered agent provider (secrets are masked as ``*_set``).""" - - id: int - name: str - is_active: bool - provider: str - selected_model: str | None - aws_region: str | None - bedrock_role_arn: str | None - anthropic_api_key_set: bool - bedrock_external_id_set: bool - aws_access_key_id_set: bool - aws_secret_access_key_set: bool - - -@dataclass(frozen=True) -class AgentProviders: - """The workspace's providers plus the UI metadata the create form needs.""" - - providers: list[ProviderEntry] - available_models: dict[str, list[str]] - encryption_configured: bool - - -@dataclass(frozen=True) -class MemberInfo: - """A workspace member (no email/name is exposed by the hub, only the id).""" - - user_id: str - role: str - invited_by: str | None - - -@dataclass(frozen=True) -class WorkspaceInfo: - """A workspace the user can act on, with its members when fetched in detail.""" - - id: int - public_id: str - is_public: bool - created_at: str | None - members: list[MemberInfo] | None - - -def require_login_token() -> str: - """Return a fresh OAuth bearer token, or error telling the user to log in. - - This gates ``skore hub api-key`` behind a prior ``skore hub login``: an - ``SKORE_HUB_API_KEY`` alone is intentionally not sufficient to mint new keys. - """ - return ensure_login() - - -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 hub login` 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: - """Revoke an 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=f"revoking API key {api_key_id}") - - -def _provider_entry(item: dict[str, Any]) -> ProviderEntry: - return ProviderEntry( - id=item["id"], - name=item["name"], - is_active=bool(item.get("is_active")), - provider=item["provider"], - selected_model=item.get("selected_model"), - aws_region=item.get("aws_region"), - bedrock_role_arn=item.get("bedrock_role_arn"), - anthropic_api_key_set=bool(item.get("anthropic_api_key_set")), - bedrock_external_id_set=bool(item.get("bedrock_external_id_set")), - aws_access_key_id_set=bool(item.get("aws_access_key_id_set")), - aws_secret_access_key_set=bool(item.get("aws_secret_access_key_set")), - ) - - -def agent_providers( - hub_url: str, token: str, workspace_id: int, *, transport: Any = None -) -> AgentProviders: - """Return a workspace's agent providers plus create-form metadata.""" - with _client(hub_url, token, transport) as client: - response = client.get(f"/agent/workspaces/{workspace_id}/providers") - _raise_for(response, context="listing agent providers") - data = response.json() - return AgentProviders( - providers=[_provider_entry(item) for item in data.get("providers") or []], - available_models={ - key: list(value) - for key, value in (data.get("available_models") or {}).items() - }, - encryption_configured=bool(data.get("encryption_configured")), - ) - - -def create_agent_provider( - hub_url: str, - token: str, - workspace_id: int, - *, - payload: dict[str, Any], - transport: Any = None, -) -> ProviderEntry: - """Register a new agent provider; return the created (masked) entry.""" - with _client(hub_url, token, transport) as client: - response = client.post( - f"/agent/workspaces/{workspace_id}/providers", json=payload - ) - _raise_for( - response, - context="adding the provider (needs workspace owner/admin)", - ) - return _provider_entry(response.json()) - - -def activate_agent_provider( - hub_url: str, - token: str, - workspace_id: int, - config_id: int, - *, - transport: Any = None, -) -> None: - """Activate one provider for the workspace (deactivates the others).""" - with _client(hub_url, token, transport) as client: - response = client.post( - f"/agent/workspaces/{workspace_id}/providers/{config_id}/activate" - ) - _raise_for( - response, - context=f"activating provider {config_id} (needs workspace owner/admin)", - ) - - -def delete_agent_provider( - hub_url: str, - token: str, - workspace_id: int, - config_id: int, - *, - transport: Any = None, -) -> None: - """Remove a provider from the workspace.""" - with _client(hub_url, token, transport) as client: - response = client.delete( - f"/agent/workspaces/{workspace_id}/providers/{config_id}" - ) - _raise_for( - response, - context=f"removing provider {config_id} (needs workspace owner/admin)", - ) - - -def _workspace_info(item: dict[str, Any]) -> WorkspaceInfo: - members = item.get("members") - return WorkspaceInfo( - id=int(item["id"]), - public_id=item["public_id"], - is_public=bool(item.get("is_public")), - created_at=item.get("created_at"), - members=( - [ - MemberInfo( - user_id=str(member["user_id"]), - role=member.get("role") or "", - invited_by=member.get("invited_by"), - ) - for member in members - ] - if members is not None - else None - ), - ) - - -def list_workspaces( - hub_url: str, token: str, *, transport: Any = None -) -> list[WorkspaceInfo]: - """Return every workspace the user belongs to (following the cursor).""" - workspaces: list[WorkspaceInfo] = [] - cursor: int | None = None - with _client(hub_url, token, transport) as client: - while True: - params: dict[str, Any] = {"limit": 100} - if cursor is not None: - params["cursor"] = cursor - response = client.get("/identity/workspaces", params=params) - _raise_for(response, context="listing your workspaces") - data = response.json() - workspaces.extend(_workspace_info(item) for item in data.get("items") or []) - cursor = data.get("next_cursor") - if cursor is None: - break - return workspaces - - -def get_workspace( - hub_url: str, token: str, workspace_id: int, *, transport: Any = None -) -> WorkspaceInfo: - """Return a single workspace (with its members) by internal id.""" - with _client(hub_url, token, transport) as client: - response = client.get(f"/identity/workspaces/{workspace_id}") - _raise_for(response, context="fetching the workspace") - return _workspace_info(response.json()) - - -def create_workspace( - hub_url: str, token: str, *, public_id: str, transport: Any = None -) -> int: - """Create a workspace; return its internal id. - - The hub slugifies ``public_id`` and auto-suffixes it when taken, so the - stored public id may differ from the requested one (re-fetch to confirm). - """ - with _client(hub_url, token, transport) as client: - response = client.post("/identity/workspaces", json={"public_id": public_id}) - _raise_for(response, context="creating the workspace") - return int(response.json()["id"]) - - -def check_public_id( - hub_url: str, token: str, public_id: str, *, transport: Any = None -) -> tuple[bool, str | None]: - """Return ``(available, suggested_slug)`` for a candidate workspace public id.""" - with _client(hub_url, token, transport) as client: - response = client.get( - "/identity/workspaces/public-id-availability", - params={"public_id": public_id}, - ) - _raise_for(response, context="checking workspace availability") - data = response.json() - return bool(data.get("available")), data.get("suggested_slug") - - -def update_workspace( - hub_url: str, - token: str, - workspace_id: int, - *, - public_id: str, - transport: Any = None, -) -> None: - """Rename a workspace (its ``public_id``); needs workspace owner/admin.""" - with _client(hub_url, token, transport) as client: - response = client.put( - f"/identity/workspaces/{workspace_id}", json={"public_id": public_id} - ) - _raise_for( - response, - context="renaming the workspace (needs workspace owner/admin)", - ) - - -def delete_workspace( - hub_url: str, token: str, workspace_id: int, *, transport: Any = None -) -> None: - """Delete a workspace; owner only (hard delete on the hub).""" - with _client(hub_url, token, transport) as client: - response = client.delete(f"/identity/workspaces/{workspace_id}") - _raise_for( - response, - context="deleting the workspace (owner only)", - ) diff --git a/src/skore_cli/hub/_commands.py b/src/skore_cli/hub/_commands.py deleted file mode 100644 index 4f89481..0000000 --- a/src/skore_cli/hub/_commands.py +++ /dev/null @@ -1,129 +0,0 @@ -"""The ``skore hub`` command group to authenticate with a Skore Hub instance. - -Auth mirrors ``skore``'s in-process authentication: - -* an **API key** is user-managed through the ``SKORE_HUB_API_KEY`` environment - variable; -* otherwise ``skore.login()`` runs the interactive device flow and keeps the - token in memory for the current process. - -Heavy ``skore`` imports are deferred into the command callbacks so building the -CLI (and ``--help``) never imports the ``skore`` package. -""" - -from __future__ import annotations - -import os - -import rich_click as click - -from skore_cli._hub_auth import API_KEY_ENV, auth_kind, clear_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 - -click.rich_click.COMMAND_GROUPS = { - **getattr(click.rich_click, "COMMAND_GROUPS", {}), - "cli hub": [ - {"name": "Authentication", "commands": ["login", "logout", "status"]}, - {"name": "API keys", "commands": ["api-key"]}, - {"name": "Agent", "commands": ["agent-provider"]}, - {"name": "Workspace", "commands": ["workspace"]}, - ], -} - - -@click.group(invoke_without_command=True) -@click.pass_context -def hub(ctx) -> None: - """Authenticate this machine with a Skore Hub instance.""" - if ctx.invoked_subcommand is None: - click.echo(ctx.get_help()) - - -@hub.command("login") -@click.option( - "--hub-url", - default=None, - help=( - "Base URL of the hub (e.g. http://127.0.0.1:8000). Defaults to " - f"the {URI_ENV} env var or the public hub." - ), -) -@click.option( - "--timeout", - default=600, - show_default=True, - help="Seconds to wait for the interactive device login to complete.", -) -def login(hub_url: str | None, timeout: int) -> None: - """Log in to the hub. - - With an API key (``SKORE_HUB_API_KEY``) there is nothing to do: it is - user-managed and read from the environment. Without one, run the interactive - device flow for this process. - """ - uri = resolve_hub_uri(hub_url, _auth) - - if os.environ.get(API_KEY_ENV): - console.print( - f"[skore.ok]Using the API key from[/] [bold]{API_KEY_ENV}[/] " - f"[skore.ok]for {uri}.[/]\n" - " [skore.muted]Nothing is stored; the key is read from the " - "environment.[/]" - ) - return - - _auth("login").login(timeout=timeout) - - -@hub.command("logout") -def logout() -> None: - """Clear the in-process interactive session. - - The API key (``SKORE_HUB_API_KEY``) is user-managed and not revoked here. - """ - if clear_login(): - console.print("[skore.ok]-[/] cleared the interactive session.") - return - - if os.environ.get(API_KEY_ENV): - console.print( - f"No interactive session. The API key from [bold]{API_KEY_ENV}[/] is " - "user-managed; unset it yourself to stop using it." - ) - else: - console.print("No interactive session to clear.") - - -@hub.command("status") -def status() -> None: - """Show how this process will authenticate to the hub.""" - kind = auth_kind() - - console.print(f"hub URI : [skore.path]{_auth('uri').URI()}[/]") - if os.environ.get(API_KEY_ENV): - console.print(f"API key (env): [skore.ok]set[/] ({API_KEY_ENV})") - else: - console.print("API key (env): [skore.muted]not set[/]") - - if kind == "bearer": - console.print("session : [skore.ok]interactive token[/] (in memory)") - elif kind == "api_key": - console.print("session : [skore.ok]API key[/] (from environment)") - else: - console.print("session : [skore.muted]none[/]") - raise click.ClickException( - "Not authenticated. Set SKORE_HUB_API_KEY or run `skore hub login`." - ) - - -from skore_cli.hub._agent_providers import ( # noqa: E402 - agent_provider as _agent_provider_group, -) -from skore_cli.hub._api_keys import api_key as _api_key_group # noqa: E402 -from skore_cli.hub._workspaces import workspace as _workspace_group # noqa: E402 - -hub.add_command(_api_key_group) -hub.add_command(_agent_provider_group) -hub.add_command(_workspace_group) diff --git a/src/skore_cli/hub/_workspaces.py b/src/skore_cli/hub/_workspaces.py deleted file mode 100644 index 2fa2eac..0000000 --- a/src/skore_cli/hub/_workspaces.py +++ /dev/null @@ -1,190 +0,0 @@ -"""The ``skore hub workspace`` group: list/show/create/rename/delete. - -Manage the lifecycle of the hub workspaces you belong to, mirroring the hub UI. -Every command requires a prior ``skore hub login`` (a stored OAuth token). -Commands that target one workspace take ``-w/--workspace`` (a public id) or, in -a terminal, fall back to an interactive picker. Heavy ``httpx``/``textual`` -imports stay deferred inside the callbacks. -""" - -from __future__ import annotations - -import rich_click as click - -from skore_cli._style import console -from skore_cli.hub import _client -from skore_cli.hub._agent_providers import _resolve_target_workspace -from skore_cli.hub._api_keys import ( - _HUB_URL_OPTION, - _is_interactive, - _resolve_session, -) - -click.rich_click.COMMAND_GROUPS = { - **getattr(click.rich_click, "COMMAND_GROUPS", {}), - "cli hub workspace": [ - {"name": "Manage", "commands": ["list", "show", "create", "rename", "delete"]}, - ], -} - - -@click.group("workspace", invoke_without_command=True) -@click.pass_context -def workspace(ctx) -> None: - """List, show, create, rename and delete hub workspaces.""" - if ctx.invoked_subcommand is None: - click.echo(ctx.get_help()) - - -def _role_for(ws: _client.WorkspaceInfo, user_id: str) -> str: - for member in ws.members or []: - if member.user_id == user_id: - return member.role or "-" - return "-" - - -@workspace.command("list") -@_HUB_URL_OPTION -def list_workspaces(hub_url: str | None) -> None: - """List every workspace you belong to.""" - uri, token, user_id, _memberships = _resolve_session(hub_url) - workspaces = _client.list_workspaces(uri, token) - - if not workspaces: - console.print("[skore.muted]No workspaces.[/]") - return - - from rich.table import Table - - table = Table(box=None, pad_edge=False) - for column in ("id", "public_id", "public", "created", "members", "role"): - table.add_column(column) - for ws in workspaces: - table.add_row( - str(ws.id), - ws.public_id, - "yes" if ws.is_public else "-", - (ws.created_at or "-")[:19], - str(len(ws.members or [])), - _role_for(ws, user_id), - ) - console.print(table) - - -@workspace.command("show") -@_HUB_URL_OPTION -@click.option( - "--workspace", "-w", default=None, help="Hub workspace (public id) to show." -) -def show(hub_url: str | None, workspace: str | None) -> None: - """Show a workspace and its members.""" - uri, token, _user_id, memberships = _resolve_session(hub_url) - membership = _resolve_target_workspace( - memberships, workspace, interactive=_is_interactive() - ) - ws = _client.get_workspace(uri, token, membership.workspace_id) - - console.print(f"[skore.ok]workspace[/] [bold]{ws.public_id}[/]") - console.print(f" id : {ws.id}") - console.print(f" public : {'yes' if ws.is_public else 'no'}") - console.print(f" created : {(ws.created_at or '-')[:19]}") - - if not ws.members: - console.print("[skore.muted] no members.[/]") - return - - from rich.table import Table - - table = Table(box=None, pad_edge=False) - for column in ("user_id", "role", "invited_by"): - table.add_column(column) - for member in ws.members: - table.add_row(member.user_id, member.role or "-", member.invited_by or "-") - console.print(table) - - -@workspace.command("create") -@_HUB_URL_OPTION -@click.option("--public-id", default=None, help="Public id (slug) for the workspace.") -def create(hub_url: str | None, public_id: str | None) -> None: - """Create a new workspace (you become its owner).""" - uri, token, _user_id, _memberships = _resolve_session(hub_url) - - if public_id is None: - if not _is_interactive(): - raise click.UsageError("pass --public-id for the workspace.") - public_id = click.prompt("Workspace public id").strip() - if not public_id: - raise click.UsageError("the workspace public id must not be empty.") - - available, suggested = _client.check_public_id(uri, token, public_id) - if not available: - hint = f" try '{suggested}'." if suggested else "" - raise click.ClickException( - f"workspace public id '{public_id}' is not available.{hint}" - ) - - workspace_id = _client.create_workspace(uri, token, public_id=public_id) - # The hub may slugify/suffix the id; re-fetch to print the stored value. - created = _client.get_workspace(uri, token, workspace_id) - console.print( - f"[skore.ok]+ created workspace[/] " - f"[skore.muted](id {created.id})[/] [bold]{created.public_id}[/]" - ) - - -@workspace.command("rename") -@_HUB_URL_OPTION -@click.option( - "--workspace", "-w", default=None, help="Hub workspace (public id) to rename." -) -@click.option("--new-public-id", default=None, help="The new public id (slug).") -def rename( - hub_url: str | None, workspace: str | None, new_public_id: str | None -) -> None: - """Rename a workspace (change its public id); needs owner/admin.""" - uri, token, _user_id, memberships = _resolve_session(hub_url) - membership = _resolve_target_workspace( - memberships, workspace, interactive=_is_interactive() - ) - - if new_public_id is None: - if not _is_interactive(): - raise click.UsageError("pass --new-public-id .") - new_public_id = click.prompt("New public id").strip() - if not new_public_id: - raise click.UsageError("the new public id must not be empty.") - - _client.update_workspace( - uri, token, membership.workspace_id, public_id=new_public_id - ) - # The hub may slugify the id; re-fetch to print the stored value. - renamed = _client.get_workspace(uri, token, membership.workspace_id) - console.print( - f"[skore.ok]+[/] renamed workspace [skore.muted](id {renamed.id})[/] " - f"to [bold]{renamed.public_id}[/]" - ) - - -@workspace.command("delete") -@_HUB_URL_OPTION -@click.option( - "--workspace", "-w", default=None, help="Hub workspace (public id) to delete." -) -@click.option( - "--yes", is_flag=True, default=False, help="Skip the confirmation prompt." -) -def delete(hub_url: str | None, workspace: str | None, yes: bool) -> None: - """Delete a workspace; owner only and irreversible.""" - uri, token, _user_id, memberships = _resolve_session(hub_url) - membership = _resolve_target_workspace( - memberships, workspace, interactive=_is_interactive() - ) - - if not yes: - click.confirm( - f"Delete workspace '{membership.public_id}'? This is irreversible.", - abort=True, - ) - _client.delete_workspace(uri, token, membership.workspace_id) - console.print(f"[skore.ok]-[/] deleted workspace {membership.public_id}.") diff --git a/src/skore_cli/hub/app/__init__.py b/src/skore_cli/hub/app/__init__.py deleted file mode 100644 index b0a4859..0000000 --- a/src/skore_cli/hub/app/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Textual applications backing the interactive ``skore hub`` commands.""" - -from skore_cli.hub.app._form import ( - VALIDITY_CHOICES, - ApiKeyForm, - ApiKeyFormResult, - IdPicker, -) -from skore_cli.hub.app._provider_form import ( - AgentProviderForm, - AgentProviderFormResult, -) - -__all__ = [ - "VALIDITY_CHOICES", - "AgentProviderForm", - "AgentProviderFormResult", - "ApiKeyForm", - "ApiKeyFormResult", - "IdPicker", -] diff --git a/src/skore_cli/hub/app/_form.py b/src/skore_cli/hub/app/_form.py deleted file mode 100644 index 2257faa..0000000 --- a/src/skore_cli/hub/app/_form.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Textual apps backing the interactive ``skore hub api-key`` commands. - -``ApiKeyForm`` is a single-screen form (name, workspace, permissions, validity) -mirroring the hub UI's create modal; ``IdPicker`` is a generic single-select -picker over ``(id, label)`` rows. Both follow the package convention: set -``self.result`` then ``exit()``; the caller reads ``app.result`` after -``app.run()``. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import VerticalScroll -from textual.widgets import ( - Footer, - Header, - Input, - Label, - RadioButton, - RadioSet, - SelectionList, -) -from textual.widgets.selection_list import Selection - -from skore_cli.app._help import HELP_BINDING, HelpInput, HelpScreen -from skore_cli.skills.app._widgets import AutoRadioSet - -# (value, label) validity choices, mirroring the hub UI (default: 3 months). -VALIDITY_CHOICES: list[tuple[str, str]] = [ - ("1", "1 month"), - ("3", "3 months"), - ("6", "6 months"), - ("never", "Never"), -] -_DEFAULT_VALIDITY_INDEX = 1 - -_INTRO = ( - "Create a workspace-scoped API key.\n" - "Pick the workspace, the permissions to grant, and a validity.\n" - "[reverse] tab [/] next field [reverse] ↑/↓ space [/] choose " - "[reverse] enter [/] create [reverse] esc [/] cancel [reverse] ? [/] help" -) - -_API_KEY_HELP = """\ -Create a workspace-scoped API key for the hub. - -Fill in a name, pick the workspace, grant permissions, -and choose how long the key stays valid. The secret is -shown only once after creation. - -Keys: - Tab next field - ↑/↓ Space choose options - Enter create key - Esc cancel - ? show this help -""" - - -@dataclass(frozen=True) -class ApiKeyFormResult: - """The choices captured by :class:`ApiKeyForm`.""" - - name: str - workspace_id: int - workspace_public_id: str - permissions: list[str] - validity: str - - -class ApiKeyForm(App[ApiKeyFormResult | None]): - """Interactive form to mint a workspace-scoped API key.""" - - CSS = """ - Screen { - align: center middle; - } - #form { - width: 90%; - height: 90%; - } - .form-intro { - margin: 1 1; - color: $text-muted; - } - .field-label { - margin: 1 1 0 1; - text-style: bold; - } - #name { - margin: 0 1; - width: 100%; - } - AutoRadioSet { - margin: 0 1; - width: 100%; - } - #permissions { - margin: 0 1; - height: auto; - max-height: 9; - border: round $surface-lighten-2; - } - #permissions:focus { - border: thick $accent; - } - """ - - BINDINGS = [ - Binding("enter", "confirm", "Create", priority=True), - Binding("escape", "cancel", "Cancel"), - Binding("tab", "focus_next", "Next", show=False), - Binding("shift+tab", "focus_previous", "Previous", show=False), - HELP_BINDING, - ] - - def __init__( - self, - workspaces: list[tuple[int, str]], - grantable: dict[int, list[str]], - *, - name: str = "", - permissions: list[str] | None = None, - validity: str = "3", - preselect_workspace_id: int | None = None, - ) -> None: - super().__init__() - self._workspaces = workspaces - self._grantable = grantable - self._name = name - self._initial_permissions = list(permissions or []) - self._validity_index = next( - (i for i, (value, _) in enumerate(VALIDITY_CHOICES) if value == validity), - _DEFAULT_VALIDITY_INDEX, - ) - self._workspace_index = next( - ( - i - for i, (ws_id, _) in enumerate(workspaces) - if ws_id == preselect_workspace_id - ), - 0, - ) - self.result: ApiKeyFormResult | None = None - - def compose(self) -> ComposeResult: - yield Header() - with VerticalScroll(id="form"): - yield Label(_INTRO, classes="form-intro") - - yield Label("Name", classes="field-label") - yield HelpInput(value=self._name, placeholder="e.g. laptop", id="name") - - yield Label("Workspace", classes="field-label") - with AutoRadioSet(id="workspaces"): - for index, (_, public_id) in enumerate(self._workspaces): - yield RadioButton(public_id, value=index == self._workspace_index) - - yield Label("Permissions", classes="field-label") - yield SelectionList[str](id="permissions") - - yield Label("Validity", classes="field-label") - with AutoRadioSet(id="validity"): - for index, (_, label) in enumerate(VALIDITY_CHOICES): - yield RadioButton(label, value=index == self._validity_index) - yield Footer() - - def on_mount(self) -> None: - self.query_one("#workspaces", AutoRadioSet).select_index(self._workspace_index) - self.query_one("#validity", AutoRadioSet).select_index(self._validity_index) - self._populate_permissions(self._initial_permissions) - self.query_one("#name", Input).focus() - - def action_show_help(self) -> None: - self.push_screen(HelpScreen("Create an API key", _API_KEY_HELP)) - - def _current_workspace_id(self) -> int: - index = self.query_one("#workspaces", AutoRadioSet).pressed_index - index = index if index >= 0 else self._workspace_index - return self._workspaces[index][0] - - def _populate_permissions(self, preselect: list[str]) -> None: - workspace_id = self._current_workspace_id() - grantable = self._grantable.get(workspace_id, []) - options = [ - Selection(permission, permission, permission in preselect) - for permission in grantable - ] - permissions = self.query_one("#permissions", SelectionList) - permissions.clear_options() - if options: - permissions.add_options(options) - - def on_radio_set_changed(self, event: RadioSet.Changed) -> None: - # Switching workspace changes which permissions are grantable; rebuild - # the list (keeping any still-grantable selections). - if event.radio_set.id == "workspaces": - keep = list(self.query_one("#permissions", SelectionList).selected) - self._populate_permissions(keep) - - def action_confirm(self) -> None: - name = self.query_one("#name", Input).value.strip() - if not name: - self.notify("Enter a name for the key.", severity="warning") - return - permissions = list(self.query_one("#permissions", SelectionList).selected) - if not permissions: - self.notify("Select at least one permission.", severity="warning") - return - - workspace_index = self.query_one("#workspaces", AutoRadioSet).pressed_index - if workspace_index < 0: - self.notify("Select a workspace.", severity="warning") - return - validity_index = self.query_one("#validity", AutoRadioSet).pressed_index - validity = VALIDITY_CHOICES[max(validity_index, 0)][0] - - workspace_id, workspace_public_id = self._workspaces[workspace_index] - self.result = ApiKeyFormResult( - name=name, - workspace_id=workspace_id, - workspace_public_id=workspace_public_id, - permissions=permissions, - validity=validity, - ) - self.exit() - - def action_cancel(self) -> None: - self.result = None - self.exit() - - -_PICKER_KEYS = ( - "[reverse] ↑/↓ [/] choose [reverse] enter [/] confirm " - "[reverse] esc [/] cancel [reverse] ? [/] help" -) - -_ID_PICKER_HELP = """\ -Select one item from the list. - -Keys: - ↑/↓ move selection - Enter confirm - Esc cancel - ? show this help -""" - - -class IdPicker(App[int | None]): - """Single-select picker over ``(id, label)`` rows; returns the chosen id. - - Used wherever a command needs the user to pick one of several hub objects - (an API key to revoke, a provider to activate/remove, a workspace). - """ - - CSS = """ - Screen { - align: center middle; - } - #picker { - width: 90%; - height: 90%; - } - .picker-intro { - margin: 1 1; - color: $text-muted; - } - AutoRadioSet { - margin: 1 1; - width: 100%; - } - """ - - BINDINGS = [ - Binding("enter", "confirm", "Confirm", priority=True), - Binding("escape", "cancel", "Cancel"), - HELP_BINDING, - ] - - def __init__( - self, - keys: list[tuple[int, str]], - *, - title: str = "Choose an item.", - preselect: int = 0, - ) -> None: - super().__init__() - self._keys = keys - self._title = title - self._preselect = preselect - self.result: int | None = None - - def action_show_help(self) -> None: - self.push_screen(HelpScreen(self._title, _ID_PICKER_HELP)) - - def compose(self) -> ComposeResult: - yield Header() - with VerticalScroll(id="picker"): - yield Label(f"{self._title}\n{_PICKER_KEYS}", classes="picker-intro") - with AutoRadioSet(id="keys"): - for index, (_, label) in enumerate(self._keys): - yield RadioButton(label, value=index == self._preselect) - yield Footer() - - def on_mount(self) -> None: - self.query_one("#keys", AutoRadioSet).select_index(self._preselect) - - def action_confirm(self) -> None: - index = self.query_one("#keys", AutoRadioSet).pressed_index - if index < 0: - self.notify("Select an item.", severity="warning") - return - self.result = self._keys[index][0] - self.exit() - - def action_cancel(self) -> None: - self.result = None - self.exit() diff --git a/src/skore_cli/hub/app/_provider_form.py b/src/skore_cli/hub/app/_provider_form.py deleted file mode 100644 index 98ef7e9..0000000 --- a/src/skore_cli/hub/app/_provider_form.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Textual app backing the interactive ``skore hub agent-provider add`` command. - -``AgentProviderForm`` is a single-screen, provider-adaptive form mirroring the -hub UI's agent provider modal: picking ``skore`` needs nothing else, while the -bring-your-own ``anthropic``/``bedrock`` providers reveal a model picker and the -relevant secret fields (only offered when the workspace has encryption -configured and the hub advertises models for them). It follows the package -convention: set ``self.result`` then ``exit()``; the caller reads ``app.result`` -after ``app.run()``. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Vertical, VerticalScroll -from textual.widgets import ( - Checkbox, - Footer, - Header, - Input, - Label, - RadioButton, - RadioSet, -) - -from skore_cli.app._help import HELP_BINDING, HelpInput, HelpScreen -from skore_cli.skills.app._widgets import AutoRadioSet - -_PROVIDER_ORDER = ["skore", "anthropic", "bedrock"] - -_INTRO = ( - "Add an agent LLM provider for the workspace.\n" - "Pick a provider; bring-your-own providers need a model (and secrets).\n" - "[reverse] tab [/] next field [reverse] ↑/↓ [/] choose " - "[reverse] enter [/] add [reverse] esc [/] cancel [reverse] ? [/] help" -) - -_PROVIDER_HELP = """\ -Register the LLM provider that powers the hub agent. - -Choose Skore's managed provider or bring your own Anthropic -or Bedrock credentials. Optional fields appear based on the -provider you pick. - -Keys: - Tab next field - ↑/↓ choose options - Enter add provider - Esc cancel - ? show this help -""" - - -@dataclass(frozen=True) -class AgentProviderFormResult: - """The choices captured by :class:`AgentProviderForm`.""" - - name: str - provider: str - selected_model: str | None - anthropic_api_key: str | None - aws_region: str | None - bedrock_role_arn: str | None - bedrock_external_id: str | None - aws_access_key_id: str | None - aws_secret_access_key: str | None - activate: bool - - -class AgentProviderForm(App[AgentProviderFormResult | None]): - """Interactive, provider-adaptive form to register an agent provider.""" - - CSS = """ - Screen { - align: center middle; - } - #form { - width: 90%; - height: 90%; - } - .form-intro { - margin: 1 1; - color: $text-muted; - } - .field-label { - margin: 1 1 0 1; - text-style: bold; - } - .field-hint { - margin: 0 1; - color: $text-muted; - } - Input { - margin: 0 1; - width: 100%; - } - AutoRadioSet { - margin: 0 1; - width: 100%; - } - """ - - BINDINGS = [ - Binding("enter", "confirm", "Add", priority=True), - Binding("escape", "cancel", "Cancel"), - Binding("tab", "focus_next", "Next", show=False), - Binding("shift+tab", "focus_previous", "Previous", show=False), - HELP_BINDING, - ] - - def __init__( - self, - available_models: dict[str, list[str]], - *, - encryption_configured: bool, - name: str = "", - activate_default: bool = True, - ) -> None: - super().__init__() - self._available_models = available_models - self._encryption_configured = encryption_configured - self._name = name - self._activate_default = activate_default - self._selectable = { - provider: self._is_selectable(provider) for provider in _PROVIDER_ORDER - } - self._initial_provider = next( - (p for p in _PROVIDER_ORDER if self._selectable[p]), "skore" - ) - self.result: AgentProviderFormResult | None = None - - def _is_selectable(self, provider: str) -> bool: - if provider == "skore": - return True - return self._encryption_configured and bool( - self._available_models.get(provider) - ) - - def compose(self) -> ComposeResult: - yield Header() - with VerticalScroll(id="form"): - yield Label(_INTRO, classes="form-intro") - - yield Label("Name", classes="field-label") - yield HelpInput( - value=self._name, placeholder="e.g. team-anthropic", id="name" - ) - - yield Label("Provider", classes="field-label") - with AutoRadioSet(id="provider"): - for provider in _PROVIDER_ORDER: - yield RadioButton( - provider, - value=provider == self._initial_provider, - disabled=not self._selectable[provider], - id=f"provider-{provider}", - ) - - with Vertical(id="skore-fields"): - yield Label( - "Uses skore's managed LLM -- no extra configuration needed.", - classes="field-hint", - ) - - with Vertical(id="anthropic-fields"): - yield Label("Model", classes="field-label") - with AutoRadioSet(id="anthropic-model"): - for index, model in enumerate( - self._available_models.get("anthropic", []) - ): - yield RadioButton(model, value=index == 0) - yield Label("Anthropic API key", classes="field-label") - yield HelpInput(password=True, id="anthropic-api-key") - - with Vertical(id="bedrock-fields"): - yield Label("Model", classes="field-label") - with AutoRadioSet(id="bedrock-model"): - for index, model in enumerate( - self._available_models.get("bedrock", []) - ): - yield RadioButton(model, value=index == 0) - yield Label("AWS region", classes="field-label") - yield HelpInput(id="aws-region", placeholder="e.g. us-east-1") - yield Label("Bedrock role ARN", classes="field-label") - yield HelpInput(id="bedrock-role-arn") - yield Label("Bedrock external id", classes="field-label") - yield HelpInput(id="bedrock-external-id") - yield Label("AWS access key id", classes="field-label") - yield HelpInput(id="aws-access-key-id") - yield Label("AWS secret access key", classes="field-label") - yield HelpInput(password=True, id="aws-secret-access-key") - - yield Checkbox("Activate now", value=self._activate_default, id="activate") - yield Footer() - - def on_mount(self) -> None: - index = _PROVIDER_ORDER.index(self._initial_provider) - self.query_one("#provider", AutoRadioSet).select_index(index) - self._show_fields(self._initial_provider) - self.query_one("#name", Input).focus() - - def action_show_help(self) -> None: - self.push_screen(HelpScreen("Add an agent provider", _PROVIDER_HELP)) - - def _current_provider(self) -> str: - index = self.query_one("#provider", AutoRadioSet).pressed_index - if index < 0: - return self._initial_provider - return _PROVIDER_ORDER[index] - - def _show_fields(self, provider: str) -> None: - for name in _PROVIDER_ORDER: - self.query_one(f"#{name}-fields", Vertical).display = name == provider - - def on_radio_set_changed(self, event: RadioSet.Changed) -> None: - if event.radio_set.id == "provider": - self._show_fields(self._current_provider()) - - def _selected_model(self, provider: str) -> str | None: - radio = self.query_one(f"#{provider}-model", AutoRadioSet) - index = radio.pressed_index - models = self._available_models.get(provider, []) - if 0 <= index < len(models): - return models[index] - return None - - def action_confirm(self) -> None: - name = self.query_one("#name", Input).value.strip() - if not name: - self.notify("Enter a name for the provider.", severity="warning") - return - provider = self._current_provider() - - selected_model: str | None = None - anthropic_api_key: str | None = None - aws_region: str | None = None - bedrock_role_arn: str | None = None - bedrock_external_id: str | None = None - aws_access_key_id: str | None = None - aws_secret_access_key: str | None = None - - if provider != "skore": - selected_model = self._selected_model(provider) - if not selected_model: - self.notify("Select a model.", severity="warning") - return - if provider == "anthropic": - anthropic_api_key = ( - self.query_one("#anthropic-api-key", Input).value.strip() or None - ) - if not anthropic_api_key: - self.notify("Enter the Anthropic API key.", severity="warning") - return - if provider == "bedrock": - aws_region = self.query_one("#aws-region", Input).value.strip() or None - bedrock_role_arn = ( - self.query_one("#bedrock-role-arn", Input).value.strip() or None - ) - bedrock_external_id = ( - self.query_one("#bedrock-external-id", Input).value.strip() or None - ) - aws_access_key_id = ( - self.query_one("#aws-access-key-id", Input).value.strip() or None - ) - aws_secret_access_key = ( - self.query_one("#aws-secret-access-key", Input).value.strip() or None - ) - - self.result = AgentProviderFormResult( - name=name, - provider=provider, - selected_model=selected_model, - anthropic_api_key=anthropic_api_key, - aws_region=aws_region, - bedrock_role_arn=bedrock_role_arn, - bedrock_external_id=bedrock_external_id, - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - activate=self.query_one("#activate", Checkbox).value, - ) - self.exit() - - def action_cancel(self) -> None: - self.result = None - self.exit() diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index 1d48e6f..dad733b 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -7,7 +7,7 @@ from click.testing import CliRunner -from skore_cli.agent import _commands, _harnesses +from skore_cli.agent import _client, _commands, _harnesses from skore_cli.agent._commands import agent from skore_cli.agent._harnesses import DEFAULT_MODEL_ID, HARNESSES, HarnessContext from skore_cli.agent._skore_file import ( @@ -15,7 +15,6 @@ SkoreConfig, ensure_gitignore_entry, ) -from skore_cli.hub import _client _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") diff --git a/tests/test_cli.py b/tests/test_cli.py index b913676..6fbf4e3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,7 +13,7 @@ def test_cli_exposes_builtin_commands(): from skore_cli import cli - assert {"skills", "hub", "agent"} <= set(cli.commands) + assert {"skills", "agent"} <= set(cli.commands) # --------------------------------------------------------------------------- # diff --git a/tests/test_hub.py b/tests/test_hub.py deleted file mode 100644 index 53ce90f..0000000 --- a/tests/test_hub.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Tests for the ``skore hub`` command group (login/logout/status).""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest -from click.testing import CliRunner - -from skore_cli import _hub_auth -from skore_cli.hub import _commands as hub - -hub_cli = hub.hub - - -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, uri="http://hub.test"): - 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 - uri_mod = SimpleNamespace(URI=lambda: uri) - return lambda name: login_mod if name == "login" else uri_mod - - -def _patch_auth(monkeypatch, auth_fn): - monkeypatch.setattr(_hub_auth, "_auth", auth_fn) - monkeypatch.setattr(hub, "_auth", auth_fn) - - -@pytest.fixture -def no_api_key(monkeypatch): - monkeypatch.delenv("SKORE_HUB_API_KEY", raising=False) - monkeypatch.delenv("SKORE_HUB_URI", raising=False) - - -def test_status_with_api_key(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - _patch_auth(monkeypatch, _make_login()) - - result = CliRunner().invoke(hub_cli, ["status"]) - - assert result.exit_code == 0, result.output - assert "API key" in result.output - - -def test_status_with_interactive_session(monkeypatch, no_api_key): - _patch_auth(monkeypatch, _make_login(credentials=_TokenCreds())) - - result = CliRunner().invoke(hub_cli, ["status"]) - - assert result.exit_code == 0, result.output - assert "interactive token" in result.output - - -def test_status_unauthenticated_errors(monkeypatch, no_api_key): - _patch_auth(monkeypatch, _make_login()) - - result = CliRunner().invoke(hub_cli, ["status"]) - - assert result.exit_code != 0 - assert "Not authenticated" in result.output - - -def test_login_with_api_key_does_nothing(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - auth = _make_login() - _patch_auth(monkeypatch, auth) - - result = CliRunner().invoke(hub_cli, ["login"]) - - assert result.exit_code == 0, result.output - assert auth("login").login_calls == 0 - - -def test_login_without_key_runs_device_flow(monkeypatch, no_api_key): - auth = _make_login() - _patch_auth(monkeypatch, auth) - - result = CliRunner().invoke(hub_cli, ["login"]) - - assert result.exit_code == 0, result.output - assert auth("login").login_calls == 1 - assert auth("login").credentials is not None - - -def test_login_hub_url_sets_env(monkeypatch, no_api_key): - _patch_auth(monkeypatch, _make_login(uri="http://127.0.0.1:9999")) - - result = CliRunner().invoke( - hub_cli, ["login", "--hub-url", "http://127.0.0.1:9999"] - ) - - assert result.exit_code == 0, result.output - import os - - assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:9999" - - -def test_resolve_hub_uri_seeds_env_and_resolves(no_api_key): - import os - - from skore_cli import _skore - - seen = {} - - def fake_auth(name): - seen["name"] = name - return SimpleNamespace(URI=lambda: "http://resolved") - - uri = _skore.resolve_hub_uri("http://127.0.0.1:8000", fake_auth) - - assert seen["name"] == "uri" - assert os.environ.get("SKORE_HUB_URI") == "http://127.0.0.1:8000" - assert uri == "http://resolved" - - -def test_resolve_hub_uri_without_url_leaves_env(no_api_key): - import os - - from skore_cli import _skore - - uri = _skore.resolve_hub_uri( - None, lambda name: SimpleNamespace(URI=lambda: "http://default") - ) - - assert "SKORE_HUB_URI" not in os.environ - assert uri == "http://default" - - -def test_logout_clears_session(monkeypatch, no_api_key): - auth = _make_login(credentials=_TokenCreds()) - _patch_auth(monkeypatch, auth) - - result = CliRunner().invoke(hub_cli, ["logout"]) - - assert result.exit_code == 0, result.output - assert auth("login").credentials is None - assert "cleared" in result.output - - -def test_logout_no_session_with_api_key(monkeypatch, no_api_key): - monkeypatch.setenv("SKORE_HUB_API_KEY", "uid:secret") - _patch_auth(monkeypatch, _make_login()) - - result = CliRunner().invoke(hub_cli, ["logout"]) - - assert result.exit_code == 0, result.output - assert "user-managed" in result.output - - -def test_logout_no_session_at_all(monkeypatch, no_api_key): - _patch_auth(monkeypatch, _make_login()) - - result = CliRunner().invoke(hub_cli, ["logout"]) - - assert result.exit_code == 0, result.output - assert "No interactive session" in result.output diff --git a/tests/test_hub_agent_provider_form.py b/tests/test_hub_agent_provider_form.py deleted file mode 100644 index 962c35b..0000000 --- a/tests/test_hub_agent_provider_form.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Textual pilot tests for the interactive ``skore hub agent-provider add`` form.""" - -from __future__ import annotations - -from textual.widgets import Input - -from skore_cli.hub.app import AgentProviderForm - -MODELS = {"anthropic": ["claude-x"], "bedrock": ["nova"]} - - -def _form(**kwargs): - kwargs.setdefault("encryption_configured", True) - kwargs.setdefault("name", "team") - return AgentProviderForm(MODELS, **kwargs) - - -async def test_provider_switch_toggles_field_groups(): - app = _form() - async with app.run_test() as pilot: - await pilot.pause() - assert app.query_one("#skore-fields").display is True - assert app.query_one("#anthropic-fields").display is False - - app.query_one("#provider").action_next_button() - await pilot.pause() - assert app.query_one("#skore-fields").display is False - assert app.query_one("#anthropic-fields").display is True - assert app.query_one("#bedrock-fields").display is False - - -async def test_skore_confirm_returns_result(): - app = _form() - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - - assert app.result is not None - assert app.result.provider == "skore" - assert app.result.selected_model is None - assert app.result.activate is True - - -async def test_anthropic_requires_key_then_confirms(): - app = _form() - async with app.run_test() as pilot: - await pilot.pause() - app.query_one("#provider").action_next_button() - await pilot.pause() - - await pilot.press("enter") - await pilot.pause() - assert app.is_running is True - assert app.result is None - - app.query_one("#anthropic-api-key", Input).value = "secret" - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - - assert app.result is not None - assert app.result.provider == "anthropic" - assert app.result.selected_model == "claude-x" - assert app.result.anthropic_api_key == "secret" - - -async def test_bedrock_confirms_with_optional_fields(): - app = _form() - async with app.run_test() as pilot: - await pilot.pause() - provider = app.query_one("#provider") - provider.action_next_button() - provider.action_next_button() - await pilot.pause() - assert app.query_one("#bedrock-fields").display is True - - app.query_one("#aws-region", Input).value = "us-east-1" - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - - assert app.result is not None - assert app.result.provider == "bedrock" - assert app.result.selected_model == "nova" - assert app.result.aws_region == "us-east-1" - assert app.result.bedrock_role_arn is None - - -async def test_activate_default_off_reflected(): - app = _form(activate_default=False) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - - assert app.result is not None - assert app.result.activate is False - - -async def test_escape_returns_none(): - app = _form() - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("escape") - await pilot.pause() - - assert app.result is None - - -async def test_byo_disabled_without_encryption(): - app = _form(encryption_configured=False) - async with app.run_test() as pilot: - await pilot.pause() - assert app.query_one("#provider-anthropic").disabled is True - assert app.query_one("#provider-bedrock").disabled is True - assert app.query_one("#provider-skore").disabled is False diff --git a/tests/test_hub_agent_providers.py b/tests/test_hub_agent_providers.py deleted file mode 100644 index 9af1b2f..0000000 --- a/tests/test_hub_agent_providers.py +++ /dev/null @@ -1,746 +0,0 @@ -"""Tests for ``skore hub agent-provider`` (client + add/list/activate/remove). - -The client functions are exercised against an in-memory ``httpx.MockTransport`` -(no network). The command callbacks are exercised via ``CliRunner`` with the -``_client`` functions and the session helpers monkeypatched, plus fake Textual -apps for the interactive paths (mirroring the api-key tests). -""" - -from __future__ import annotations - -import json - -import httpx -import pytest -import rich_click as click -from click.testing import CliRunner - -from skore_cli.hub import _agent_providers, _api_keys, _client -from skore_cli.hub._client import AgentProviders, Membership, ProviderEntry - - -def _transport(handler): - return httpx.MockTransport(handler) - - -def _entry(**overrides): - base = { - "id": 11, - "name": "team", - "is_active": False, - "provider": "skore", - "selected_model": None, - "aws_region": None, - "bedrock_role_arn": None, - "anthropic_api_key_set": False, - "bedrock_external_id_set": False, - "aws_access_key_id_set": False, - "aws_secret_access_key_set": False, - } - base.update(overrides) - return base - - -# --------------------------------------------------------------------------- # -# _client: HTTP calls via MockTransport -# --------------------------------------------------------------------------- # - - -def test_agent_providers_parses(): - def handler(request): - assert request.url.path == "/agent/workspaces/7/providers" - assert request.headers["Authorization"] == "Bearer tok" - return httpx.Response( - 200, - json={ - "providers": [ - _entry( - id=1, - name="byo", - is_active=True, - provider="anthropic", - selected_model="claude-x", - anthropic_api_key_set=True, - ) - ], - "available_models": {"anthropic": ["claude-x"], "bedrock": ["nova"]}, - "encryption_configured": True, - }, - ) - - result = _client.agent_providers( - "http://hub.test", "tok", 7, transport=_transport(handler) - ) - assert result == AgentProviders( - providers=[ - ProviderEntry( - id=1, - name="byo", - is_active=True, - provider="anthropic", - selected_model="claude-x", - aws_region=None, - bedrock_role_arn=None, - anthropic_api_key_set=True, - bedrock_external_id_set=False, - aws_access_key_id_set=False, - aws_secret_access_key_set=False, - ) - ], - available_models={"anthropic": ["claude-x"], "bedrock": ["nova"]}, - encryption_configured=True, - ) - - -def test_create_agent_provider_posts_body_and_returns_entry(): - seen = {} - - def handler(request): - seen["path"] = request.url.path - seen["body"] = json.loads(request.content) - return httpx.Response( - 201, - json=_entry(id=42, name="team", provider="anthropic", selected_model="cx"), - ) - - entry = _client.create_agent_provider( - "http://hub.test", - "tok", - 7, - payload={"name": "team", "provider": "anthropic", "selected_model": "cx"}, - transport=_transport(handler), - ) - assert entry.id == 42 - assert seen["path"] == "/agent/workspaces/7/providers" - assert seen["body"] == { - "name": "team", - "provider": "anthropic", - "selected_model": "cx", - } - - -def test_activate_agent_provider_hits_path(): - def handler(request): - assert request.method == "POST" - assert request.url.path == "/agent/workspaces/7/providers/3/activate" - return httpx.Response(204) - - _client.activate_agent_provider( - "http://hub.test", "tok", 7, 3, transport=_transport(handler) - ) - - -def test_delete_agent_provider_hits_path(): - def handler(request): - assert request.method == "DELETE" - assert request.url.path == "/agent/workspaces/7/providers/3" - return httpx.Response(204) - - _client.delete_agent_provider( - "http://hub.test", "tok", 7, 3, transport=_transport(handler) - ) - - -def test_create_403_maps_to_owner_admin_message(): - def handler(request): - return httpx.Response(403, json={"detail": "forbidden"}) - - with pytest.raises(click.ClickException) as exc: - _client.create_agent_provider( - "http://hub.test", "tok", 7, payload={}, transport=_transport(handler) - ) - assert "owner/admin" in str(exc.value) - - -def test_create_400_surfaces_detail(): - def handler(request): - return httpx.Response(400, json={"detail": "encryption not configured"}) - - with pytest.raises(click.ClickException) as exc: - _client.create_agent_provider( - "http://hub.test", "tok", 7, payload={}, transport=_transport(handler) - ) - assert "encryption not configured" in str(exc.value) - - -# --------------------------------------------------------------------------- # -# helpers for command tests -# --------------------------------------------------------------------------- # - - -def _patch_session(monkeypatch, memberships, *, user_id="user-1"): - monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: url or "h") - monkeypatch.setattr(_client, "require_login_token", lambda: "tok") - monkeypatch.setattr(_client, "me", lambda uri, token: (user_id, memberships)) - - -def _patch_providers( - monkeypatch, *, providers=None, available_models=None, encryption=False -): - value = AgentProviders( - providers=providers or [], - available_models=available_models or {}, - encryption_configured=encryption, - ) - monkeypatch.setattr(_client, "agent_providers", lambda uri, token, ws_id: value) - return value - - -_WS_A = Membership(7, "ws-a", frozenset()) -_WS_B = Membership(8, "ws-b", frozenset()) - - -# --------------------------------------------------------------------------- # -# add -# --------------------------------------------------------------------------- # - - -def test_add_requires_login(monkeypatch): - monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: "h") - - def boom(): - raise click.ClickException("not logged in; run `skore hub login` first.") - - monkeypatch.setattr(_client, "require_login_token", boom) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - ["add", "-w", "ws-a", "--name", "x", "--provider", "skore"], - ) - assert result.exit_code != 0 - assert "not logged in" in result.output - - -def test_add_unknown_workspace_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers(monkeypatch) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - ["add", "-w", "nope", "--name", "x", "--provider", "skore"], - ) - assert result.exit_code != 0 - assert "unknown workspace" in result.output - - -def test_add_multi_workspace_non_interactive_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A, _WS_B]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers(monkeypatch) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - ["add", "--name", "x", "--provider", "skore"], - ) - assert result.exit_code != 0 - assert "--workspace" in result.output - - -def test_add_skore_builds_name_only_payload(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers(monkeypatch) - captured = {} - - def fake_create(uri, token, ws_id, *, payload): - captured.update(ws_id=ws_id, payload=payload) - return ProviderEntry( - 11, "team", False, "skore", None, None, None, False, False, False, False - ) - - monkeypatch.setattr(_client, "create_agent_provider", fake_create) - monkeypatch.setattr( - _client, - "activate_agent_provider", - lambda *a, **k: pytest.fail("should not activate"), - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - ["add", "--name", "team", "--provider", "skore"], - ) - assert result.exit_code == 0, result.output - assert captured["ws_id"] == 7 - assert captured["payload"] == {"name": "team", "provider": "skore"} - - -def test_add_anthropic_payload_and_activate(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers( - monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=True - ) - captured = {} - - def fake_create(uri, token, ws_id, *, payload): - captured["payload"] = payload - return ProviderEntry( - 12, - "team", - False, - "anthropic", - "claude-x", - None, - None, - True, - False, - False, - False, - ) - - activated = {} - monkeypatch.setattr(_client, "create_agent_provider", fake_create) - monkeypatch.setattr( - _client, - "activate_agent_provider", - lambda uri, token, ws_id, cid: activated.update(ws_id=ws_id, id=cid), - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - [ - "add", - "-w", - "ws-a", - "--name", - "team", - "--provider", - "anthropic", - "--model", - "claude-x", - "--anthropic-api-key", - "secret", - "--activate", - ], - ) - assert result.exit_code == 0, result.output - assert captured["payload"] == { - "name": "team", - "provider": "anthropic", - "selected_model": "claude-x", - "anthropic_api_key": "secret", - } - assert activated == {"ws_id": 7, "id": 12} - - -def test_add_bedrock_optional_fields(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers( - monkeypatch, available_models={"bedrock": ["nova"]}, encryption=True - ) - captured = {} - - def fake_create(uri, token, ws_id, *, payload): - captured["payload"] = payload - return ProviderEntry( - 13, - "team", - False, - "bedrock", - "nova", - "us-east-1", - None, - False, - False, - False, - False, - ) - - monkeypatch.setattr(_client, "create_agent_provider", fake_create) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - [ - "add", - "-w", - "ws-a", - "--name", - "team", - "--provider", - "bedrock", - "--model", - "nova", - "--aws-region", - "us-east-1", - "--bedrock-role-arn", - "arn:aws:iam::1:role/x", - ], - ) - assert result.exit_code == 0, result.output - assert captured["payload"] == { - "name": "team", - "provider": "bedrock", - "selected_model": "nova", - "aws_region": "us-east-1", - "bedrock_role_arn": "arn:aws:iam::1:role/x", - } - - -def test_add_byo_without_encryption_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers( - monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=False - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - [ - "add", - "-w", - "ws-a", - "--name", - "team", - "--provider", - "anthropic", - "--model", - "claude-x", - "--anthropic-api-key", - "secret", - ], - ) - assert result.exit_code != 0 - assert "encryption" in result.output - - -def test_add_model_not_available_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers( - monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=True - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - [ - "add", - "-w", - "ws-a", - "--name", - "team", - "--provider", - "anthropic", - "--model", - "nope", - "--anthropic-api-key", - "secret", - ], - ) - assert result.exit_code != 0 - assert "unknown model" in result.output - - -def test_add_anthropic_missing_key_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers( - monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=True - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - [ - "add", - "-w", - "ws-a", - "--name", - "team", - "--provider", - "anthropic", - "--model", - "claude-x", - "--yes", - ], - ) - assert result.exit_code != 0 - assert "anthropic-api-key" in result.output - - -def test_add_interactive_uses_form(monkeypatch): - from skore_cli.hub import app as hub_app - - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: True) - _patch_providers( - monkeypatch, available_models={"anthropic": ["claude-x"]}, encryption=True - ) - - class _FakeForm: - def __init__(self, *a, **k): - self.result = hub_app.AgentProviderFormResult( - name="team", - provider="anthropic", - selected_model="claude-x", - anthropic_api_key="secret", - aws_region=None, - bedrock_role_arn=None, - bedrock_external_id=None, - aws_access_key_id=None, - aws_secret_access_key=None, - activate=True, - ) - - def run(self): - return None - - monkeypatch.setattr(hub_app, "AgentProviderForm", _FakeForm) - - captured = {} - - def fake_create(uri, token, ws_id, *, payload): - captured["payload"] = payload - return ProviderEntry( - 14, - "team", - False, - "anthropic", - "claude-x", - None, - None, - True, - False, - False, - False, - ) - - activated = {} - monkeypatch.setattr(_client, "create_agent_provider", fake_create) - monkeypatch.setattr( - _client, - "activate_agent_provider", - lambda uri, token, ws_id, cid: activated.update(id=cid), - ) - - result = CliRunner().invoke(_agent_providers.agent_provider, ["add", "-w", "ws-a"]) - assert result.exit_code == 0, result.output - assert captured["payload"] == { - "name": "team", - "provider": "anthropic", - "selected_model": "claude-x", - "anthropic_api_key": "secret", - } - assert activated == {"id": 14} - - -def test_add_interactive_cancel(monkeypatch): - from skore_cli.hub import app as hub_app - - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: True) - _patch_providers(monkeypatch) - - class _FakeForm: - def __init__(self, *a, **k): - self.result = None - - def run(self): - return None - - monkeypatch.setattr(hub_app, "AgentProviderForm", _FakeForm) - monkeypatch.setattr( - _client, - "create_agent_provider", - lambda *a, **k: pytest.fail("should not create on cancel"), - ) - - result = CliRunner().invoke(_agent_providers.agent_provider, ["add", "-w", "ws-a"]) - assert result.exit_code == 0, result.output - assert "Nothing added" in result.output - - -# --------------------------------------------------------------------------- # -# list -# --------------------------------------------------------------------------- # - - -def test_list_prints_table(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - _patch_providers( - monkeypatch, - providers=[ - ProviderEntry( - 1, - "byo", - True, - "anthropic", - "claude-x", - None, - None, - True, - False, - False, - False, - ) - ], - encryption=True, - ) - - result = CliRunner().invoke(_agent_providers.agent_provider, ["list", "-w", "ws-a"]) - assert result.exit_code == 0, result.output - assert "byo" in result.output - assert "anthropic" in result.output - assert "claude-x" in result.output - - -def test_list_empty_with_encryption_note(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - _patch_providers(monkeypatch, encryption=False) - - result = CliRunner().invoke(_agent_providers.agent_provider, ["list", "-w", "ws-a"]) - assert result.exit_code == 0, result.output - assert "No agent providers" in result.output - assert "Encryption is not configured" in result.output - - -# --------------------------------------------------------------------------- # -# activate -# --------------------------------------------------------------------------- # - - -def test_activate_by_id(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - activated = {} - monkeypatch.setattr( - _client, - "activate_agent_provider", - lambda uri, token, ws_id, cid: activated.update(id=cid), - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, ["activate", "-w", "ws-a", "--id", "5"] - ) - assert result.exit_code == 0, result.output - assert activated == {"id": 5} - assert "activated provider 5" in result.output - - -def test_activate_non_interactive_without_id_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: False) - _patch_providers( - monkeypatch, - providers=[ - ProviderEntry( - 5, "x", False, "skore", None, None, None, False, False, False, False - ) - ], - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, ["activate", "-w", "ws-a"] - ) - assert result.exit_code != 0 - assert "--id" in result.output - - -def test_activate_interactive_picker(monkeypatch): - from skore_cli.hub import app as hub_app - - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: True) - _patch_providers( - monkeypatch, - providers=[ - ProviderEntry( - 5, "x", False, "skore", None, None, None, False, False, False, False - ) - ], - ) - - class _FakePicker: - def __init__(self, *a, **k): - self.result = 5 - - def run(self): - return None - - monkeypatch.setattr(hub_app, "IdPicker", _FakePicker) - activated = {} - monkeypatch.setattr( - _client, - "activate_agent_provider", - lambda uri, token, ws_id, cid: activated.update(id=cid), - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, ["activate", "-w", "ws-a"] - ) - assert result.exit_code == 0, result.output - assert activated == {"id": 5} - - -# --------------------------------------------------------------------------- # -# remove -# --------------------------------------------------------------------------- # - - -def test_remove_by_id_yes(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - deleted = {} - monkeypatch.setattr( - _client, - "delete_agent_provider", - lambda uri, token, ws_id, cid: deleted.update(id=cid), - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - ["remove", "-w", "ws-a", "--id", "5", "--yes"], - ) - assert result.exit_code == 0, result.output - assert deleted == {"id": 5} - assert "removed provider 5" in result.output - - -def test_remove_interactive_picker(monkeypatch): - from skore_cli.hub import app as hub_app - - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_agent_providers, "_is_interactive", lambda: True) - _patch_providers( - monkeypatch, - providers=[ - ProviderEntry( - 6, "x", False, "skore", None, None, None, False, False, False, False - ) - ], - ) - - class _FakePicker: - def __init__(self, *a, **k): - self.result = 6 - - def run(self): - return None - - monkeypatch.setattr(hub_app, "IdPicker", _FakePicker) - deleted = {} - monkeypatch.setattr( - _client, - "delete_agent_provider", - lambda uri, token, ws_id, cid: deleted.update(id=cid), - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, ["remove", "-w", "ws-a", "--yes"] - ) - assert result.exit_code == 0, result.output - assert deleted == {"id": 6} - - -def test_remove_confirm_abort(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr( - _client, - "delete_agent_provider", - lambda *a, **k: pytest.fail("should not delete on abort"), - ) - - result = CliRunner().invoke( - _agent_providers.agent_provider, - ["remove", "-w", "ws-a", "--id", "5"], - input="n\n", - ) - assert result.exit_code != 0 diff --git a/tests/test_hub_api_key_form.py b/tests/test_hub_api_key_form.py deleted file mode 100644 index 624e8a4..0000000 --- a/tests/test_hub_api_key_form.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Textual pilot tests for the interactive ``skore hub api-key`` form/picker.""" - -from __future__ import annotations - -from textual.widgets import SelectionList - -from skore_cli.app._help import HelpScreen -from skore_cli.hub.app import ApiKeyForm, IdPicker - -WORKSPACES = [(7, "ws-a"), (8, "ws-b")] -GRANTABLE = {7: ["create:project", "read:project"], 8: ["read:project"]} - - -# --------------------------------------------------------------------------- # -# ApiKeyForm -# --------------------------------------------------------------------------- # - - -async def test_form_confirm_returns_result(): - app = ApiKeyForm( - WORKSPACES, GRANTABLE, name="laptop", validity="never", preselect_workspace_id=7 - ) - async with app.run_test() as pilot: - await pilot.pause() - app.query_one("#permissions", SelectionList).select_all() - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - - assert app.result is not None - assert app.result.name == "laptop" - assert app.result.workspace_id == 7 - assert app.result.workspace_public_id == "ws-a" - assert set(app.result.permissions) == {"create:project", "read:project"} - assert app.result.validity == "never" - - -async def test_form_requires_name_stays_open(): - app = ApiKeyForm(WORKSPACES, GRANTABLE, name="", preselect_workspace_id=7) - async with app.run_test() as pilot: - await pilot.pause() - app.query_one("#permissions", SelectionList).select_all() - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - assert app.is_running is True - assert app.result is None - await pilot.press("escape") - await pilot.pause() - - assert app.result is None - - -async def test_form_requires_permission_stays_open(): - app = ApiKeyForm(WORKSPACES, GRANTABLE, name="x", preselect_workspace_id=7) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - assert app.is_running is True - assert app.result is None - await pilot.press("escape") - await pilot.pause() - - assert app.result is None - - -async def test_form_cancel_returns_none(): - app = ApiKeyForm(WORKSPACES, GRANTABLE, name="x", preselect_workspace_id=7) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("escape") - await pilot.pause() - - assert app.result is None - - -async def test_form_help_screen(): - app = ApiKeyForm(WORKSPACES, GRANTABLE, name="x", preselect_workspace_id=7) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("?") - await pilot.pause() - assert isinstance(app.screen, HelpScreen) - await pilot.press("escape") - await pilot.pause() - assert app.is_running is True - - -def _permission_values(app): - return [ - option.value for option in app.query_one("#permissions", SelectionList).options - ] - - -async def test_form_permissions_track_workspace(): - # ws-b grants only read:project; switching to it should drop create:project. - app = ApiKeyForm(WORKSPACES, GRANTABLE, name="x", preselect_workspace_id=7) - async with app.run_test() as pilot: - await pilot.pause() - assert sorted(_permission_values(app)) == ["create:project", "read:project"] - - app.query_one("#workspaces").action_next_button() - await pilot.pause() - values = _permission_values(app) - - assert values == ["read:project"] - - -# --------------------------------------------------------------------------- # -# IdPicker -# --------------------------------------------------------------------------- # - - -async def test_picker_confirms_selection(): - app = IdPicker([(5, "5 laptop"), (6, "6 ci")], preselect=0) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("enter") - await pilot.pause() - - assert app.result == 5 - - -async def test_picker_cancel_returns_none(): - app = IdPicker([(5, "5 laptop")]) - async with app.run_test() as pilot: - await pilot.pause() - await pilot.press("escape") - await pilot.pause() - - assert app.result is None diff --git a/tests/test_hub_api_keys.py b/tests/test_hub_api_keys.py deleted file mode 100644 index 91066a2..0000000 --- a/tests/test_hub_api_keys.py +++ /dev/null @@ -1,472 +0,0 @@ -"""Tests for ``skore hub api-key`` (client + create/list/revoke commands). - -The client functions are exercised against an in-memory ``httpx.MockTransport`` -(no network). The command callbacks are exercised via ``CliRunner`` with the -``_client`` functions and ``require_login_token`` monkeypatched, plus a fake -Textual app for the interactive paths (mirroring the skills CLI tests). -""" - -from __future__ import annotations - -import json -from datetime import UTC - -import httpx -import pytest -import rich_click as click -from click.testing import CliRunner - -from skore_cli.hub import _api_keys, _client -from skore_cli.hub._client import ApiKeyInfo, Membership - - -def _transport(handler): - return httpx.MockTransport(handler) - - -# --------------------------------------------------------------------------- # -# _client: require_login_token gate -# --------------------------------------------------------------------------- # - - -def test_require_login_token_errors_without_token(monkeypatch): - monkeypatch.setattr( - _client, - "ensure_login", - lambda: (_ for _ in ()).throw(click.ClickException("not logged in")), - ) - with pytest.raises(click.ClickException) as exc: - _client.require_login_token() - assert "not logged in" in str(exc.value) - - -def test_require_login_token_returns_access_token(monkeypatch): - monkeypatch.setattr(_client, "ensure_login", lambda: "abc") - assert _client.require_login_token() == "abc" - - -# --------------------------------------------------------------------------- # -# _client: HTTP calls via MockTransport -# --------------------------------------------------------------------------- # - - -def test_me_parses_id_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-a", - "role": "admin", - "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(7, "ws-a", frozenset({"read:project", "create:project"})) - ] - - -def test_create_sends_body_and_returns_secret(): - seen = {} - - def handler(request): - seen["path"] = request.url.path - seen["body"] = json.loads(request.content) - return httpx.Response(201, json={"api_key_id": 42, "api_key": "uid:secret"}) - - key_id, secret = _client.create_api_key( - "http://hub.test", - "tok", - "user-1", - name="laptop", - permissions=["read:project"], - workspace_id=7, - expires_at="2026-09-19T00:00:00+00:00", - transport=_transport(handler), - ) - assert (key_id, secret) == (42, "uid:secret") - assert seen["path"] == "/identity/users/user-1/api-keys" - assert seen["body"] == { - "name": "laptop", - "permissions": ["read:project"], - "workspace_id": 7, - "expires_at": "2026-09-19T00:00:00+00:00", - } - - -def test_create_omits_expires_at_when_never(): - seen = {} - - def handler(request): - seen["body"] = json.loads(request.content) - return httpx.Response(201, json={"api_key_id": 1, "api_key": "k"}) - - _client.create_api_key( - "http://hub.test", - "tok", - "u", - name="n", - permissions=["read:project"], - workspace_id=1, - expires_at=None, - transport=_transport(handler), - ) - assert "expires_at" not in seen["body"] - - -def test_list_api_keys_parses(): - def handler(request): - assert request.url.path == "/identity/users/u/api-keys" - return httpx.Response( - 200, - json=[ - { - "id": 1, - "name": "a", - "workspace_id": 7, - "created_at": "2026-01-01T00:00:00Z", - "expires_at": None, - } - ], - ) - - keys = _client.list_api_keys( - "http://hub.test", "tok", "u", transport=_transport(handler) - ) - assert keys == [ApiKeyInfo(1, "a", 7, "2026-01-01T00:00:00Z", None)] - - -def test_delete_api_key_ok(): - def handler(request): - assert request.method == "DELETE" - assert request.url.path == "/identity/users/u/api-keys/5" - return httpx.Response(204) - - _client.delete_api_key( - "http://hub.test", "tok", "u", 5, transport=_transport(handler) - ) - - -@pytest.mark.parametrize( - "code,snippet", - [ - (401, "skore hub login"), - (403, "not allowed"), - (404, "not found"), - (500, "hub request failed"), - ], -) -def test_error_mapping(code, snippet): - def handler(request): - return httpx.Response(code, json={"detail": "nope"}) - - with pytest.raises(click.ClickException) as exc: - _client.list_api_keys( - "http://hub.test", "tok", "u", transport=_transport(handler) - ) - assert snippet in str(exc.value) - - -# --------------------------------------------------------------------------- # -# helpers for command tests -# --------------------------------------------------------------------------- # - - -def _patch_session(monkeypatch, memberships, *, user_id="user-1"): - monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: url or "h") - monkeypatch.setattr(_client, "require_login_token", lambda: "tok") - monkeypatch.setattr(_client, "me", lambda uri, token: (user_id, memberships)) - - -_WS_A = Membership(7, "ws-a", frozenset({"read:project", "create:project"})) - - -# --------------------------------------------------------------------------- # -# create -# --------------------------------------------------------------------------- # - - -def test_create_requires_login(monkeypatch): - monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: "h") - - def boom(): - raise click.ClickException("not logged in; run `skore hub login` first.") - - monkeypatch.setattr(_client, "require_login_token", boom) - - result = CliRunner().invoke( - _api_keys.api_key, ["create", "-w", "ws-a", "--name", "x", "-p", "read:project"] - ) - assert result.exit_code != 0 - assert "not logged in" in result.output - - -def test_create_non_interactive_builds_payload(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) - captured = {} - - def fake_create( - uri, token, user_id, *, name, permissions, workspace_id, expires_at - ): - captured.update( - user_id=user_id, - name=name, - permissions=permissions, - workspace_id=workspace_id, - expires_at=expires_at, - ) - return 42, "uid:secret" - - monkeypatch.setattr(_client, "create_api_key", fake_create) - - result = CliRunner().invoke( - _api_keys.api_key, - [ - "create", - "--hub-url", - "http://hub.test", - "-w", - "ws-a", - "--name", - "laptop", - "-p", - "read:project", - "--validity", - "never", - ], - ) - assert result.exit_code == 0, result.output - assert "uid:secret" in result.output - assert captured == { - "user_id": "user-1", - "name": "laptop", - "permissions": ["read:project"], - "workspace_id": 7, - "expires_at": None, - } - - -def test_create_requires_workspace(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) - - result = CliRunner().invoke( - _api_keys.api_key, ["create", "--name", "x", "-p", "read:project"] - ) - assert result.exit_code != 0 - assert "--workspace" in result.output - - -def test_create_unknown_workspace_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) - - result = CliRunner().invoke( - _api_keys.api_key, - ["create", "-w", "nope", "--name", "x", "-p", "read:project"], - ) - assert result.exit_code != 0 - assert "unknown workspace" in result.output - - -def test_create_rejects_ungranted_permission(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) - - result = CliRunner().invoke( - _api_keys.api_key, - ["create", "-w", "ws-a", "--name", "x", "-p", "delete:project"], - ) - assert result.exit_code != 0 - assert "cannot grant delete:project" in result.output - - -def test_create_interactive_uses_form(monkeypatch): - from skore_cli.hub import app as hub_app - - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_api_keys, "_is_interactive", lambda: True) - - class _FakeForm: - def __init__(self, *a, **k): - self.result = hub_app.ApiKeyFormResult( - name="laptop", - workspace_id=7, - workspace_public_id="ws-a", - permissions=["read:project"], - validity="never", - ) - - def run(self): - return None - - monkeypatch.setattr(hub_app, "ApiKeyForm", _FakeForm) - - captured = {} - - def fake_create( - uri, token, user_id, *, name, permissions, workspace_id, expires_at - ): - captured.update(name=name, workspace_id=workspace_id, expires_at=expires_at) - return 7, "uid:secret" - - monkeypatch.setattr(_client, "create_api_key", fake_create) - - # No -w/-p flags => interactive form path. - result = CliRunner().invoke(_api_keys.api_key, ["create"]) - assert result.exit_code == 0, result.output - assert "uid:secret" in result.output - assert captured == {"name": "laptop", "workspace_id": 7, "expires_at": None} - - -def test_create_interactive_cancel(monkeypatch): - from skore_cli.hub import app as hub_app - - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_api_keys, "_is_interactive", lambda: True) - - class _FakeForm: - def __init__(self, *a, **k): - self.result = None - - def run(self): - return None - - monkeypatch.setattr(hub_app, "ApiKeyForm", _FakeForm) - monkeypatch.setattr( - _client, - "create_api_key", - lambda *a, **k: pytest.fail("create should not be called on cancel"), - ) - - result = CliRunner().invoke(_api_keys.api_key, ["create"]) - assert result.exit_code == 0, result.output - assert "Nothing created" in result.output - - -# --------------------------------------------------------------------------- # -# list -# --------------------------------------------------------------------------- # - - -def test_list_prints_table(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr( - _client, - "list_api_keys", - lambda uri, token, user_id: [ - ApiKeyInfo(1, "laptop", 7, "2026-01-01T00:00:00Z", None) - ], - ) - - result = CliRunner().invoke(_api_keys.api_key, ["list", "--hub-url", "http://h"]) - assert result.exit_code == 0, result.output - assert "laptop" in result.output - assert "ws-a" in result.output - assert "never" in result.output - - -def test_list_empty(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_client, "list_api_keys", lambda uri, token, user_id: []) - - result = CliRunner().invoke(_api_keys.api_key, ["list"]) - assert result.exit_code == 0, result.output - assert "No API keys" in result.output - - -# --------------------------------------------------------------------------- # -# revoke -# --------------------------------------------------------------------------- # - - -def test_revoke_by_id(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - deleted = {} - monkeypatch.setattr( - _client, - "delete_api_key", - lambda uri, token, user_id, api_key_id: deleted.update(id=api_key_id), - ) - - result = CliRunner().invoke( - _api_keys.api_key, ["revoke", "--hub-url", "http://h", "--id", "5", "--yes"] - ) - assert result.exit_code == 0, result.output - assert deleted["id"] == 5 - assert "revoked API key 5" in result.output - - -def test_revoke_non_interactive_without_id_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_api_keys, "_is_interactive", lambda: False) - monkeypatch.setattr( - _client, - "list_api_keys", - lambda uri, token, user_id: [ApiKeyInfo(5, "laptop", 7, None, None)], - ) - - result = CliRunner().invoke(_api_keys.api_key, ["revoke"]) - assert result.exit_code != 0 - assert "--id" in result.output - - -def test_revoke_interactive_picker(monkeypatch): - from skore_cli.hub import app as hub_app - - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_api_keys, "_is_interactive", lambda: True) - monkeypatch.setattr( - _client, - "list_api_keys", - lambda uri, token, user_id: [ApiKeyInfo(5, "laptop", 7, None, None)], - ) - - class _FakePicker: - def __init__(self, *a, **k): - self.result = 5 - - def run(self): - return None - - monkeypatch.setattr(hub_app, "IdPicker", _FakePicker) - deleted = {} - monkeypatch.setattr( - _client, - "delete_api_key", - lambda uri, token, user_id, api_key_id: deleted.update(id=api_key_id), - ) - - result = CliRunner().invoke(_api_keys.api_key, ["revoke", "--yes"]) - assert result.exit_code == 0, result.output - assert deleted["id"] == 5 - - -# --------------------------------------------------------------------------- # -# _expires_at -# --------------------------------------------------------------------------- # - - -def test_expires_at_never_is_none(): - assert _api_keys._expires_at("never") is None - - -def test_expires_at_months_is_future_iso(): - from datetime import datetime - - value = _api_keys._expires_at("3") - parsed = datetime.fromisoformat(value) - assert parsed > datetime.now(UTC) diff --git a/tests/test_hub_auth.py b/tests/test_hub_auth.py index 4b50162..653f827 100644 --- a/tests/test_hub_auth.py +++ b/tests/test_hub_auth.py @@ -1,4 +1,4 @@ -"""Tests for ``_hub_auth`` and hub client login gating.""" +"""Tests for ``_hub_auth`` login gating.""" from __future__ import annotations diff --git a/tests/test_hub_workspaces.py b/tests/test_hub_workspaces.py deleted file mode 100644 index ce18eae..0000000 --- a/tests/test_hub_workspaces.py +++ /dev/null @@ -1,457 +0,0 @@ -"""Tests for ``skore hub workspace`` (client + list/show/create/rename/delete). - -The client functions are exercised against an in-memory ``httpx.MockTransport`` -(no network). The command callbacks are exercised via ``CliRunner`` with the -``_client`` functions and the session helpers monkeypatched, plus a fake -``IdPicker`` for the interactive selection path (mirroring the api-key and -agent-provider tests). -""" - -from __future__ import annotations - -import json - -import httpx -import pytest -import rich_click as click -from click.testing import CliRunner - -from skore_cli.hub import _api_keys, _client, _workspaces -from skore_cli.hub._client import MemberInfo, Membership, WorkspaceInfo - - -def _transport(handler): - return httpx.MockTransport(handler) - - -def _ws(**overrides): - base = { - "id": 7, - "public_id": "ws-a", - "is_public": False, - "created_at": "2026-01-01T00:00:00Z", - "members": [{"user_id": "user-1", "role": "owner", "invited_by": None}], - } - base.update(overrides) - return base - - -# --------------------------------------------------------------------------- # -# _client: HTTP calls via MockTransport -# --------------------------------------------------------------------------- # - - -def test_list_workspaces_parses_and_paginates(): - seen = {"calls": 0} - - def handler(request): - assert request.url.path == "/identity/workspaces" - assert request.headers["Authorization"] == "Bearer tok" - seen["calls"] += 1 - cursor = request.url.params.get("cursor") - if cursor is None: - return httpx.Response( - 200, json={"items": [_ws(id=7, public_id="ws-a")], "next_cursor": 5} - ) - assert cursor == "5" - return httpx.Response( - 200, json={"items": [_ws(id=8, public_id="ws-b")], "next_cursor": None} - ) - - result = _client.list_workspaces( - "http://hub.test", "tok", transport=_transport(handler) - ) - assert seen["calls"] == 2 - assert [w.id for w in result] == [7, 8] - assert result[0].members == [MemberInfo("user-1", "owner", None)] - - -def test_get_workspace_parses_members(): - def handler(request): - assert request.url.path == "/identity/workspaces/7" - return httpx.Response( - 200, - json=_ws( - members=[ - {"user_id": "user-1", "role": "owner", "invited_by": None}, - {"user_id": "user-2", "role": "reader", "invited_by": "user-1"}, - ] - ), - ) - - ws = _client.get_workspace( - "http://hub.test", "tok", 7, transport=_transport(handler) - ) - assert ws.id == 7 - assert ws.members == [ - MemberInfo("user-1", "owner", None), - MemberInfo("user-2", "reader", "user-1"), - ] - - -def test_create_workspace_posts_body_and_returns_id(): - seen = {} - - def handler(request): - seen["path"] = request.url.path - seen["body"] = json.loads(request.content) - return httpx.Response(201, json={"id": 42}) - - workspace_id = _client.create_workspace( - "http://hub.test", "tok", public_id="my-ws", transport=_transport(handler) - ) - assert workspace_id == 42 - assert seen["path"] == "/identity/workspaces" - assert seen["body"] == {"public_id": "my-ws"} - - -def test_check_public_id_parses(): - def handler(request): - assert request.url.path == "/identity/workspaces/public-id-availability" - assert request.url.params.get("public_id") == "my-ws" - return httpx.Response( - 200, json={"available": False, "suggested_slug": "my-ws-1"} - ) - - available, suggested = _client.check_public_id( - "http://hub.test", "tok", "my-ws", transport=_transport(handler) - ) - assert (available, suggested) == (False, "my-ws-1") - - -def test_update_workspace_puts_body(): - seen = {} - - def handler(request): - assert request.method == "PUT" - seen["path"] = request.url.path - seen["body"] = json.loads(request.content) - return httpx.Response(204) - - _client.update_workspace( - "http://hub.test", "tok", 7, public_id="new-ws", transport=_transport(handler) - ) - assert seen["path"] == "/identity/workspaces/7" - assert seen["body"] == {"public_id": "new-ws"} - - -def test_delete_workspace_hits_path(): - def handler(request): - assert request.method == "DELETE" - assert request.url.path == "/identity/workspaces/7" - return httpx.Response(204) - - _client.delete_workspace("http://hub.test", "tok", 7, transport=_transport(handler)) - - -def test_delete_403_maps_owner_only(): - def handler(request): - return httpx.Response(403, json={"detail": "forbidden"}) - - with pytest.raises(click.ClickException) as exc: - _client.delete_workspace( - "http://hub.test", "tok", 7, transport=_transport(handler) - ) - assert "owner only" in str(exc.value) - - -def test_get_404_maps_not_found(): - def handler(request): - return httpx.Response(404, json={"detail": "nope"}) - - with pytest.raises(click.ClickException) as exc: - _client.get_workspace( - "http://hub.test", "tok", 7, transport=_transport(handler) - ) - assert "not found" in str(exc.value) - - -# --------------------------------------------------------------------------- # -# helpers for command tests -# --------------------------------------------------------------------------- # - - -def _patch_session(monkeypatch, memberships, *, user_id="user-1"): - monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: url or "h") - monkeypatch.setattr(_client, "require_login_token", lambda: "tok") - monkeypatch.setattr(_client, "me", lambda uri, token: (user_id, memberships)) - - -_WS_A = Membership(7, "ws-a", frozenset()) -_WS_B = Membership(8, "ws-b", frozenset()) - - -# --------------------------------------------------------------------------- # -# list -# --------------------------------------------------------------------------- # - - -def test_list_requires_login(monkeypatch): - monkeypatch.setattr(_api_keys, "resolve_hub_uri", lambda url, *a, **k: "h") - - def boom(): - raise click.ClickException("not logged in; run `skore hub login` first.") - - monkeypatch.setattr(_client, "require_login_token", boom) - - result = CliRunner().invoke(_workspaces.workspace, ["list"]) - assert result.exit_code != 0 - assert "not logged in" in result.output - - -def test_list_prints_table(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr( - _client, - "list_workspaces", - lambda uri, token: [ - WorkspaceInfo( - 7, - "ws-a", - False, - "2026-01-01T00:00:00Z", - [MemberInfo("user-1", "owner", None)], - ) - ], - ) - - result = CliRunner().invoke( - _workspaces.workspace, ["list", "--hub-url", "http://h"] - ) - assert result.exit_code == 0, result.output - assert "ws-a" in result.output - assert "owner" in result.output - - -def test_list_empty(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_client, "list_workspaces", lambda uri, token: []) - - result = CliRunner().invoke(_workspaces.workspace, ["list"]) - assert result.exit_code == 0, result.output - assert "No workspaces" in result.output - - -# --------------------------------------------------------------------------- # -# show -# --------------------------------------------------------------------------- # - - -def test_show_prints_members(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - monkeypatch.setattr( - _client, - "get_workspace", - lambda uri, token, ws_id: WorkspaceInfo( - 7, - "ws-a", - False, - "2026-01-01T00:00:00Z", - [MemberInfo("user-1", "owner", None)], - ), - ) - - result = CliRunner().invoke(_workspaces.workspace, ["show", "-w", "ws-a"]) - assert result.exit_code == 0, result.output - assert "ws-a" in result.output - assert "user-1" in result.output - assert "owner" in result.output - - -def test_show_unknown_workspace_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - - result = CliRunner().invoke(_workspaces.workspace, ["show", "-w", "nope"]) - assert result.exit_code != 0 - assert "unknown workspace" in result.output - - -# --------------------------------------------------------------------------- # -# create -# --------------------------------------------------------------------------- # - - -def test_create_with_public_id(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - monkeypatch.setattr( - _client, "check_public_id", lambda uri, token, pid: (True, None) - ) - captured = {} - - def fake_create(uri, token, *, public_id): - captured["public_id"] = public_id - return 42 - - monkeypatch.setattr(_client, "create_workspace", fake_create) - monkeypatch.setattr( - _client, - "get_workspace", - lambda uri, token, ws_id: WorkspaceInfo(42, "my-ws", False, None, None), - ) - - result = CliRunner().invoke( - _workspaces.workspace, ["create", "--public-id", "my-ws"] - ) - assert result.exit_code == 0, result.output - assert captured["public_id"] == "my-ws" - assert "created workspace" in result.output - assert "my-ws" in result.output - - -def test_create_taken_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - monkeypatch.setattr( - _client, "check_public_id", lambda uri, token, pid: (False, "my-ws-1") - ) - monkeypatch.setattr( - _client, - "create_workspace", - lambda *a, **k: pytest.fail("should not create when taken"), - ) - - result = CliRunner().invoke( - _workspaces.workspace, ["create", "--public-id", "my-ws"] - ) - assert result.exit_code != 0 - assert "not available" in result.output - assert "my-ws-1" in result.output - - -def test_create_interactive_prompt(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: True) - monkeypatch.setattr( - _client, "check_public_id", lambda uri, token, pid: (True, None) - ) - captured = {} - - def fake_create(uri, token, *, public_id): - captured["public_id"] = public_id - return 42 - - monkeypatch.setattr(_client, "create_workspace", fake_create) - monkeypatch.setattr( - _client, - "get_workspace", - lambda uri, token, ws_id: WorkspaceInfo(42, "prompted", False, None, None), - ) - - result = CliRunner().invoke(_workspaces.workspace, ["create"], input="prompted\n") - assert result.exit_code == 0, result.output - assert captured["public_id"] == "prompted" - - -def test_create_non_interactive_missing_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - - result = CliRunner().invoke(_workspaces.workspace, ["create"]) - assert result.exit_code != 0 - assert "--public-id" in result.output - - -# --------------------------------------------------------------------------- # -# rename -# --------------------------------------------------------------------------- # - - -def test_rename_with_flags(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - captured = {} - - def fake_update(uri, token, ws_id, *, public_id): - captured.update(ws_id=ws_id, public_id=public_id) - - monkeypatch.setattr(_client, "update_workspace", fake_update) - monkeypatch.setattr( - _client, - "get_workspace", - lambda uri, token, ws_id: WorkspaceInfo(7, "new-ws", False, None, None), - ) - - result = CliRunner().invoke( - _workspaces.workspace, - ["rename", "-w", "ws-a", "--new-public-id", "new-ws"], - ) - assert result.exit_code == 0, result.output - assert captured == {"ws_id": 7, "public_id": "new-ws"} - assert "new-ws" in result.output - - -def test_rename_non_interactive_missing_new_errors(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - - result = CliRunner().invoke(_workspaces.workspace, ["rename", "-w", "ws-a"]) - assert result.exit_code != 0 - assert "--new-public-id" in result.output - - -# --------------------------------------------------------------------------- # -# delete -# --------------------------------------------------------------------------- # - - -def test_delete_yes(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - deleted = {} - monkeypatch.setattr( - _client, - "delete_workspace", - lambda uri, token, ws_id: deleted.update(ws_id=ws_id), - ) - - result = CliRunner().invoke( - _workspaces.workspace, ["delete", "-w", "ws-a", "--yes"] - ) - assert result.exit_code == 0, result.output - assert deleted == {"ws_id": 7} - assert "deleted workspace ws-a" in result.output - - -def test_delete_confirm_abort(monkeypatch): - _patch_session(monkeypatch, [_WS_A]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: False) - monkeypatch.setattr( - _client, - "delete_workspace", - lambda *a, **k: pytest.fail("should not delete on abort"), - ) - - result = CliRunner().invoke( - _workspaces.workspace, ["delete", "-w", "ws-a"], input="n\n" - ) - assert result.exit_code != 0 - - -def test_delete_interactive_picker(monkeypatch): - from skore_cli.hub import app as hub_app - - _patch_session(monkeypatch, [_WS_A, _WS_B]) - monkeypatch.setattr(_workspaces, "_is_interactive", lambda: True) - - class _FakePicker: - def __init__(self, *a, **k): - self.result = 8 - - def run(self): - return None - - monkeypatch.setattr(hub_app, "IdPicker", _FakePicker) - deleted = {} - monkeypatch.setattr( - _client, - "delete_workspace", - lambda uri, token, ws_id: deleted.update(ws_id=ws_id), - ) - - result = CliRunner().invoke(_workspaces.workspace, ["delete", "--yes"]) - assert result.exit_code == 0, result.output - assert deleted == {"ws_id": 8} - assert "deleted workspace ws-b" in result.output