diff --git a/README.md b/README.md index 85049f4..f91834d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # contree-cli [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) -[![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](#zero-dependencies) [![PyPI](https://img.shields.io/pypi/v/contree-cli.svg)](https://pypi.org/project/contree-cli/) Command-line client for the [ConTree](https://contree.dev) sandboxing platform — secure, VM-isolated sandboxes with git-like branching for AI agents and developers. @@ -11,8 +10,9 @@ eval $(contree use tag:ubuntu:latest) # pick a base image for current session contree run apt-get update -qq # each run snapshots the result contree run apt-get install -y curl # builds on the previous snapshot contree session branch experiment # branch the sandbox state +contree session checkout experiment # switch to the branch contree run -- make test # experiment freely -contree session checkout main # switch back instantly +contree session checkout main # switch back contree session rollback -- -2 # or rewind two steps ``` @@ -27,8 +27,6 @@ contree session rollback -- -2 # or rewind two steps - **Safe code execution** — run untrusted or LLM-generated code inside VM-level isolation; crashes and side effects stay in the sandbox - **Session continuity** — rewind and resume long-running agent workflows with full filesystem context preserved -`contree-cli` talks to the ConTree API. Install it, authenticate with your project token, and create sandboxes, run commands, inspect filesystems, and manage sessions — all from your terminal, shell scripts, or agent toolchains. - ## Install ```bash @@ -62,7 +60,7 @@ Verify: contree --help ``` -**Requirements:** Python 3.10+ and nothing else. Zero external dependencies — stdlib only. +**Requirements:** Python 3.10+. ## Quick Start @@ -82,7 +80,7 @@ If `--token`/`--url`/`--project` flags are omitted, `contree auth` reads `CONTRE contree skill install ``` -Autodetects installed agents (Claude Code, Codex, OpenCode, Cline, Amp) and installs ConTree skill files into their skill directories. Use `contree skill install -F` to force-overwrite. +Autodetects installed agents (Claude Code, Codex, OpenCode, Cline, Amp) and installs ConTree skill files into their skill directories. Use `contree skill install -f` to force-overwrite. ### 3. Start a session @@ -114,7 +112,8 @@ contree cp /app/output.log . # download to local machine ```bash contree session branch experiment # create a branch -contree run -- make test # experiment on it +contree session checkout experiment # switch to it +contree run -- make test # experiment on the branch contree session checkout main # switch back contree session rollback # undo last run (default: back 1 entry) ``` @@ -234,7 +233,7 @@ main: A ── B ── C ── D experiment: E ── F ``` -Every `run` creates a checkpoint. Branch to explore alternatives. Roll back to any point. Switch branches instantly. +Every non-disposable `run` creates a checkpoint in the session history. ```bash contree session # show current state @@ -247,14 +246,14 @@ contree session use other-session # import image from another session ## Output Formats -All commands support structured output via `-f`/`--format`: +All commands support structured output via `-o`/`--format`/`--output`: ```bash -contree images -f json # JSON (one object per line) -contree images -f json-pretty # pretty-printed JSON array -contree ps -f csv # RFC 4180 CSV -contree ps -f tsv # tab-separated values -contree ls -f table # ASCII table +contree -o json images # JSON (one object per line) +contree -o json-pretty images # pretty-printed JSON array +contree -o csv ps # RFC 4180 CSV +contree -o tsv ps # tab-separated values +contree -o table ls # ASCII table ``` Pipe JSON output into `jq`, feed CSV into spreadsheets, or parse programmatically in your agent toolchain. @@ -283,7 +282,7 @@ contree auth --profile=staging # save staging token contree auth --profile=prod # save production token contree auth profiles # list all profiles + status probe contree auth profiles --offline # list profiles without network checks -contree -f json auth profiles # structured profile health output +contree -o json auth profiles # structured profile health output contree auth switch staging # switch active profile ``` @@ -308,9 +307,9 @@ Read only by `contree auth` (registration-time fallbacks for omitted flags): Credentials come strictly from the saved profile at runtime. `--token`, `--url`, `--project` CLI flags override profile fields for a single invocation. -## Zero Dependencies +## Dependencies -`contree-cli` uses only the Python standard library. No `requests`, no `click`, no `rich` — just `http.client`, `argparse`, `json`, `sqlite3`, and friends. It runs anywhere Python 3.10+ is available with nothing to install beyond the package itself. +`contree-cli` has a single runtime dependency: the `contree-client` library, which provides the HTTP transport and typed API bindings. ## Development @@ -327,7 +326,7 @@ make check # lint + types make tests # lint + types + pytest ``` -The project enforces strict mypy, ruff linting (E/F/W/I/UP/B/SIM/RUF rules), and full test coverage across 23+ test modules. +The project enforces strict mypy and ruff linting (E/F/W/I/UP/B/SIM/RUF rules). ## Documentation diff --git a/contree_cli/__init__.py b/contree_cli/__init__.py index b66f68c..255212c 100644 --- a/contree_cli/__init__.py +++ b/contree_cli/__init__.py @@ -6,13 +6,14 @@ from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: - from contree_cli.client import ContreeClient - from contree_cli.config import ConfigProfile + from contree_client.profiles import Profile + + from contree_cli.client import CliClient from contree_cli.output import OutputFormatter from contree_cli.session import SessionStore -PROFILE: ContextVar[ConfigProfile] = ContextVar("PROFILE") -CLIENT: ContextVar[ContreeClient] = ContextVar("CLIENT") +PROFILE: ContextVar[Profile] = ContextVar("PROFILE") +CLIENT: ContextVar[CliClient] = ContextVar("CLIENT") FORMATTER: ContextVar[OutputFormatter] = ContextVar("FORMATTER") SESSION_STORE: ContextVar[SessionStore] = ContextVar("SESSION_STORE") IN_SHELL: ContextVar[bool] = ContextVar("IN_SHELL", default=False) diff --git a/contree_cli/__main__.py b/contree_cli/__main__.py index d749071..f123078 100644 --- a/contree_cli/__main__.py +++ b/contree_cli/__main__.py @@ -3,13 +3,15 @@ import logging import sys from collections.abc import Callable -from contextlib import suppress +from contextlib import ExitStack, suppress from dataclasses import replace +from contree_client.exceptions import ContreeError + import contree_cli.config as config_mod from contree_cli import CLIENT, FORMATTER, PROFILE, SESSION_STORE, ArgumentsProtocol from contree_cli.arguments import parser -from contree_cli.client import ApiError, client_from_profile +from contree_cli.client import client_from_profile from contree_cli.config import SETTINGS, Config from contree_cli.log import setup_logging from contree_cli.output import FORMATTERS @@ -71,24 +73,30 @@ def main() -> None: ) exit(1) - if needs_client: - try: - client = client_from_profile(profile) - except ValueError as exc: - log.error("%s", exc) - exit(1) - CLIENT.set(client) - - formatter = FORMATTERS[args.output_format]() - - session_key = get_session_key(profile.name, override=args.session_key) - db_path = profile.session_db_path - log.debug("Running in session: %s", session_key) + # One stack owns every resource the command needs; entries are + # added as they come to life and unwound together on the way out. + with ExitStack() as stack: + if needs_client: + # Session-based transports (requests/httpx/urllib3) hold + # pooled connections; enter the client so open()/close() + # run around the whole command. + try: + client = client_from_profile(profile) + except ValueError as exc: + log.error("%s", exc) + exit(1) + CLIENT.set(stack.enter_context(client)) + + formatter = FORMATTERS[args.output_format]() + stack.callback(formatter.close) + + session_key = get_session_key(profile.name, override=args.session_key) + db_path = config_mod.session_db_path(profile.name) + log.debug("Running in session: %s", session_key) - with SessionStore(db_path, session_key) as store: PROFILE.set(profile) FORMATTER.set(formatter) - SESSION_STORE.set(store) + SESSION_STORE.set(stack.enter_context(SessionStore(db_path, session_key))) ctx = contextvars.copy_context() loader: type[ArgumentsProtocol] = args.load_args @@ -96,7 +104,7 @@ def main() -> None: try: exit_code = ctx.run(handler, loader.from_args(args)) - except ApiError as exc: + except ContreeError as exc: log.error("%s", exc) exit(1) except ValueError as exc: @@ -110,8 +118,6 @@ def main() -> None: except KeyboardInterrupt: log.error("User interrupted") exit(1) - finally: - formatter.close() exit(exit_code or 0) diff --git a/contree_cli/agent.md b/contree_cli/agent.md index e3a4ea3..9509235 100644 --- a/contree_cli/agent.md +++ b/contree_cli/agent.md @@ -18,6 +18,7 @@ Agent protocol — follow this sequence for every task: 4. Inspect first (read-only): contree ls /path contree cat /path/file + contree export /path -F subtree.tar.gz contree images --prefix=... contree session show @@ -104,7 +105,7 @@ Listing: contree images --prefix=python filter by tag prefix contree images -a include untagged contree images --since 1d last 24 hours - contree -f json images JSON output for scripting + contree -o json images JSON output for scripting Tagging: contree tag my-app:v1.0 tag current session image @@ -145,7 +146,7 @@ Building from a Dockerfile: .dockerignore is applied to every COPY/ADD walk on top of the default exclude list (.git, __pycache__, node_modules, etc.). - build runs in its own session keyed by abspath(CONTEXT) (visible as "session": "build:" in -f json output). `-S ` on `build` is harmless but does not bind the build to your agent session. Verify the resulting image from a normal session: + build runs in its own session keyed by abspath(CONTEXT) (visible as "session": "build:" in -o json output). `-S ` on `build` is harmless but does not bind the build to your agent session. Verify the resulting image from a normal session: contree build . --tag myapp:dev contree -S agent_verify use tag:myapp:dev contree -S agent_verify run -D -- myapp --version @@ -188,7 +189,7 @@ Listing uploaded files: contree file ls list all uploaded files in the project contree file ls --since 1d narrow by upload time contree file ls -q uuid + sha256 + source only (quiet) - contree -f json file ls JSON output for jq + contree -o json file ls JSON output for jq Output joins remote files (uuid, sha256, size, created_at) with the local upload cache. The SOURCE column shows whatever this machine used to produce the file: - absolute host path for files uploaded via `run --file` / `COPY`; @@ -243,7 +244,7 @@ Detached mode (-d): contree session wait block until done + advance branch contree session wait UUID1 UUID2 poll only (NO branch advance) - NOTE: status filtering uses --status, NOT -S. `-S` is the global session flag and only works BEFORE the subcommand. Also, the default `run -d` output is plain/table -- use `contree -f json run -d ...` to capture the UUID via `jq -r .uuid` reliably. + NOTE: status filtering uses --status, NOT -S. `-S` is the global session flag and only works BEFORE the subcommand. Also, the default `run -d` output is plain/table -- use `contree -o json run -d ...` to capture the UUID via `jq -r .uuid` reliably. Operation references (UUID_OR_REF): Every positional that `--help` labels `UUID_OR_REF` -- `op show`, `op cancel`, `op wait`, top-level `show`/`kill`, and `session wait` -- accepts either a real operation UUID OR a session-history reference. References are resolved against the active session (the one selected by `-S `) before the API is called, so the same notation works everywhere. @@ -281,24 +282,24 @@ Monitoring background operations: Default `op ls`/`ps` lists only `EXECUTING`; `PENDING` and `ASSIGNED` are hidden until `-a` or an explicit `--status`. For a full active snapshot, fetch with `-a` and filter client-side. - `op wait` is a pure observer: polls and prints one operation record per completion. Default formatter pins uuid, status, exit_code, timed_out, duration first and error last; every other scalar API field appears between them, so column count is not fixed. For scripts use `-f json` (one object per line) or `-f tsv` and select fields explicitly. `status` is the server's word verbatim (orchestration outcome — did the API run the job?); the sandbox process's exit code lives in the separate `exit_code` column. The CLI exit code is 1 when any op finishes non-SUCCESS, or the actual `exit_code` when a SUCCESS op had a non-zero sandbox exit (so `op wait && next-step` still composes naturally with `run -- false`). --timeout (default 60s) caps the wait. Use --all to wait for every currently active op in the project. + `op wait` is a pure observer: polls and prints one operation record per completion. Default formatter pins uuid, status, exit_code, timed_out, duration first and error last; every other scalar API field appears between them, so column count is not fixed. For scripts use `-o json` (one object per line) or `-o tsv` and select fields explicitly. `status` is the server's word verbatim (orchestration outcome — did the API run the job?); the sandbox process's exit code lives in the separate `exit_code` column. The CLI exit code is 1 when any op finishes non-SUCCESS, or the actual `exit_code` when a SUCCESS op had a non-zero sandbox exit (so `op wait && next-step` still composes naturally with `run -- false`). --timeout (default 60s) caps the wait. Use --all to wait for every currently active op in the project. Rule of thumb -- use `op wait` ONLY outside session context: `op wait` is the right tool when the UUIDs came from somewhere else (different session, different agent, `images import`, raw API call) and you only need "is it done yet?". For ops you spawned in *this* session, use `session wait` (no-arg form) instead -- it polls AND advances the active branch to each result image, which `op wait` will not do. Caveat 1 -- `op wait` does NOT advance session state: each `run -d` (non-disposable) creates a `detached-` branch pointing at the START image. `op wait` does not move those branches to the result image; the result lives only on the server. After fan-out + wait the session looks the same as before the wait, just with `detached-*` branches accumulated. PREFERRED fan-out (--disposable, no image-tracking concerns): - A=$(contree -S -f json run -d --disposable -- pytest tests/a | jq -r .uuid) - B=$(contree -S -f json run -d --disposable -- pytest tests/b | jq -r .uuid) - C=$(contree -S -f json run -d --disposable -- pytest tests/c | jq -r .uuid) + A=$(contree -S -o json run -d --disposable -- pytest tests/a | jq -r .uuid) + B=$(contree -S -o json run -d --disposable -- pytest tests/b | jq -r .uuid) + C=$(contree -S -o json run -d --disposable -- pytest tests/c | jq -r .uuid) contree -S op wait "$A" "$B" "$C" block until all complete contree -S op show "$A" "$B" "$C" stdout/stderr per op Non-disposable fan-out (must recover images manually): - A=$(contree -S -f json run -d -- apt-get install -y curl | jq -r .uuid) - B=$(contree -S -f json run -d -- apt-get install -y wget | jq -r .uuid) + A=$(contree -S -o json run -d -- apt-get install -y curl | jq -r .uuid) + B=$(contree -S -o json run -d -- apt-get install -y wget | jq -r .uuid) contree -S op wait "$A" "$B" - IMG_A=$(contree -f json op show "$A" | jq -r .image) + IMG_A=$(contree -o json op show "$A" | jq -r .image) contree use "$IMG_A" bind chosen result back Caveat 2 -- `op wait --all` is project-wide: if another agent (or another shell of yours) is running concurrently in the same project, your --all will block on its ops too. The result is still a valid wait, just possibly not over the set you expected. For session-spawned fan-out the correct alternative is `contree -S session wait` (no args): it drains only this session's pending detached ops and advances the active branch with each result image. Reach for `op wait --all` only when you really want a project-wide observer (admin/cleanup tooling). @@ -362,10 +363,10 @@ Rules for reliable agent workflows: 3. Why split? Chained runs collapse into one checkpoint. If `make test` fails, you can't rollback to just after `apt install`. Split runs give you granular rollback. 4. Global flags (-f, -S, -p) MUST come before the subcommand: - Right: contree -S key -f json images - Wrong: contree images -S key -f json + Right: contree -S key -o json images + Wrong: contree images -S key -o json -5. Use -f json for structured output in automation: `contree -f json images | jq '.uuid'`. +5. Use -o json for structured output in automation: `contree -o json images | jq '.uuid'`. 6. Agents must never run `contree auth`. Only users manage auth. @@ -378,23 +379,23 @@ Rules for reliable agent workflows: Output formats ============== -Global -f flag goes before the subcommand. Always available formats: +Global -o flag goes before the subcommand. Always available formats: - contree -f json images one JSON object per line (JSONL) - contree -f json-pretty ps pretty-printed JSON array - contree -f csv images CSV with header row - contree -f tsv ps tab-separated values - contree -f plain images key: value blocks + contree -o json images one JSON object per line (JSONL) + contree -o json-pretty ps pretty-printed JSON array + contree -o csv images CSV with header row + contree -o tsv ps tab-separated values + contree -o plain images key: value blocks -`-f toml` is available only on Python 3.11+ (it relies on stdlib `tomllib`). On Python 3.10 it is silently absent from --help. +`-o toml` is available only on Python 3.11+ (it relies on stdlib `tomllib`). On Python 3.10 it is silently absent from --help. Scripting examples: - contree -f json images --prefix=python | jq -r '.uuid' - contree -f json ps -a | jq 'select(.status=="SUCCESS")' - contree -f csv images > images.csv + contree -o json images --prefix=python | jq -r '.uuid' + contree -o json ps -a | jq 'select(.status=="SUCCESS")' + contree -o csv images > images.csv contree ps -q | xargs contree show -Note: `run` with default formatter prints raw stdout/stderr. Use -f json to get structured operation metadata instead. +Note: `run` with default formatter prints raw stdout/stderr. Use -o json to get structured operation metadata instead. Profiles ======== @@ -450,6 +451,7 @@ All commands ls [PATH] List files in image (no VM) cat PATH Show file content (no VM) cp PATH DEST Download file from image + export [PATH] Export rootfs or a subtree as tar.gz (-F FILE or pipe; --decompress for plain tar) cd [PATH] Change session working directory env [KEY=VALUE ...] Session env vars (-U to unset) file edit PATH Edit remote file via $EDITOR diff --git a/contree_cli/arguments.py b/contree_cli/arguments.py index 4b00675..403fa36 100644 --- a/contree_cli/arguments.py +++ b/contree_cli/arguments.py @@ -11,6 +11,7 @@ cd, cp, env, + export, file, images, ls, @@ -235,6 +236,7 @@ def register( register("ls", "List files in image", ls.setup_parser) register("cat", "Show file content from image", cat.setup_parser) register("cp", "Copy file from image to local path", cp.setup_parser) +register("export", "Export image rootfs or a subtree as tar.gz", export.setup_parser) register("file", "Manage files in session image", file.setup_parser, aliases=["f"]) register( "session", diff --git a/contree_cli/cli/auth.py b/contree_cli/cli/auth.py index 59e7ad8..1037a85 100644 --- a/contree_cli/cli/auth.py +++ b/contree_cli/cli/auth.py @@ -26,15 +26,23 @@ import argparse import getpass import hashlib -import json import logging import os from dataclasses import dataclass from multiprocessing.pool import ThreadPool +from contree_client.exceptions import ContreeAPIError +from contree_client.models import WhoAmIResponse + from contree_cli import FORMATTER, ArgumentsProtocol, SetupResult -from contree_cli.client import ApiError, client_from_profile -from contree_cli.config import AuthType, Config, ConfigProfile +from contree_cli.client import client_from_profile +from contree_cli.config import ( + AUTH_TYPE_IAM, + AUTH_TYPE_JWT, + DEFAULT_IAM_URL, + Config, + Profile, +) from contree_cli.types import FLAGS logger = logging.getLogger(__name__) @@ -54,7 +62,7 @@ class AuthArgs(ArgumentsProtocol): token: str | None = None url: str | None = None - auth_type: AuthType = AuthType.IAM + auth_type: str = AUTH_TYPE_IAM project: str | None = None profile: str = "default" force: bool = False @@ -64,7 +72,7 @@ def from_args(cls, ns: argparse.Namespace) -> AuthArgs: return cls( token=ns.auth_token or None, url=ns.auth_url or None, - auth_type=AuthType(ns.auth_type) if ns.auth_type else AuthType.IAM, + auth_type=ns.auth_type or AUTH_TYPE_IAM, project=ns.auth_project or None, profile=ns.profile or "default", force=ns.force, @@ -113,8 +121,8 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult: p.add_argument( "--type", dest="auth_type", - choices=list(AuthType), - default=AuthType.IAM, + choices=(AUTH_TYPE_IAM, AUTH_TYPE_JWT), + default=AUTH_TYPE_IAM, help="Auth type", ) p.add_argument( @@ -184,13 +192,8 @@ def env_fallback(names: tuple[str, ...], *, what: str) -> str | None: return None -def check_permission(payload: object, permission: str) -> bool: - if not isinstance(payload, dict): - return False - perms = payload.get("permissions") - if not isinstance(perms, dict): - return False - return bool(perms.get(permission)) +def check_permission(payload: WhoAmIResponse, permission: str) -> bool: + return bool(payload.permissions.get(permission)) def cmd_auth(args: AuthArgs) -> int | None: @@ -223,8 +226,8 @@ def cmd_auth(args: AuthArgs) -> int | None: # URL: --url > CONTREE_URL > type-specific default > interactive prompt url = args.url or env_fallback(("CONTREE_URL",), what="URL") if url is None: - if args.auth_type == AuthType.IAM: - url = Config.DEFAULT_IAM_URL + if args.auth_type == AUTH_TYPE_IAM: + url = DEFAULT_IAM_URL else: url = input("URL: ").strip().rstrip("/") if not url: @@ -233,14 +236,14 @@ def cmd_auth(args: AuthArgs) -> int | None: # Project (IAM only): --project > CONTREE_PROJECT > NEBIUS_AI_PROJECT > prompt project: str | None = None - if args.auth_type == AuthType.IAM: + if args.auth_type == AUTH_TYPE_IAM: project = args.project or env_fallback( ("CONTREE_PROJECT", "NEBIUS_AI_PROJECT"), what="project", ) if project is None: project = input("Project ID: ").strip() - profile = ConfigProfile( + profile = Profile( name=args.profile, token=token, url=url, @@ -255,14 +258,16 @@ def cmd_auth(args: AuthArgs) -> int | None: return 1 try: - resp = client.get("/v1/whoami") - whoami = json.loads(resp.read() or b"{}") - except ApiError as exc: + with client: + whoami = client.whoami() + except ContreeAPIError as exc: # Logs the API error message, not the token itself. # nosemgrep: python-logger-credential-disclosure logger.error("Token verification failed: %s. Profile not changed.", exc) return 1 - except ValueError as exc: + except (KeyError, TypeError, ValueError) as exc: + # Strict contree-client models raise TypeError (missing required + # field) or KeyError (parse_fields lookup) on incomplete payloads. logger.error("Could not parse /v1/whoami response: %s", exc) return 1 @@ -306,8 +311,8 @@ def cmd_list(args: ProfilesArgs) -> None: ) def check_status( - profile: ConfigProfile, - ) -> tuple[ConfigProfile, str]: + profile: Profile, + ) -> tuple[Profile, str]: if not profile.token: return profile, "error" if args.offline: @@ -321,16 +326,12 @@ def check_status( return profile, "no url" try: - resp = client.get("/v1/whoami") - payload = resp.read() + with client: + whoami = client.whoami() except TimeoutError: return profile, "timeout" except Exception: return profile, "error" - try: - whoami = json.loads(payload or b"{}") - except ValueError: - return profile, "error" if not check_permission(whoami, REQUIRED_PERMISSION): return profile, "inactive" return profile, "ok" diff --git a/contree_cli/cli/build.py b/contree_cli/cli/build.py index d0a6804..5a7cb88 100644 --- a/contree_cli/cli/build.py +++ b/contree_cli/cli/build.py @@ -6,11 +6,22 @@ materialised as branches named ``layer:`` so that re-running the same Dockerfile reuses prior work. -Supported directives (MVP): FROM, RUN, COPY, ADD (local files/dirs -and http(s) URLs; no tar auto-extraction), WORKDIR, ENV, ARG, USER. -Other Dockerfile directives parse cleanly but are skipped with a -warning (CMD, ENTRYPOINT, LABEL, EXPOSE, VOLUME, STOPSIGNAL, -MAINTAINER, HEALTHCHECK, ONBUILD, SHELL). +Supported directives (MVP): FROM (multistage via ``FROM ... AS name``), +RUN, COPY (including ``--from=``), ADD (local +files/dirs and http(s) URLs; no tar auto-extraction, no ``--from``), +WORKDIR, ENV, ARG, USER. Other Dockerfile directives parse cleanly but +are skipped with a warning (CMD, ENTRYPOINT, LABEL, EXPOSE, VOLUME, +STOPSIGNAL, MAINTAINER, HEALTHCHECK, ONBUILD, SHELL). + +Multistage notes: ``COPY --from`` exports the source path from the +referenced stage image as a tar archive, uploads it once (content +deduplicated) and unpacks it with an extraction RUN inside the target +sandbox. For now this means the target image must provide ``/bin/sh``, +``tar``, ``cp`` and ``mv`` (busybox suffices; ``FROM scratch`` targets +cannot receive ``COPY --from``). This is a temporary limitation of the +client-side extraction and will be lifted in a future release once the +backend unpacks archives itself. Unlike docker, ARG/ENV live in one +global namespace across stages. """ from __future__ import annotations @@ -29,6 +40,7 @@ ArgumentsProtocol, SetupResult, ) +from contree_cli.config import session_db_path from contree_cli.docker import ( ArgKeyword, BuildContext, @@ -56,6 +68,9 @@ mutating command, may create operations against the API layer cache is per-context (session keyed by abspath(context)) use --no-cache to bypass cached layers and rebuild from scratch + multistage supported: FROM ... AS name + COPY --from= + COPY --from unpacks a tar inside the target: needs sh/tar/cp/mv there + (temporary limitation, to be lifted in a future release) """ @@ -151,45 +166,44 @@ def cmd_build(args: BuildArgs) -> int | None: profile = PROFILE.get() client = CLIENT.get() session_key = make_session_key(context_dir) - store = SessionStore(profile.session_db_path, session_key) - SESSION_STORE.set(store) - - ctx = BuildContext( - client=client, - store=store, - local=LocalContext.from_dir(context_dir), - build_args=build_args, - no_cache=args.no_cache, - timeout=args.timeout, - ) - - try: - for kw in directives: - kw.execute(ctx) - finalize_pending(ctx) - except Exception as exc: - logger.error("build failed: %s", exc) - return 1 - - if not ctx.last_image: - logger.error("build produced no image") - return 1 - - if args.tag: - client.patch_json( - f"/v1/images/{ctx.last_image}/tag", - {"tag": args.tag}, - ) - logger.info("tagged %s as %s", ctx.last_image, args.tag) - - formatter = FORMATTER.get() - formatter( - image=ctx.last_image, - tag=args.tag, - session=session_key, - ) - formatter.flush() - return None + with SessionStore(session_db_path(profile.name), session_key) as store: + store_token = SESSION_STORE.set(store) + try: + ctx = BuildContext( + client=client, + store=store, + local=LocalContext.from_dir(context_dir), + build_args=build_args, + no_cache=args.no_cache, + timeout=args.timeout, + ) + + try: + for kw in directives: + kw.execute(ctx) + finalize_pending(ctx) + except Exception as exc: + logger.error("build failed: %s", exc) + return 1 + + if not ctx.last_image: + logger.error("build produced no image") + return 1 + + if args.tag: + client.update_image_tag(ctx.last_image, args.tag) + logger.info("tagged %s as %s", ctx.last_image, args.tag) + + formatter = FORMATTER.get() + formatter( + image=ctx.last_image, + tag=args.tag, + session=session_key, + ) + formatter.flush() + return None + finally: + SESSION_STORE.reset(store_token) def validate_first_directive(directives: list[DockerKeyword]) -> bool: diff --git a/contree_cli/cli/cat.py b/contree_cli/cli/cat.py index 43987ac..d4fd474 100644 --- a/contree_cli/cli/cat.py +++ b/contree_cli/cli/cat.py @@ -18,7 +18,6 @@ from typing import cast from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult -from contree_cli.client import resolve_image from contree_cli.output import DefaultFormatter logger = logging.getLogger(__name__) @@ -54,15 +53,14 @@ def cmd_cat(args: CatArgs) -> int | None: store = SESSION_STORE.get() image = store.current_image path = store.resolve_path(args.path) - uuid = resolve_image(client, image) + uuid = client.resolve_image(image) cache_key = (uuid, f"download:{path}") cached = store.cache.get(cache_key) if cached is not None: - data = base64.b64decode(cast(str, cached)) + data = base64.b64decode(cast("str", cached)) else: - resp = client.get(f"/v1/inspect/{uuid}/download", params={"path": path}) - data = resp.read() + data = client.inspect_image_download(uuid, path) store.cache[cache_key] = base64.b64encode(data).decode("ascii") if not sys.stdout.isatty(): diff --git a/contree_cli/cli/cd.py b/contree_cli/cli/cd.py index ebd0b0d..7cbe7f3 100644 --- a/contree_cli/cli/cd.py +++ b/contree_cli/cli/cd.py @@ -10,13 +10,13 @@ from __future__ import annotations import argparse -import json import logging import posixpath from dataclasses import dataclass +from contree_client.exceptions import ContreeAPIError + from contree_cli import CLIENT, SESSION_STORE, ArgumentsProtocol, SetupResult -from contree_cli.client import ApiError, resolve_image logger = logging.getLogger(__name__) @@ -62,13 +62,9 @@ def cmd_cd(args: CdArgs) -> int | None: if session is not None: try: client = CLIENT.get() - uuid = resolve_image(client, session.current_image) - resp = client.get(f"/v1/inspect/{uuid}/list", params={"path": new_cwd}) - data = json.loads(resp.read()) - if not data: - logger.error("cd: %s: not a directory", new_cwd) - return 1 - except ApiError as exc: + uuid = client.resolve_image(session.current_image) + client.inspect_image_list(uuid, new_cwd) + except ContreeAPIError as exc: if exc.status == 404: logger.error("cd: %s: no such directory", new_cwd) return 1 diff --git a/contree_cli/cli/cp.py b/contree_cli/cli/cp.py index ab030d5..9c207bb 100644 --- a/contree_cli/cli/cp.py +++ b/contree_cli/cli/cp.py @@ -15,7 +15,6 @@ from pathlib import Path from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult -from contree_cli.client import resolve_image, stream_response from contree_cli.output import DefaultFormatter logger = logging.getLogger(__name__) @@ -38,16 +37,6 @@ def fmt_size(n: int | float) -> str: return f"{n:.1f} TiB" -def fmt_duration(seconds: float) -> str: - if seconds < 60: - return f"{seconds:.0f}s" - minutes, secs = divmod(int(seconds), 60) - if minutes < 60: - return f"{minutes}m{secs:02d}s" - hours, minutes = divmod(minutes, 60) - return f"{hours}h{minutes:02d}m{secs:02d}s" - - @dataclass(frozen=True) class CpArgs(ArgumentsProtocol): path: str @@ -73,20 +62,21 @@ def cmd_cp(args: CpArgs) -> int | None: store = SESSION_STORE.get() image = store.current_image path = store.resolve_path(args.path) - uuid = resolve_image(client, image) - resp = client.get(f"/v1/inspect/{uuid}/download", params={"path": path}) + uuid = client.resolve_image(image) - total: int | None = None - cl = resp.getheader("Content-Length") - if cl is not None: - total = int(cl) + dest = Path(args.dest) + if dest.is_dir(): + dest = dest / Path(path).name + # The streaming download API exposes no response headers, so the + # total size (Content-Length) is unknown and progress is reported + # as running volume/speed only. downloaded = 0 start = time.monotonic() last_log = start - with Path(args.dest).open("wb") as f: - for chunk in stream_response(resp): + with dest.open("wb") as f: + for chunk in client.inspect_image_download_stream(uuid, path): f.write(chunk) downloaded += len(chunk) @@ -95,21 +85,18 @@ def cmd_cp(args: CpArgs) -> int | None: last_log = now elapsed = now - start speed = downloaded / elapsed if elapsed > 0 else 0 - parts = [f"{fmt_size(downloaded)} downloaded"] - if total: - pct = downloaded / total * 100 - remaining = (total - downloaded) / speed if speed > 0 else 0 - parts.append(f"{pct:.0f}%") - parts.append(f"ETA {fmt_duration(remaining)}") - parts.append(f"{fmt_size(speed)}/s") - logger.info("%s", " | ".join(parts)) + logger.info( + "%s downloaded | %s/s", + fmt_size(downloaded), + fmt_size(speed), + ) elapsed = time.monotonic() - start speed = downloaded / elapsed if elapsed > 0 else 0 logger.info( "Written %s to %s (%s/s)", fmt_size(downloaded), - args.dest, + dest, fmt_size(speed), ) return None diff --git a/contree_cli/cli/export.py b/contree_cli/cli/export.py new file mode 100644 index 0000000..34a2eff --- /dev/null +++ b/contree_cli/cli/export.py @@ -0,0 +1,164 @@ +"""Export the session image rootfs (or a subtree) as a tar archive. + +Docker-export-like, with one extra power: PATH selects any subtree of +the image instead of the whole rootfs. The archive is streamed from +the /inspect/ API; by default the gzip the server applies on the wire +is passed through untouched, so the output is a ``.tar.gz`` produced +with zero local compression work. ``--decompress`` writes a plain tar +instead (the client inflates the stream). + +Output goes to ``-F FILE`` or to stdout for piping +(``contree export | tar -tzf -``); a terminal stdout is refused, the +stream is binary. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +import time +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import IO + +from contree_client.exceptions import NotFoundError + +from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult +from contree_cli.cli.cp import LOG_INTERVAL, fmt_size +from contree_cli.output import DefaultFormatter +from contree_cli.types import FLAGS + +logger = logging.getLogger(__name__) + +EPILOG = """\ +examples: + contree export -F rootfs.tar.gz # whole rootfs + contree export /etc -F etc.tar.gz # a subtree only + contree export /opt/app -F app.tar # .tar implies --decompress + contree export /etc | tar -tzf - | head # stream to a pipe + +for coding agents: + read-only command (inspect API, no instance spawn) + default output is gzip (tar.gz) streamed as served by the API + --decompress (or -F *.tar) writes a plain tar instead + stdout must be redirected or piped; a terminal is refused + --format is ignored; output is the raw archive stream +""" + + +@dataclass(frozen=True) +class ExportArgs(ArgumentsProtocol): + path: str + output: str = "" + decompress: bool = False + + @classmethod + def from_args(cls, ns: argparse.Namespace) -> ExportArgs: + return cls( + path=ns.path if ns.path is not None else "/", + output=ns.output or "", + decompress=ns.decompress, + ) + + +def setup_parser(p: argparse.ArgumentParser) -> SetupResult: + p.add_argument( + "path", + nargs="?", + default=None, + help="Path inside image to export (default: / — the whole rootfs)", + ) + p.add_argument( + *FLAGS["file"], + dest="output", + default="", + metavar="FILE", + help="Write the archive to FILE (default: stdout)", + ) + p.add_argument( + *FLAGS["decompress"], + action="store_true", + help="Write a plain tar instead of the default tar.gz", + ) + return cmd_export, ExportArgs + + +def wants_plain_tar(args: ExportArgs) -> bool: + """Plain tar on --decompress or when the target is named *.tar.""" + if args.decompress: + return True + return args.output.endswith(".tar") + + +def write_stream(chunks: Iterator[bytes], sink: IO[bytes]) -> int: + """Pump chunks into *sink* with periodic progress on stderr.""" + written = 0 + start = time.monotonic() + last_log = start + for chunk in chunks: + sink.write(chunk) + written += len(chunk) + now = time.monotonic() + if now - last_log >= LOG_INTERVAL: + last_log = now + elapsed = now - start + speed = written / elapsed if elapsed > 0 else 0 + logger.info( + "%s exported | %s/s", + fmt_size(written), + fmt_size(speed), + ) + return written + + +def cmd_export(args: ExportArgs) -> int | None: + client = CLIENT.get() + formatter = FORMATTER.get() + if not isinstance(formatter, DefaultFormatter): + logger.warning("export always outputs the raw archive; --format is ignored") + + if not args.output and sys.stdout.isatty(): + logger.error( + "The archive is a binary stream and stdout is a terminal." + " Write it to a file: contree export %(path)s -F rootfs.tar.gz\n" + "or pipe it: contree export %(path)s | tar -tzf -", + {"path": args.path}, + ) + return 1 + + store = SESSION_STORE.get() + path = store.resolve_path(args.path) + uuid = client.resolve_image(store.current_image) + + chunks: Iterator[bytes] = client.inspect_image_archive( + uuid, + path, + compressed=not wants_plain_tar(args), + ) + + start = time.monotonic() + try: + if args.output: + with Path(args.output).open("wb") as sink: + written = write_stream(chunks, sink) + else: + written = write_stream(chunks, sys.stdout.buffer) + sys.stdout.buffer.flush() + except NotFoundError: + if args.output: + Path(args.output).unlink(missing_ok=True) + logger.error("export: %s: not found in image", path) + return 1 + + elapsed = time.monotonic() - start + speed = written / elapsed if elapsed > 0 else 0 + logger.info( + "Exported %s from %s to %s (%s/s)", + fmt_size(written), + path, + args.output or "stdout", + fmt_size(speed), + ) + return None diff --git a/contree_cli/cli/file.py b/contree_cli/cli/file.py index 75b9706..02ffb94 100644 --- a/contree_cli/cli/file.py +++ b/contree_cli/cli/file.py @@ -17,7 +17,6 @@ import argparse import hashlib -import json import logging import shlex import subprocess @@ -26,6 +25,8 @@ from datetime import datetime from pathlib import Path +from contree_client.exceptions import NotFoundError + from contree_cli import ( CLIENT, FORMATTER, @@ -33,18 +34,11 @@ ArgumentsProtocol, SetupResult, ) -from contree_cli.client import ( - ApiError, - ContreeClient, - PaginatedFetcher, - resolve_image, - stream_response, -) +from contree_cli.client import CliClient from contree_cli.config import EDITOR -from contree_cli.session import CONTREE_CONCURRENCY, SessionStore +from contree_cli.session import SessionStore from contree_cli.types import ( FLAGS, - isoformat_datetime, parse_interval, positive_int, ) @@ -79,8 +73,8 @@ def from_args(cls, ns: argparse.Namespace) -> FileCpArgs: return cls(src=ns.src, dest=ns.dest) +PAGE_SIZE = 1000 FILE_LIST_LIMIT_DEFAULT = 1000 -FILE_LIST_PAGE_SIZE = PaginatedFetcher.DEFAULT_PAGE_SIZE @dataclass(frozen=True) @@ -164,7 +158,7 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult: " contree file ls --since 1d\n" " contree file ls --limit 5000\n" " contree file ls -q # uuid + sha256 + source\n" - " contree -f json file ls\n" + " contree -o json file ls\n" ), ) ls_p.add_argument( @@ -205,30 +199,16 @@ def _file_sha256(path: Path) -> str: def _upload_and_record( - client: ContreeClient, + client: CliClient, store: SessionStore, local_path: Path, instance_path: str, title: str, ) -> str: """Upload a local file (with dedup) and record as pending.""" - sha = _file_sha256(local_path) - try: - resp = client.get(f"/v1/files/{sha}") - file_uuid = json.loads(resp.read())["uuid"] - logger.info("File already exists on server (%s)", file_uuid) - except ApiError as exc: - if exc.status != 404: - raise - with open(local_path, "rb") as fh: - resp = client.request( - "POST", - "/v1/files", - body=fh, - headers={"Content-Type": "application/octet-stream"}, - ) - file_uuid = json.loads(resp.read())["uuid"] - logger.info("Uploaded %s (%s)", instance_path, file_uuid) + with open(local_path, "rb") as fh: + file_uuid = client.ensure_file(fh).uuid + logger.info("Uploaded %s (%s)", instance_path, file_uuid) history_id = store.set_image( store.current_image, @@ -247,24 +227,18 @@ def _upload_and_record( def cmd_file_edit(args: FileEditArgs) -> int | None: client = CLIENT.get() store = SESSION_STORE.get() - image_uuid = resolve_image(client, store.current_image) + image_uuid = client.resolve_image(store.current_image) # 1. Download to temp file (or create empty) tmp_dir = Path(tempfile.mkdtemp(prefix="contree-")) filename = Path(args.path).name or "file" tmp_file = tmp_dir / filename try: - resp = client.get( - f"/v1/inspect/{image_uuid}/download", - params={"path": args.path}, - ) with tmp_file.open("wb") as f: - for chunk in stream_response(resp): + for chunk in client.inspect_image_download_stream(image_uuid, args.path): f.write(chunk) logger.info("Downloaded %s to %s", args.path, tmp_file) - except ApiError as exc: - if exc.status != 404: - raise + except NotFoundError: tmp_file.write_bytes(b"") logger.info("File %s not found, creating empty file", args.path) @@ -322,41 +296,35 @@ def cmd_file_ls(args: FileListArgs) -> int | None: sources = store.cache.local_file_paths() - params: dict[str, str] = {} - if args.since is not None: - params["since"] = isoformat_datetime(args.since) - if args.until is not None: - params["until"] = isoformat_datetime(args.until) - - emitted = 0 hit_limit = False - with PaginatedFetcher( - client, - "/v1/files", - params, - lambda body: json.loads(body).get("files", []), - limit=args.limit, - concurrency=CONTREE_CONCURRENCY, - ) as fetcher: - for page in fetcher: - for entry in page: - if emitted >= args.limit: - hit_limit = True - break - uuid_str = entry.get("uuid") - source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else "" - if args.quiet: - formatter( - uuid=uuid_str, - sha256=entry.get("sha256", ""), - source=source, - ) - else: - formatter(**{**entry, "source": source}) - emitted += 1 - formatter.flush() - if hit_limit: - break + # Fetch one extra record past the budget so truncation is + # detectable and the warning below can fire. + files = client.iter_files( + since=args.since, + until=args.until, + page_size=PAGE_SIZE, + limit=args.limit + 1, + ) + for emitted, file in enumerate(files): + if emitted >= args.limit: + hit_limit = True + break + entry = file.to_dict() + uuid_str = entry.get("uuid") + source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else "" + if args.quiet: + formatter( + uuid=uuid_str, + sha256=entry.get("sha256", ""), + source=source, + ) + else: + formatter(**{**entry, "source": source}) + # Buffering formatters (table) print nothing until the end, so + # each consumed page reports progress while the next one loads. + if (emitted + 1) % PAGE_SIZE == 0: + logger.info("Fetched %d files, loading more...", emitted + 1) + formatter.flush() if hit_limit: logger.warning( diff --git a/contree_cli/cli/images.py b/contree_cli/cli/images.py index c60965d..f574e46 100644 --- a/contree_cli/cli/images.py +++ b/contree_cli/cli/images.py @@ -11,28 +11,30 @@ import argparse import getpass -import json import logging import time from dataclasses import dataclass, field from datetime import datetime +from contree_client.exceptions import ContreeAPIError +from contree_client.models import ( + TERMINAL_STATUSES, + ImageImportRegistry, + ImageImportRegistryCredentials, +) + from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult -from contree_cli.client import ApiError, PaginatedFetcher -from contree_cli.session import CONTREE_CONCURRENCY from contree_cli.types import ( FLAGS, ArgumentsFormatter, - isoformat_datetime, parse_interval, positive_int, ) logger = logging.getLogger(__name__) -PAGE_SIZE = PaginatedFetcher.DEFAULT_PAGE_SIZE +PAGE_SIZE = 1000 LIMIT_DEFAULT = 3000 -TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"}) DOCKER_HUB = "docker.io" EPILOG = """\ @@ -260,38 +262,28 @@ def cmd_images(args: ImagesArgs) -> None: client = CLIENT.get() formatter = FORMATTER.get() - base_params: dict[str, str] = {} - if args.prefix is not None: - base_params["tag"] = args.prefix - if args.uuid is not None: - base_params["uuid"] = args.uuid - if not args.all_images: - base_params["tagged"] = "1" - if args.since is not None: - base_params["since"] = isoformat_datetime(args.since) - if args.until is not None: - base_params["until"] = isoformat_datetime(args.until) - - emitted = 0 hit_limit = False - with PaginatedFetcher( - client, - "/v1/images", - base_params, - lambda body: json.loads(body)["images"], - limit=args.limit, - concurrency=CONTREE_CONCURRENCY, - ) as fetcher: - for page in fetcher: - for image in page: - if emitted >= args.limit: - hit_limit = True - break - formatter(**image) - emitted += 1 - formatter.flush() - if hit_limit: - break + # Fetch one extra record past the budget so truncation is + # detectable and the warning below can fire. + images = client.iter_images( + tag=args.prefix, + uuid=args.uuid, + tagged=not args.all_images, + since=args.since, + until=args.until, + page_size=PAGE_SIZE, + limit=args.limit + 1, + ) + for emitted, image in enumerate(images): + if emitted >= args.limit: + hit_limit = True + break + formatter(**image.to_dict()) + # Buffering formatters (table) print nothing until the end, so + # each consumed page reports progress while the next one loads. + if (emitted + 1) % PAGE_SIZE == 0: + logger.info("Fetched %d images, loading more...", emitted + 1) + formatter.flush() if hit_limit: logger.warning( @@ -334,14 +326,14 @@ def cmd_import(args: ImportArgs) -> int | None: formatter.configure(tail=("error",)) # 1. Build credentials (prompt for password when --username given) - credentials: dict[str, str] | None = None + credentials: ImageImportRegistryCredentials | None = None if args.username is not None: password = args.password or getpass.getpass("Registry password: ") - credentials = {"username": args.username, "password": password} - - if credentials is not None: - masked = credentials["password"][:3] + "***" - cred_info = f"credentials {credentials['username']}:{masked}" + credentials = ImageImportRegistryCredentials( + username=args.username, + password=password, + ) + cred_info = f"credentials {args.username}:{password[:3]}***" else: cred_info = "anonymous credentials" @@ -363,18 +355,17 @@ def cmd_import(args: ImportArgs) -> int | None: # 3. Issue all POST /v1/images/import requests up-front op_uuids: list[str] = [] for url, tag in imports: - registry: dict[str, object] = {"url": url} - if credentials is not None: - registry["credentials"] = credentials - payload: dict[str, object] = { - "registry": registry, - "tag": tag, - } - if args.timeout is not None: - payload["timeout"] = args.timeout - resp = client.post_json("/v1/images/import", payload) - data = json.loads(resp.read()) - op_uuids.append(data["uuid"]) + registry = ImageImportRegistry( + url=url, + credentials=credentials if credentials is not None else ..., + ) + op_uuids.append( + client.import_image( + registry, + tag=tag, + timeout=args.timeout if args.timeout is not None else ..., + ) + ) # 3. Poll every 5 seconds until all operations reach a terminal state pending = set(range(len(op_uuids))) @@ -383,8 +374,7 @@ def cmd_import(args: ImportArgs) -> int | None: while pending: time.sleep(5) for idx in list(pending): - resp = client.get(f"/v1/operations/{op_uuids[idx]}") - op = json.loads(resp.read()) + op = client.get_operation_status(op_uuids[idx]).to_dict() if op["status"] in TERMINAL_STATUSES: pending.discard(idx) if op["status"] != "SUCCESS": @@ -401,9 +391,9 @@ def cmd_import(args: ImportArgs) -> int | None: # Cancel ALL operations on Ctrl+C for op_uuid in op_uuids: try: - client.delete(f"/v1/operations/{op_uuid}") + client.cancel_operation(op_uuid) logger.info("Cancelled operation %s", op_uuid) - except (ApiError, KeyboardInterrupt, OSError): + except (ContreeAPIError, KeyboardInterrupt, OSError): pass raise diff --git a/contree_cli/cli/ls.py b/contree_cli/cli/ls.py index de52c80..e1270ca 100644 --- a/contree_cli/cli/ls.py +++ b/contree_cli/cli/ls.py @@ -11,20 +11,20 @@ from __future__ import annotations import argparse -import json import sys from dataclasses import dataclass from typing import Any, cast +from contree_client.runtime import RequestSpec, error_for_response + from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult -from contree_cli.client import resolve_image from contree_cli.output import DefaultFormatter EPILOG = """\ for coding agents: read-only command (inspect API, no instance spawn) defaults to session cwd when PATH is omitted - use -f json for cacheable structured listings + use -o json for cacheable structured listings """ @@ -53,27 +53,33 @@ def cmd_ls(args: LsArgs) -> None: formatter.configure(tail=("type",)) store = SESSION_STORE.get() image = store.current_image - uuid = resolve_image(client, image) + uuid = client.resolve_image(image) if args.path is not None: path = store.resolve_path(args.path) else: path = store.get_cwd() or "/" if isinstance(formatter, DefaultFormatter): - resp = client.get( - f"/v1/inspect/{uuid}/list", - params={"path": path, "text": "1"}, + # The pre-formatted text listing (?text=1) has no typed method + # in contree-client; issue the request through the raw spec. + response = client.request( + RequestSpec( + method="GET", + path=f"/inspect/{uuid}/list", + query={"path": path, "text": "1"}, + ) ) - sys.stdout.write(resp.read().decode()) + if response.status != 200: + raise error_for_response(response) + sys.stdout.write(response.body.decode()) return cache_key = (uuid, f"list:{path}") cached = store.cache.get(cache_key) if cached is not None: - data = cast(dict[str, Any], cached) + data = cast("dict[str, Any]", cached) else: - resp = client.get(f"/v1/inspect/{uuid}/list", params={"path": path}) - data = json.loads(resp.read()) + data = client.inspect_image_list(uuid, path).to_dict() store.cache[cache_key] = data for f in data["files"]: if f.get("is_dir"): diff --git a/contree_cli/cli/operation.py b/contree_cli/cli/operation.py index 5fbc35b..4cdc04f 100644 --- a/contree_cli/cli/operation.py +++ b/contree_cli/cli/operation.py @@ -15,26 +15,26 @@ import argparse import contextlib import itertools -import json import logging import time from dataclasses import dataclass, field from datetime import datetime from typing import Any +from contree_client.exceptions import ContreeAPIError +from contree_client.models import ACTIVE_STATUSES, TERMINAL_STATUSES + from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult from contree_cli.cli.show import ShowArgs, cmd_show -from contree_cli.client import ApiError, ContreeClient, PaginatedFetcher +from contree_cli.client import CliClient from contree_cli.output import OutputFormatter from contree_cli.refs import ( history_spec_from_ref, looks_like_history_ref, resolve_operation_uuids, ) -from contree_cli.session import CONTREE_CONCURRENCY from contree_cli.types import ( FLAGS, - isoformat_datetime, parse_interval, positive_int, ) @@ -49,10 +49,6 @@ logger = logging.getLogger(__name__) -PAGE_SIZE = PaginatedFetcher.DEFAULT_PAGE_SIZE - -ACTIVE_STATUSES = frozenset({"PENDING", "ASSIGNED", "EXECUTING"}) -TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"}) WAIT_TIMEOUT_DEFAULT = 60 STATUS_CHOICES = { "P": "PENDING", @@ -322,6 +318,7 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult: return cmd_show_multi, ShowMultiArgs +PAGE_SIZE = 1000 CANCEL_ACTIVE_PAGE_SIZE = 100 @@ -347,26 +344,16 @@ def extract_exit_code(op: dict[str, Any]) -> int | None: return None -def list_active(client: ContreeClient) -> list[str]: +def list_active(client: CliClient) -> list[str]: """Collect UUIDs of all active (PENDING/ASSIGNED/EXECUTING) operations.""" - uuids: list[str] = [] - for status in ACTIVE_STATUSES: - offset = 0 - while True: - params = { - "status": status, - "limit": str(CANCEL_ACTIVE_PAGE_SIZE), - "offset": str(offset), - } - resp = client.get("/v1/operations", params=params) - operations = json.loads(resp.read()) - if not operations: - break - uuids.extend(op["uuid"] for op in operations) - if len(operations) < CANCEL_ACTIVE_PAGE_SIZE: - break - offset += len(operations) - return uuids + return [ + str(op.uuid) + for status in ACTIVE_STATUSES + for op in client.iter_operations( + status=status, + page_size=CANCEL_ACTIVE_PAGE_SIZE, + ) + ] def cmd_list(args: ListArgs) -> None: @@ -383,40 +370,34 @@ def cmd_list(args: ListArgs) -> None: elif not args.all: status = "EXECUTING" - base_params: dict[str, str] = {} - if status: - base_params["status"] = status - if args.kind: - base_params["kind"] = args.kind - if args.since is not None: - base_params["since"] = isoformat_datetime(args.since) - if args.until is not None: - base_params["until"] = isoformat_datetime(args.until) + kind = args.kind limit = args.show_max - emitted = 0 hit_limit = False - with PaginatedFetcher( - client, - "/v1/operations", - base_params, - json.loads, - limit=limit, - concurrency=CONTREE_CONCURRENCY, - ) as fetcher: - for page in fetcher: - for op in page: - if limit is not None and emitted >= limit: - hit_limit = True - break - if args.quiet: - print(op["uuid"]) - else: - formatter(**op) - emitted += 1 - formatter.flush() - if hit_limit: - break + # Fetch one extra record past the budget so truncation is + # detectable and the warning below can fire. + summaries = client.iter_operations( + status=status, + kind=kind, # type: ignore[arg-type] + since=args.since, + until=args.until, + page_size=PAGE_SIZE, + limit=limit + 1 if limit is not None else None, + ) + for emitted, summary in enumerate(summaries): + if limit is not None and emitted >= limit: + hit_limit = True + break + op = summary.to_dict() + if args.quiet: + print(op["uuid"]) + else: + formatter(**op) + # Buffering formatters (table) print nothing until the end, so + # each consumed page reports progress while the next one loads. + if (emitted + 1) % PAGE_SIZE == 0: + logger.info("Fetched %d operations, loading more...", emitted + 1) + formatter.flush() if hit_limit: logger.warning( @@ -432,7 +413,7 @@ def cmd_show_multi(args: ShowMultiArgs) -> int | None: for uuid in args.uuids: try: result = cmd_show(ShowArgs(uuid=uuid, raw=args.raw)) - except ApiError as exc: + except ContreeAPIError as exc: logger.error("Failed to fetch %s: %s", uuid, exc) exit_code = max(exit_code, 1) continue @@ -460,9 +441,9 @@ def cmd_cancel(args: CancelArgs) -> int | None: failed = 0 for uuid in uuids: try: - client.delete(f"/v1/operations/{uuid}") + client.cancel_operation(uuid) logger.info("Cancelled operation %s", uuid) - except ApiError as exc: + except ContreeAPIError as exc: logger.error("Failed to cancel %s: %s", uuid, exc) failed += 1 return 1 if failed else None @@ -507,8 +488,7 @@ def cmd_wait(args: WaitArgs) -> int | None: while pending and time.monotonic() < deadline: for uuid in list(pending): - resp = client.get(f"/v1/operations/{uuid}") - op = json.loads(resp.read()) + op = client.get_operation_status(uuid).to_dict() if op.get("status") in TERMINAL_STATUSES: if store is not None: @@ -547,9 +527,8 @@ def cmd_wait(args: WaitArgs) -> int | None: # what state each operation was stuck in. for uuid in sorted(pending): try: - resp = client.get(f"/v1/operations/{uuid}") - op = json.loads(resp.read()) - except ApiError as exc: + op = client.get_operation_status(uuid).to_dict() + except ContreeAPIError as exc: logger.error("Failed to fetch %s: %s", uuid, exc) continue formatter(**{**op, "timed_out": True}) diff --git a/contree_cli/cli/run.py b/contree_cli/cli/run.py index ab07273..d126e31 100644 --- a/contree_cli/cli/run.py +++ b/contree_cli/cli/run.py @@ -47,12 +47,10 @@ from __future__ import annotations import argparse -import base64 import contextlib import fnmatch import functools import io -import json import logging import os import re @@ -65,16 +63,19 @@ from multiprocessing.pool import ThreadPool from typing import Any -from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult -from contree_cli.client import ( - RETRYABLE_NETWORK_ERRORS, - ApiError, - ContreeClient, - decode_event_chunk, +from contree_client.exceptions import ContreeAPIError +from contree_client.models import ( + TERMINAL_STATUSES, + File, + FileSpec, + OperationEvent, + StreamRepr, + decode_chunk, decode_stream, - iter_sse_events, - resolve_image, ) + +from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult +from contree_cli.client import CliClient from contree_cli.mapped_file import MAPPING_RULES, MappedFile from contree_cli.output import ( DefaultFormatter, @@ -103,10 +104,9 @@ local file cache avoids re-upload when path+inode+mtime+size unchanged returns command exit code when available default formatter prints raw stdout/stderr only - use -f json for structured operation metadata + use -o json for structured operation metadata """ -TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"}) DEFAULT_FILE_EXCLUDES = ( ".*", ".git", @@ -392,32 +392,22 @@ def record_local_uuid(mf: MappedFile, file_uuid: str, store: SessionStore) -> No } -def upload_one_remote(client: ContreeClient, mf: MappedFile) -> tuple[MappedFile, str]: - """HTTP-only upload (sha256 dedup + POST /v1/files). Thread-safe.""" - sha = mf.sha256() - try: - resp = client.get(f"/v1/files/{sha}") - file_uuid = str(json.loads(resp.read())["uuid"]) - logger.info("File reused: %s -> %s", mf.host_path, file_uuid) - return mf, file_uuid - except ApiError as exc: - if exc.status != 404: - raise - +def upload_one_remote(client: CliClient, mf: MappedFile) -> tuple[MappedFile, str]: + """Deduplicated upload via the client. Thread-safe.""" with open(mf.host_path, "rb") as fh: - resp = client.request( - "POST", - "/v1/files", - body=fh, - headers={"Content-Type": "application/octet-stream"}, - ) - file_uuid = str(json.loads(resp.read())["uuid"]) - logger.debug("Uploaded %s (%s)", mf.host_path, file_uuid) + stored = client.ensure_file(fh) + file_uuid = str(stored.uuid) + if isinstance(stored, File): + # ensure_file returns the existing record on a digest hit and + # the fresh upload response otherwise. + logger.info("File reused: %s -> %s", mf.host_path, file_uuid) + else: + logger.debug("Uploaded %s (%s)", mf.host_path, file_uuid) return mf, file_uuid def upload_files( - client: ContreeClient, + client: CliClient, files: list[MappedFile], store: SessionStore, ) -> dict[str, str]: @@ -508,16 +498,13 @@ def _build_payload( "uuid": file_uuid, "uid": mf.uid, "gid": mf.gid, - "mode": f"{mf.mode:04o}", + "mode": mf.mode, } payload["files"] = payload_files return payload -TERMINAL_OP_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"}) - - @dataclass class TerminalSummary: """Authoritative end-of-stream snapshot built from the SSE events @@ -528,46 +515,24 @@ class TerminalSummary: detects the op is already terminal, the full op dict lands in ``fallback_op`` so `cmd_run` can use it directly.""" - completion: dict[str, Any] | None = None - exit_event: dict[str, Any] | None = None + completion: OperationEvent | None = None + exit_event: OperationEvent | None = None stdout: bytearray = field(default_factory=bytearray) stderr: bytearray = field(default_factory=bytearray) fallback_op: dict[str, Any] | None = None -def _check_terminal_via_get( - client: ContreeClient, op_uuid: str, summary: TerminalSummary -) -> bool: - """Poll `GET /v1/operations/{uuid}`; if the op is in a terminal - status, park the full response on ``summary.fallback_op`` and - return True. Any GET failure or non-terminal status returns False - so the caller keeps retrying SSE.""" - try: - resp = client.request("GET", f"/v1/operations/{op_uuid}") - op = json.loads(resp.read()) - except (ApiError, ValueError) as exc: - logger.debug("terminal check GET failed: %s", exc) - return False - except RETRYABLE_NETWORK_ERRORS as exc: - logger.debug("terminal check GET failed: %s", exc) - return False - if isinstance(op, dict) and op.get("status") in TERMINAL_OP_STATUSES: - summary.fallback_op = op - return True - return False - - -TIGHT_LOOP_FLOOR = 0.5 - - -def _stream_events_until_close( - client: ContreeClient, +def stream_events_until_close( + client: CliClient, op_uuid: str, formatter: OutputFormatter, ) -> TerminalSummary: - """Open `follow=1` SSE for *op_uuid* and write events to stdio, - transparently resuming on network drops / mid-stream errors using - ``Last-Event-Id`` for replay-free continuation. + """Stream *op_uuid* events to stdio and collect a terminal summary. + + Reconnection is the library's job: ``follow_operation_events`` + resumes dropped streams with ``Last-Event-Id`` and stops after the + ``completion`` event or once a status poll reports the operation + terminal. This wrapper only routes the events. For ``DefaultFormatter``: ``stdout`` / ``stderr`` events go to ``sys.std*.buffer`` directly so the user sees output as it arrives. @@ -576,107 +541,60 @@ def _stream_events_until_close( ``TerminalSummary`` so the caller can render full stdout/stderr without a follow-up GET. - Loops until one of: `completion` event received, GET-detected - terminal status, ``BrokenPipeError`` from a local stdio write, or - ``KeyboardInterrupt``. Backoff between attempts is delegated to - ``client.request`` — every SSE reconnect goes through its - ``RETRY_DELAYS`` ladder before raising, so an extra streamer-level - ramp would double up. The one floor kept here (``TIGHT_LOOP_FLOOR``) - only kicks in when a cycle made no forward progress, guarding - against a server that returns immediate empty streams for an - executing op. + When the stream ends without a ``completion`` frame (the library + detected the terminal status via a poll), the full operation + payload is fetched once and parked on ``fallback_op``. ``BrokenPipeError`` from a local stdio write propagates unchanged — it means the shell pipe closed and retrying cannot help; the caller cancels the op and exits. """ is_default = isinstance(formatter, DefaultFormatter) - last_id: int = -1 summary = TerminalSummary() - while True: - headers: dict[str, str] | None = None - if last_id >= 0: - headers = {"Last-Event-Id": str(last_id)} - try: - resp = client.request( - "GET", - f"/v1/operations/{op_uuid}/events?follow=1", - headers=headers, - ) - except ApiError as exc: - logger.debug("SSE connect failed after client retries: %s", exc) - if _check_terminal_via_get(client, op_uuid, summary): - return summary - continue - except RETRYABLE_NETWORK_ERRORS as exc: - logger.debug("SSE connect network error after client retries: %s", exc) - if _check_terminal_via_get(client, op_uuid, summary): - return summary - continue - - events_before = last_id + for ev in client.follow_operation_events(op_uuid): + match ev.type: + case "stdout": + chunk = decode_chunk(ev.data) + summary.stdout.extend(chunk) + if is_default: + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + case "stderr": + chunk = decode_chunk(ev.data) + summary.stderr.extend(chunk) + if is_default: + sys.stderr.buffer.write(chunk) + sys.stderr.buffer.flush() + case "exit": + # spid=1 is the main process — its exit code/timed_out + # drive the CLI's own exit code. + if ev.spid == 1: + summary.exit_event = ev + logger.debug("event: %s", ev) + case "completion": + # Authoritative terminal frame; the library ends the + # stream right after yielding it. + summary.completion = ev + logger.debug("event: %s", ev) + case _: + logger.debug("event: %s", ev) + + if summary.completion is None: + # The stream ended via the terminal-status probe; fetch the op + # payload the caller would otherwise build from `completion`. try: - for ev in iter_sse_events(resp): - ev_id = ev.get("id") - if isinstance(ev_id, int): - last_id = ev_id - ev_type = ev.get("type") - data = ev.get("data") - match ev_type: - case "stdout": - chunk = decode_event_chunk(data) - summary.stdout.extend(chunk) - if is_default: - sys.stdout.buffer.write(chunk) - sys.stdout.buffer.flush() - case "stderr": - chunk = decode_event_chunk(data) - summary.stderr.extend(chunk) - if is_default: - sys.stderr.buffer.write(chunk) - sys.stderr.buffer.flush() - case "exit": - # spid=1 is the main process — its exit code/timed_out - # drive the CLI's own exit code. - if ev.get("spid") == 1: - summary.exit_event = ev - logger.debug("event: %s", ev) - case "sse_error": - logger.warning( - "server-side stream error (last_id=%s): %s", - last_id, - ev.get("message"), - ) - case "completion": - # Authoritative terminal frame — don't wait for the server - # to close, return the summary for the caller. - summary.completion = ev - logger.debug("event: %s", ev) - return summary - case _: - logger.debug("event: %s", ev) - except BrokenPipeError: - # Local stdout/stderr was closed by the shell (e.g. piping - # into `head`). Retrying cannot help — the caller cancels - # the op and exits. - raise - except RETRYABLE_NETWORK_ERRORS as exc: - logger.debug("SSE stream broken (last_id=%s): %s", last_id, exc) - finally: - with contextlib.suppress(Exception): - resp.close() - - if _check_terminal_via_get(client, op_uuid, summary): + op = client.get_operation_status(op_uuid).to_dict() + except ContreeAPIError as exc: + logger.debug("terminal op fetch failed: %s", exc) return summary + if op.get("status") in TERMINAL_STATUSES: + summary.fallback_op = op - # No forward progress this cycle → briefly floor the loop so a - # server that keeps returning immediate EOFs doesn't spin us. - if last_id == events_before: - time.sleep(TIGHT_LOOP_FLOOR) + return summary -def _build_op_from_summary(op_uuid: str, summary: TerminalSummary) -> dict[str, Any]: +def build_op_from_summary(op_uuid: str, summary: TerminalSummary) -> dict[str, Any]: """Synthesize a `GET /operations/{uuid}` shape from the SSE terminal summary — completion event drives status / error / duration / image metadata; exit event drives exit_code + timed_out; stdout/stderr @@ -686,11 +604,19 @@ def _build_op_from_summary(op_uuid: str, summary: TerminalSummary) -> dict[str, while keeping the downstream `_display_operation` consumers (default and JSON formatters) untouched.""" assert summary.completion is not None - completion_data = summary.completion.get("data") or {} + completion_data = ( + summary.completion.data.to_dict() + if not isinstance(summary.completion.data, dict) + else summary.completion.data + ) status = completion_data.get("status") state: dict[str, Any] = {} if summary.exit_event: - exit_data = summary.exit_event.get("data") or {} + exit_data = ( + summary.exit_event.data.to_dict() + if not isinstance(summary.exit_event.data, dict) + else summary.exit_event.data + ) if "code" in exit_data: state["exit_code"] = int(exit_data["code"]) if "timed_out" in exit_data: @@ -795,10 +721,10 @@ def cmd_run(args: RunArgs) -> int | None: # 1. Resolve image: --use switches session first, otherwise use active if args.use: - image_uuid = resolve_image(client, args.use) + image_uuid = client.resolve_image(args.use) store.set_image(image_uuid, kind="use", title=args.use) else: - image_uuid = resolve_image(client, store.current_image) + image_uuid = client.resolve_image(store.current_image) # 2. Expand and upload attached files (supports directories) try: @@ -847,10 +773,7 @@ def cmd_run(args: RunArgs) -> int | None: shebang_line, _, script_body = script_data.partition(b"\n") logger.debug("Shebang line: %s", shebang_line.decode(errors="replace")) if script_body: - payload["stdin"] = { - "value": base64.b64encode(script_body).decode(), - "encoding": "base64", - } + payload["stdin"] = StreamRepr.from_bytes(script_body) logger.debug("Script body: %d bytes", len(script_body)) payload["command"] = "/bin/sh" payload["shell"] = True @@ -865,17 +788,22 @@ def cmd_run(args: RunArgs) -> int | None: if "stdin" not in payload: stdin_data = _read_piped_stdin() if stdin_data: - payload["stdin"] = { - "value": base64.b64encode(stdin_data).decode(), - "encoding": "base64", - } + payload["stdin"] = StreamRepr.from_bytes(stdin_data) logger.debug("Piped stdin: %d bytes", len(stdin_data)) elif not sys.stdin.isatty(): logger.debug("No piped stdin available; skipping read") - resp = client.post_json("/v1/instances", payload) - op = json.loads(resp.read()) - op_uuid: str = op["uuid"] + command = str(payload.pop("command")) + image = str(payload.pop("image")) + raw_files = payload.pop("files", None) + if isinstance(raw_files, dict) and raw_files: + payload["files"] = { + path: FileSpec.from_dict(spec) for path, spec in raw_files.items() + } + + spawn_response = client.spawn_instance(command, image, **payload) # type: ignore[arg-type] + op = spawn_response.to_dict() + op_uuid = str(op["uuid"]) logger.debug("Spawned operation %s", op_uuid) @@ -922,11 +850,11 @@ def _norm(item: object) -> dict[str, object]: # come, log other events at debug, accumulate the terminal frame. store = SESSION_STORE.get() try: - summary = _stream_events_until_close(client, op_uuid, formatter) + summary = stream_events_until_close(client, op_uuid, formatter) if summary.completion is not None: # Authoritative terminal frame from the server — build the # full op dict from the SSE events themselves, no GET. - op = _build_op_from_summary(op_uuid, summary) + op = build_op_from_summary(op_uuid, summary) elif summary.fallback_op is not None: # SSE couldn't deliver `completion`, but a GET between # retries confirmed the op is terminal — use that dict. @@ -935,23 +863,22 @@ def _norm(item: object) -> dict[str, object]: # Safety net: streamer normally loops until either # completion or a terminal GET, so this path is only hit # in tests where the stub queue drains early. - resp = client.get(f"/v1/operations/{op_uuid}") - op = json.loads(resp.read()) + op = client.get_operation_status(op_uuid).to_dict() cache_key = (op_uuid, "operation") store.cache[cache_key] = op except KeyboardInterrupt: try: - client.delete(f"/v1/operations/{op_uuid}") + client.cancel_operation(op_uuid) logger.info("Cancelled operation %s", op_uuid) - except (ApiError, KeyboardInterrupt, OSError): + except (ContreeAPIError, KeyboardInterrupt, OSError): pass raise except BrokenPipeError: # Local stdout/stderr was closed (e.g. `contree run | head`). # Cancel the op, silence further stdio writes, then exit 141 # so callers see the SIGPIPE convention (128 + 13). - with contextlib.suppress(ApiError, OSError): - client.delete(f"/v1/operations/{op_uuid}") + with contextlib.suppress(ContreeAPIError, OSError): + client.cancel_operation(op_uuid) with contextlib.suppress(OSError): devnull = os.open(os.devnull, os.O_WRONLY) os.dup2(devnull, sys.stdout.fileno()) diff --git a/contree_cli/cli/session.py b/contree_cli/cli/session.py index 2440042..f9cd5f3 100644 --- a/contree_cli/cli/session.py +++ b/contree_cli/cli/session.py @@ -23,6 +23,9 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from contree_client.models import ACTIVE_STATUSES, TERMINAL_STATUSES +from contree_client.runtime import RequestSpec, error_for_response + from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult from contree_cli.output import DefaultFormatter from contree_cli.refs import resolve_operation_uuids @@ -39,9 +42,6 @@ `session wait [OPS...]` waits for active or specified operations """ -WAIT_TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"}) -ACTIVE_STATUSES = frozenset({"PENDING", "ASSIGNED", "EXECUTING"}) - @dataclass(frozen=True) class SessionInfoArgs(ArgumentsProtocol): @@ -656,15 +656,21 @@ def cmd_wait(args: WaitArgs) -> int | None: if pending_ops: op_ids = list(pending_ops) else: - resp = client.get("/v1/operations") - operations = json.loads(resp.read()) + # The session_key field is not part of the OperationSummary + # model in contree-client, so the typed list_operations() + # would drop it. Fetch the raw payload to keep the + # per-session filter working. + response = client.request(RequestSpec(method="GET", path="/operations")) + if response.status != 200: + raise error_for_response(response) + operations = json.loads(response.body) api_op_ids: list[str] = [] for op in operations: if op.get("status") not in ACTIVE_STATUSES: continue op_session = op.get("session_key") if op_session == session.session_key: - api_op_ids.append(op["uuid"]) + api_op_ids.append(str(op["uuid"])) op_ids = api_op_ids if not op_ids: print("No active operations for this session.", file=sys.stderr) @@ -680,10 +686,9 @@ def cmd_wait(args: WaitArgs) -> int | None: for op_id in op_ids: sleep_time = 0.5 while True: - resp = client.get(f"/v1/operations/{op_id}") - op = json.loads(resp.read()) + op = client.get_operation_status(op_id).to_dict() status = op.get("status", "") - if status in WAIT_TERMINAL_STATUSES: + if status in TERMINAL_STATUSES: metadata = op.get("metadata") or {} instance_result = metadata.get("result") or {} state = instance_result.get("state") or {} diff --git a/contree_cli/cli/show.py b/contree_cli/cli/show.py index 8944f5d..79d480d 100644 --- a/contree_cli/cli/show.py +++ b/contree_cli/cli/show.py @@ -17,8 +17,9 @@ from dataclasses import dataclass from typing import Any, cast +from contree_client.models import TERMINAL_STATUSES, decode_stream + from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol -from contree_cli.client import decode_stream from contree_cli.output import DefaultFormatter, JSONFormatter, JSONPrettyFormatter from contree_cli.refs import history_spec_from_ref, resolve_operation_uuid @@ -44,9 +45,6 @@ def from_args(cls, ns: argparse.Namespace) -> ShowArgs: return cls(uuid=ns.uuid, raw=getattr(ns, "raw", False)) -TERMINAL = frozenset({"SUCCESS", "FAILED", "CANCELLED"}) - - def cmd_show(args: ShowArgs) -> int | None: client = CLIENT.get() formatter = FORMATTER.get() @@ -61,12 +59,14 @@ def cmd_show(args: ShowArgs) -> int | None: cache_key = (op_uuid, "operation") cached = store.cache.get(cache_key) - if isinstance(cached, dict) and cached.get("status") in TERMINAL: - op = cast(dict[str, Any], cached) + if isinstance(cached, dict) and cached.get("status") in TERMINAL_STATUSES: + op = cast("dict[str, Any]", cached) else: - resp = client.get(f"/v1/operations/{op_uuid}") - op = json.loads(resp.read()) - if op.get("status") in TERMINAL: + # Note: to_dict() round-trips through the typed model, so + # fields unknown to the model are dropped -- `--raw` is only + # as raw as the model allows. + op = client.get_operation_status(op_uuid).to_dict() + if op.get("status") in TERMINAL_STATUSES: store.cache[cache_key] = op if args.raw: diff --git a/contree_cli/cli/skill.py b/contree_cli/cli/skill.py index dff0742..bc0f6ab 100644 --- a/contree_cli/cli/skill.py +++ b/contree_cli/cli/skill.py @@ -2,8 +2,8 @@ contree skill install # autodetect agent homes contree skill install claude:~ # global ~/.claude -contree skill install codex: # project-level .codex -contree skill install ./path # raw path, class guessed +contree skill install codex: # project-level .agents/skills +contree skill install . # project root: every kind under it """ from __future__ import annotations @@ -11,6 +11,7 @@ import argparse import logging from dataclasses import dataclass +from itertools import chain from pathlib import Path from contree_cli import FORMATTER, ArgumentsProtocol, SetupResult @@ -21,8 +22,8 @@ forget_installed, list_installed, remember_installed, - skill_from_spec, skill_version, + skills_from_spec, ) from contree_cli.types import FLAGS @@ -55,7 +56,7 @@ class SkillInstallArgs(ArgumentsProtocol): @classmethod def from_args(cls, ns: argparse.Namespace) -> SkillInstallArgs: - return cls(specs=frozenset(ns.specs or []), force=ns.force) + return cls(specs=frozenset(chain.from_iterable(ns.specs or [])), force=ns.force) @dataclass(frozen=True) @@ -65,7 +66,7 @@ class SkillRemoveArgs(ArgumentsProtocol): @classmethod def from_args(cls, ns: argparse.Namespace) -> SkillRemoveArgs: - return cls(specs=frozenset(ns.specs or []), force=ns.force) + return cls(specs=frozenset(chain.from_iterable(ns.specs or [])), force=ns.force) @dataclass(frozen=True) @@ -74,7 +75,7 @@ class SkillUpgradeArgs(ArgumentsProtocol): @classmethod def from_args(cls, ns: argparse.Namespace) -> SkillUpgradeArgs: - return cls(specs=frozenset(ns.specs or [])) + return cls(specs=frozenset(chain.from_iterable(ns.specs or []))) @dataclass(frozen=True) @@ -95,8 +96,8 @@ def spec_arg(parser: argparse.ArgumentParser) -> None: "specs", nargs="*", metavar="SPEC", - type=skill_from_spec, - help="claude:~ codex:~ or raw path", + type=skills_from_spec, + help="claude:~ codex:~ skill path or project root", ) list_p = sub.add_parser( @@ -179,6 +180,7 @@ def cmd_skill_remove(args: SkillRemoveArgs) -> int | None: failed = False for skill in targets: if not skill.exists: + forget_installed(skill) logger.error("Not installed at %s", display_path(skill.path)) failed = True continue diff --git a/contree_cli/cli/tag.py b/contree_cli/cli/tag.py index 872cb37..d6f7137 100644 --- a/contree_cli/cli/tag.py +++ b/contree_cli/cli/tag.py @@ -16,7 +16,6 @@ from dataclasses import dataclass from contree_cli import CLIENT, SESSION_STORE, ArgumentsProtocol, SetupResult -from contree_cli.client import resolve_image from contree_cli.types import FLAGS logger = logging.getLogger(__name__) @@ -67,7 +66,11 @@ def cmd_tag(args: TagArgs) -> int | None: client = CLIENT.get() if args.image_ref is not None: - image_uuid = resolve_image(client, args.image_ref) + image_uuid = client.resolve_image(args.image_ref) + elif args.delete: + # The tag itself names its image uniquely; resolving the session + # image instead would silently no-op when the tag lives elsewhere. + image_uuid = client.resolve_image(f"tag:{args.tag}") else: store = SESSION_STORE.get() session = store.session @@ -77,10 +80,10 @@ def cmd_tag(args: TagArgs) -> int | None: image_uuid = session.current_image if args.delete: - client.delete(f"/v1/images/{image_uuid}/tag?tag={args.tag}") + client.delete_image_tag(image_uuid, tag=args.tag) logger.info("Removed tag %r from image %s", args.tag, image_uuid) return None - client.patch_json(f"/v1/images/{image_uuid}/tag", {"tag": args.tag}) + client.update_image_tag(image_uuid, args.tag) logger.info("Tagged image %s as %s", image_uuid, args.tag) return None diff --git a/contree_cli/cli/use.py b/contree_cli/cli/use.py index 85281b5..1279e56 100644 --- a/contree_cli/cli/use.py +++ b/contree_cli/cli/use.py @@ -27,13 +27,10 @@ CLIENT, FORMATTER, IN_SHELL, - PROFILE, SESSION_STORE, ArgumentsProtocol, SetupResult, ) -from contree_cli.client import resolve_image -from contree_cli.session import SessionStore from contree_cli.types import FLAGS log = logging.getLogger(__name__) @@ -91,12 +88,11 @@ def cmd_use(args: UseArgs) -> int | None: ) return 1 new_key = str(uuid.uuid4()) - store = SessionStore(PROFILE.get().session_db_path, new_key) - SESSION_STORE.set(store) + store.select_session(new_key) if args.image is not None: client = CLIENT.get() - image_uuid = resolve_image(client, args.image) + image_uuid = client.resolve_image(args.image) store.set_image(image_uuid, kind="use", title=args.image) if not IN_SHELL.get(False): _print_shell_export("CONTREE_SESSION", store.session_key) diff --git a/contree_cli/client.py b/contree_cli/client.py index 5bf15dc..37dea2c 100644 --- a/contree_cli/client.py +++ b/contree_cli/client.py @@ -1,68 +1,39 @@ +"""CLI-side glue over the contree-client library. + +Transport, typed API methods, models, SSE parsing, retries +(RetryPolicy), operation helpers (wait_operation, +follow_operation_events), image reference resolution, stream payload +decoding and User-Agent composition all live in ``contree_client``. +This module keeps only what is genuinely CLI-specific: + +- ``CliClient``: the auto-detected transport backend announcing the + CLI identity in the User-Agent. +- ``client_from_profile``: build a client from the CLI config profile + with the CLI retry policy. +- ``cli_version`` / ``CLI_USER_AGENT``: version reporting. +""" + from __future__ import annotations -import base64 -import binascii -import collections -import contextlib -import http.client -import io import json import logging import platform -import socket import sys -import threading -import time -import zlib -from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable, Iterator -from concurrent.futures import Future, ThreadPoolExecutor -from contextlib import suppress from importlib.metadata import PackageNotFoundError, distribution -from typing import IO, Any, cast -from urllib.parse import urlencode, urlsplit - -from contree_cli.config import AuthType, ConfigProfile - -log = logging.getLogger(__name__) - -RETRY_DELAYS = (0.1, 0.2, 0.5, 1, 2, 5) - - -def retry_generator() -> Iterator[float]: - """Return a primed generator that yields successive backoff delays. +from typing import TYPE_CHECKING, Any - The first ``next(g)`` returns ``RETRY_DELAYS[0]``; subsequent calls - walk the ladder and, once exhausted, keep yielding the tail delay - forever. Callers don't have to guard against ``StopIteration`` — - ``next(g)`` always returns a valid sleep time — and don't have to - do any index arithmetic to derive it. A caller that wants a - finite retry budget bounds it externally (e.g. an attempt counter).""" +from contree_client.profiles import AUTH_TYPE_IAM, Profile +from contree_client.runtime import RetryPolicy - def gen() -> Iterator[float]: - # `yield 0` gives a caller who *doesn't* prime a clean way to - # sleep uniformly before every attempt (including the first, - # which is a no-op). We prime here so callers who don't want - # that get the real first delay straight away. - yield 0 - yield from RETRY_DELAYS - while True: - yield RETRY_DELAYS[-1] +if TYPE_CHECKING: + # The auto-detected backend class is a runtime variable, invalid + # as a static base; the checker sees the abstract sync interface + # instead (construction sites carry an ignore[abstract]). + from contree_client.base import ContreeSyncClient as ClientBase +else: + from contree_client.sync import ContreeClient as ClientBase - g = gen() - next(g) - return g - - -# Socket-level / connection-level errors that warrant a retry. DNS hiccups -# (gaierror), refused/reset connections, and broken HTTP framing are all -# transient — the server may come back. TimeoutError already retried below. -RETRYABLE_NETWORK_ERRORS: tuple[type[BaseException], ...] = ( - socket.gaierror, - ConnectionError, - http.client.HTTPException, - OSError, -) +log = logging.getLogger(__name__) def cli_version() -> str: @@ -80,766 +51,60 @@ def cli_version() -> str: return dist.version +CLI_IDENTITY = f"contree-cli/{cli_version()}" + +# Standalone User-Agent for requests that do not go through the API +# client (the PyPI update check) and for `contree --version`. CLI_USER_AGENT = ( - f"contree-cli/{cli_version()} " + f"{CLI_IDENTITY} " f"Python/{'.'.join(map(str, sys.version_info))} " f"{platform.platform()} " ) -class HeaderFormatter: - """Lazy redactor for HTTP headers, formats only on emit.""" - - SENSITIVE_HEADERS = frozenset( - { - "authorization", - "proxy-authorization", - "cookie", - "set-cookie", - "x-api-key", - "x-auth-token", - } - ) - - def __init__( - self, - headers: dict[str, str] | list[tuple[str, str]], - ) -> None: - self.headers = headers - - def __str__(self) -> str: - items: Iterable[tuple[str, str]] = ( - self.headers.items() if isinstance(self.headers, dict) else self.headers - ) - redacted = { - k: "" if k.lower() in self.SENSITIVE_HEADERS else v - for k, v in items - } - return repr(redacted) - - -class BodyFormatter: - """Lazy %s-arg for logging HTTP bodies — formats only on emit.""" - - def __init__( - self, - body: bytes | str | IO[bytes] | None, - content_type: str = "", - binary_max_size: int = 4096, - ) -> None: - self.body = body - self.binary_max_size = binary_max_size - self.content_type = content_type - - def __str__(self) -> str: - match self.body: - case None: - return self.format_none() - case bytes() | bytearray() as data: - return self.dispatch_bytes(bytes(data)) - case str() as text: - return self.dispatch_bytes(text.encode("utf-8", errors="replace")) - case _: - return self.format_stream() - - def format_none(self) -> str: - return "" - - def format_stream(self) -> str: - return "" - - def dispatch_bytes(self, data: bytes) -> str: - if not data: - return "" - if self.content_type and ( - "json" in self.content_type or "text" in self.content_type - ): - return self.format_json(data) - if self.content_type: - return self.format_bytes(data) - return self.format_json(data) - - def format_json(self, data: bytes) -> str: - truncated = data[: self.binary_max_size] - try: - text = truncated.decode("utf-8") - except UnicodeDecodeError: - return self.format_bytes(data) - if len(data) > self.binary_max_size: - return f"{text}... " - return text - - def format_bytes(self, data: bytes) -> str: - if self.content_type: - return f"" - return f"" - - -class GzipResponse: - """Wrap an HTTPResponse, inflating `Content-Encoding: gzip` via - `zlib.decompressobj(wbits=31)` so Z_SYNC_FLUSH'd SSE frames decode - incrementally without buffering whole responses.""" - - def __init__(self, resp: http.client.HTTPResponse) -> None: - self.resp = resp - self.decomp = zlib.decompressobj(wbits=31) - self.buf = bytearray() - self.eof = False - - def pump(self, want: int = -1) -> None: - while not self.eof and (want < 0 or len(self.buf) < want): - # read1() returns whatever's in the socket buffer after one - # underlying read; plain read(n) blocks for n bytes, which - # stalls SSE streaming because tiny frames never fill it. - chunk = self.resp.read1(8192) - if not chunk: - tail = self.decomp.flush() - if tail: - self.buf.extend(tail) - self.eof = True - return - decoded = self.decomp.decompress(chunk) - if decoded: - self.buf.extend(decoded) - - def read(self, amt: int | None = None) -> bytes: - if amt is None or amt < 0: - self.pump() - out = bytes(self.buf) - self.buf.clear() - return out - self.pump(amt) - out = bytes(self.buf[:amt]) - del self.buf[:amt] - return out - - def readline(self, limit: int = -1) -> bytes: - while True: - nl = self.buf.find(b"\n") - if nl >= 0: - end = nl + 1 - if 0 <= limit < end: - end = limit - out = bytes(self.buf[:end]) - del self.buf[:end] - return out - if self.eof: - out = bytes(self.buf) - self.buf.clear() - return out - self.pump(want=len(self.buf) + 1) - - def close(self) -> None: - with contextlib.suppress(Exception): - self.resp.close() - - def getheader(self, name: str, default: str | None = None) -> str | None: - # Hide Content-Encoding so downstream readers don't try a - # second decompression; Content-Length is bogus post-inflate. - lname = name.lower() - if lname in ("content-encoding", "content-length"): - return default - return self.resp.getheader(name, default) - - def getheaders(self) -> list[tuple[str, str]]: - return [ - (k, v) - for k, v in self.resp.getheaders() - if k.lower() not in ("content-encoding", "content-length") - ] - - @property - def status(self) -> int: - return self.resp.status - - @property - def reason(self) -> str: - return self.resp.reason - - -class BufferedResponse: - """Replay an HTTPResponse from buffered bytes (for debug body logging).""" - - def __init__( - self, - status: int, - reason: str, - headers: list[tuple[str, str]], - data: bytes, - ) -> None: - self.status = status - self.reason = reason - self.headers = headers - self.buf = io.BytesIO(data) - - def read(self, amt: int | None = None) -> bytes: - if amt is None: - return self.buf.read() - return self.buf.read(amt) - - def getheader(self, name: str, default: str | None = None) -> str | None: - for k, v in self.headers: - if k.lower() == name.lower(): - return v - return default - - def getheaders(self) -> list[tuple[str, str]]: - return list(self.headers) - - -class ApiError(Exception): - """Raised when the contree API returns a non-2xx status.""" - - def __init__(self, status: int, reason: str, body: str) -> None: - self.status = status - self.reason = reason - self.body = body - - def __str__(self) -> str: - return f"API {self.status} {self.reason}: {self.body}" - - -class ContreeClient(ABC): - """Abstract HTTP client for the contree REST API (stdlib only).""" - - def __init__( - self, url: str, token: str | None, timeout: float | None = None - ) -> None: - parts = urlsplit(url) - self._scheme = parts.scheme or "https" - self._host = parts.hostname or "localhost" - self._port = parts.port - self._prefix = (parts.path or "").rstrip("/") - self._token = token - self._timeout = timeout - - @abstractmethod - def _build_headers(self) -> dict[str, str]: - """Return authentication headers for the request.""" - - def _connect(self) -> http.client.HTTPConnection: - if self._scheme == "https": - # Stdlib http.client is the only option — project has - # zero external dependencies. SSL context uses defaults. - # nosemgrep: httpsconnection-detected - return http.client.HTTPSConnection( - self._host, - self._port, - timeout=self._timeout, - ) - return http.client.HTTPConnection(self._host, self._port, timeout=self._timeout) - - def request( - self, - method: str, - path: str, - *, - body: bytes | IO[bytes] | None = None, - headers: dict[str, str] | None = None, - ) -> http.client.HTTPResponse: - merged: dict[str, str] = { - **self._build_headers(), - "User-Agent": CLI_USER_AGENT, - "Accept-Encoding": "gzip", - } - if body is not None: - merged.setdefault("Content-Type", "application/json") - if headers: - merged.update(headers) - - full_path = self._prefix + path - - log.debug( - "%s %s headers=%s body=%s", - method, - full_path, - HeaderFormatter(merged), - BodyFormatter(body, content_type=merged.get("Content-Type", "")), - ) - - # Backoff delays come from a primed `retry_generator()` — each - # failure just calls `next(delays)` and gets the next value. - # The generator yields the tail delay forever, and there is no - # outer retry budget: transient errors are retried until either - # the server returns a non-retryable response or the user hits - # Ctrl+C. Non-retryable 4xx responses raise immediately. - delays = retry_generator() - - def sleep_for_retry(reason_fmt: str, *reason_args: object) -> None: - """Pull the next backoff delay, log the reason, sleep, and - rewind the request body if it's seekable.""" - delay = next(delays) - log.warning(reason_fmt + " retrying in %ss…", *reason_args, delay) - time.sleep(delay) - if body is not None and hasattr(body, "seek"): - stream = cast(IO[bytes], body) - if not stream.seekable(): - raise ApiError( - 0, - "RetryNotSeekable", - "Cannot retry: streaming body is not seekable", - ) - stream.seek(0) - - while True: - try: - conn = self._connect() - conn.request(method, full_path, body, merged) - resp = conn.getresponse() - except TimeoutError as exc: - raise TimeoutError(f"Request timed out: {method} {full_path}") from exc - except http.client.InvalidURL: - # Malformed URL is a permanent caller-side error — retrying - # would just spin through the back-off ladder for nothing. - raise - except RETRYABLE_NETWORK_ERRORS as exc: - sleep_for_retry("Network error (%s),", type(exc).__name__) - continue - - if (resp.getheader("Content-Encoding", "") or "").lower() == "gzip": - resp = cast(http.client.HTTPResponse, GzipResponse(resp)) - - if 200 <= resp.status < 300: - log.debug( - "%s %s -> %d %s headers=%s", - method, - full_path, - resp.status, - resp.reason, - HeaderFormatter(list(resp.getheaders())), - ) - if log.isEnabledFor(logging.DEBUG): - return self.log_and_buffer(method, full_path, resp) - return resp - - resp_headers = list(resp.getheaders()) - resp_body = resp.read().decode("utf-8", errors="replace") - log.debug( - "%s %s -> %d %s (%dB) headers=%s", - method, - full_path, - resp.status, - resp.reason, - len(resp_body), - HeaderFormatter(resp_headers), - ) - log.debug( - "%s %s response body: %s", - method, - full_path, - BodyFormatter( - resp_body, - content_type=resp.getheader("Content-Type", "") or "", - ), - ) - error = ApiError(resp.status, resp.reason, resp_body) +class CliClient(ClientBase): + """Contree client that announces the CLI in the User-Agent. - if resp.status in (410, 425) or 500 <= resp.status < 600: - sleep_for_retry("Server error %d,", resp.status) - continue - - raise error - - def log_and_buffer( - self, - method: str, - full_path: str, - resp: http.client.HTTPResponse, - ) -> http.client.HTTPResponse: - """Read & log a textual response body; pass binary streams through.""" - content_type = resp.getheader("Content-Type", "") or "" - # event-stream is line-streamed and unbounded — buffering it would block. - is_event_stream = "event-stream" in content_type - textual = not content_type or "json" in content_type or "text" in content_type - if is_event_stream or not textual: - log.debug( - "%s %s response body: ", - method, - full_path, - content_type, - ) - return resp - data = resp.read() - log.debug( - "%s %s response body: %s", - method, - full_path, - BodyFormatter(data, content_type=content_type), - ) - return cast( - http.client.HTTPResponse, - BufferedResponse( - status=resp.status, - reason=resp.reason, - headers=list(resp.getheaders()), - data=data, - ), - ) - - # -- convenience methods -------------------------------------------------- - - def get( - self, - path: str, - params: dict[str, str] | None = None, - ) -> http.client.HTTPResponse: - if params: - path = f"{path}?{urlencode(params)}" - return self.request("GET", path) - - def post_json( - self, - path: str, - payload: dict[str, object], - ) -> http.client.HTTPResponse: - return self.request( - "POST", - path, - body=json.dumps(payload).encode(), - headers={"Content-Type": "application/json"}, - ) - - def patch_json( - self, - path: str, - payload: dict[str, object], - ) -> http.client.HTTPResponse: - return self.request( - "PATCH", - path, - body=json.dumps(payload).encode(), - headers={"Content-Type": "application/json"}, - ) - - def delete(self, path: str) -> http.client.HTTPResponse: - return self.request("DELETE", path) - - -class ContreeJWTClient(ContreeClient): - """Client using JWT bearer-token authentication.""" - - @classmethod - def from_profile( - cls, - profile: ConfigProfile, - timeout: float | None = None, - ) -> ContreeJWTClient: - if not profile.url: - raise ValueError( - f"No URL configured for JWT profile {profile.name!r}." - " Run `contree auth` or pass --url." - ) - return cls(profile.url, profile.token, timeout=timeout) - - def _build_headers(self) -> dict[str, str]: - if self._token is None: - raise ApiError( - 0, - "Unauthorized", - "No token configured. Run `contree auth` first.", - ) - return {"Authorization": f"Bearer {self._token}"} - - -class ContreeIAMClient(ContreeClient): - """Client using IAM authentication with a project header.""" - - DEFAULT_URL = "https://api.tokenfactory.nebius.com/sandboxes" - - def __init__( - self, - url: str, - token: str | None, - project: str | None, - timeout: float | None = None, - ) -> None: - super().__init__(url, token, timeout=timeout) - self._project = project - - @classmethod - def from_profile( - cls, - profile: ConfigProfile, - timeout: float | None = None, - ) -> ContreeIAMClient: - return cls( - profile.url or cls.DEFAULT_URL, - profile.token, - profile.project, - timeout=timeout, - ) - - def _build_headers(self) -> dict[str, str]: - if self._token is None: - raise ApiError( - 0, - "Unauthorized", - "No token configured. Run `contree auth` first.", - ) - if self._project is None: - raise ApiError( - 0, - "No project", - "No project configured. Run `contree auth` first.", - ) - return { - "Authorization": f"Bearer {self._token}", - "Project": self._project, - } - - -def client_from_profile( - profile: ConfigProfile, - timeout: float | None = None, -) -> ContreeClient: - """Create the appropriate client for a profile's auth type.""" - if profile.auth_type == AuthType.IAM: - return ContreeIAMClient.from_profile(profile, timeout=timeout) - return ContreeJWTClient.from_profile(profile, timeout=timeout) - - -_UUID_RE_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - - -def _is_uuid(ref: str) -> bool: - """Return True if *ref* looks like a UUID.""" - import re - - return re.match(_UUID_RE_PATTERN, ref, re.ASCII) is not None - - -def _resolve_tag(client: ContreeClient, tag: str) -> str: - resp = client.get("/v1/images", params={"tag": tag}) - data = json.loads(resp.read()) - images = data.get("images", []) - if not images: - raise ApiError(404, "Not Found", f"No image with tag '{tag}'") - return str(images[0]["uuid"]) - - -def resolve_image(client: ContreeClient, ref: str) -> str: - """Resolve an image reference to a UUID. - - Accepts a raw UUID, ``tag:NAME``, or a bare tag name. For bare - references that are not valid UUIDs the function tries to resolve - them as tag names. + The library composes the header from its own product tokens; the + ``identity`` kwarg prepends the application token, so server logs + attribute the traffic to contree-cli without losing the library + and transport tokens. """ - if ref.startswith("tag:"): - return _resolve_tag(client, ref[4:]) - if _is_uuid(ref): - return ref - return _resolve_tag(client, ref) - - -CHUNK_SIZE = 256 * 1024 # 256 KiB + def __init__(self, token: str, **kwargs: Any) -> None: + kwargs.setdefault("identity", CLI_IDENTITY) + super().__init__(token, **kwargs) -def stream_response( - resp: http.client.HTTPResponse, -) -> Iterator[bytes]: - """Yield chunks from *resp*.""" - while True: - chunk = resp.read(CHUNK_SIZE) - if not chunk: - break - yield chunk - -def iter_sse_events(resp: http.client.HTTPResponse) -> Iterator[dict[str, object]]: - """Yield one dict per SSE frame. - - Normal frames carry a JSON object in ``data:`` — that object is - yielded as-is. Server-pushed error frames use ``event: sse_error`` - with a plain-text ``data:`` body (the exception message) — those - surface as ``{"type": "sse_error", "message": , "id": ...}`` - so callers can log + decide whether to reconnect with - ``Last-Event-Id`` set to the last good id. +def client_from_profile( + profile: Profile, + timeout: float | None = 300.0, +) -> CliClient: + """Create a client for a config profile. + + The CLI retries transient failures without a budget: the retry + loop ends only on success, a non-retryable response, or Ctrl+C. + The guards stay CLI-side for the friendly messages and because + the library's from_profile checks neither the IAM project nor + empty-string tokens (Profile.save round-trips None as ""). """ - data_lines: list[str] = [] - event_name: str | None = None - event_id: str | None = None - - def emit() -> Iterator[dict[str, object]]: - nonlocal event_name, event_id - if not data_lines: - return - body = "\n".join(data_lines) - if event_name == "sse_error": - payload: dict[str, object] = {"type": "sse_error", "message": body} - if event_id is not None: - payload["id"] = event_id - yield payload - else: - with suppress(json.JSONDecodeError): - decoded = json.loads(body) - if isinstance(decoded, dict): - yield decoded - data_lines.clear() - event_name = None - event_id = None - - while True: - line_bytes = resp.readline() - if not line_bytes: - yield from emit() - return - line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n") - match line: - case "": - yield from emit() - case _ if line.startswith(":"): - pass # SSE comment / keepalive - case _ if line.startswith("data:"): - data_lines.append(line[5:].lstrip(" ")) - case _ if line.startswith("event:"): - event_name = line[6:].lstrip(" ").strip() or None - case _ if line.startswith("id:"): - event_id = line[3:].lstrip(" ").strip() or None - - -def decode_event_chunk(data: object) -> bytes: - """Decode `{value, encoding}` event payload to raw bytes (ascii or base64).""" - if not isinstance(data, dict): - return b"" - value = data.get("value", "") - if not isinstance(value, str) or not value: - return b"" - encoding = data.get("encoding", "ascii") - if encoding == "base64": - with suppress(binascii.Error, ValueError): - return base64.b64decode(value) - return b"" - return value.encode("utf-8", errors="replace") - - -def decode_stream(stream: dict[str, object] | None) -> str: - """Decode an API StreamRepr object to a string.""" - if not stream: - return "" - value = stream.get("value", "") - if not isinstance(value, str) or not value: - return "" - encoding = stream.get("encoding", "ascii") - if encoding == "base64": - return base64.b64decode(value).decode( - "utf-8", - errors="replace", + if not profile.token: + raise ValueError( + f"No token configured for profile {profile.name!r}." + " Run `contree auth` first." ) - return value - - -class PaginatedFetcher: - """Iterate paginated API endpoint pages concurrently. - - Issues GET ``path`` requests with ``offset``/``limit`` query params, - pulling pages in parallel via :class:`ThreadPool.imap` (results - delivered in offset order). Stops on the first empty or short - (``< page_size``) page or after ``max_pages`` requests. May - over-fetch by up to ``concurrency - 1`` pages past the actual end - of data; the trade-off buys roughly ``concurrency``-fold latency - reduction on multi-page listings. - - Callers that hit their own record limit mid-iteration should call - :meth:`stop` to short-circuit pending workers — fetches that have - not yet issued their HTTP request will skip it and return an empty - page, ending iteration. - - :attr:`exhausted` is ``True`` after iteration finishes only if the - helper saw the end of data (short/empty page) — including via the - ``stop`` signal. It stays ``False`` if it stopped because - ``max_pages`` was reached without seeing the end. - """ - - DEFAULT_PAGE_SIZE = 1000 - UNLIMITED_MAX_PAGES = 1000 # 1M-record safety cap when limit=None. - - def __init__( - self, - client: ContreeClient, - path: str, - params: dict[str, str], - extract: Callable[[bytes], list[dict[str, Any]]], - *, - limit: int | None, - page_size: int | None = None, - concurrency: int = 8, - ) -> None: - """Configure a paginated fetch. - - ``limit`` is the caller's record budget (``--limit``, ``--show-max``). - ``None`` means "fetch everything up to ``UNLIMITED_MAX_PAGES * page_size`` - records". When set, ``page_size`` is capped at ``limit + 1`` so a - small budget like ``--limit 5`` doesn't pull a 1000-row page just - to discard 995, and ``max_pages`` is sized to cover ``limit + 1`` - records (the extra record lets callers detect "more available" - and warn). ``page_size`` defaults to :attr:`DEFAULT_PAGE_SIZE`. - """ - self.client = client - self.path = path - self.params = params - self.extract = extract - self.concurrency = concurrency - self.exhausted = False - self._stop = threading.Event() - - default_page_size = page_size or self.DEFAULT_PAGE_SIZE - if limit is None: - self.page_size = default_page_size - self.max_pages = self.UNLIMITED_MAX_PAGES - else: - # Fetch one extra record so callers can detect "more results - # exist past --limit" and emit a warning. - self.page_size = min(default_page_size, limit + 1) - self.max_pages = (limit + self.page_size) // self.page_size + 1 - - def stop(self) -> None: - """Signal that the caller has seen enough; skip pending fetches.""" - self._stop.set() - - def __enter__(self) -> PaginatedFetcher: - return self - - def __exit__(self, *_: object) -> None: - # Setting the stop event short-circuits any worker that hasn't - # started yet and prevents the iterator's refill from enqueueing - # more fetches. Callers wrap iteration in `with PaginatedFetcher(...)` - # so they don't have to remember an explicit `stop()` after - # breaking out of a paged loop. - self.stop() - - def _fetch(self, offset: int) -> list[dict[str, Any]]: - if self._stop.is_set(): - return [] - page_params = { - **self.params, - "offset": str(offset), - "limit": str(self.page_size), - } - resp = self.client.get(self.path, params=page_params) - return self.extract(resp.read()) - - def __iter__(self) -> Iterator[list[dict[str, Any]]]: - offsets = iter(i * self.page_size for i in range(self.max_pages)) - pending: collections.deque[Future[list[dict[str, Any]]]] = collections.deque() - with ThreadPoolExecutor(max_workers=self.concurrency) as pool: - # Prime the pool with up to `concurrency` in-flight fetches. - for _ in range(self.concurrency): - with contextlib.suppress(StopIteration): - pending.append(pool.submit(self._fetch, next(offsets))) - - while pending: - page = pending.popleft().result() - if not page: - self.exhausted = True - self._stop.set() - continue - if len(page) < self.page_size: - # Mark exhausted before yielding so callers that break - # out of the loop still see the correct end-of-data flag. - self.exhausted = True - self._stop.set() - yield page - # Refill so in-flight count stays at `concurrency`. - if not self._stop.is_set(): - with contextlib.suppress(StopIteration): - pending.append(pool.submit(self._fetch, next(offsets))) + if profile.auth_type == AUTH_TYPE_IAM and not profile.project: + raise ValueError( + f"No project configured for IAM profile {profile.name!r}." + " Run `contree auth` first." + ) + if profile.auth_type != AUTH_TYPE_IAM and not profile.url: + raise ValueError( + f"No URL configured for JWT profile {profile.name!r}." + " Run `contree auth` or pass --url." + ) + return CliClient.from_profile( + profile, + timeout=timeout, + retry=RetryPolicy(max_attempts=None, retry_unsafe=True), + ) diff --git a/contree_cli/config.py b/contree_cli/config.py index fa8e097..233afd2 100644 --- a/contree_cli/config.py +++ b/contree_cli/config.py @@ -1,3 +1,13 @@ +"""CLI-owned configuration on top of ``contree_client.profiles``. + +Profile parsing, the auth.ini/cli.ini merge and the resolution +precedence (explicit name > ``CONTREE_PROFILE`` > active) live in the +library; this module keeps what only the CLI needs: the writable +profile store (atomic 0600 save, active-profile switching, deletion +with session-DB cleanup), the ``[cli]`` settings section, the editor +choice and the session database layout. +""" + from __future__ import annotations import configparser @@ -6,12 +16,37 @@ import shutil import stat from collections.abc import Iterator, MutableMapping -from dataclasses import dataclass -from enum import Enum from pathlib import Path +from contree_client.profiles import ( + AUTH_TYPE_IAM, + AUTH_TYPE_JWT, + DEFAULT_IAM_URL, + Profile, + ProfileError, + load_profiles, + resolve_profile, +) + from .migrations import run_migrations +__all__ = [ + "AUTH_TYPE_IAM", + "AUTH_TYPE_JWT", + "CLI_CONFIG_FILE", + "CONFIG_DIR", + "CONFIG_FILE", + "CONTREE_HOME", + "DEFAULT_IAM_URL", + "EDITOR", + "SETTINGS", + "Config", + "Profile", + "get_default_path", + "remove_session_db", + "session_db_path", +] + log = logging.getLogger(__name__) @@ -42,55 +77,29 @@ def get_default_path(env: str, default: str | Path) -> Path: ) -class AuthType(str, Enum): - IAM = "iam" - JWT = "jwt" - - def __str__(self) -> str: - return self.value +def session_db_path(profile_name: str) -> Path: + """Per-profile session database location (CLI-owned layout).""" + return CONTREE_HOME / "cli" / "sessions" / f"{profile_name}.db" -@dataclass(frozen=True, repr=False) -class ConfigProfile: - name: str - url: str - token: str | None - auth_type: AuthType = AuthType.JWT - project: str | None = None +def remove_session_db(profile_name: str) -> None: + db = session_db_path(profile_name) + for suffix in ("", "-wal", "-shm"): + p = db.with_name(db.name + suffix) + p.unlink(missing_ok=True) - def __repr__(self) -> str: - masked = "***" if self.token else None - return ( - f"{self.__class__.__name__}(name={self.name!r}, url={self.url!r}," - f" token={masked!r}, auth_type={self.auth_type!r}," - f" project={self.project!r})" - ) - @property - def session_db_path(self) -> Path: - return CONTREE_HOME / "cli" / "sessions" / f"{self.name}.db" - - def remove_session_db(self) -> None: - db = self.session_db_path - for suffix in ("", "-wal", "-shm"): - p = db.with_name(db.name + suffix) - p.unlink(missing_ok=True) - - -class Config(MutableMapping[str, ConfigProfile]): - """INI-backed profile store. +class Config(MutableMapping[str, Profile]): + """Writable INI-backed profile store over the library reader. Dict-like: ``cfg[name]``, ``cfg[name] = profile``, ``del cfg[name]``, ``name in cfg``, ``len(cfg)``, iteration. """ - DEFAULT_IAM_URL = "https://api.tokenfactory.nebius.com/sandboxes" - PROFILE_PREFIX = "profile:" - def __init__(self, path: Path | None = None) -> None: run_migrations(CONTREE_HOME) self.__path = path or CONFIG_FILE - self.__profiles: dict[str, ConfigProfile] = {} + self.__profiles: dict[str, Profile] = {} self.__active: str = "default" self._load() @@ -101,48 +110,14 @@ def path(self) -> Path: # -- persistence --------------------------------------------------------- def _load(self) -> None: - cp = configparser.ConfigParser() - # Read cli.ini first, then auth.ini — later files override earlier - # ones, so secrets in auth.ini take precedence over any non-secret - # profile fields a user keeps in cli.ini (url, project, type, …). - sources = [CLI_CONFIG_FILE, self.__path] - log.debug("Loading config from %s", sources) - cp.read(sources) - self.__active = cp.defaults().get("profile", "default") - self.__profiles.clear() - for section in cp.sections(): - if section.startswith(self.PROFILE_PREFIX): - p = self._parse_profile(cp, section) - self.__profiles[p.name] = p - - @classmethod - def _parse_profile( - cls, - cp: configparser.ConfigParser, - section: str, - ) -> ConfigProfile: - auth_type = AuthType(cp.get(section, "type", fallback=AuthType.JWT.value)) - default_url = cls.DEFAULT_IAM_URL if auth_type == AuthType.IAM else "" - return ConfigProfile( - name=section[len(cls.PROFILE_PREFIX) :], - token=cp.get(section, "token", fallback=None), - url=cp.get(section, "url", fallback=default_url), - auth_type=auth_type, - project=cp.get(section, "project", fallback=None), - ) + log.debug("Loading profiles for %s", self.__path) + self.__profiles, self.__active = load_profiles(self.__path) def _save(self) -> None: cp = configparser.ConfigParser() cp["DEFAULT"]["profile"] = self.__active for profile in self.__profiles.values(): - section = self.PROFILE_PREFIX + profile.name - cp.add_section(section) - if profile.token is not None: - cp.set(section, "token", profile.token) - cp.set(section, "url", profile.url.rstrip("/")) - cp.set(section, "type", profile.auth_type) - if profile.project is not None: - cp.set(section, "project", profile.project) + profile.save(cp) self.__path.parent.mkdir(parents=True, exist_ok=True) # Create with 0o600 from the start so the token is never readable # by other users — even between create() and chmod(). @@ -160,10 +135,10 @@ def _save(self) -> None: def __contains__(self, name: object) -> bool: return name in self.__profiles - def __getitem__(self, name: str) -> ConfigProfile: + def __getitem__(self, name: str) -> Profile: return self.__profiles[name] - def __setitem__(self, name: str, profile: ConfigProfile) -> None: + def __setitem__(self, name: str, profile: Profile) -> None: assert name == profile.name, "profile name must match key" self.__profiles[name] = profile self._save() @@ -171,11 +146,11 @@ def __setitem__(self, name: str, profile: ConfigProfile) -> None: def __delitem__(self, name: str) -> None: if name not in self.__profiles: raise KeyError(name) - profile = self.__profiles.pop(name) + self.__profiles.pop(name) if self.__active == name: self.__active = next(iter(self.__profiles), "default") self._save() - profile.remove_session_db() + remove_session_db(name) def __len__(self) -> int: return len(self.__profiles) @@ -186,33 +161,33 @@ def __iter__(self) -> Iterator[str]: # -- profile management -------------------------------------------------- @property - def current(self) -> ConfigProfile: + def current(self) -> Profile: return self.__profiles[self.__active] @current.setter - def current(self, profile: ConfigProfile) -> None: + def current(self, profile: Profile) -> None: self.__active = profile.name self._save() - def resolve(self, profile_override: str | None = None) -> ConfigProfile: + def resolve(self, profile_override: str | None = None) -> Profile: """Resolve the active profile by name. - Priority: *profile_override* > ``CONTREE_PROFILE`` > config default. + The library owns the precedence (*profile_override* > + ``CONTREE_PROFILE`` > config default). A missing profile + yields a credential-less stub instead of an error so local + commands still run and main() reports remote ones itself. Credentials come strictly from the saved profile; runtime commands do not read tokens, URLs, or project IDs from the environment. To register/refresh credentials from env vars use ``contree auth``. """ - name = profile_override or os.environ.get("CONTREE_PROFILE") or self.__active - if name in self.__profiles: - return self.__profiles[name] - return ConfigProfile( - name=name, - token=None, - url="", - auth_type=AuthType.JWT, - project=None, - ) + try: + return resolve_profile(profile_override, path=self.__path) + except ProfileError: + name = ( + profile_override or os.environ.get("CONTREE_PROFILE") or self.__active + ) + return Profile(name=name, url="", token=None) def switch(self, name: str) -> None: """Set the active profile.""" diff --git a/contree_cli/docker/context.py b/contree_cli/docker/context.py index 6e5524b..70a0d27 100644 --- a/contree_cli/docker/context.py +++ b/contree_cli/docker/context.py @@ -6,8 +6,9 @@ import json import logging from dataclasses import dataclass, field +from typing import Any -from contree_cli.client import ContreeClient +from contree_cli.client import CliClient from contree_cli.session import SessionStore from .local_context import LocalContext @@ -29,7 +30,7 @@ class PendingFile: @dataclass class BuildContext: - client: ContreeClient + client: CliClient store: SessionStore local: LocalContext build_args: dict[str, str] = field(default_factory=dict) @@ -39,6 +40,12 @@ class BuildContext: workdir: str = "/" user: str = "" parent_hash: str = "" + # Sealed build stages: every FROM past the first closes the stage + # before it. `stages` maps `FROM ... AS name` aliases, `stage_images` + # indexes the same images for numeric `COPY --from=N` references. + stages: dict[str, str] = field(default_factory=dict) + stage_images: list[str] = field(default_factory=list) + current_stage_alias: str = "" pending: list[PendingFile] = field(default_factory=list) no_cache: bool = False timeout: int = BUILD_TIMEOUT_DEFAULT @@ -99,7 +106,7 @@ def chain(self, contribution: str) -> str: def short_hash(full: str) -> str: return full[:16] - def pending_files_payload(self) -> dict[str, object]: + def pending_files_payload(self) -> dict[str, dict[str, Any]]: return { p.instance_path: { "uuid": p.file_uuid, diff --git a/contree_cli/docker/kw_add.py b/contree_cli/docker/kw_add.py index b906aef..512fbc0 100644 --- a/contree_cli/docker/kw_add.py +++ b/contree_cli/docker/kw_add.py @@ -54,8 +54,9 @@ def serialize(self) -> str: def execute(self, ctx: BuildContext) -> None: if self.from_stage: - logger.warning("ADD --from=%s not supported, skipping", self.from_stage) - return + raise ValueError( + "ADD does not support --from; use COPY --from for stage copies" + ) sub_dest = ctx.substitute(self.dest) if not posixpath.isabs(sub_dest): diff --git a/contree_cli/docker/kw_copy.py b/contree_cli/docker/kw_copy.py index f3c182c..ddd9540 100644 --- a/contree_cli/docker/kw_copy.py +++ b/contree_cli/docker/kw_copy.py @@ -1,4 +1,15 @@ -"""``COPY [--chown=...] [--chmod=...] SRC... DEST`` - stage files into the build.""" +"""``COPY [--from=...] [--chown=...] [--chmod=...] SRC... DEST``. + +Local sources are uploaded from the build context and attached to the +next RUN. ``--from=`` sources are exported from the +referenced image as a tar archive, uploaded as a single file and +unpacked by an extraction RUN inside the sandbox, so ownership, modes +and symlinks survive natively. For now the extraction needs +``/bin/sh``, ``tar``, ``cp`` and ``mv`` in the target image (busybox +suffices; ``FROM scratch`` targets cannot receive ``COPY --from``) - +a temporary limitation that goes away once the backend unpacks +archives itself. +""" from __future__ import annotations @@ -6,18 +17,26 @@ import logging import posixpath import shlex +import tarfile +import tempfile from dataclasses import dataclass, field -from typing import ClassVar, TypeVar +from typing import IO, ClassVar, TypeVar + +from contree_client.exceptions import NotFoundError from contree_cli.cli.run import upload_files from .context import BuildContext, PendingFile from .keyword import DockerKeyword +from .kw_run import RunKeyword logger = logging.getLogger(__name__) T = TypeVar("T", bound=DockerKeyword) +SCRATCH_DIR = "/.contree-build" +SPOOL_MAX_MEMORY = 8 * 1024 * 1024 + @dataclass(frozen=True, repr=False) class CopyKeyword(DockerKeyword): @@ -37,13 +56,20 @@ def parse(cls, args_text: str) -> CopyKeyword: def serialize(self) -> str: return ( - f"COPY chown={self.chown} chmod={self.chmod} " + f"COPY from={self.from_stage} chown={self.chown} chmod={self.chmod} " f"sources={json.dumps(list(self.sources))} dest={self.dest}" ) def execute(self, ctx: BuildContext) -> None: if self.from_stage: - logger.warning("COPY --from=%s not supported, skipping", self.from_stage) + copy_from_image( + ctx, + self.from_stage, + self.sources, + self.dest, + self.chown, + self.chmod, + ) return stage_copy(ctx, self.sources, self.dest, self.chown, self.chmod) @@ -137,6 +163,154 @@ def stage_copy( ) +def resolve_stage_image(ctx: BuildContext, ref: str) -> str: + """Map a ``--from`` reference to an image UUID. + + Numeric references address sealed stages by position, names go + through the alias registry, anything else is treated as an + external image reference (docker parity: ``--from=image:tag``). + """ + if ref.isdigit(): + index = int(ref) + if index >= len(ctx.stage_images): + raise ValueError( + f"COPY --from={ref}: stage index out of range" + f" ({len(ctx.stage_images)} stage(s) sealed so far)" + ) + return ctx.stage_images[index] + if ref in ctx.stages: + return ctx.stages[ref] + try: + return ctx.client.resolve_image(ref) + except NotFoundError: + raise ValueError( + f"COPY --from={ref}: unknown build stage and no such image" + ) from None + + +def fetch_archive( + ctx: BuildContext, + stage_ref: str, + image_uuid: str, + src: str, + buffer: IO[bytes], +) -> None: + """Fill *buffer* with the tar export of *src* from *image_uuid*.""" + try: + for chunk in ctx.client.inspect_image_archive(image_uuid, src): + buffer.write(chunk) + except NotFoundError: + raise ValueError( + f"COPY --from={stage_ref}: {src} not found in {image_uuid}" + ) from None + buffer.seek(0) + + +def archive_root(buffer: IO[bytes], src: str) -> tuple[str, bool]: + """Peek the buffered tar: its root member name and directory-ness. + + Archiving a directory yields members rooted at the directory + basename (``/etc`` -> ``etc/hosts``); a single file yields one + entry named after the file. The peek only drives the extraction + command - the archive itself is uploaded untouched. + """ + with tarfile.open(fileobj=buffer, mode="r:") as tar: + member = tar.next() + if member is None: + raise ValueError(f"COPY --from: empty archive for {src}") + root = member.name.split("/", 1)[0] + is_dir = member.isdir() or member.name.rstrip("/") != root + buffer.seek(0) + return root, is_dir + + +def copy_from_image( + ctx: BuildContext, + from_stage: str, + sources: tuple[str, ...], + dest: str, + chown: str, + chmod: str, +) -> None: + """Stage a ``COPY --from`` directive as one extraction layer. + + Each source is exported from the referenced image as a tar + archive, uploaded once (deduplicated) and attached under + ``/.contree-build``; a single RUN then unpacks every archive into + place and removes the scratch directory. Ownership and modes come + from the tar unless ``--chown``/``--chmod`` override them. + """ + stage_ref = ctx.substitute(from_stage) + image_uuid = resolve_stage_image(ctx, stage_ref) + + sub_sources = tuple(ctx.substitute(s) for s in sources) + sub_dest = ctx.substitute(dest) + dest_is_dir = sub_dest.endswith("/") or len(sub_sources) > 1 + if not posixpath.isabs(sub_dest): + sub_dest = posixpath.join(ctx.workdir or "/", sub_dest) + sub_dest = posixpath.normpath(sub_dest) + + sub_chown = ctx.substitute(chown) + sub_chmod = ctx.substitute(chmod) + uid, gid = parse_chown(sub_chown) + mode_override = parse_chmod(sub_chmod) + + script: list[str] = [] + for index, raw_src in enumerate(sub_sources): + src = posixpath.normpath(posixpath.join("/", raw_src)) + tar_path = f"{SCRATCH_DIR}/copy-{index}.tar" + extract_dir = f"{SCRATCH_DIR}/extract-{index}" + + # Small archives stay in memory; big ones spill to a temp file. + with tempfile.SpooledTemporaryFile(max_size=SPOOL_MAX_MEMORY) as buffer: + fetch_archive(ctx, stage_ref, image_uuid, src, buffer) + root, is_dir = archive_root(buffer, src) + stored = ctx.client.ensure_file(buffer) + + ctx.pending.append( + PendingFile( + instance_path=tar_path, + file_uuid=str(stored.uuid), + sha256=str(stored.sha256), + uid=0, + gid=0, + mode="0600", + ) + ) + + unpacked = f"{extract_dir}/{root}" + script.append(f"mkdir -p {shlex.quote(extract_dir)}") + script.append(f"tar -xf {shlex.quote(tar_path)} -C {shlex.quote(extract_dir)}") + if sub_chown: + script.append(f"chown -R {uid}:{gid} {shlex.quote(unpacked)}") + if mode_override is not None: + flag = "-R " if is_dir else "" + script.append(f"chmod {flag}{mode_override:o} {shlex.quote(unpacked)}") + if is_dir: + # Docker copies the CONTENTS of a directory source. + script.append(f"mkdir -p {shlex.quote(sub_dest)}") + script.append(f"cp -a {shlex.quote(unpacked)}/. {shlex.quote(sub_dest)}/") + else: + if dest_is_dir: + target = posixpath.join(sub_dest, posixpath.basename(src)) + else: + target = sub_dest + script.append(f"mkdir -p {shlex.quote(posixpath.dirname(target) or '/')}") + script.append(f"mv {shlex.quote(unpacked)} {shlex.quote(target)}") + + script.append(f"rm -rf {shlex.quote(SCRATCH_DIR)}") + command = " && ".join(script) + + # The extraction runs as root regardless of an active USER (docker + # COPY semantics); --chown above handles ownership. + saved_user = ctx.user + ctx.user = "" + try: + RunKeyword(parts=(command,), shell_form=True).execute(ctx) + finally: + ctx.user = saved_user + + def parse_chown(spec: str) -> tuple[int, int]: if not spec: return 0, 0 diff --git a/contree_cli/docker/kw_from.py b/contree_cli/docker/kw_from.py index ba52cc8..c4d0503 100644 --- a/contree_cli/docker/kw_from.py +++ b/contree_cli/docker/kw_from.py @@ -4,22 +4,21 @@ import contextlib import hashlib -import json import logging -import time from dataclasses import dataclass from typing import ClassVar +from contree_client.exceptions import ContreeAPIError +from contree_client.models import ImageImportRegistry + from contree_cli.cli.images import normalize_registry_url -from contree_cli.client import ApiError, resolve_image from .context import BuildContext from .keyword import DockerKeyword +from .kw_run import RunKeyword logger = logging.getLogger(__name__) -TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"}) - @dataclass(frozen=True, repr=False) class FromKeyword(DockerKeyword): @@ -48,6 +47,13 @@ def serialize(self) -> str: return f"FROM {self.image_ref}" + (f" AS {self.alias}" if self.alias else "") def execute(self, ctx: BuildContext) -> None: + if ctx.last_image: + seal_stage(ctx) + ctx.current_stage_alias = self.alias + ctx.env.clear() + ctx.workdir = "/" + ctx.user = "" + ref = ctx.substitute(self.image_ref) image_uuid = resolve_or_import(ctx, ref) @@ -70,11 +76,32 @@ def execute(self, ctx: BuildContext) -> None: ctx.parent_hash = from_hash +def seal_stage(ctx: BuildContext) -> None: + """Close the stage in progress before the next FROM starts. + + Pending files exist only as attachments for a future RUN, so a + stage that ends with COPY/ADD is committed through the same + trivial closer that finalize_pending uses; the sealed image then + becomes addressable via ``COPY --from=``. + """ + if ctx.pending: + closer = RunKeyword(parts=(":",), shell_form=True) + closer.execute(ctx) + ctx.stage_images.append(ctx.last_image) + if ctx.current_stage_alias: + ctx.stages[ctx.current_stage_alias] = ctx.last_image + logger.info( + "stage %s sealed -> %s", + ctx.current_stage_alias or len(ctx.stage_images) - 1, + ctx.last_image, + ) + + def resolve_or_import(ctx: BuildContext, ref: str) -> str: """Resolve ``ref`` to a UUID, importing from a registry on miss.""" try: - return resolve_image(ctx.client, ref) - except ApiError as exc: + return ctx.client.resolve_image(ref) + except ContreeAPIError as exc: if exc.status != 404: raise @@ -82,31 +109,22 @@ def resolve_or_import(ctx: BuildContext, ref: str) -> str: tag = ref if not ref.startswith("docker://") else url.removeprefix("docker://") logger.info("FROM auto-import %s as tag %s", url, tag) - payload: dict[str, object] = {"registry": {"url": url}, "tag": tag} - if ctx.timeout: - payload["timeout"] = ctx.timeout - resp = ctx.client.post_json("/v1/images/import", payload) - op = json.loads(resp.read()) - op_uuid: str = op["uuid"] + op_uuid = ctx.client.import_image( + ImageImportRegistry(url=url), + tag=tag, + timeout=ctx.timeout if ctx.timeout else ..., + ) try: return wait_import(ctx, op_uuid, tag) except KeyboardInterrupt: - with contextlib.suppress(ApiError, OSError): - ctx.client.delete(f"/v1/operations/{op_uuid}") + with contextlib.suppress(ContreeAPIError, OSError): + ctx.client.cancel_operation(op_uuid) raise def wait_import(ctx: BuildContext, op_uuid: str, tag: str) -> str: - delay = 1.0 - while True: - time.sleep(delay) - resp = ctx.client.get(f"/v1/operations/{op_uuid}") - op = json.loads(resp.read()) - if op["status"] in TERMINAL_STATUSES: - break - if delay < 5: - delay += delay + op = ctx.client.wait_operation(op_uuid).to_dict() if op["status"] != "SUCCESS": raise RuntimeError( f"image import {tag!r} ended with {op['status']}" diff --git a/contree_cli/docker/kw_run.py b/contree_cli/docker/kw_run.py index 53ee6f1..6d725f1 100644 --- a/contree_cli/docker/kw_run.py +++ b/contree_cli/docker/kw_run.py @@ -8,12 +8,12 @@ from dataclasses import dataclass, field from typing import Any, ClassVar +from contree_client.models import TERMINAL_STATUSES, FileSpec, decode_stream + from contree_cli.cli.run import ( - TERMINAL_OP_STATUSES, - _build_op_from_summary, - _stream_events_until_close, + build_op_from_summary, + stream_events_until_close, ) -from contree_cli.client import decode_stream from contree_cli.output import DefaultFormatter from .context import BuildContext @@ -74,28 +74,28 @@ def execute(self, ctx: BuildContext) -> None: def spawn(self, ctx: BuildContext, parts: tuple[str, ...]) -> tuple[str, str]: command, args, shell = build_command(parts, self.shell_form, ctx.user) - payload: dict[str, object] = { - "image": ctx.last_image, - "command": command, - "shell": shell, - "disposable": False, - "hostname": "linuxkit", - "truncate_output_at": 65536, - } - if args: - payload["args"] = args - if ctx.timeout: - payload["timeout"] = ctx.timeout - if ctx.workdir and ctx.workdir != "/": - payload["cwd"] = ctx.workdir - if ctx.env: - payload["env"] = dict(ctx.env) - if ctx.pending: - payload["files"] = ctx.pending_files_payload() - - resp = ctx.client.post_json("/v1/instances", payload) - spawn_op = json.loads(resp.read()) - op_uuid: str = spawn_op["uuid"] + files = ( + { + path: FileSpec.from_dict(spec) + for path, spec in ctx.pending_files_payload().items() + } + if ctx.pending + else ... + ) + spawn_op = ctx.client.spawn_instance( + command, + ctx.last_image, + shell=shell, + disposable=False, + hostname="linuxkit", + truncate_output_at=65536, + args=args if args else ..., + timeout=ctx.timeout if ctx.timeout else ..., + cwd=ctx.workdir if ctx.workdir and ctx.workdir != "/" else ..., + env=dict(ctx.env) if ctx.env else ..., + files=files, + ) + op_uuid = str(spawn_op.uuid) logger.info( "RUN spawned op=%s: %s", op_uuid, display_title(parts, self.shell_form) ) @@ -119,15 +119,14 @@ def stream_and_resolve(ctx: BuildContext, op_uuid: str) -> dict[str, Any]: safety-net GET. When we fell back to the plain endpoint (no live stream), replay the captured stdout/stderr from the op's metadata so build users still see what the RUN produced.""" - summary = _stream_events_until_close(ctx.client, op_uuid, DefaultFormatter()) + summary = stream_events_until_close(ctx.client, op_uuid, DefaultFormatter()) if summary.completion is not None: - return _build_op_from_summary(op_uuid, summary) + return build_op_from_summary(op_uuid, summary) if summary.fallback_op is not None: log_streams(summary.fallback_op) return summary.fallback_op - resp = ctx.client.get(f"/v1/operations/{op_uuid}") - op: dict[str, Any] = json.loads(resp.read()) - if op.get("status") in TERMINAL_OP_STATUSES: + op: dict[str, Any] = ctx.client.get_operation_status(op_uuid).to_dict() + if op.get("status") in TERMINAL_STATUSES: log_streams(op) return op diff --git a/contree_cli/docker/url_fetch.py b/contree_cli/docker/url_fetch.py index 84e1c35..76d8e7c 100644 --- a/contree_cli/docker/url_fetch.py +++ b/contree_cli/docker/url_fetch.py @@ -16,7 +16,6 @@ from __future__ import annotations import hashlib -import json import logging import time import urllib.error @@ -24,7 +23,7 @@ import urllib.request from dataclasses import dataclass -from contree_cli.client import ApiError, ContreeClient +from contree_cli.client import CliClient from contree_cli.session import SessionStore logger = logging.getLogger(__name__) @@ -44,7 +43,7 @@ class FetchedUrl: def fetch_and_upload( url: str, - client: ContreeClient, + client: CliClient, store: SessionStore, *, timeout: int = DOWNLOAD_TIMEOUT_DEFAULT, @@ -86,16 +85,10 @@ def fetch_and_upload( assert isinstance(body_bytes, (bytes, bytearray)) body_bytes = bytes(body_bytes) - resp = client.request( - "POST", - "/v1/files", - body=body_bytes, - headers={"Content-Type": "application/octet-stream"}, - ) - data = json.loads(resp.read()) - - file_uuid = str(data["uuid"]) sha = hashlib.sha256(body_bytes).hexdigest() + uploaded = client.ensure_file(body_bytes, sha256=sha) + + file_uuid = str(uploaded.uuid) size = len(body_bytes) write_metadata( @@ -207,7 +200,9 @@ def http_head(url: str, *, timeout: int = DOWNLOAD_TIMEOUT_DEFAULT) -> dict[str, with urllib.request.urlopen(req, timeout=timeout) as resp: return {k.lower(): v for k, v in resp.headers.items()} except urllib.error.HTTPError as exc: - logger.debug("HEAD %s returned %d, skipping validator probe", url, exc.code) + code = exc.code + exc.close() + logger.debug("HEAD %s returned %d, skipping validator probe", url, code) return {} except (urllib.error.URLError, OSError, ValueError) as exc: logger.debug("HEAD %s failed (%s), skipping validator probe", url, exc) @@ -260,7 +255,6 @@ def url_basename(url: str, fallback: str = "downloaded") -> str: __all__ = [ - "ApiError", "FetchedUrl", "fetch_and_upload", "is_url", diff --git a/contree_cli/log.py b/contree_cli/log.py index 785c9ad..7981e08 100644 --- a/contree_cli/log.py +++ b/contree_cli/log.py @@ -2,6 +2,8 @@ import sys from types import MappingProxyType +from contree_client.types import set_log_level + from contree_cli.types import STDERR_IS_A_TTY, Colors @@ -42,3 +44,6 @@ def setup_logging(level: int = logging.INFO) -> None: handler = logging.StreamHandler(sys.stderr) handler.setFormatter(ColorFormatter(tty=STDERR_IS_A_TTY)) logging.basicConfig(level=level, handlers=[handler], force=True) + # The contree_client package logger is pinned to ERROR by default; + # mirror the CLI level so --log-level debug covers the transport too. + set_log_level(level) diff --git a/contree_cli/manual.md b/contree_cli/manual.md index b96a9e8..e8f122d 100644 --- a/contree_cli/manual.md +++ b/contree_cli/manual.md @@ -161,9 +161,9 @@ Detached workflow: contree show UUID view result contree op wait UUID block until terminal -Fan-out + join (use -f json BEFORE run so jq sees JSON): - A=$(contree -f json run -d -- make a | jq -r .uuid) - B=$(contree -f json run -d -- make b | jq -r .uuid) +Fan-out + join (use -o json BEFORE run so jq sees JSON): + A=$(contree -o json run -d -- make a | jq -r .uuid) + B=$(contree -o json run -d -- make b | jq -r .uuid) contree op wait "$A" "$B" wait for both; one row each contree op wait --all --timeout 600 or block on every active op @@ -172,15 +172,15 @@ More: contree run --help Output formats ============== -Global -f flag goes before the subcommand: +Global -o flag goes before the subcommand: - contree -f json images one JSON object per line - contree -f json-pretty ps pretty JSON array - contree -f csv images CSV with header - contree -f tsv ps tab-separated + contree -o json images one JSON object per line + contree -o json-pretty ps pretty JSON array + contree -o csv images CSV with header + contree -o tsv ps tab-separated Scripting: - contree -f json images | jq -r '.uuid' + contree -o json images | jq -r '.uuid' contree ps -q | xargs -I {} contree show {} More: contree --help diff --git a/contree_cli/session.py b/contree_cli/session.py index f5cdbc2..5f63d62 100644 --- a/contree_cli/session.py +++ b/contree_cli/session.py @@ -332,6 +332,14 @@ def _query_sessions( def session_key(self) -> str: return self._session_key + def select_session(self, session_key: str) -> None: + """Select another session in the same profile database. + + A session key is only a scope for queries made through this store; + changing it does not require opening a second SQLite connection. + """ + self._session_key = session_key + @property def current_image(self) -> str: s = self.session diff --git a/contree_cli/shell/argmap.py b/contree_cli/shell/argmap.py index 66abc11..d0046b4 100644 --- a/contree_cli/shell/argmap.py +++ b/contree_cli/shell/argmap.py @@ -46,6 +46,8 @@ (("cat",), "path"): "sandbox-path", (("cp",), "path"): "sandbox-path", (("cp",), "dest"): "host-path", + (("export",), "path"): "sandbox-path", + (("export",), "output"): "host-path", (("cd",), "path"): "sandbox-dir", (("file", "edit"), "path"): "sandbox-path", (("file", "edit"), "editor"): "editor", diff --git a/contree_cli/shell/completer.py b/contree_cli/shell/completer.py index 887480d..8083305 100644 --- a/contree_cli/shell/completer.py +++ b/contree_cli/shell/completer.py @@ -42,7 +42,7 @@ from contree_cli.shell.trie import Handler, PrefixRouter if TYPE_CHECKING: - from contree_cli.client import ContreeClient + from contree_cli.client import CliClient from contree_cli.session import ImageCache, SessionStore log = logging.getLogger(__name__) @@ -63,7 +63,8 @@ "ls", "cat", "--format", - "-f", + "--output", + "-o", ) @@ -73,7 +74,7 @@ class ShellCompleter: def __init__( self, commands: dict[str, CommandInfo], - client: ContreeClient | None = None, + client: CliClient | None = None, store: SessionStore | None = None, root_parser: ShellArgumentParser | None = None, ) -> None: @@ -159,7 +160,8 @@ def build_router(self) -> None: # Format flag belongs to the trie so "--format " works as a # standalone shell builtin (intercepted in repl.execute). r[("--format",)] = self.handler_format - r[("-f",)] = self.handler_format + r[("--output",)] = self.handler_format + r[("-o",)] = self.handler_format # Register every contree command name (and aliases) as router roots # so first-token completion still lists them. Their values are @@ -175,8 +177,8 @@ def dispatch_trie( ) -> list[str]: """Resolve a builtin via the trie.""" node, depth = self.router.resolve(tuple(tokens)) - # Format flag value: "--format " or "-f ". - if tokens and tokens[-1] in ("--format", "-f"): + # Format flag value: "--format " or "-o ". + if tokens and tokens[-1] in ("--format", "--output", "-o"): return self.handler_format((), text, ctx) if node.value is not None: remaining = tuple(tokens[depth:]) diff --git a/contree_cli/shell/repl.py b/contree_cli/shell/repl.py index e892d19..c4c6a60 100644 --- a/contree_cli/shell/repl.py +++ b/contree_cli/shell/repl.py @@ -11,8 +11,9 @@ from dataclasses import dataclass from functools import cached_property +from contree_client.exceptions import ContreeAPIError + from contree_cli import FORMATTER, IN_SHELL, PROFILE, SESSION_STORE, ArgumentsProtocol -from contree_cli.client import ApiError from contree_cli.output import FORMATTERS, OutputFormatter from contree_cli.session import SessionStore from contree_cli.shell.cache import SourceCache @@ -101,7 +102,8 @@ def intercept_timeout(line: str) -> tuple[int, str] | None: # Aliases for ``help `` lookup. HELP_ALIASES: dict[str, str] = { "quit": "exit", - "-f": "--format", + "-o": "--format", + "--output": "--format", "vi": "vim", } @@ -202,7 +204,7 @@ def parse_builtin( ), "exit": "Usage: exit | quit\n\nExit the interactive shell (Ctrl-D also works).", "--format": ( - "Usage: --format [NAME] | -f [NAME]\n" + "Usage: --format [NAME] | --output [NAME] | -o [NAME]\n" "\n" "Change the output format for the session, or show the current\n" "format name when called without arguments.\n" @@ -394,7 +396,7 @@ def execute(self, line: str) -> None: case "clear": sys.stdout.write("\033[2J\033[H") sys.stdout.flush() - case "--format" | "-f": + case "--format" | "--output" | "-o": self.handle_format_command(tokens[1:]) case "cd": self.handle_cd(tokens[1:]) @@ -517,7 +519,7 @@ def dispatch_contree(self, tokens: list[str]) -> None: before = self.session_snapshot() try: handler(loader.from_args(ns)) - except ApiError as exc: + except ContreeAPIError as exc: print(f"API error: {exc}", file=sys.stderr) except KeyboardInterrupt: print() @@ -556,7 +558,7 @@ def dispatch_run(self, line: str, *, timeout: int | None = None) -> None: before = self.session_snapshot() try: cmd_run(args) - except ApiError as exc: + except ContreeAPIError as exc: print(f"API error: {exc}", file=sys.stderr) except KeyboardInterrupt: print() diff --git a/contree_cli/shell/sources.py b/contree_cli/shell/sources.py index 73af09e..eb0e14b 100644 --- a/contree_cli/shell/sources.py +++ b/contree_cli/shell/sources.py @@ -12,13 +12,13 @@ from __future__ import annotations -import json import logging import os import posixpath import re from collections.abc import Callable, Iterable from dataclasses import dataclass +from types import EllipsisType from typing import TYPE_CHECKING from contree_cli.config import Config @@ -27,7 +27,7 @@ from contree_cli.shell.cache import SourceCache if TYPE_CHECKING: - from contree_cli.client import ContreeClient + from contree_cli.client import CliClient from contree_cli.session import SessionStore log = logging.getLogger(__name__) @@ -46,7 +46,7 @@ @dataclass(frozen=True) class CompletionContext: - client: ContreeClient | None + client: CliClient | None store: SessionStore | None cache: SourceCache | None profile: str @@ -95,23 +95,39 @@ def with_trailing_space(names: Iterable[str], text: str) -> list[str]: # --------------------------------------------------------------------------- -def fetch_images(ctx: CompletionContext) -> list[dict[str, object]]: - """Return the list of images (cached, profile-namespaced).""" +UUID_PREFIX_RE = re.compile(r"^[0-9a-f][0-9a-f-]*$", re.ASCII) + + +def fetch_images( + ctx: CompletionContext, + tag_prefix: str = "", +) -> list[dict[str, object]]: + """Return the list of images (cached, profile-namespaced). + + ``tag_prefix`` goes into the API's server-side tag prefix filter, + so completion sees matching images even when the account holds + more than one page of them. + """ if ctx.client is None: return [] + cache_kind = f"images:{tag_prefix}" if tag_prefix else "images" if ctx.cache is not None: - cached = ctx.cache.get(scope="", kind="images", ttl=60.0) + cached = ctx.cache.get(scope="", kind=cache_kind, ttl=60.0) if isinstance(cached, list): return cached try: - resp = ctx.client.get("/v1/images", params={"limit": "100"}) - data = json.loads(resp.read()) - images: list[dict[str, object]] = data.get("images", []) + listed = ctx.client.list_images( + limit=100, + tag=tag_prefix or None, + ).images + if isinstance(listed, EllipsisType): + listed = [] + images: list[dict[str, object]] = [img.to_dict() for img in listed] except Exception: log.debug("image source: API call failed", exc_info=True) return [] if ctx.cache is not None: - ctx.cache.set(scope="", kind="images", value=images) + ctx.cache.set(scope="", kind=cache_kind, value=images) return images @@ -120,8 +136,13 @@ def complete_image(text: str, ctx: CompletionContext) -> list[str]: Bare text matching a tag name auto-prefixes the candidate with ``tag:``; explicit ``tag:`` text filters by the full candidate. + The typed prefix is forwarded to the API request (server-side tag + prefix filter). UUIDs cannot be prefix-filtered server-side, so a + hex-looking text is additionally matched against the unfiltered + first page. """ - images = fetch_images(ctx) + tag_prefix = text.removeprefix("tag:") + images = fetch_images(ctx, tag_prefix) results: list[str] = [] for img in images: tag = img.get("tag") @@ -135,6 +156,14 @@ def complete_image(text: str, ctx: CompletionContext) -> list[str]: uuid_str = img.get("uuid") if isinstance(uuid_str, str) and uuid_str.startswith(text): results.append(uuid_str + " ") + + if text and UUID_PREFIX_RE.match(text): + for img in fetch_images(ctx): + uuid_str = img.get("uuid") + if isinstance(uuid_str, str) and uuid_str.startswith(text): + candidate = uuid_str + " " + if candidate not in results: + results.append(candidate) return results @@ -148,14 +177,7 @@ def fetch_operations(ctx: CompletionContext) -> list[dict[str, object]]: return cached ops: list[dict[str, object]] try: - resp = ctx.client.get("/v1/operations", params={"limit": "100"}) - data = json.loads(resp.read()) - if isinstance(data, dict): - ops = list(data.get("operations", [])) - elif isinstance(data, list): - ops = list(data) - else: - ops = [] + ops = [op.to_dict() for op in ctx.client.list_operations(limit=100)] except Exception: log.debug("operation source: API call failed", exc_info=True) return [] @@ -227,15 +249,9 @@ def list_sandbox_dir( if isinstance(cached, list): return cached try: - from contree_cli.client import resolve_image - - uuid = resolve_image(ctx.client, image_uuid) - resp = ctx.client.get( - f"/v1/inspect/{uuid}/list", - params={"path": dir_path}, - ) - data = json.loads(resp.read()) - files: list[dict[str, object]] = data.get("files", []) + uuid = ctx.client.resolve_image(image_uuid) + listing = ctx.client.inspect_image_list(uuid, dir_path) + files: list[dict[str, object]] = [f.to_dict() for f in listing.files] except Exception: log.debug("sandbox source: API call failed", exc_info=True) return None @@ -458,7 +474,7 @@ def complete_command_name(text: str, ctx: CompletionContext) -> list[str]: from contree_cli.shell.parser import get_command_names builtins = ("cd", "pwd", "history", "help", "clear", "exit", "quit") - aliases = ("ls", "cat", "vim", "vi", "nvim", "nano", "--format", "-f") + aliases = ("ls", "cat", "vim", "vi", "nvim", "nano", "--format", "--output", "-o") names = sorted({*get_command_names(), *builtins, *aliases}) return with_trailing_space(names, text) diff --git a/contree_cli/skill.py b/contree_cli/skill.py index 3c7634f..624d76d 100644 --- a/contree_cli/skill.py +++ b/contree_cli/skill.py @@ -2,11 +2,9 @@ import abc import os -import shutil import sqlite3 -import tempfile from collections.abc import Iterable, Iterator -from contextlib import contextmanager +from contextlib import contextmanager, suppress from dataclasses import dataclass from functools import cache from pathlib import Path @@ -59,6 +57,10 @@ def default_claude_home() -> Path: return Path.home() / ".claude" +def default_agents_home() -> Path: + return Path.home() / ".agents" + + # ── registry ───────────────────────────────────────────── @@ -82,6 +84,22 @@ def normalize_install_path(path: Path) -> str: return str(path.expanduser()) +def prune_empty_parents(path: Path) -> None: + """Remove empty skill-layout parents (`skills`, `agents`, dot-dirs) of path. + + Stops at the first directory that is not part of a skill layout or is + not empty, so project and home directories are never touched. + """ + for directory in path.parents: + name = directory.name + if name not in {"skills", "agents"} and not name.startswith("."): + return + try: + directory.rmdir() + except OSError: + return + + def remember_installed(skill: Skill) -> None: normalized = normalize_install_path(skill.path) with connect_registry() as conn: @@ -113,15 +131,25 @@ def forget_installed(skill: Skill) -> None: It uses the `contree` CLI from PATH.\ """ -BUNDLED_FIRST_STEP = """\ -1. Use `contree` from PATH — no bundled wrapper needed. -2. Output formats (global `-f` flag BEFORE the subcommand): - `-f json` (JSONL), `-f json-pretty`, `-f csv`, `-f tsv`, - `-f plain`, `-f table`, `-f default`, and `-f toml` on Python 3.11+. - The authoritative list is whatever `contree --help` shows on this - install — if `toml` is missing there, this Python lacks `tomllib` - and `-f toml` will fail with an argparse error. -3. For the full built-in manual: `contree agent`\ +CODEX_SANDBOX = """\ + +## Codex Sandbox + +`contree` needs network access and write access to its data directory, \ +which resolved to `{contree_home}` when this skill was installed. + +Codex config for that directory: + +```toml +[sandbox_workspace_write] +network_access = true +writable_roots = ["{contree_home}"] +``` + +If `CONTREE_HOME` or `XDG_CONFIG_HOME` changes, the writable root must \ +point at the newly resolved ConTree data directory. Without this, the CLI \ +can fail with `sqlite3.OperationalError`. If the sandbox cannot be \ +configured, stop and ask the user. """ BUNDLED_FALLBACK = """\ @@ -132,7 +160,7 @@ def forget_installed(skill: Skill) -> None: # 1) Discover what images are available -- do NOT assume a tag exists. contree images --prefix ubuntu # narrow listing (preferred) # Fallback for when you don't know the prefix shape -- much slower: -# contree -f plain images | grep -i ubuntu +# contree -o plain images | grep -i ubuntu # 2) Bootstrap a session against a tag actually present in the listing. contree -S use @@ -141,22 +169,9 @@ def forget_installed(skill: Skill) -> None: # pipes / redirects / && / ; / variable expansion. contree -S run -- true contree -S run -- uname -a - -# 4) See the full manual / per-command help. -contree agent # full manual -contree --help # per-command help ```\ """ -BUNDLED_REFERENCES = """\ - -## Where to look next - -- Run `contree agent` for the full built-in manual. -- Run `contree agent ` for details on a specific topic. -- Run `contree --help` for per-command syntax. -""" - SUBAGENT_DESCRIPTION = """\ Use proactively when the user needs to operate ConTree sandboxes through the \ contree CLI, especially for explicit session management, rollback-safe \ @@ -170,11 +185,9 @@ def forget_installed(skill: Skill) -> None: and available on `PATH`.\ """ -SUBAGENT_FIRST_STEP = "1. Use the local `contree` executable from `PATH`." - AGENT_DESCRIPTION = ( "Use proactively when the user needs sandboxed execution " - "through ConTree — sessions, images, run, rollback, tagging." + "through ConTree: sessions, images, run, rollback, tagging." ) AGENT_TEMPLATE = """\ @@ -182,10 +195,7 @@ def forget_installed(skill: Skill) -> None: name: {name} description: >- {description} -tools: - - Bash - - Read - - Grep +tools: Bash, Read, Grep skills: - contree --- @@ -201,7 +211,7 @@ def forget_installed(skill: Skill) -> None: """ OPENAI_DESCRIPTION = """\ -Run ConTree workflows through a bundled dependency-free CLI\ +Operate ConTree sandboxes through the contree CLI\ """ OPENAI_TEMPLATE = """\ @@ -238,24 +248,20 @@ def description(self) -> str: def intro(self) -> str: return BUNDLED_INTRO.format(version=skill_version()) - def first_step(self) -> str: - return BUNDLED_FIRST_STEP + def sandbox(self) -> str: + return "" def fallback(self) -> str: return BUNDLED_FALLBACK - def references(self) -> str: - return BUNDLED_REFERENCES - def frontmatter(self) -> str: return f'---\nname: "{self.name}"\ndescription: "{self.description}"\n---\n' def body(self) -> str: return skill_body_template().format( intro=self.intro(), - first_step=self.first_step(), + sandbox=self.sandbox(), fallback=self.fallback(), - references=self.references(), ) def render(self) -> str: @@ -274,13 +280,24 @@ def home_dir(cls) -> Path: ... def skills_dir(cls) -> Path: return cls.home_dir() / "skills" + @classmethod + def project_path(cls, root: Path) -> Path: + return root / f".{cls.kind}" / "skills" / SKILL_NAME + + @classmethod + def global_path(cls) -> Path: + return cls.skills_dir() / SKILL_NAME + @classmethod def resolve_path(cls, hint: str) -> Path: if not hint: - return (Path(f".{cls.kind}") / "skills" / SKILL_NAME).resolve() + return cls.project_path(Path()).resolve() if hint == "~": - return cls.skills_dir() / SKILL_NAME - return Path(hint).expanduser().resolve() + return cls.global_path() + path = Path(hint).expanduser().resolve() + if is_skill_target(path): + return path + return cls.project_path(path) @property def installed_version(self) -> str: @@ -298,22 +315,20 @@ def needs_upgrade(self) -> bool: @property def exists(self) -> bool: - return self.path.exists() + return (self.path / "SKILL.md").is_file() + + def owned_files(self) -> tuple[Path, ...]: + """Files this install writes; the only paths remove() may touch.""" + return ( + self.path / "SKILL.md", + self.path / ".version", + self.path / "agents" / "openai.yaml", + ) def install(self, *, force: bool = False) -> None: - if self.path.exists() and not force: + if self.exists and not force: raise FileExistsError(self.path) - - self.path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory( - prefix=f"{SKILL_NAME}-", - dir=self.path.parent, - ) as tmp_str: - tmp = Path(tmp_str) - self.build_tree(tmp) - if self.path.exists(): - shutil.rmtree(self.path) - tmp.replace(self.path) + self.build_tree(self.path) def build_tree(self, dest: Path) -> None: dest.mkdir(parents=True, exist_ok=True) @@ -325,7 +340,13 @@ def build_tree(self, dest: Path) -> None: (agents / "openai.yaml").write_text(self.openai_yaml(), encoding="utf-8") def remove(self) -> None: - shutil.rmtree(self.path) + for file in self.owned_files(): + if file.is_file(): + file.unlink() + for directory in (self.path / "agents", self.path): + with suppress(OSError): + directory.rmdir() + prune_empty_parents(self.path) def __hash__(self) -> int: return hash((self.kind, self.path)) @@ -359,16 +380,34 @@ def frontmatter(self) -> str: @dataclass(frozen=True) class CodexSkill(Skill): + """Codex reads skills from `.agents/skills` trees; rules stay in CODEX_HOME.""" + kind: str = "codex" @classmethod def home_dir(cls) -> Path: return default_codex_home() + @classmethod + def skills_dir(cls) -> Path: + return default_agents_home() / "skills" + + @classmethod + def project_path(cls, root: Path) -> Path: + return root / ".agents" / "skills" / SKILL_NAME + @classmethod def rules_path(cls) -> Path: return cls.home_dir() / "rules" / f"{SKILL_NAME}.rules" + def sandbox(self) -> str: + home = config_mod.CONTREE_HOME + try: + display = "~/" + home.relative_to(Path.home()).as_posix() + except ValueError: + display = home.as_posix() + return CODEX_SANDBOX.format(contree_home=display) + def install(self, *, force: bool = False) -> None: super().install(force=force) rules = self.rules_path() @@ -424,12 +463,16 @@ def home_dir(cls) -> Path: return default_claude_home() @classmethod - def resolve_path(cls, hint: str) -> Path: - if not hint: - return (Path(".claude") / "agents" / f"{SKILL_NAME}-subagent.md").resolve() - if hint == "~": - return cls.home_dir() / "agents" / f"{SKILL_NAME}-subagent.md" - return Path(hint).expanduser().resolve() + def project_path(cls, root: Path) -> Path: + return root / ".claude" / "agents" / f"{SKILL_NAME}-subagent.md" + + @classmethod + def global_path(cls) -> Path: + return cls.home_dir() / "agents" / f"{SKILL_NAME}-subagent.md" + + @property + def exists(self) -> bool: + return self.path.is_file() @property def description(self) -> str: @@ -438,21 +481,15 @@ def description(self) -> str: def intro(self) -> str: return SUBAGENT_INTRO - def first_step(self) -> str: - return SUBAGENT_FIRST_STEP - def fallback(self) -> str: return "" - def references(self) -> str: - return "" - def frontmatter(self) -> str: return ( "---\n" f"name: {self.name}\n" f"description: {self.description}\n" - f"allowed-tools: {self.allowed_tools}\n" + "tools: Bash, Read, Grep\n" "---\n" ) @@ -464,23 +501,28 @@ def install(self, *, force: bool = False) -> None: def remove(self) -> None: self.path.unlink() + prune_empty_parents(self.path) @dataclass(frozen=True) class ClaudeAgentSkill(Skill): kind: str = "claude-agent" + @property + def exists(self) -> bool: + return self.path.is_file() + @classmethod def home_dir(cls) -> Path: return default_claude_home() @classmethod - def resolve_path(cls, hint: str) -> Path: - if not hint: - return (Path(".claude") / "agents" / f"{SKILL_NAME}.md").resolve() - if hint == "~": - return cls.home_dir() / "agents" / f"{SKILL_NAME}.md" - return Path(hint).expanduser().resolve() + def project_path(cls, root: Path) -> Path: + return root / ".claude" / "agents" / f"{SKILL_NAME}.md" + + @classmethod + def global_path(cls) -> Path: + return cls.home_dir() / "agents" / f"{SKILL_NAME}.md" def render(self) -> str: return AGENT_TEMPLATE.format( @@ -496,6 +538,7 @@ def install(self, *, force: bool = False) -> None: def remove(self) -> None: self.path.unlink() + prune_empty_parents(self.path) # ── Skill type registry ────────────────────────────────── @@ -515,23 +558,36 @@ def remove(self) -> None: PATH_MARKERS: dict[str, type[Skill]] = { ".claude": ClaudeSkill, ".codex": CodexSkill, + ".agents": CodexSkill, "opencode": OpenCodeSkill, "agents": AmpSkill, ".cline": ClineSkill, } +def is_skill_target(path: Path) -> bool: + """True when path points at a skill artifact rather than a project root. + + Only signals of the artifact itself count (`SKILL.md` inside, the + skill basename, an `.md` file); the directories the path goes + through do not, so a project living inside `.claude`, `.codex`, or + `.agents` still expands as a project root. + """ + if path.suffix == ".md" or path.name == SKILL_NAME: + return True + return (path / "SKILL.md").is_file() + + def skill_from_spec(spec: str) -> Skill: """Parse a spec string into a Skill instance. claude: → ClaudeSkill(path=$PWD/.claude/skills/contree) claude:~ → ClaudeSkill(path=~/.claude/skills/contree) - codex:~ → CodexSkill(path=~/.codex/skills/contree) + claude:DIR → ClaudeSkill(path=DIR/.claude/skills/contree) ./path → guessed class with explicit path """ if ":" in spec: kind, hint = spec.split(":", 1) - hint = hint.lstrip("/") skill_cls = SKILL_BY_KIND.get(kind) if skill_cls is not None: return skill_cls(path=skill_cls.resolve_path(hint)) @@ -539,6 +595,21 @@ def skill_from_spec(spec: str) -> Skill: return guess_skill(path) +def skills_from_spec(spec: str) -> tuple[Skill, ...]: + """Parse a CLI spec into one or more Skill instances. + + kind:HINT specs and paths that point at a skill artifact resolve to a + single skill. Any other directory path is a project root: it expands + to every kind at its project location under that root. + """ + if spec.split(":", 1)[0] in SKILL_BY_KIND: + return (skill_from_spec(spec),) + path = Path(spec).expanduser().resolve() + if is_skill_target(path): + return (guess_skill(path),) + return tuple(project_install_specs(path)) + + CLAUDE_SKILL_TYPES: frozenset[type[Skill]] = frozenset( {ClaudeSkill, ClaudeAgentSkill, ClaudeSubagentSkill} ) @@ -562,6 +633,24 @@ def default_install_specs() -> Iterable[Skill]: return specs +def project_install_specs(root: Path) -> Iterable[Skill]: + """Return skill instances for installing every kind under a project root. + + Same gating as the global default: Claude-based types require + ``~/.claude`` to exist, all other types are included unconditionally. + """ + specs: list[Skill] = [ + cls(path=cls.project_path(root)) + for cls in ALL_SKILL_TYPES + if cls not in CLAUDE_SKILL_TYPES + ] + if default_claude_home().is_dir(): + for cls in ALL_SKILL_TYPES: + if cls in CLAUDE_SKILL_TYPES: + specs.append(cls(path=cls.project_path(root))) + return specs + + def guess_skill(path: Path) -> Skill: """Guess skill type from a raw path.""" normalized = path.expanduser() diff --git a/contree_cli/skill_body.md b/contree_cli/skill_body.md index 3419793..97a175a 100644 --- a/contree_cli/skill_body.md +++ b/contree_cli/skill_body.md @@ -3,31 +3,16 @@ {intro} Use `contree` from PATH. If it is missing, ask the user to install it: `uv tool install contree-cli`, `pipx install contree-cli`, or `pip install contree-cli`. - -## Codex Sandbox - -`contree` needs network access and write access to its data directory: `$CONTREE_HOME`, or `$XDG_CONFIG_HOME/contree`, or `~/.config/contree`. - -For default Codex config: - -```toml -[sandbox_workspace_write] -network_access = true -writable_roots = ["~/.config/contree"] -``` - -If the user overrides `CONTREE_HOME` or `XDG_CONFIG_HOME`, the writable root must point at the resolved ConTree data directory. Without this, the CLI can fail with `sqlite3.OperationalError`. If the sandbox cannot be configured, stop and ask the user. - +{sandbox} ## Required Workflow -{first_step} -4. If syntax or behavior is unclear, consult the built-in manual before retrying: `contree agent ` or `contree --help`. Useful topics: `sessions`, `images`, `files`, `execution`, `output`, `profiles`, `command_safety`, `all_commands`, `all`. -5. Do not run bare or mutating auth commands. Agents may run read-only `contree -f json auth ls` / `auth profiles`; if auth is missing or invalid, ask the user to run `contree auth`. -6. Choose one explicit session key, then pass `-S ` on every current-session command: `use`, `run`, `cd`, `env`, `ls`, `cat`, `cp`, `file`, implicit-current-image `tag`, and current-session `session show/branch/checkout/rollback/wait`. -7. Before `use`, list available images with a prefix. Do not assume a tag exists: `contree images --prefix python`, `contree images --prefix ubuntu`, `contree images --prefix compiler/`. An empty result just means that prefix has no tags in this project — broaden or vary the prefix (`python` vs `python-`, `compiler/` vs `compiler/python/`) before importing or rebuilding. -8. Bootstrap: `contree -S use ` then `contree -S cd /root`. -9. Inspect first with `ls`, `cat`, `session show`, `ps`/`op ls`, or `op show`. Mutate in small rollbackable steps. -10. After installing tools or setting up an environment, tag the result: `contree -S tag `. +1. If syntax or behavior is unclear, consult the built-in manual before retrying: `contree agent ` or `contree --help`. Useful topics: `sessions`, `images`, `files`, `execution`, `output`, `profiles`, `command_safety`, `all_commands`, `all`. +2. Do not run bare or mutating auth commands. Agents may run read-only `contree -o json auth ls` / `auth profiles`; if auth is missing or invalid, ask the user to run `contree auth`. +3. Choose one explicit session key, then pass `-S ` on every current-session command: `use`, `run`, `cd`, `env`, `ls`, `cat`, `cp`, `file`, implicit-current-image `tag`, and current-session `session show/branch/checkout/rollback/wait`. +4. Before `use`, list available images with a prefix. Do not assume a tag exists: `contree images --prefix python`, `contree images --prefix ubuntu`, `contree images --prefix compiler/`. An empty result just means that prefix has no tags in this project; broaden or vary the prefix (`python` vs `python-`, `compiler/` vs `compiler/python/`) before importing or rebuilding. +5. Bootstrap: `contree -S use ` then `contree -S cd /root`. +6. Inspect first with `ls`, `cat`, `session show`, `ps`/`op ls`, or `op show`. Mutate in small rollbackable steps. +7. After installing tools or setting up an environment, tag the result: `contree -S tag `. Project-scoped or explicit-target commands usually do not need `-S`: `images`, `auth ls/profiles`, `op ls/show/wait/cancel`, `skill`, `agent`, `build`, `session list`, `session show NAME`, and help. @@ -60,8 +45,8 @@ Directory attachments recurse and exclude common junk such as `.git`, hidden fil ## Detached Work -- Start detached: `contree -S -f json run -d --disposable -- pytest tests/a`. -- Capture UUIDs with global `-f json` before `run`; default detached output is not reliable for `jq`. +- Start detached: `contree -S -o json run -d --disposable -- pytest tests/a`. +- Capture UUIDs with global `-o json` before `run`; default detached output is not reliable for `jq`. - `op wait UUID...` is a pure observer. It polls, prints one row per completion with the server-reported `status` (`SUCCESS`/`FAILED`/`CANCELLED`) and a separate `exit_code` column for the sandbox process. It does not advance session state. The CLI's own exit code is 1 when any op finished non-`SUCCESS`, or the sandbox `exit_code` when a `SUCCESS` op exited non-zero, so `op wait && next` still composes naturally. - `op wait --all` is project-wide. Prefer explicit UUIDs when multiple agents or shells may share the project. - `session wait` with no UUIDs drains detached operations spawned from this session's local cache. Successful non-disposable runs advance the active branch; disposable runs are recorded as disposable branches. @@ -77,15 +62,15 @@ Directory attachments recurse and exclude common junk such as `.git`, hidden fil ## Output And Automation -- Global flags go before the subcommand: `contree -f json images --prefix python`. -- Prefer structured output in automation: `json`, `json-pretty`, `csv`, or `tsv`. `toml` is available only on Python 3.11+. +- Global flags go before the subcommand: `contree -o json images --prefix python`. +- Prefer structured output in automation: `json`, `json-pretty`, `csv`, or `tsv`. Human-oriented formats are `plain`, `table`, and `default`; `toml` needs Python 3.11+. The authoritative format list is whatever `contree --help` shows on this install. - `json` is line-delimited for streaming and multi-row commands. - Default `run` output prints raw stdout/stderr, not a structured row. - `cat` and `cp` are content-oriented; do not parse them as table/json listings. ## Operation References -Anywhere `--help` shows a positional named `UUID_OR_REF` (`op show`, `op cancel`, `op wait`, top-level `show`/`kill`, `session wait`) the CLI accepts both real operation UUIDs and current-session history refs. Refs require `-S ` and a history entry whose `operation_uuid` is set (`use` entries have none — error is "has no operation UUID"). Accepted forms: +Anywhere `--help` shows a positional named `UUID_OR_REF` (`op show`, `op cancel`, `op wait`, top-level `show`/`kill`, `session wait`) the CLI accepts both real operation UUIDs and current-session history refs. Refs require `-S ` and a history entry whose `operation_uuid` is set (`use` entries have none; the error is "has no operation UUID"). Accepted forms: | Form | Meaning | |---|---| @@ -97,7 +82,7 @@ Anywhere `--help` shows a positional named `UUID_OR_REF` (`op show`, `op cancel` When unsure, use `session show` to find the absolute id and pass that. -`contree op show --raw UUID_OR_REF...` (also `contree show --raw ...`) prints each operation's full server payload as JSONL — one compact JSON object per line, no derived columns, no stdout/stderr decoding. Use it when the flat row hides what you need (`metadata`, `resources`, raw `result.state`, …) or pipe it into `jq -c`. +`contree op show --raw UUID_OR_REF...` (also `contree show --raw ...`) prints each operation's full server payload as JSONL: one compact JSON object per line, no derived columns, no stdout/stderr decoding. Use it when the flat row hides what you need (`metadata`, `resources`, raw `result.state`, ...) or pipe it into `jq -c`. ## Subagents @@ -118,4 +103,4 @@ contree agent profiles contree --help ``` -{fallback}{references} +{fallback} diff --git a/contree_cli/types.py b/contree_cli/types.py index 21f940e..7f3bdb1 100644 --- a/contree_cli/types.py +++ b/contree_cli/types.py @@ -24,7 +24,7 @@ # global "version": ("-v", "--version"), "config": ("-c", "--config"), - "format": ("-f", "--format"), + "format": ("-o", "--format", "--output"), "log_level": ("-L", "--log-level"), "session": ("-S", "--session"), "project": ("-P", "--project"), @@ -33,7 +33,7 @@ # shared across commands "all": ("-a", "--all"), "delete": ("-U", "--delete", "--rm"), - "force": ("-y", "--force"), + "force": ("-f", "-y", "--force"), "kind": ("-k", "--kind"), "quiet": ("-q", "--quiet"), "since": ("--since",), @@ -76,6 +76,8 @@ "tag_name": ("--tag",), "build_arg": ("--build-arg",), "no_cache": ("--no-cache",), + # export + "decompress": ("--decompress",), } ) @@ -171,10 +173,6 @@ def parse_datetime(value: str) -> datetime: return datetime.fromisoformat(value).astimezone(tz=timezone.utc) -def isoformat_datetime(dt: datetime) -> str: - return dt.astimezone(tz=timezone.utc).isoformat().replace("+00:00", "Z") - - _INTERVAL_RE = re.compile(r"([+-]?\d+)([smhdMy]?)") _DATE_RE = re.compile( r"^(?P\d{4})(?:[./\-\s]?(?P\d{1,2}))?(?:[./\-\s]?(?P\d{1,2}))?" diff --git a/docs/commands/auth.md b/docs/commands/auth.md index 02aade0..574525e 100644 --- a/docs/commands/auth.md +++ b/docs/commands/auth.md @@ -23,7 +23,7 @@ contree auth -y contree auth ls # Structured output for scripts and agents -contree -f json auth ls +contree -o json auth ls # List profiles without network probes contree auth ls --offline @@ -111,8 +111,8 @@ Possible `status` values: For automation and agents, prefer: ```bash -contree -f json auth ls -contree -f json auth ls --offline +contree -o json auth ls +contree -o json auth ls --offline ``` ## `auth switch` diff --git a/docs/commands/cd.md b/docs/commands/cd.md index 541c639..e7bb2ea 100644 --- a/docs/commands/cd.md +++ b/docs/commands/cd.md @@ -13,7 +13,7 @@ contree cd /app contree run -- ls # runs in /app contree cd /etc contree cat os-release # reads /etc/os-release -contree cd # reset to sandbox default +contree cd # print current working directory ``` ## Help output @@ -26,9 +26,9 @@ contree cd # reset to sandbox default `cd` stores the path in the session state. Subsequent `run`, `ls`, `cat`, and `cp` commands resolve relative paths against it. -`cd` without arguments resets to the sandbox's default working directory. +`cd` without arguments prints the current working directory. :::{note} -`cd` does not validate that the path exists in the sandbox. Errors -surface only when the next command uses the invalid path. +`cd` validates the target against the image filesystem via the +inspect API and reports an error when the directory does not exist. ::: diff --git a/docs/commands/file.md b/docs/commands/file.md index f439c23..bc4ac39 100644 --- a/docs/commands/file.md +++ b/docs/commands/file.md @@ -83,7 +83,7 @@ machine -- the server stores only `uuid`, `sha256`, `size`, contree file ls contree file ls --since 1d --limit 200 contree file ls -q # uuid + sha256 + source only -contree -f json file ls | jq 'select(.source != "")' +contree -o json file ls | jq 'select(.source != "")' ``` ```{terminal-shell} contree file ls --help diff --git a/docs/commands/images.md b/docs/commands/images.md index 4480407..3f6ef8f 100644 --- a/docs/commands/images.md +++ b/docs/commands/images.md @@ -16,17 +16,17 @@ contree images # Filter by tag prefix contree images --prefix=ubuntu -# Only tagged images -contree images --tagged +# Include untagged intermediate images too +contree images -a # Images created in the last hour contree images --since=1h -# Find a specific image by UUID prefix -contree images --uuid=3f2a7b +# Find a specific image by UUID +contree images --uuid=3f2a7b1c-9d2e-4f60-8a1b-5c3d7e9f0a2b # JSON output for scripting -contree -f json images --tagged | jq -r '.tag' +contree -o json images | jq -r '.tag' ``` ## Help output diff --git a/docs/commands/ls.md b/docs/commands/ls.md index 0c9a4ad..36ba212 100644 --- a/docs/commands/ls.md +++ b/docs/commands/ls.md @@ -16,7 +16,7 @@ contree ls / contree ls /etc/nginx # JSON output with file metadata -contree -f json ls /usr/bin +contree -o json ls /usr/bin ``` ## Help output diff --git a/docs/commands/operation.md b/docs/commands/operation.md index 674ca67..e39985a 100644 --- a/docs/commands/operation.md +++ b/docs/commands/operation.md @@ -105,7 +105,7 @@ failure and continues with the remaining UUIDs, exiting with status ``` :::{note} -With table output (`-f table`) and several UUIDs, each operation +With table output (`-o table`) and several UUIDs, each operation currently renders as its own mini-table. Use `default` or `json` for a unified stream view across multiple UUIDs. ::: @@ -157,13 +157,13 @@ might expect. For multi-agent setups, prefer the explicit ``` Preferred — `--disposable` fan-out, no image to track. Note the -global `-f json` before `run` so `jq` sees JSON; the default +global `-o json` before `run` so `jq` sees JSON; the default formatter is plain. ```bash -A=$(contree -f json run -d --disposable -- pytest tests/a | jq -r .uuid) -B=$(contree -f json run -d --disposable -- pytest tests/b | jq -r .uuid) -C=$(contree -f json run -d --disposable -- pytest tests/c | jq -r .uuid) +A=$(contree -o json run -d --disposable -- pytest tests/a | jq -r .uuid) +B=$(contree -o json run -d --disposable -- pytest tests/b | jq -r .uuid) +C=$(contree -o json run -d --disposable -- pytest tests/c | jq -r .uuid) contree op wait "$A" "$B" "$C" contree op show "$A" "$B" "$C" # stdout/stderr per leg ``` @@ -171,13 +171,13 @@ contree op show "$A" "$B" "$C" # stdout/stderr per leg Non-disposable fan-out — must recover the chosen leg's image yourself: ```bash -A=$(contree -f json run -d -- apt-get install -y curl | jq -r .uuid) -B=$(contree -f json run -d -- apt-get install -y wget | jq -r .uuid) +A=$(contree -o json run -d -- apt-get install -y curl | jq -r .uuid) +B=$(contree -o json run -d -- apt-get install -y wget | jq -r .uuid) contree op wait "$A" "$B" # Pull the result image out and bind it back into the session, # or tag it for later reuse. -IMG_A=$(contree -f json op show "$A" | jq -r .image) +IMG_A=$(contree -o json op show "$A" | jq -r .image) contree use "$IMG_A" contree tag "$IMG_A" feature/curl-tools ``` diff --git a/docs/commands/ps.md b/docs/commands/ps.md index c9e8326..8ca1da3 100644 --- a/docs/commands/ps.md +++ b/docs/commands/ps.md @@ -33,7 +33,7 @@ contree ps -q contree ps --status FAILED # Filter by kind -contree ps -K instance +contree ps -k instance # Operations from the last hour contree ps -a --since=1h diff --git a/docs/commands/shell.md b/docs/commands/shell.md index 97c98ec..a2abc09 100644 --- a/docs/commands/shell.md +++ b/docs/commands/shell.md @@ -18,7 +18,7 @@ Start an interactive REPL for managing sessions and running sandbox commands. contree shell # Start with a specific output format -contree -f json shell +contree -o json shell # Start with a named profile contree --profile=personal shell diff --git a/docs/commands/show.md b/docs/commands/show.md index 2a44ef2..4cda4a4 100644 --- a/docs/commands/show.md +++ b/docs/commands/show.md @@ -43,7 +43,7 @@ contree show HEAD~ # one step back (shorthand for HEAD~1) contree show HEAD~3 # three steps back from the tip # JSON output for scripting -contree -f json show 3f2a7b... +contree -o json show 3f2a7b... # Show result of a detached run contree run -d -- make test @@ -74,7 +74,7 @@ operation to completion, not whether the sandbox process exited with zero. A `SUCCESS` row with `exit_code=1` means "the API completed the job; your command returned 1". `error` is pinned to the last column. Nested objects (`metadata`, `result`) are dropped from the flat row -— use `--raw` for the full server payload, or `-f json` to keep the +— use `--raw` for the full server payload, or `-o json` to keep the flat structured row. Pass `--raw` to skip all of the above and print each operation's diff --git a/docs/index.md b/docs/index.md index 87275ec..8fd7b09 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,15 +26,21 @@ Built for **AI agents that think ahead**: workflows with full filesystem context preserved. `contree-cli` is the command-line client that talks to the ConTree API. -Install it, authenticate with your project token, and you can create -sandboxes, run commands, inspect filesystems, and manage sessions -- all -from your terminal, shell scripts, or agent toolchains. ```bash -eval $(contree use tag:ubuntu:latest) # pick a base image -contree run apt update -qq # each run snapshots the result -contree run apt install -y curl # builds on the previous snapshot -contree ls /usr/bin/curl # inspect without spawning a VM +contree use tag:ubuntu:latest # pick an image for current terminal +contree run apt update -qq # each run snapshots the result +contree run apt install -y curl # builds on the previous snapshot +contree ls /usr/bin/curl # inspect without spawning a VM +``` + +You can choose name for session by setting the environment variable +`CONTREE_SESSION`. + +```bash +export CONTREE_SESSION=demo_session # Pick a name for session manually +contree use tag:ubuntu:latest # pick an image for demo_session +contree run -- find /root # run commands ``` ## Get started @@ -88,9 +94,8 @@ JSON, CSV, and TSV output. Detached runs, operation monitoring, shebang scripts — built for automation. ::: -:::{grid-item-card} Zero Dependencies -Zero external packages. Stdlib-only Python, runs anywhere 3.10+ is -available. +:::{grid-item-card} Lightweight +A single runtime dependency: the `contree-client` library. ::: :::{grid-item-card} Multi-Profile diff --git a/docs/tutorial/configuration.md b/docs/tutorial/configuration.md index 47a5dac..d6e3f80 100644 --- a/docs/tutorial/configuration.md +++ b/docs/tutorial/configuration.md @@ -82,7 +82,7 @@ contree auth ls -O For automation, use structured output: ```bash -contree -f json auth ls +contree -o json auth ls ``` ## Switching profiles diff --git a/docs/tutorial/images.md b/docs/tutorial/images.md index 7894956..46e47f2 100644 --- a/docs/tutorial/images.md +++ b/docs/tutorial/images.md @@ -66,7 +66,7 @@ contree tag UUID common/python-ml/python:3.11-slim Remove a tag: ```bash -contree tag -d UUID my-app:v1.0 +contree tag -U UUID my-app:v1.0 ``` ### Tag rules diff --git a/docs/tutorial/installation.md b/docs/tutorial/installation.md index 1121f5b..f526867 100644 --- a/docs/tutorial/installation.md +++ b/docs/tutorial/installation.md @@ -7,7 +7,7 @@ icon: download ## Requirements - Python 3.10 or later -- No external dependencies (stdlib only) +- The `contree-client` library (installed automatically) ## Install diff --git a/docs/tutorial/shell.md b/docs/tutorial/shell.md index 9ce71f5..43a1bbe 100644 --- a/docs/tutorial/shell.md +++ b/docs/tutorial/shell.md @@ -318,7 +318,7 @@ Change the output format mid-session, or show the current format: ```text contree:/> --format json # switch to JSON output -contree:/> -f table # switch to table output +contree:/> -o table # switch to table output contree:/> --format # show current format name ``` @@ -344,7 +344,7 @@ contree:/app> contree tag UUID my-app:v1 ## Limitations - **Output format is fixed** -- the `--format` flag is set at `contree shell` - launch. To use JSON output: `contree -f json shell`. + launch. To use JSON output: `contree -o json shell`. - **No local pipes or redirects** -- `|`, `>`, `<` are sent to the sandbox, not interpreted locally. - **No job control** -- no `&`, `bg`, `fg`, or Ctrl-Z. Use `contree run -d` diff --git a/docs/tutorial/workflows.md b/docs/tutorial/workflows.md index 6e2c927..92b1b7f 100644 --- a/docs/tutorial/workflows.md +++ b/docs/tutorial/workflows.md @@ -318,14 +318,14 @@ recovery example below. ::: The preferred shape — disposable runs, parallel independent checks. -The global `-f json` must come BEFORE the subcommand so that `jq` +The global `-o json` must come BEFORE the subcommand so that `jq` gets JSON; the default `run -d` formatter is plain. ```bash # Three parallel test suites, results discarded after the runs -A=$(contree -f json run -d --disposable -- pytest tests/a | jq -r .uuid) -B=$(contree -f json run -d --disposable -- pytest tests/b | jq -r .uuid) -C=$(contree -f json run -d --disposable -- pytest tests/c | jq -r .uuid) +A=$(contree -o json run -d --disposable -- pytest tests/a | jq -r .uuid) +B=$(contree -o json run -d --disposable -- pytest tests/b | jq -r .uuid) +C=$(contree -o json run -d --disposable -- pytest tests/c | jq -r .uuid) # Block until each one finishes (or 60 s elapses, whichever comes first) contree op wait "$A" "$B" "$C" @@ -338,13 +338,13 @@ Non-disposable fan-out works too, but you have to recover the result images yourself — `op wait` will not bind them into the session: ```bash -A=$(contree -f json run -d -- apt-get install -y curl | jq -r .uuid) -B=$(contree -f json run -d -- apt-get install -y wget | jq -r .uuid) +A=$(contree -o json run -d -- apt-get install -y curl | jq -r .uuid) +B=$(contree -o json run -d -- apt-get install -y wget | jq -r .uuid) contree op wait "$A" "$B" # Pull the winning leg's image out of the operation result and # attach it to the active session. -IMG_A=$(contree -f json op show "$A" | jq -r .image) +IMG_A=$(contree -o json op show "$A" | jq -r .image) contree use "$IMG_A" # Or tag it for reuse later. @@ -448,16 +448,16 @@ before the subcommand: ```bash # Pipe JSON to jq -contree -f json ps | jq '.uuid' +contree -o json ps | jq '.uuid' # CSV for scripting -contree -f csv images --tagged > images.csv +contree -o csv images > images.csv # Tab-separated for column alignment -contree -f tsv ps | column -t +contree -o tsv ps | column -t # Get image UUID from tag -contree -f json images --prefix=ubuntu | jq -r '.uuid' +contree -o json images --prefix=ubuntu | jq -r '.uuid' ``` ### Streaming behavior diff --git a/pyproject.toml b/pyproject.toml index 33ae52f..199a6c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,9 @@ classifiers = [ "Topic :: System :: Shells", "Typing :: Typed", ] -dependencies = [] +dependencies = [ + "contree-client>=0.1.3", +] [project.urls] Homepage = "https://contree.dev" diff --git a/tests/conftest.py b/tests/conftest.py index faf4332..d90d936 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,11 @@ from __future__ import annotations import atexit -import http.client -import json import os import shutil import tempfile +from collections import deque from collections.abc import Generator -from dataclasses import dataclass from pathlib import Path # Redirect CONTREE_HOME to a throwaway directory BEFORE importing @@ -22,12 +20,13 @@ # The CONTREE_HOME override above MUST run before any contree_cli import # touches contree_cli.config, hence the deferred import block below. import pytest # noqa: E402 +from contree_client import testing # noqa: E402 +from contree_client.profiles import Profile # noqa: E402 +from contree_client.runtime import RequestSpec, ResponseData # noqa: E402 import contree_cli.arguments # noqa: E402, F401 populates COMMAND_REGISTRY import contree_cli.config as config_mod # noqa: E402 from contree_cli import CLIENT, PROFILE # noqa: E402 -from contree_cli.client import ContreeClient, ContreeIAMClient # noqa: E402 -from contree_cli.config import ConfigProfile # noqa: E402 from contree_cli.session import ImageCache, SessionStore # noqa: E402 for var in ( @@ -43,159 +42,49 @@ os.environ.pop(var, None) -@pytest.fixture(autouse=True) -def sequential_pagination(monkeypatch: pytest.MonkeyPatch) -> None: - """Force PaginatedFetcher to use concurrency=1 in tests. +class ContreeTestClient(testing.ContreeClient): + """Method-level mock double for CLI handler tests. - The mock client's response queue is FIFO and not thread-safe; - parallel fetches would race on it. Sequential keeps tests - deterministic without affecting handler logic under test. - """ - for mod in ( - "contree_cli.cli.operation", - "contree_cli.cli.images", - "contree_cli.cli.file", - ): - monkeypatch.setattr(f"{mod}.CONTREE_CONCURRENCY", 1, raising=False) - - -@dataclass -class FakeResponse: - """Minimal HTTPResponse-compatible object for tests.""" - - status: int = 200 - reason: str = "" - body: bytes = b"{}" - headers: dict[str, str] | None = None - - def __post_init__(self) -> None: - if not self.reason: - self.reason = "OK" if self.status < 300 else "Error" - if self.headers is None: - self.headers = {} - - def read(self, amt: int | None = None) -> bytes: - return self.body - - def readline(self, size: int = -1) -> bytes: - """Empty bytes signals EOF to `iter_sse_events`, which makes any - SSE attempt against a non-SSE mock no-op out without raising — - the test then exercises the GET fallback as before.""" - return b"" - - def read1(self, amt: int | None = None) -> bytes: - """Match BufferedReader.read1 for streaming-style consumers.""" - return self.body - - def getheader(self, name: str, default: str | None = None) -> str | None: - assert self.headers is not None - for key, value in self.headers.items(): - if key.lower() == name.lower(): - return value - return default - - def getheaders(self) -> list[tuple[str, str]]: - assert self.headers is not None - return list(self.headers.items()) - - @staticmethod - def json(body: object, *, status: int = 200) -> FakeResponse: - return FakeResponse( - status=status, - body=json.dumps(body).encode(), - headers={"Content-Type": "application/json"}, - ) - - -@dataclass -class RecordedRequest: - """A single HTTP request captured by FakeConnection.""" + API methods are mocked per operation via ``client.mock("name", + result_model)`` (see ``contree_client.testing``); calls are + recorded and available through ``calls_for("name")``. - method: str - path: str - body: bytes | None - headers: dict[str, str] - - -class FakeConnection: - """Drop-in replacement for http.client.HTTPConnection in tests.""" + Two CLI handlers bypass the typed surface with a hand-built + ``RequestSpec`` (`ls` text mode and the `session wait` session_key + filter); ``respond_raw()`` queues buffered responses for those, and + ``raw_requests`` records the specs for assertions. + """ - def __init__(self) -> None: - self.requests: list[RecordedRequest] = [] - self.responses: list[FakeResponse] = [] - self._last_path: str = "" + def __init__( + self, + url: str = "https://contree.dev", + token: str = "tok", + project: str | None = None, + ) -> None: + super().__init__(token, base_url=url, project=project) + self.raw_responses: deque[ResponseData] = deque() + self.raw_requests: list[RequestSpec] = [] - def request( + def respond_raw( self, - method: str, - path: str, - body: object = None, + *, + status: int = 200, + body: bytes = b"", headers: dict[str, str] | None = None, ) -> None: - if hasattr(body, "read") and not isinstance(body, (bytes, bytearray)): - chunks: list[bytes] = [] - while True: - chunk = body.read(64 * 1024) - if not chunk: - break - chunks.append(chunk) - body = b"".join(chunks) - recorded = body if isinstance(body, (bytes, bytearray, type(None))) else None - self.requests.append(RecordedRequest(method, path, recorded, headers or {})) - self._last_path = path - - def getresponse(self) -> FakeResponse: - # `/events?follow=1` is the modern wait path — auto-serve an empty - # SSE response so the GET-/operations mock the test queued up - # isn't consumed by the events probe. Real tests that want to - # exercise the SSE wire format must mock the connection - # differently anyway. - if "/events" in self._last_path: - return FakeResponse( - status=200, - body=b"", - headers={"Content-Type": "text/event-stream"}, - ) - return self.responses.pop(0) - - -class ContreeTestClient(ContreeClient): - """Test client with FakeConnection and Bearer auth.""" - - def __init__(self, url: str = "https://contree.dev", token: str = "tok") -> None: - super().__init__(url, token) - self.fake = FakeConnection() - - def _build_headers(self) -> dict[str, str]: - return {"Authorization": "Hello"} - - def _connect(self) -> http.client.HTTPConnection: - return self.fake # type: ignore[return-value] - - # -- response helpers -- - - def respond(self, *, status: int = 200, body: bytes = b"{}") -> None: - self.fake.responses.append(FakeResponse(status=status, body=body)) - - def respond_json(self, body: object, *, status: int = 200) -> None: - self.fake.responses.append(FakeResponse.json(body, status=status)) - - # -- request introspection -- - - @property - def request_count(self) -> int: - return len(self.fake.requests) - - @property - def request_paths(self) -> list[str]: - return [r.path for r in self.fake.requests] + self.raw_responses.append( + ResponseData(status=status, headers=headers or {}, body=body) + ) - def get_request(self, index: int = -1) -> RecordedRequest: - return self.fake.requests[index] + def request(self, spec: RequestSpec) -> ResponseData: + self.raw_requests.append(spec) + if self.raw_responses: + return self.raw_responses.popleft() + raise testing.unmocked(spec) -class ContreeTestIAMClient(ContreeIAMClient): - """IAM client that uses FakeConnection instead of real HTTP.""" +class ContreeTestIAMClient(ContreeTestClient): + """Test double configured like an IAM profile (project set).""" def __init__( self, @@ -204,27 +93,39 @@ def __init__( project: str = "aiproject-test", ) -> None: super().__init__(url, token, project) - self.fake = FakeConnection() - - def _connect(self) -> http.client.HTTPConnection: - return self.fake # type: ignore[return-value] - - def respond(self, *, status: int = 200, body: bytes = b"{}") -> None: - self.fake.responses.append(FakeResponse(status=status, body=body)) - def respond_json(self, body: object, *, status: int = 200) -> None: - self.fake.responses.append(FakeResponse.json(body, status=status)) - @property - def request_count(self) -> int: - return len(self.fake.requests) +def make_file_item(path: str, **overrides: object) -> dict[str, object]: + """Full FileItem payload exactly as the inspect API returns it. - @property - def request_paths(self) -> list[str]: - return [r.path for r in self.fake.requests] - - def get_request(self, index: int = -1) -> RecordedRequest: - return self.fake.requests[index] + The contree-client `FileItem` model requires every field, so test + fixtures must always send the complete realistic shape. Overrides + replace individual fields; `is_regular` is derived from the other + type flags unless overridden explicitly. + """ + item: dict[str, object] = { + "path": path, + "size": 128, + "owner": "root", + "group": "root", + "uid": 0, + "gid": 0, + "mode": 0o644, + "mtime": 1700000000, + "nlink": 1, + "is_dir": False, + "is_regular": True, + "is_symlink": False, + "is_socket": False, + "is_fifo": False, + "symlink_to": "", + } + item.update(overrides) + if "is_regular" not in overrides: + item["is_regular"] = not ( + item["is_dir"] or item["is_symlink"] or item["is_socket"] or item["is_fifo"] + ) + return item # --------------------------------------------------------------------------- @@ -235,14 +136,14 @@ def get_request(self, index: int = -1) -> RecordedRequest: @pytest.fixture() def contree_client() -> ContreeTestClient: tc = ContreeTestClient() - CLIENT.set(tc) + CLIENT.set(tc) # type: ignore[arg-type] return tc @pytest.fixture() def iam_client() -> ContreeTestIAMClient: tc = ContreeTestIAMClient() - CLIENT.set(tc) + CLIENT.set(tc) # type: ignore[arg-type] return tc @@ -259,9 +160,9 @@ def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: @pytest.fixture() -def profile() -> Generator[ConfigProfile]: +def profile() -> Generator[Profile]: """Set PROFILE context var to a test profile, reset after.""" - p = ConfigProfile(name="test", url="http://localhost", token="tok") + p = Profile(name="test", url="http://localhost", token="tok") token = PROFILE.set(p) yield p # type: ignore[misc] PROFILE.reset(token) diff --git a/tests/test_auth.py b/tests/test_auth.py index c43ecc5..629cbb4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,6 +6,9 @@ import pytest from conftest import ContreeTestClient +from contree_client import operations +from contree_client.models import WhoAmIResponse +from contree_client.runtime import ResponseData from contree_cli import FORMATTER from contree_cli.cli.auth import ( @@ -18,7 +21,7 @@ cmd_remove, cmd_switch, ) -from contree_cli.config import AuthType, Config +from contree_cli.config import AUTH_TYPE_IAM, AUTH_TYPE_JWT, Config def _make_auth_args(**kwargs) -> AuthArgs: @@ -26,7 +29,7 @@ def _make_auth_args(**kwargs) -> AuthArgs: defaults: dict[str, object] = dict( token="", url="https://test.dev", - auth_type=AuthType.JWT, + auth_type=AUTH_TYPE_JWT, project=None, profile="default", ) @@ -39,7 +42,7 @@ def _make_iam_args(**kwargs) -> AuthArgs: defaults: dict[str, object] = dict( token="", url="https://iam.test", - auth_type=AuthType.IAM, + auth_type=AUTH_TYPE_IAM, project="aiproject-test", profile="default", ) @@ -57,6 +60,30 @@ def whoami_body(*, permissions: dict[str, bool] | None = None) -> bytes: return json.dumps(body).encode() +def whoami_result(*, permissions: dict[str, bool] | None = None) -> WhoAmIResponse: + return WhoAmIResponse( + token_uuid="00000000-0000-0000-0000-000000000000", + token_expiration=None, + permissions={"list": True} if permissions is None else permissions, + operations_stat={}, + ) + + +def mock_whoami_response(tc: ContreeTestClient, status: int, body: bytes) -> None: + """Mock ``whoami`` exactly as the real client would parse the wire. + + Runs ``operations.parse_whoami`` over a synthetic response so the + tests keep exercising the client's own status/JSON/model error + behavior (401 -> UnauthorizedError, non-dict -> ContreeAPIError, + missing model field -> TypeError, invalid JSON -> ValueError). + """ + response = ResponseData(status=status, headers={}, body=body) + try: + tc.mock("whoami", operations.parse_whoami(response)) + except Exception as exc: + tc.mock("whoami", error=exc) + + @contextmanager def mock_whoami(status=200, *, body: bytes | None = None): """Patch client_from_profile to return a fresh ContreeTestClient per call.""" @@ -64,7 +91,7 @@ def mock_whoami(status=200, *, body: bytes | None = None): def factory(profile, timeout=None): # type: ignore[no-untyped-def] tc = ContreeTestClient() - tc.respond(status=status, body=body if body is not None else whoami_body()) + mock_whoami_response(tc, status, body if body is not None else whoami_body()) last_client.clear() last_client.append(tc) return tc @@ -141,13 +168,13 @@ def test_save_jwt_stores_type(self, config_dir): with mock_whoami(): cmd_auth(_make_auth_args(token="tok")) p = Config().resolve() - assert p.auth_type == AuthType.JWT + assert p.auth_type == AUTH_TYPE_JWT def test_save_iam_stores_type_and_project(self, config_dir): with mock_whoami(): cmd_auth(_make_iam_args(token="tok")) p = Config().resolve() - assert p.auth_type == AuthType.IAM + assert p.auth_type == AUTH_TYPE_IAM assert p.project == "aiproject-test" assert p.url == "https://iam.test" @@ -165,7 +192,7 @@ def test_no_prompt_in_from_args(self, config_dir): assert args.token is None def test_prompts_when_no_token_jwt(self, config_dir): - ns = _make_ns(auth_type=AuthType.JWT, auth_url="https://test.dev") + ns = _make_ns(auth_type=AUTH_TYPE_JWT, auth_url="https://test.dev") args = AuthArgs.from_args(ns) with ( patch( @@ -181,7 +208,7 @@ def test_prompts_when_no_token_jwt(self, config_dir): def test_from_args_defaults_to_iam(self): ns = _make_ns() args = AuthArgs.from_args(ns) - assert args.auth_type == AuthType.IAM + assert args.auth_type == AUTH_TYPE_IAM # --------------------------------------------------------------------------- @@ -225,14 +252,16 @@ def test_no_list_permission_warning_includes_project(self, config_dir, caplog): cmd_auth(args) assert "aiproject-restricted" in caplog.text - def test_missing_permissions_field_warns(self, config_dir, caplog): + def test_missing_permissions_field_rejected(self, config_dir, caplog): + """`permissions` is required by WhoAmIResponse; a payload without + it fails parsing and the profile is not saved.""" args = _make_auth_args(token="tok") body = b'{"token_uuid":"x","token_expiration":null,"operations_stat":{}}' - with caplog.at_level("WARNING"), mock_whoami(body=body): + with caplog.at_level("ERROR"), mock_whoami(body=body): rc = cmd_auth(args) - assert rc is None - assert "sandboxes are disabled" in caplog.text - assert Config().resolve().token == "tok" + assert rc == 1 + assert "Could not parse /v1/whoami response" in caplog.text + assert Config().resolve().token is None def test_unparseable_whoami_rejected(self, config_dir, caplog): args = _make_auth_args(token="tok") @@ -241,14 +270,15 @@ def test_unparseable_whoami_rejected(self, config_dir, caplog): assert rc == 1 assert Config().resolve().token is None - def test_non_dict_whoami_payload_warns_but_saves(self, config_dir, caplog): - """JSON list (or other non-dict) is treated as missing permissions.""" + def test_non_dict_whoami_payload_rejected(self, config_dir, caplog): + """JSON list (or other non-dict) fails whoami parsing; the + profile is not saved.""" args = _make_auth_args(token="tok") - with caplog.at_level("WARNING"), mock_whoami(body=b"[]"): + with caplog.at_level("ERROR"), mock_whoami(body=b"[]"): rc = cmd_auth(args) - assert rc is None - assert "sandboxes are disabled" in caplog.text - assert Config().resolve().token == "tok" + assert rc == 1 + assert "Token verification failed" in caplog.text + assert Config().resolve().token is None def test_success_logs_saved(self, config_dir, caplog): args = _make_auth_args(token="good") @@ -261,8 +291,8 @@ def test_whoami_called(self, config_dir): with mock_whoami() as clients: cmd_auth(args) tc = clients[0] - assert tc.request_count == 1 - assert "/v1/whoami" in tc.request_paths[0] + assert len(tc.calls_for("whoami")) == 1 + assert len(tc.calls) == 1 # --------------------------------------------------------------------------- @@ -277,7 +307,7 @@ def test_nebius_api_key_used_as_token(self, config_dir, caplog, monkeypatch): cmd_auth( AuthArgs( url="https://test.dev", - auth_type=AuthType.JWT, + auth_type=AUTH_TYPE_JWT, ) ) p = Config().resolve() @@ -290,7 +320,7 @@ def test_nebius_ai_project_used(self, config_dir, caplog, monkeypatch): cmd_auth( AuthArgs( token="tok", - auth_type=AuthType.IAM, + auth_type=AUTH_TYPE_IAM, url="https://iam.test", ) ) @@ -302,7 +332,7 @@ def test_both_nebius_vars_skip_all_prompts(self, config_dir, caplog, monkeypatch monkeypatch.setenv("NEBIUS_API_KEY", "neb-tok") monkeypatch.setenv("NEBIUS_AI_PROJECT", "aiproject-auto") with caplog.at_level("INFO"), mock_whoami(): - cmd_auth(AuthArgs(auth_type=AuthType.IAM, url="https://iam.test")) + cmd_auth(AuthArgs(auth_type=AUTH_TYPE_IAM, url="https://iam.test")) p = Config().resolve() assert p.token == "neb-tok" assert p.project == "aiproject-auto" @@ -314,7 +344,7 @@ def test_contree_token_used_when_token_omitted( ): monkeypatch.setenv("CONTREE_TOKEN", "ctok") with caplog.at_level("INFO"), mock_whoami(): - cmd_auth(AuthArgs(url="https://test.dev", auth_type=AuthType.JWT)) + cmd_auth(AuthArgs(url="https://test.dev", auth_type=AUTH_TYPE_JWT)) p = Config().resolve() assert p.token == "ctok" assert "Using token from CONTREE_TOKEN" in caplog.text @@ -325,7 +355,7 @@ def test_contree_token_preferred_over_nebius_api_key( monkeypatch.setenv("CONTREE_TOKEN", "ctok") monkeypatch.setenv("NEBIUS_API_KEY", "ntok") with caplog.at_level("INFO"), mock_whoami(): - cmd_auth(AuthArgs(url="https://test.dev", auth_type=AuthType.JWT)) + cmd_auth(AuthArgs(url="https://test.dev", auth_type=AUTH_TYPE_JWT)) p = Config().resolve() assert p.token == "ctok" @@ -333,7 +363,7 @@ def test_contree_url_used_when_url_omitted(self, config_dir, caplog, monkeypatch monkeypatch.setenv("CONTREE_URL", "https://env-url.dev") monkeypatch.setenv("NEBIUS_API_KEY", "tok") with caplog.at_level("INFO"), mock_whoami(): - cmd_auth(AuthArgs(auth_type=AuthType.JWT)) + cmd_auth(AuthArgs(auth_type=AUTH_TYPE_JWT)) p = Config().resolve() assert p.url == "https://env-url.dev" @@ -346,7 +376,7 @@ def test_contree_project_used_when_project_omitted( AuthArgs( token="tok", url="https://iam.test", - auth_type=AuthType.IAM, + auth_type=AUTH_TYPE_IAM, ) ) p = Config().resolve() @@ -360,7 +390,7 @@ def test_explicit_token_flag_beats_env(self, config_dir, monkeypatch): AuthArgs( token="from-flag", url="https://test.dev", - auth_type=AuthType.JWT, + auth_type=AUTH_TYPE_JWT, ) ) p = Config().resolve() @@ -412,19 +442,11 @@ def flush(self) -> None: def fake_factory(profile, timeout=None): # type: ignore[no-untyped-def] tc = ContreeTestClient(token=profile.token) if profile.token == "tok-ok": - tc.respond(status=200, body=whoami_body()) + tc.mock("whoami", whoami_result()) elif profile.token == "tok-timeout": - - def timeout_get(path, params=None): # type: ignore[no-untyped-def] - raise TimeoutError("timeout") - - tc.get = timeout_get # type: ignore[assignment] + tc.mock("whoami", error=TimeoutError("timeout")) else: - - def error_get(path, params=None): # type: ignore[no-untyped-def] - raise OSError("boom") - - tc.get = error_get # type: ignore[assignment] + tc.mock("whoami", error=OSError("boom")) return tc FORMATTER.set(CaptureFormatter()) @@ -456,9 +478,9 @@ def flush(self) -> None: def fake_factory(profile, timeout=None): # type: ignore[no-untyped-def] tc = ContreeTestClient(token=profile.token) - tc.respond( - status=200, - body=whoami_body(permissions={"list": False, "spawn": True}), + tc.mock( + "whoami", + whoami_result(permissions={"list": False, "spawn": True}), ) return tc @@ -636,7 +658,7 @@ def test_from_args_all_fields(self): ns = _make_ns( auth_token="tok", auth_url="https://url.dev", - auth_type=AuthType.JWT, + auth_type=AUTH_TYPE_JWT, auth_project="aiproject-x", profile="prod", force=True, @@ -644,7 +666,7 @@ def test_from_args_all_fields(self): args = AuthArgs.from_args(ns) assert args.token == "tok" assert args.url == "https://url.dev" - assert args.auth_type == AuthType.JWT + assert args.auth_type == AUTH_TYPE_JWT assert args.project == "aiproject-x" assert args.profile == "prod" assert args.force is True @@ -654,7 +676,7 @@ def test_from_args_defaults(self): args = AuthArgs.from_args(ns) assert args.token is None assert args.url is None - assert args.auth_type == AuthType.IAM + assert args.auth_type == AUTH_TYPE_IAM assert args.project is None assert args.profile == "default" assert args.force is False diff --git a/tests/test_build.py b/tests/test_build.py index 67f3f84..7a3941f 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,12 +1,19 @@ from __future__ import annotations -import json from contextvars import copy_context from pathlib import Path from unittest.mock import patch import pytest -from conftest import ContreeTestClient, FakeResponse +from conftest import ContreeTestClient +from contree_client.exceptions import NotFoundError +from contree_client.models import ( + FileResponse, + Image, + InstanceSpawnResponse, + OperationResponse, +) +from contree_client.profiles import Profile from contree_cli import CLIENT, FORMATTER, PROFILE, SESSION_STORE from contree_cli.cli.build import ( @@ -14,7 +21,6 @@ cmd_build, make_session_key, ) -from contree_cli.config import ConfigProfile from contree_cli.output import JSONFormatter from contree_cli.session import SessionStore @@ -22,69 +28,74 @@ NEW_IMG = "22222222-2222-2222-2222-222222222222" NEW_IMG_2 = "33333333-3333-3333-3333-333333333333" - -def make_op_success(image: str, op_uuid: str = "op-1") -> FakeResponse: - return FakeResponse.json( - { - "uuid": op_uuid, - "kind": "instance", - "status": "SUCCESS", - "duration": 1.0, - "metadata": { - "result": { - "state": {"exit_code": 0}, - "stdout": None, - "stderr": None, - } - }, - "result": {"image": image, "tag": ""}, - } +# A prepared mock outcome: (operation name, result model or exception). +MockSpec = tuple[str, object] + + +def make_op_success(image: str, op_uuid: str = "op-1") -> MockSpec: + # OperationInstanceMetadata requires `command` and `image` on the + # wire; the API always echoes the spawn parameters back here. + return ( + "get_operation_status", + OperationResponse.from_dict( + { + "uuid": op_uuid, + "kind": "instance", + "status": "SUCCESS", + "duration": 1.0, + "metadata": { + "command": "echo hi", + "image": BASE_IMG, + "shell": True, + "result": { + "state": {"exit_code": 0}, + "stdout": None, + "stderr": None, + }, + }, + "result": {"image": image, "tag": ""}, + } + ), ) -def make_spawn(op_uuid: str = "op-1") -> FakeResponse: - return FakeResponse.json({"uuid": op_uuid, "status": "PENDING"}, status=201) +def make_spawn(op_uuid: str = "op-1") -> MockSpec: + return ("spawn_instance", InstanceSpawnResponse.from_dict({"uuid": op_uuid})) -def make_tag_lookup(image_uuid: str) -> FakeResponse: - return FakeResponse.json({"images": [{"uuid": image_uuid, "tag": "ubuntu:latest"}]}) +def make_tag_lookup(image_uuid: str) -> MockSpec: + return ("inspect_find_image_by_tag", image_uuid) def run_build( tc: ContreeTestClient, args: BuildArgs, - responses: list[FakeResponse], + mocks: list[MockSpec], db_path: Path, ): - tc.fake.responses.extend(responses) - profile = ConfigProfile(name="test", url="http://x", token="t") + for name, value in mocks: + if isinstance(value, BaseException): + tc.mock(name, error=value) + else: + tc.mock(name, value) + # The RUN streamer always opens the SSE event stream before falling + # back to the terminal GET; serve it empty unless the test cares. + if all(name != "iter_operation_events" for name, _ in mocks): + tc.mock("iter_operation_events", []) + profile = Profile(name="test", url="http://x", token="t") PROFILE.set(profile) - monkey_profile_path(profile, db_path) FORMATTER.set(JSONFormatter()) CLIENT.set(tc) - SESSION_STORE.set(SessionStore(db_path, "placeholder")) ctx = copy_context() + previous_store = ctx.get(SESSION_STORE) with ( + patch("contree_cli.cli.build.session_db_path", lambda name: db_path), patch("contree_cli.cli.run.time.sleep"), - patch("contree_cli.docker.kw_from.time.sleep"), + patch("contree_client.base.time.sleep"), ): - return ctx.run(cmd_build, args) - - -def monkey_profile_path(profile: ConfigProfile, db_path: Path): - object.__setattr__(profile, "_session_db_override", db_path) - from contree_cli.config import ConfigProfile as RealProfile - - if not hasattr(RealProfile, "_original_session_db_path"): - RealProfile._original_session_db_path = RealProfile.session_db_path # type: ignore[attr-defined] - - def patched(self): - override = getattr(self, "_session_db_override", None) - if override is not None: - return override - return RealProfile._original_session_db_path.fget(self) # type: ignore[attr-defined] - - RealProfile.session_db_path = property(patched) # type: ignore[assignment,misc] + result = ctx.run(cmd_build, args) + assert ctx.get(SESSION_STORE) is previous_store + return result @pytest.fixture @@ -121,6 +132,32 @@ def test_build_arg_namespace_decodes_to_build_args(self): class TestSimpleBuild: + def test_store_is_closed_when_build_fails(self, context_dir, db_path): + write_dockerfile( + context_dir, + "FROM tag:ubuntu:latest\nRUN false\n", + ) + closed: list[SessionStore] = [] + original_close = SessionStore.close + + def track_close(store: SessionStore) -> None: + original_close(store) + closed.append(store) + + with patch.object(SessionStore, "close", track_close): + rc = run_build( + ContreeTestClient(), + BuildArgs(context=str(context_dir)), + [ + make_tag_lookup(BASE_IMG), + ("spawn_instance", RuntimeError("spawn failed")), + ], + db_path, + ) + + assert rc == 1 + assert len(closed) == 1 + def test_from_run_creates_expected_api_calls(self, context_dir, db_path): write_dockerfile( context_dir, @@ -128,27 +165,27 @@ def test_from_run_creates_expected_api_calls(self, context_dir, db_path): ) tc = ContreeTestClient() args = BuildArgs(context=str(context_dir)) - responses = [ + mocks = [ make_tag_lookup(BASE_IMG), make_spawn(), make_op_success(NEW_IMG), ] - rc = run_build(tc, args, responses, db_path) + rc = run_build(tc, args, mocks, db_path) assert rc is None - # 4 wire calls: FROM's tag lookup, POST instances, SSE events - # follow, then the streamer's terminal GET. - assert tc.request_count == 4 - assert tc.get_request(0).method == "GET" - assert "/v1/images" in tc.get_request(0).path - assert tc.get_request(1).method == "POST" - assert "/v1/instances" in tc.get_request(1).path - assert tc.get_request(2).method == "GET" - assert "/v1/operations/op-1/events?follow=1" in tc.get_request(2).path - assert tc.get_request(3).method == "GET" - assert ( - tc.get_request(3).path[-len("/v1/operations/op-1") :] - == "/v1/operations/op-1" - ) + # FROM's tag lookup, spawn, SSE events follow, the library's + # terminal probe and the streamer's fallback payload fetch. + assert [c.operation for c in tc.calls] == [ + "inspect_find_image_by_tag", + "spawn_instance", + "iter_operation_events", + "get_operation_status", + "get_operation_status", + ] + assert tc.calls_for("inspect_find_image_by_tag")[0].args == ("ubuntu:latest",) + events_call = tc.calls_for("iter_operation_events")[0] + assert events_call.args == ("op-1",) + assert events_call.kwargs["follow"] is True + assert tc.calls_for("get_operation_status")[0].args == ("op-1",) def test_run_payload_carries_command(self, context_dir, db_path): write_dockerfile( @@ -167,11 +204,9 @@ def test_run_payload_carries_command(self, context_dir, db_path): ], db_path, ) - spawn = tc.get_request(1) - body = json.loads(spawn.body.decode()) - assert body["image"] == BASE_IMG - assert body["command"] == "apt-get update" - assert body["shell"] is True + spawn = tc.calls_for("spawn_instance")[0] + assert spawn.args == ("apt-get update", BASE_IMG) + assert spawn.kwargs["shell"] is True def test_run_streams_stdout_live(self, context_dir, db_path, capsys): """Docker-compat mode: RUN output must reach the user's terminal @@ -179,10 +214,12 @@ def test_run_streams_stdout_live(self, context_dir, db_path, capsys): Uses a stubbed streamer that writes a chunk to `sys.stdout.buffer` and returns a completion-populated summary so the build finishes - with the streamed image — mirrors what a live SSE `stdout` frame + with the streamed image; mirrors what a live SSE `stdout` frame followed by a `completion` frame would produce.""" import sys + from contree_client.models import OperationEvent + from contree_cli.cli.run import TerminalSummary write_dockerfile( @@ -195,16 +232,34 @@ def fake_stream(_client, op_uuid, _formatter): sys.stdout.buffer.write(b"hi from RUN\n") sys.stdout.buffer.flush() summary = TerminalSummary() - summary.completion = { - "type": "completion", - "data": {"status": "SUCCESS", "result_image_uuid": NEW_IMG}, - } - summary.exit_event = {"type": "exit", "spid": 1, "data": {"code": 0}} + summary.completion = OperationEvent.from_dict( + { + "id": 2, + "ts": "2026-01-01T00:00:00+00:00", + "type": "completion", + "data": { + "status": "SUCCESS", + "duration_ms": 1000, + "result_image_uuid": NEW_IMG, + "error": None, + "image_size_bytes": 4096, + }, + } + ) + summary.exit_event = OperationEvent.from_dict( + { + "id": 1, + "ts": "2026-01-01T00:00:00+00:00", + "type": "exit", + "spid": 1, + "data": {"code": 0, "timed_out": False}, + } + ) summary.stdout.extend(b"hi from RUN\n") return summary with patch( - "contree_cli.docker.kw_run._stream_events_until_close", + "contree_cli.docker.kw_run.stream_events_until_close", side_effect=fake_stream, ): rc = run_build( @@ -215,7 +270,7 @@ def fake_stream(_client, op_uuid, _formatter): ) assert rc is None # The live-streamed chunk lands on stdout before the final - # formatter record — verify both are present. + # formatter record; verify both are present. out = capsys.readouterr().out assert "hi from RUN" in out @@ -247,8 +302,7 @@ def test_second_build_is_full_cache_hit(self, context_dir, db_path): [make_tag_lookup(BASE_IMG)], db_path, ) - assert second.request_count == 1 - assert "/v1/images" in second.get_request(0).path + assert [c.operation for c in second.calls] == ["inspect_find_image_by_tag"] def test_no_cache_reruns(self, context_dir, db_path): write_dockerfile( @@ -280,9 +334,15 @@ def test_no_cache_reruns(self, context_dir, db_path): db_path, ) assert rc is None - # 4 wire calls per build: FROM tag lookup, POST instances, SSE - # follow, and the streamer's terminal GET. - assert second.request_count == 4 + # FROM tag lookup, spawn, SSE follow, the library's terminal + # probe and the streamer's fallback payload fetch. + assert [c.operation for c in second.calls] == [ + "inspect_find_image_by_tag", + "spawn_instance", + "iter_operation_events", + "get_operation_status", + "get_operation_status", + ] def test_no_cache_when_from_layer_is_active_branch(self, context_dir, db_path): """Regression: --no-cache must not blow up when the target layer @@ -347,25 +407,24 @@ def test_copy_pending_attaches_to_next_run(self, context_dir, db_path): "FROM tag:ubuntu:latest\nCOPY app.py /app.py\nRUN python /app.py\n", ) tc = ContreeTestClient() - responses = [ + mocks = [ make_tag_lookup(BASE_IMG), - FakeResponse.json({}, status=404), - FakeResponse.json({"uuid": "file-1", "sha256": "abc"}), + ("get_file", NotFoundError(404, "not found")), + ("upload_file", FileResponse(uuid="file-1", sha256="abc", size=11)), make_spawn(), make_op_success(NEW_IMG), ] rc = run_build( tc, BuildArgs(context=str(context_dir)), - responses, + mocks, db_path, ) assert rc is None - spawn = tc.get_request(3) - body = json.loads(spawn.body.decode()) - assert "files" in body - assert "/app.py" in body["files"] - assert body["files"]["/app.py"]["uuid"] == "file-1" + spawn = tc.calls_for("spawn_instance")[0] + files = spawn.kwargs["files"] + assert "/app.py" in files + assert files["/app.py"].uuid == "file-1" class TestUnsupportedDirective: @@ -406,8 +465,8 @@ def test_build_arg_substitutes_in_run(self, context_dir, db_path): ], db_path, ) - spawn_body = json.loads(tc.get_request(1).body.decode()) - assert spawn_body["command"] == "echo 2.5" + spawn = tc.calls_for("spawn_instance")[0] + assert spawn.args[0] == "echo 2.5" def test_arg_default_flows_into_env(self, context_dir, db_path): write_dockerfile( @@ -428,9 +487,9 @@ def test_arg_default_flows_into_env(self, context_dir, db_path): ], db_path, ) - spawn_body = json.loads(tc.get_request(1).body.decode()) - assert spawn_body["command"] == "echo /opt/streamforge" - assert spawn_body["env"] == {"APP_HOME": "/opt/streamforge"} + spawn = tc.calls_for("spawn_instance")[0] + assert spawn.args[0] == "echo /opt/streamforge" + assert spawn.kwargs["env"] == {"APP_HOME": "/opt/streamforge"} def test_arg_default_referencing_earlier_arg(self, context_dir, db_path): write_dockerfile( @@ -452,9 +511,9 @@ def test_arg_default_referencing_earlier_arg(self, context_dir, db_path): ], db_path, ) - spawn_body = json.loads(tc.get_request(1).body.decode()) - assert spawn_body["command"] == "echo /opt/streamforge" - assert spawn_body["env"] == {"APP_HOME": "/opt/streamforge"} + spawn = tc.calls_for("spawn_instance")[0] + assert spawn.args[0] == "echo /opt/streamforge" + assert spawn.kwargs["env"] == {"APP_HOME": "/opt/streamforge"} class TestSessionKey: @@ -481,15 +540,346 @@ def test_final_image_tagged(self, context_dir, db_path): make_tag_lookup(BASE_IMG), make_spawn(), make_op_success(NEW_IMG), - FakeResponse.json({}), + ( + "update_image_tag", + Image.from_dict({"uuid": NEW_IMG, "tag": "mybuild:test"}), + ), ], db_path, ) assert rc is None - # PATCH lands after: tag lookup (0), spawn (1), SSE events (2), - # streamer's terminal GET (3). - tag_req = tc.get_request(4) - assert tag_req.method == "PATCH" - assert NEW_IMG in tag_req.path - body = json.loads(tag_req.body.decode()) - assert body == {"tag": "mybuild:test"} + # The tag update lands after: tag lookup, spawn, SSE events, + # and the streamer's terminal GET. + assert tc.calls[-1].operation == "update_image_tag" + tag_call = tc.calls_for("update_image_tag")[0] + assert tag_call.args == (NEW_IMG, "mybuild:test") + + +# ── Multistage builds ──────────────────────────────────────────────── + + +STAGE_IMG = "44444444-4444-4444-4444-444444444444" +FINAL_IMG = "55555555-5555-5555-5555-555555555555" + + +def tar_bytes(entries: dict[str, bytes], dirs: tuple[str, ...] = ()) -> bytes: + """Build an in-memory tar the way the archive endpoint serves it: + members rooted at the archived basename, no leading "./".""" + import io + import tarfile + + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as tar: + for name in dirs: + info = tarfile.TarInfo(name) + info.type = tarfile.DIRTYPE + info.mode = 0o755 + tar.addfile(info) + for name, content in entries.items(): + info = tarfile.TarInfo(name) + info.size = len(content) + info.mode = 0o644 + tar.addfile(info, io.BytesIO(content)) + return buffer.getvalue() + + +def make_archive(entries: dict[str, bytes], dirs: tuple[str, ...] = ()) -> MockSpec: + return ("inspect_image_archive", [tar_bytes(entries, dirs)]) + + +def make_ensure_file(uuid: str = "tar-1", sha: str = "sha-1") -> MockSpec: + return ("ensure_file", FileResponse(uuid=uuid, sha256=sha, size=1024)) + + +def two_stage_dockerfile(copy_line: str) -> str: + return ( + "FROM tag:ubuntu:latest AS build\n" + "RUN make\n" + "FROM tag:ubuntu:latest\n" + f"{copy_line}\n" + "RUN app\n" + ) + + +def extraction_spawn(tc: ContreeTestClient, index: int = 1): + """The extraction RUN sits between the stage RUN and the final RUN.""" + return tc.calls_for("spawn_instance")[index] + + +class TestMultistage: + def build_two_stage(self, tc, context_dir, db_path, copy_line, extra_mocks=()): + write_dockerfile(context_dir, two_stage_dockerfile(copy_line)) + mocks = [ + make_tag_lookup(BASE_IMG), # FROM ... AS build + make_spawn("op-1"), # RUN make + make_op_success(STAGE_IMG, "op-1"), + make_op_success(STAGE_IMG, "op-1"), + make_tag_lookup(BASE_IMG), # FROM (final stage) + *extra_mocks, + make_spawn("op-2"), # extraction RUN + make_op_success(NEW_IMG, "op-2"), + make_op_success(NEW_IMG, "op-2"), + make_spawn("op-3"), # RUN app + make_op_success(FINAL_IMG, "op-3"), + make_op_success(FINAL_IMG, "op-3"), + ] + return run_build(tc, BuildArgs(context=str(context_dir)), mocks, db_path) + + def test_copy_from_alias_single_file(self, context_dir, db_path): + tc = ContreeTestClient() + rc = self.build_two_stage( + tc, + context_dir, + db_path, + "COPY --from=build /out/app /usr/local/bin/app", + extra_mocks=[ + make_archive({"app": b"binary"}), + make_ensure_file(), + ], + ) + assert rc is None + + # The stage alias resolves locally: no image lookup beyond the + # two FROM directives. + assert len(tc.calls_for("inspect_find_image_by_tag")) == 2 + archive_call = tc.calls_for("inspect_image_archive")[0] + assert archive_call.args == (STAGE_IMG, "/out/app") + + spawn = extraction_spawn(tc) + files = spawn.kwargs["files"] + assert files["/.contree-build/copy-0.tar"].uuid == "tar-1" + command = spawn.args[0] + assert "tar -xf /.contree-build/copy-0.tar" in command + assert "mv /.contree-build/extract-0/app /usr/local/bin/app" in command + assert "rm -rf /.contree-build" in command + + def test_copy_from_numeric_index(self, context_dir, db_path): + tc = ContreeTestClient() + rc = self.build_two_stage( + tc, + context_dir, + db_path, + "COPY --from=0 /out/app /app", + extra_mocks=[ + make_archive({"app": b"binary"}), + make_ensure_file(), + ], + ) + assert rc is None + assert tc.calls_for("inspect_image_archive")[0].args == (STAGE_IMG, "/out/app") + + def test_copy_from_external_image(self, context_dir, db_path): + tc = ContreeTestClient() + tc.mock("resolve_image", STAGE_IMG) + rc = self.build_two_stage( + tc, + context_dir, + db_path, + "COPY --from=someimage:latest /bin/tool /bin/tool", + extra_mocks=[ + make_archive({"tool": b"binary"}), + make_ensure_file(), + ], + ) + assert rc is None + # resolve_image also serves the FROM lookups once mocked; the + # external --from reference must be among the resolved refs. + refs = [call.args[0] for call in tc.calls_for("resolve_image")] + assert "someimage:latest" in refs + + def test_copy_from_directory_source(self, context_dir, db_path): + tc = ContreeTestClient() + rc = self.build_two_stage( + tc, + context_dir, + db_path, + "COPY --from=build /out /srv/out", + extra_mocks=[ + make_archive( + {"out/a.txt": b"a", "out/sub/b.txt": b"b"}, + dirs=("out", "out/sub"), + ), + make_ensure_file(), + ], + ) + assert rc is None + command = extraction_spawn(tc).args[0] + # Directory sources copy their CONTENTS into dest. + assert "mkdir -p /srv/out" in command + assert "cp -a /.contree-build/extract-0/out/. /srv/out/" in command + + def test_copy_from_file_into_dir_dest(self, context_dir, db_path): + tc = ContreeTestClient() + rc = self.build_two_stage( + tc, + context_dir, + db_path, + "COPY --from=build /out/app /usr/local/bin/", + extra_mocks=[ + make_archive({"app": b"binary"}), + make_ensure_file(), + ], + ) + assert rc is None + command = extraction_spawn(tc).args[0] + assert "mv /.contree-build/extract-0/app /usr/local/bin/app" in command + + def test_copy_from_chown_chmod(self, context_dir, db_path): + tc = ContreeTestClient() + rc = self.build_two_stage( + tc, + context_dir, + db_path, + "COPY --from=build --chown=10:20 --chmod=0755 /out/app /app", + extra_mocks=[ + make_archive({"app": b"binary"}), + make_ensure_file(), + ], + ) + assert rc is None + command = extraction_spawn(tc).args[0] + assert "chown -R 10:20 /.contree-build/extract-0/app" in command + assert "chmod 755 /.contree-build/extract-0/app" in command + + def test_extraction_not_wrapped_with_user(self, context_dir, db_path): + """COPY --from extracts as root even under an active USER.""" + write_dockerfile( + context_dir, + "FROM tag:ubuntu:latest AS build\n" + "RUN make\n" + "FROM tag:ubuntu:latest\n" + "USER app\n" + "COPY --from=build /out/app /app\n" + "RUN app\n", + ) + tc = ContreeTestClient() + mocks = [ + make_tag_lookup(BASE_IMG), + make_spawn("op-1"), + make_op_success(STAGE_IMG, "op-1"), + make_op_success(STAGE_IMG, "op-1"), + make_tag_lookup(BASE_IMG), + make_archive({"app": b"binary"}), + make_ensure_file(), + make_spawn("op-2"), + make_op_success(NEW_IMG, "op-2"), + make_op_success(NEW_IMG, "op-2"), + make_spawn("op-3"), + make_op_success(FINAL_IMG, "op-3"), + make_op_success(FINAL_IMG, "op-3"), + ] + rc = run_build(tc, BuildArgs(context=str(context_dir)), mocks, db_path) + assert rc is None + extraction = extraction_spawn(tc).args[0] + assert "su -s" not in extraction + # The user RUN after it is still wrapped. + final = tc.calls_for("spawn_instance")[2].args[0] + assert "su -s" in final + + def test_stage_state_reset_on_from(self, context_dir, db_path): + """USER, ENV, and WORKDIR do not leak into the next stage.""" + write_dockerfile( + context_dir, + "FROM tag:ubuntu:latest AS build\n" + "USER app\n" + "ENV FOO=bar\n" + "WORKDIR /src\n" + "RUN make\n" + "FROM tag:ubuntu:latest\n" + "RUN id\n", + ) + tc = ContreeTestClient() + mocks = [ + make_tag_lookup(BASE_IMG), + make_spawn("op-1"), + make_op_success(STAGE_IMG, "op-1"), + make_op_success(STAGE_IMG, "op-1"), + make_tag_lookup(BASE_IMG), + make_spawn("op-2"), + make_op_success(NEW_IMG, "op-2"), + make_op_success(NEW_IMG, "op-2"), + ] + rc = run_build(tc, BuildArgs(context=str(context_dir)), mocks, db_path) + assert rc is None + first, second = tc.calls_for("spawn_instance") + assert "su -s" in first.args[0] + assert first.kwargs["cwd"] == "/src" + assert first.kwargs["env"] == {"FOO": "bar"} + assert "su -s" not in second.args[0] + assert second.kwargs["cwd"] is ... + assert second.kwargs["env"] is ... + + def test_stage_with_pending_files_sealed_via_closer(self, context_dir, db_path): + """A stage ending with a local COPY is committed by the closer + RUN before the next FROM starts.""" + (context_dir / "a.txt").write_text("a") + write_dockerfile( + context_dir, + "FROM tag:ubuntu:latest AS build\n" + "COPY a.txt /a.txt\n" + "FROM tag:ubuntu:latest\n" + "COPY --from=build /a.txt /b.txt\n", + ) + tc = ContreeTestClient() + mocks = [ + make_tag_lookup(BASE_IMG), + ("get_file", NotFoundError(404, "missing")), + ("upload_file", FileResponse(uuid="file-1", sha256="s", size=1)), + make_spawn("op-1"), # closer RUN sealing stage `build` + make_op_success(STAGE_IMG, "op-1"), + make_op_success(STAGE_IMG, "op-1"), + make_tag_lookup(BASE_IMG), + make_archive({"a.txt": b"a"}), + make_ensure_file(), + make_spawn("op-2"), # extraction RUN + make_op_success(NEW_IMG, "op-2"), + make_op_success(NEW_IMG, "op-2"), + ] + rc = run_build(tc, BuildArgs(context=str(context_dir)), mocks, db_path) + assert rc is None + closer = tc.calls_for("spawn_instance")[0] + assert closer.args[0] == ":" + assert "/a.txt" in closer.kwargs["files"] + # The archive is exported from the sealed stage image. + assert tc.calls_for("inspect_image_archive")[0].args == (STAGE_IMG, "/a.txt") + + def test_unknown_stage_fails(self, context_dir, db_path): + write_dockerfile( + context_dir, + "FROM tag:ubuntu:latest\nCOPY --from=nosuch /x /x\n", + ) + tc = ContreeTestClient() + tc.mock("resolve_image", error=NotFoundError(404, "no such image")) + mocks = [make_tag_lookup(BASE_IMG)] + rc = run_build(tc, BuildArgs(context=str(context_dir)), mocks, db_path) + assert rc == 1 + + def test_missing_path_in_stage_fails(self, context_dir, db_path): + write_dockerfile( + context_dir, + "FROM tag:ubuntu:latest AS build\n" + "RUN make\n" + "FROM tag:ubuntu:latest\n" + "COPY --from=build /nope /x\n", + ) + tc = ContreeTestClient() + mocks = [ + make_tag_lookup(BASE_IMG), + make_spawn("op-1"), + make_op_success(STAGE_IMG, "op-1"), + make_op_success(STAGE_IMG, "op-1"), + make_tag_lookup(BASE_IMG), + ("inspect_image_archive", NotFoundError(404, "path not found")), + ] + rc = run_build(tc, BuildArgs(context=str(context_dir)), mocks, db_path) + assert rc == 1 + + def test_add_from_fails(self, context_dir, db_path): + write_dockerfile( + context_dir, + "FROM tag:ubuntu:latest\nADD --from=build /x /x\n", + ) + tc = ContreeTestClient() + mocks = [make_tag_lookup(BASE_IMG)] + rc = run_build(tc, BuildArgs(context=str(context_dir)), mocks, db_path) + assert rc == 1 diff --git a/tests/test_cat.py b/tests/test_cat.py index 3e65bb2..0c55713 100644 --- a/tests/test_cat.py +++ b/tests/test_cat.py @@ -4,7 +4,7 @@ from contextvars import copy_context from unittest.mock import patch -from conftest import ContreeTestClient, FakeResponse +from conftest import ContreeTestClient from contree_cli import FORMATTER, SESSION_STORE from contree_cli.cli.cat import CatArgs, cmd_cat @@ -25,8 +25,8 @@ def _run_cmd( ): """Run cmd_cat with mocked responses and stdout.""" if images_response is not None: - tc.respond_json(images_response) - tc.fake.responses.append(FakeResponse(body=data)) + tc.mock("inspect_find_image_by_tag", images_response["images"][0]["uuid"]) + tc.mock("inspect_image_download", data) FORMATTER.set(formatter or DefaultFormatter()) store.set_image(image, kind="test") @@ -44,12 +44,14 @@ def _run_cmd( class TestCmdCatTTY: - def test_request_path(self, contree_client, session_store): + def test_request_args(self, contree_client, session_store): _run_cmd(contree_client, b"hello", store=session_store, path="/etc/hosts") - paths = contree_client.request_paths - assert len(paths) == 1 - assert "/v1/inspect/a1b2c3d4-5678-9abc-def0-111111111111/download" in paths[0] - assert "path=%2Fetc%2Fhosts" in paths[0] + calls = contree_client.calls_for("inspect_image_download") + assert len(calls) == 1 + assert calls[0].args == ( + "a1b2c3d4-5678-9abc-def0-111111111111", + "/etc/hosts", + ) def test_outputs_content(self, contree_client, session_store): buf, result = _run_cmd( @@ -67,10 +69,12 @@ def test_tag_resolution(self, contree_client, session_store): image="tag:latest", images_response=images_resp, ) - paths = contree_client.request_paths - assert len(paths) == 2 - assert "tag=latest" in paths[0] - assert "/v1/inspect/resolved-uuid/download" in paths[1] + resolve_calls = contree_client.calls_for("inspect_find_image_by_tag") + assert len(resolve_calls) == 1 + assert resolve_calls[0].args == ("latest",) + download_calls = contree_client.calls_for("inspect_image_download") + assert len(download_calls) == 1 + assert download_calls[0].args[0] == "resolved-uuid" def test_binary_rejected_on_tty(self, contree_client, session_store): buf, result = _run_cmd(contree_client, b"\x80\x81\x82\xff", store=session_store) @@ -135,19 +139,19 @@ class TestCmdCatCaching: def test_tty_caches_result(self, contree_client, session_store): """Second TTY call should not hit the API.""" _run_cmd(contree_client, b"hello", store=session_store) - assert contree_client.request_count == 1 + assert len(contree_client.calls) == 1 buf2, _ = _run_cmd(contree_client, b"hello", store=session_store) - assert contree_client.request_count == 1 # no new request (cached) + assert len(contree_client.calls) == 1 # no new request (cached) assert buf2.getvalue() == b"hello" def test_pipe_caches_result(self, contree_client, session_store): """Second piped call should not hit the API.""" _run_cmd(contree_client, b"data", store=session_store, isatty=False) - assert contree_client.request_count == 1 + assert len(contree_client.calls) == 1 buf2, _ = _run_cmd(contree_client, b"data", store=session_store, isatty=False) - assert contree_client.request_count == 1 # no new request (cached) + assert len(contree_client.calls) == 1 # no new request (cached) assert buf2.getvalue() == b"data" def test_cache_hit_returns_correct_data(self, contree_client, session_store): diff --git a/tests/test_cd.py b/tests/test_cd.py index 1c2ee66..bceb6f3 100644 --- a/tests/test_cd.py +++ b/tests/test_cd.py @@ -2,7 +2,9 @@ from contextvars import copy_context -from conftest import ContreeTestClient +from conftest import ContreeTestClient, make_file_item +from contree_client.exceptions import NotFoundError +from contree_client.models import DirectoryList from contree_cli import SESSION_STORE from contree_cli.cli.cd import CdArgs, cmd_cd @@ -10,7 +12,7 @@ IMG_UUID = "a1b2c3d4-5678-9abc-def0-111111111111" -LISTING = [{"name": ".", "type": "dir"}] +LISTING = {"path": "/", "files": [make_file_item("/etc", is_dir=True)]} def _run_cmd( @@ -20,7 +22,7 @@ def _run_cmd( ) -> int | None: SESSION_STORE.set(store) if tc is not None: - tc.respond_json(LISTING) + tc.mock("inspect_image_list", DirectoryList.from_dict(LISTING)) ctx = copy_context() args = CdArgs(path=path) return ctx.run(cmd_cd, args) @@ -83,17 +85,19 @@ def test_dot(self, session_store, contree_client): class TestCdValidation: + def test_validates_against_image(self, session_store, contree_client): + """cd sends an inspect list request for the target directory.""" + session_store.set_image(IMG_UUID, kind="use") + _run_cmd(session_store, "/usr/local", contree_client) + calls = contree_client.calls_for("inspect_image_list") + assert len(calls) == 1 + assert calls[0].args == (IMG_UUID, "/usr/local") + def test_nonexistent_path_fails(self, session_store, contree_client): session_store.set_image(IMG_UUID, kind="use") - contree_client.respond(status=404, body=b"not found") + contree_client.mock("inspect_image_list", error=NotFoundError(404, "not found")) SESSION_STORE.set(session_store) ctx = copy_context() rc = ctx.run(cmd_cd, CdArgs(path="/nonexistent")) assert rc == 1 assert session_store.get_cwd() != "/nonexistent" - - def test_no_image_skips_validation(self, session_store, contree_client): - """cd with an image but no CLIENT should still set cwd.""" - session_store.set_image(IMG_UUID, kind="use") - _run_cmd(session_store, "/app", contree_client) - assert session_store.get_cwd() == "/app" diff --git a/tests/test_client.py b/tests/test_client.py index 47aff44..a434e73 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,656 +1,106 @@ +"""Tests for the CLI-side glue in ``contree_cli.client``. + +Everything client-shaped (transport, headers, retries, typed methods, +models) is covered by the contree-client test suite; here the client +appears only as the ``contree_client.testing`` double. This file +covers what the CLI adds on top: profile-to-client construction and version reporting. +""" + from __future__ import annotations -import base64 -import contextlib -import io import json -import logging from importlib.metadata import PackageNotFoundError from unittest.mock import MagicMock, patch import pytest -from conftest import ContreeTestClient, ContreeTestIAMClient, FakeResponse - -from contree_cli.client import ( - CLI_USER_AGENT, - RETRY_DELAYS, - ApiError, - BodyFormatter, - ContreeClient, - ContreeJWTClient, - HeaderFormatter, - PaginatedFetcher, - cli_version, - decode_event_chunk, - iter_sse_events, - resolve_image, -) - -# --------------------------------------------------------------------------- -# URL parsing -# --------------------------------------------------------------------------- - - -class TestUrlParsing: - def test_https_default(self): - c = ContreeJWTClient("https://contree.dev", "tok") - assert c._scheme == "https" - assert c._host == "contree.dev" - assert c._port is None - assert c._prefix == "" - - def test_http_scheme(self): - c = ContreeJWTClient("http://localhost:8080", "tok") - assert c._scheme == "http" - assert c._host == "localhost" - assert c._port == 8080 - - def test_path_prefix_stripped(self): - c = ContreeJWTClient("https://contree.dev/api/", "tok") - assert c._prefix == "/api" - - def test_bare_host_defaults_https(self): - c = ContreeJWTClient("https://example.com", "tok") - assert c._scheme == "https" - - -# --------------------------------------------------------------------------- -# Connection type -# --------------------------------------------------------------------------- - - -class TestConnect: - def test_https_creates_https_connection(self): - c = ContreeJWTClient("https://contree.dev", "tok") - conn = c._connect() - import http.client - - assert isinstance(conn, http.client.HTTPSConnection) - - def test_http_creates_http_connection(self): - c = ContreeJWTClient("http://localhost", "tok") - conn = c._connect() - import http.client - - assert isinstance(conn, http.client.HTTPConnection) - - -# --------------------------------------------------------------------------- -# ApiError -# --------------------------------------------------------------------------- - - -class TestApiError: - def test_str(self): - e = ApiError(404, "Not Found", '{"error":"gone"}') - assert str(e) == 'API 404 Not Found: {"error":"gone"}' - - def test_attributes(self): - e = ApiError(500, "Internal Server Error", "oops") - assert e.status == 500 - assert e.reason == "Internal Server Error" - assert e.body == "oops" - - -# --------------------------------------------------------------------------- -# request() -# --------------------------------------------------------------------------- - - -class TestRequest: - def test_sets_user_agent(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=200, body=b"{}") - c.request("GET", "/v1/images") - headers = c.get_request(-1).headers - assert headers["User-Agent"] == CLI_USER_AGENT - - def test_prepends_prefix(self): - c = ContreeTestClient("https://contree.dev/api", "tok") - c.respond(status=200, body=b"{}") - c.request("GET", "/v1/images") - path = c.get_request(-1).path - assert path == "/api/v1/images" - - def test_raises_on_non_2xx(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=403, body=b"nope") - with pytest.raises(ApiError) as exc_info: - c.request("GET", "/v1/images") - assert exc_info.value.status == 403 - assert exc_info.value.body == "nope" - - def test_returns_response_on_success(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=200, body=b'{"ok":true}') - result = c.request("GET", "/v1/images") - assert isinstance(result, FakeResponse) - assert result.body == b'{"ok":true}' - - -# --------------------------------------------------------------------------- -# Retry on 5xx -# --------------------------------------------------------------------------- - - -class TestRetry: - def test_retries_on_5xx_then_succeeds(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=502, body=b"down") - c.respond(status=200, body=b'{"ok":true}') - - with patch("contree_cli.client.time.sleep") as mock_sleep: - result = c.request("GET", "/v1/images") - - assert isinstance(result, FakeResponse) - assert result.body == b'{"ok":true}' - mock_sleep.assert_called_once_with(RETRY_DELAYS[0]) - - def test_retries_until_success(self): - """`client.request` retries transient failures without a - budget — only success or a non-retryable status ends the - loop. Verified here by queuing many 500s followed by a 200.""" - c = ContreeTestClient("https://contree.dev", "tok") - failures = 20 - for _ in range(failures): - c.respond(status=500, body=b"err") - c.respond(status=200, body=b'{"ok":true}') - - with patch("contree_cli.client.time.sleep") as mock_sleep: - result = c.request("GET", "/v1/images") - - assert result.body == b'{"ok":true}' - assert mock_sleep.call_count == failures - # First failures walk the ladder, the rest reuse the tail delay. - delays = [call.args[0] for call in mock_sleep.call_args_list] - assert delays[: len(RETRY_DELAYS)] == list(RETRY_DELAYS) - assert all(d == RETRY_DELAYS[-1] for d in delays[len(RETRY_DELAYS) :]) - - def test_no_retry_on_4xx(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=404, body=b"nope") - - with ( - patch("contree_cli.client.time.sleep") as mock_sleep, - pytest.raises(ApiError) as exc_info, - ): - c.request("GET", "/v1/images") - - assert exc_info.value.status == 404 - mock_sleep.assert_not_called() - - def test_retry_recovers_midway(self): - c = ContreeTestClient("https://contree.dev", "tok") - for _ in range(3): - c.respond(status=503, body=b"down") - c.respond(status=200, body=b'{"ok":true}') - - with patch("contree_cli.client.time.sleep") as mock_sleep: - result = c.request("GET", "/v1/images") - - assert isinstance(result, FakeResponse) - assert result.body == b'{"ok":true}' - assert mock_sleep.call_count == 3 - delays = [call.args[0] for call in mock_sleep.call_args_list] - assert delays == list(RETRY_DELAYS[:3]) - - def test_retry_on_network_error_then_succeeds(self): - """A transient gaierror is retried like a 5xx response.""" - import socket - - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=200, body=b'{"ok":true}') - - call_count = {"n": 0} - real_connect = c._connect - - def flaky_connect(): - call_count["n"] += 1 - if call_count["n"] < 3: - raise socket.gaierror(8, "nodename nor servname provided") - return real_connect() - - c._connect = flaky_connect # type: ignore[method-assign] - - with patch("contree_cli.client.time.sleep") as mock_sleep: - result = c.request("GET", "/v1/images") - - assert result.body == b'{"ok":true}' - assert call_count["n"] == 3 - assert mock_sleep.call_count == 2 - - def test_first_attempt_410_uses_short_delay(self): - """410 on the very first attempt sleeps `RETRY_DELAYS[0]`, not - the wraparound `RETRY_DELAYS[-1]` from the old `attempt-1` index.""" - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=410, body=b"gone") - c.respond(status=200, body=b'{"ok":true}') - - with patch("contree_cli.client.time.sleep") as mock_sleep: - c.request("GET", "/v1/images") - delays = [call.args[0] for call in mock_sleep.call_args_list] - assert delays[0] == RETRY_DELAYS[0] - - def test_first_attempt_425_uses_short_delay(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=425, body=b"too early") - c.respond(status=200, body=b'{"ok":true}') - - with patch("contree_cli.client.time.sleep") as mock_sleep: - c.request("GET", "/v1/images") - delays = [call.args[0] for call in mock_sleep.call_args_list] - assert delays[0] == RETRY_DELAYS[0] - - def test_410_sleeps_once_per_failure(self): - """Regression: 410/425 used to `time.sleep` inside the response - branch AND at the top of the next iteration — doubling the - actual backoff. With the retry-generator refactor each failure - pulls exactly one delay from the ladder.""" - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=410, body=b"gone") - c.respond(status=200, body=b'{"ok":true}') - - with patch("contree_cli.client.time.sleep") as mock_sleep: - c.request("GET", "/v1/images") - assert mock_sleep.call_count == 1 - assert mock_sleep.call_args_list[0].args[0] == RETRY_DELAYS[0] - - def test_invalid_url_is_not_retried(self): - """InvalidURL is a permanent caller-side error — should raise immediately.""" - import http.client - - c = ContreeTestClient("https://contree.dev", "tok") - - call_count = {"n": 0} - - def fail_with_invalid_url(): - call_count["n"] += 1 - raise http.client.InvalidURL("control characters in URL") - - c._connect = fail_with_invalid_url # type: ignore[method-assign] - - with ( - patch("contree_cli.client.time.sleep") as mock_sleep, - pytest.raises(http.client.InvalidURL), - ): - c.request("GET", "/v1/images") - assert call_count["n"] == 1 - mock_sleep.assert_not_called() - - -# --------------------------------------------------------------------------- -# Convenience methods -# --------------------------------------------------------------------------- - - -class TestGet: - def test_without_params(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=200, body=b"{}") - c.get("/v1/images") - path = c.get_request(-1).path - assert path == "/v1/images" - - def test_with_params(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=200, body=b"{}") - c.get("/v1/images", params={"prefix": "ubuntu"}) - path = c.get_request(-1).path - assert path == "/v1/images?prefix=ubuntu" - - -class TestPostJson: - def test_sends_json_body(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=201, body=b"{}") - c.post_json("/v1/instances", {"image": "ubuntu"}) - req = c.get_request(-1) - assert req.method == "POST" - body = json.loads(req.body) - assert body == {"image": "ubuntu"} - assert req.headers["Content-Type"] == "application/json" - - -class TestPatchJson: - def test_sends_patch(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=200, body=b"{}") - c.patch_json("/v1/images/abc/tag", {"tag": "latest"}) - req = c.get_request(-1) - assert req.method == "PATCH" - body = json.loads(req.body) - assert body == {"tag": "latest"} - - -class TestDelete: - def test_sends_delete(self): - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=200, body=b"{}") - c.delete("/v1/operations/abc") - req = c.get_request(-1) - assert req.method == "DELETE" - assert req.path == "/v1/operations/abc" - - -# --------------------------------------------------------------------------- -# resolve_image() -# --------------------------------------------------------------------------- - - -class TestResolveImage: - def test_uuid_passthrough(self): - c = ContreeTestClient("https://contree.dev", "tok") - uuid = "a1b2c3d4-5678-9abc-def0-111111111111" - assert resolve_image(c, uuid) == uuid - - def test_tag_resolution(self): - c = ContreeTestClient("https://contree.dev", "tok") - body = json.dumps({"images": [{"uuid": "resolved-uuid", "tag": "latest"}]}) - c.respond(status=200, body=body.encode()) - result = resolve_image(c, "tag:latest") - assert result == "resolved-uuid" - - def test_tag_not_found(self): - c = ContreeTestClient("https://contree.dev", "tok") - body = json.dumps({"images": []}) - c.respond(status=200, body=body.encode()) - with pytest.raises(ApiError) as exc_info: - resolve_image(c, "tag:nonexistent") - assert exc_info.value.status == 404 - - def test_tag_queries_images_endpoint(self): - c = ContreeTestClient("https://contree.dev", "tok") - body = json.dumps({"images": [{"uuid": "u1", "tag": "mytag"}]}) - c.respond(status=200, body=body.encode()) - resolve_image(c, "tag:mytag") - path = c.get_request(-1).path - assert "/v1/images" in path - assert "tag=mytag" in path - - def test_bare_tag_resolves(self): - """Non-UUID bare ref is resolved as a tag name.""" - c = ContreeTestClient("https://contree.dev", "tok") - body = json.dumps({"images": [{"uuid": "u2", "tag": "common/py"}]}) - c.respond(status=200, body=body.encode()) - result = resolve_image(c, "common/py") - assert result == "u2" - - def test_bare_tag_not_found(self): - c = ContreeTestClient("https://contree.dev", "tok") - body = json.dumps({"images": []}) - c.respond(status=200, body=body.encode()) - with pytest.raises(ApiError) as exc_info: - resolve_image(c, "no-such-tag") - assert exc_info.value.status == 404 - - -# --------------------------------------------------------------------------- -# Abstract base class -# --------------------------------------------------------------------------- - - -class ContreeTestClientABC: - def test_cannot_instantiate_directly(self): - with pytest.raises(TypeError): - ContreeClient("https://example.com", "tok") # type: ignore[abstract] - - -# --------------------------------------------------------------------------- -# Streaming body -# --------------------------------------------------------------------------- - - -class TestStreamingBody: - def test_passes_file_object_to_connection(self): - import io - - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=201, body=b'{"uuid":"u1"}') - stream = io.BytesIO(b"a" * 1024) - c.request( - "POST", - "/v1/files", - body=stream, - headers={"Content-Type": "application/octet-stream"}, +from contree_client import testing +from contree_client.profiles import AUTH_TYPE_IAM, Profile +from contree_client.runtime import RetryPolicy +from contree_client.spec_info import DEFAULT_BASE_URL + +from contree_cli.client import cli_version, client_from_profile + +# --------------------------------------------------------------------------- +# client_from_profile +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def test_client_double( + monkeypatch: pytest.MonkeyPatch, +) -> type[testing.ContreeClient]: + """Build clients on the official testing double: it records its + constructor kwargs in ``constructed_with`` for exactly this kind + of factory test.""" + monkeypatch.setattr("contree_cli.client.CliClient", testing.ContreeClient) + return testing.ContreeClient + + +class TestClientFromProfile: + def test_jwt_profile(self, test_client_double): + profile = Profile(name="p", url="https://contree.dev", token="tok") + client = client_from_profile(profile) + assert isinstance(client, testing.ContreeClient) + assert client.base_url == "https://contree.dev" + assert client.token == "tok" + assert client.project is None + + def test_attaches_unbounded_retry_policy(self, test_client_double): + profile = Profile(name="p", url="https://contree.dev", token="tok") + client = client_from_profile(profile) + retry = client.constructed_with["retry"] + assert isinstance(retry, RetryPolicy) + assert retry.max_attempts is None + + def test_retries_unsafe_methods(self, test_client_double): + """POST/PATCH calls (spawn, import, upload) keep their 410/425 + retries: the server signals those before processing the request.""" + profile = Profile(name="p", url="https://contree.dev", token="tok") + retry = client_from_profile(profile).constructed_with["retry"] + assert retry.retry_unsafe is True + + def test_jwt_profile_requires_url(self): + profile = Profile(name="p", url="", token="tok") + with pytest.raises(ValueError, match="No URL configured"): + client_from_profile(profile) + + def test_missing_token_raises(self): + profile = Profile(name="p", url="https://contree.dev", token=None) + with pytest.raises(ValueError, match="No token configured"): + client_from_profile(profile) + + def test_iam_profile_defaults_url(self, test_client_double): + profile = Profile( + name="p", + url="", + token="tok", + auth_type=AUTH_TYPE_IAM, + project="aiproject-x", ) - sent = c.get_request(-1).body - assert sent == b"a" * 1024 - - def test_retry_seeks_back_to_start(self): - import io - - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=503, body=b"down") - c.respond(status=201, body=b'{"uuid":"u1"}') - - seeks: list[int] = [] - - class TrackingStream(io.BytesIO): - def seek(self, pos, whence=0): # type: ignore[override] - seeks.append(pos) - return super().seek(pos, whence) - - def seekable(self) -> bool: # type: ignore[override] - return True - - stream = TrackingStream(b"payload") - - with patch("contree_cli.client.time.sleep"): - c.request("POST", "/v1/files", body=stream) - - assert seeks == [0] - - def test_retry_unseekable_stream_raises(self): - import io - - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=500, body=b"down") - - class Unseekable(io.BytesIO): - def seekable(self) -> bool: # type: ignore[override] - return False - - with patch("contree_cli.client.time.sleep"), pytest.raises(ApiError) as ei: - c.request("POST", "/v1/files", body=Unseekable(b"x")) - - assert ei.value.reason == "RetryNotSeekable" + client = client_from_profile(profile) + assert client.base_url == DEFAULT_BASE_URL.rstrip("/") + assert client.project == "aiproject-x" + + def test_iam_profile_requires_project(self): + profile = Profile( + name="p", + url="", + token="tok", + auth_type=AUTH_TYPE_IAM, + project=None, + ) + with pytest.raises(ValueError, match="No project configured"): + client_from_profile(profile) # --------------------------------------------------------------------------- -# IAM client +# cli_version # --------------------------------------------------------------------------- -class TestDebugLogging: - def _enable_debug(self, caplog: pytest.LogCaptureFixture) -> None: - caplog.set_level(logging.DEBUG, logger="contree_cli.client") - - def test_logs_request_body_when_debug(self, caplog): - self._enable_debug(caplog) - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=201, body=b'{"uuid":"x"}') - c.post_json("/v1/instances", {"image": "ubuntu", "command": "uname -a"}) - msgs = "\n".join(r.getMessage() for r in caplog.records) - assert '"image": "ubuntu"' in msgs - assert '"command": "uname -a"' in msgs - - def test_logs_error_response_body_when_debug(self, caplog): - self._enable_debug(caplog) - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=400, body=b'{"error":"bad payload"}') - with pytest.raises(ApiError): - c.post_json("/v1/instances", {"image": "ubuntu"}) - msgs = "\n".join(r.getMessage() for r in caplog.records) - assert '"error":"bad payload"' in msgs - - def test_no_body_logs_when_not_debug(self, caplog): - caplog.set_level(logging.INFO, logger="contree_cli.client") - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=200, body=b'{"a":1}') - c.post_json("/v1/instances", {"x": 1}) - msgs = "\n".join(r.getMessage() for r in caplog.records) - assert "request body" not in msgs - - def test_octet_stream_request_body_not_dumped(self, caplog): - self._enable_debug(caplog) - c = ContreeTestClient("https://contree.dev", "tok") - c.respond(status=201, body=b"{}") - binary = bytes(range(256)) - c.request( - "POST", - "/v1/files", - body=binary, - headers={"Content-Type": "application/octet-stream"}, - ) - msgs = "\n".join(r.getMessage() for r in caplog.records) - assert "" in msgs - - def test_response_headers_logged(self, caplog): - self._enable_debug(caplog) - c = ContreeTestClient("https://contree.dev", "tok") - c.fake.responses.append( - FakeResponse( - status=200, - body=b"{}", - headers={"Content-Type": "application/json", "X-Trace-Id": "abc"}, - ) - ) - c.request("GET", "/v1/images") - msgs = "\n".join(r.getMessage() for r in caplog.records) - assert "X-Trace-Id" in msgs - assert "abc" in msgs - - def test_error_response_headers_logged(self, caplog): - self._enable_debug(caplog) - c = ContreeTestClient("https://contree.dev", "tok") - c.fake.responses.append( - FakeResponse( - status=400, - body=b"bad", - headers={"X-Trace-Id": "trace-err"}, - ) - ) - with pytest.raises(ApiError): - c.request("GET", "/v1/images") - msgs = "\n".join(r.getMessage() for r in caplog.records) - assert "trace-err" in msgs - - -class TestHeaderFormatter: - def test_redacts_authorization(self): - out = str(HeaderFormatter({"Authorization": "Bearer secret", "X-Foo": "bar"})) - assert "secret" not in out - assert "" in out - assert "bar" in out - - def test_redaction_is_case_insensitive(self): - out = str(HeaderFormatter({"AUTHORIZATION": "Bearer secret"})) - assert "secret" not in out - assert "" in out - - def test_accepts_list_of_tuples(self): - out = str( - HeaderFormatter( - [("Authorization", "Bearer secret"), ("X-Trace-Id", "abc")], - ) - ) - assert "secret" not in out - assert "abc" in out - - def test_redacts_cookie(self): - out = str(HeaderFormatter({"Cookie": "session=xyz"})) - assert "xyz" not in out - - def test_non_sensitive_passes_through(self): - out = str(HeaderFormatter({"User-Agent": "ua/1.0", "Project": "proj"})) - assert "ua/1.0" in out - assert "proj" in out - - -class TestBodyFormatter: - def test_none(self): - assert str(BodyFormatter(None)) == "" - - def test_empty(self): - assert str(BodyFormatter(b"")) == "" - - def test_text_body(self): - assert str(BodyFormatter(b'{"hi":1}')) == '{"hi":1}' - - def test_truncation(self): - body = b"a" * 5000 - out = str(BodyFormatter(body, binary_max_size=100)) - assert out.startswith("a" * 100) - assert "5000B total" in out - - def test_binary_content_type(self): - out = str( - BodyFormatter( - b"\xff\xfe\x00", - content_type="application/octet-stream", - ) - ) - assert "" - - def test_lazy_str_only_called_at_format(self): - """BodyFormatter must defer its work until __str__ is invoked.""" - calls: list[int] = [] - - class Counting(BodyFormatter): - def __str__(self) -> str: - calls.append(1) - return super().__str__() - - # Logging at a level above DEBUG must not call __str__. - logger = logging.getLogger("contree_cli.client") - prev = logger.level - logger.setLevel(logging.WARNING) - try: - logger.debug("body=%s", Counting(b"hello")) - finally: - logger.setLevel(prev) - assert calls == [] - - -class TestContreeIAMClient: - def test_injects_project_header(self): - c = ContreeTestIAMClient("https://example.com", "tok", "aiproject-test") - c.respond(status=200, body=b"{}") - c.request("GET", "/v1/images") - headers = c.get_request(-1).headers - assert headers["Project"] == "aiproject-test" - assert headers["Authorization"] == "Bearer tok" - - def test_raises_without_project(self): - c = ContreeTestIAMClient("https://example.com", "tok", None) - with pytest.raises(ApiError, match="No project"): - c.request("GET", "/v1/images") - - def test_raises_without_token(self): - c = ContreeTestIAMClient("https://example.com", None, "aiproject-x") - with pytest.raises(ApiError, match="No token"): - c.request("GET", "/v1/images") - - class TestCliVersion: """``cli_version()`` must return ``"editable"`` whenever the install is - not a regular wheel — either the package is missing from metadata, or + not a regular wheel: either the package is missing from metadata, or PEP 610 ``direct_url.json`` marks the install as editable. The update checker keys off this sentinel to skip PyPI pings during local dev.""" @@ -702,185 +152,3 @@ def test_returns_version_when_direct_url_is_malformed(self): dist = self.make_dist(version="0.5.0", direct_url="not json {") with patch("contree_cli.client.distribution", return_value=dist): assert cli_version() == "0.5.0" - - -class TestPaginatedFetcherLimit: - """``limit=`` lives in :class:`PaginatedFetcher` so callers don't repeat - the page-size math, and a small budget like ``--limit 5`` doesn't pull a - full 1000-row page just to discard 995.""" - - def make_fetcher( - self, - client: ContreeTestClient, - *, - limit: int | None, - page_size: int | None = None, - ) -> PaginatedFetcher: - return PaginatedFetcher( - client, - "/v1/things", - {}, - lambda body: json.loads(body)["items"], - limit=limit, - page_size=page_size, - concurrency=1, - ) - - def test_small_limit_caps_page_size(self, contree_client): - f = self.make_fetcher(contree_client, limit=5) - # +1 so callers can detect "more exists past the limit". - assert f.page_size == 6 - # Need to cover at most limit+1 records; +1 page of safety pad - # plus the +1 ceiling makes the math straightforward. - assert f.max_pages >= 2 - - def test_large_limit_uses_default_page_size(self, contree_client): - f = self.make_fetcher(contree_client, limit=10000) - assert f.page_size == PaginatedFetcher.DEFAULT_PAGE_SIZE - assert f.max_pages >= 10000 // PaginatedFetcher.DEFAULT_PAGE_SIZE - - def test_no_limit_uses_default_and_safety_cap(self, contree_client): - f = self.make_fetcher(contree_client, limit=None) - assert f.page_size == PaginatedFetcher.DEFAULT_PAGE_SIZE - assert f.max_pages == PaginatedFetcher.UNLIMITED_MAX_PAGES - - def test_explicit_page_size_still_capped_by_limit(self, contree_client): - # Caller-supplied page_size is an upper bound; limit can still - # squash it smaller. - f = self.make_fetcher(contree_client, limit=3, page_size=100) - assert f.page_size == 4 - - def test_small_limit_uses_capped_page_size_in_request(self, contree_client): - # `limit=5` must request `limit=6` (capped page size + 1 for the - # truncation probe), not the default 1000. - contree_client.respond_json({"items": [{"i": i} for i in range(6)]}) - with self.make_fetcher(contree_client, limit=5) as f: - pages_iter = iter(f) - first = next(pages_iter) - assert len(first) == 6 - # Context manager exit calls stop() automatically; mirrors the - # real caller which breaks out of the loop after hitting limit. - with contextlib.suppress(StopIteration): - next(pages_iter) - req = contree_client.get_request(0) - assert "limit=6" in req.path - assert "limit=1000" not in req.path - - -# --------------------------------------------------------------------------- -# SSE parser: iter_sse_events -# --------------------------------------------------------------------------- - - -def _sse(body: str) -> io.BytesIO: - return io.BytesIO(body.encode("utf-8")) - - -class TestIterSseEvents: - def test_single_frame(self): - body = ( - "id: 1\nevent: stdout\n" - 'data: {"type":"stdout","data":{"value":"hi","encoding":"ascii"}}\n\n' - ) - events = list(iter_sse_events(_sse(body))) - assert events == [ - {"type": "stdout", "data": {"value": "hi", "encoding": "ascii"}} - ] - - def test_multiple_frames(self): - body = ( - 'id: 0\nevent: init\ndata: {"type":"init","data":{}}\n\n' - 'id: 1\nevent: exit\ndata: {"type":"exit","spid":1,"data":{"code":0}}\n\n' - ) - events = list(iter_sse_events(_sse(body))) - assert [e["type"] for e in events] == ["init", "exit"] - assert events[1]["spid"] == 1 - - def test_keepalive_and_blank_lines_ignored(self): - body = ( - ": keepalive\n" - "\n" - ": keepalive again\n" - 'id: 5\nevent: stdout\ndata: {"type":"stdout"}\n\n' - ) - events = list(iter_sse_events(_sse(body))) - assert events == [{"type": "stdout"}] - - def test_sse_error_frame_surfaces_as_dict(self): - body = ( - ": stream ended with error, retry since last event id\n\n" - "event: sse_error\ndata: upstream closed unexpectedly\n\n" - ) - events = list(iter_sse_events(_sse(body))) - assert events == [ - {"type": "sse_error", "message": "upstream closed unexpectedly"} - ] - - def test_sse_error_with_id_carries_id(self): - body = "id: 42\nevent: sse_error\ndata: boom\n\n" - events = list(iter_sse_events(_sse(body))) - assert events == [{"type": "sse_error", "message": "boom", "id": "42"}] - - def test_multiline_data_joined_with_newline(self): - body = 'event: stdout\ndata: {"type":"stdout",\ndata: "value":"x"}\n\n' - events = list(iter_sse_events(_sse(body))) - assert events == [{"type": "stdout", "value": "x"}] - - def test_invalid_json_data_dropped_silently(self): - body = 'event: stdout\ndata: not-json\n\nevent: exit\ndata: {"type":"exit"}\n\n' - events = list(iter_sse_events(_sse(body))) - assert events == [{"type": "exit"}] - - def test_non_dict_json_dropped(self): - body = 'event: stdout\ndata: [1,2,3]\n\nevent: exit\ndata: {"type":"exit"}\n\n' - events = list(iter_sse_events(_sse(body))) - assert events == [{"type": "exit"}] - - def test_frame_at_eof_without_blank_line_still_emits(self): - body = 'event: stdout\ndata: {"type":"stdout"}\n' - events = list(iter_sse_events(_sse(body))) - assert events == [{"type": "stdout"}] - - def test_empty_stream_yields_nothing(self): - events = list(iter_sse_events(_sse(""))) - assert events == [] - - -# --------------------------------------------------------------------------- -# SSE chunk decoder: decode_event_chunk -# --------------------------------------------------------------------------- - - -class TestDecodeEventChunk: - def test_ascii_encoding_default(self): - assert decode_event_chunk({"value": "hello"}) == b"hello" - - def test_explicit_ascii(self): - assert decode_event_chunk({"value": "hi", "encoding": "ascii"}) == b"hi" - - def test_base64_encoding(self): - payload = base64.b64encode(b"\x00\x01\xff").decode("ascii") - assert ( - decode_event_chunk({"value": payload, "encoding": "base64"}) - == b"\x00\x01\xff" - ) - - def test_base64_invalid_returns_empty(self): - assert ( - decode_event_chunk({"value": "!!!not-base64!!!", "encoding": "base64"}) - == b"" - ) - - def test_missing_value_returns_empty(self): - assert decode_event_chunk({}) == b"" - - def test_empty_value_returns_empty(self): - assert decode_event_chunk({"value": ""}) == b"" - - def test_non_dict_returns_empty(self): - assert decode_event_chunk("not a dict") == b"" - assert decode_event_chunk(None) == b"" - assert decode_event_chunk(42) == b"" - - def test_non_string_value_returns_empty(self): - assert decode_event_chunk({"value": 123}) == b"" diff --git a/tests/test_config.py b/tests/test_config.py index 14d971a..571884c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,12 +4,17 @@ import sys import pytest +from contree_client.profiles import ( + AUTH_TYPE_IAM, + AUTH_TYPE_JWT, + DEFAULT_IAM_URL, + PROFILE_PREFIX, + Profile, +) import contree_cli.config as config_mod from contree_cli.config import ( - AuthType, Config, - ConfigProfile, get_default_path, ) @@ -21,7 +26,7 @@ class TestSaveAndLoad: def test_save_creates_file(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok123", url="https://test.dev", @@ -30,7 +35,7 @@ def test_save_creates_file(self, config_dir): def test_load_reads_saved_profile(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok123", url="https://test.dev", @@ -42,12 +47,12 @@ def test_load_reads_saved_profile(self, config_dir): def test_save_multiple_profiles(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok1", url="https://test.dev", ) - cfg["staging"] = ConfigProfile( + cfg["staging"] = Profile( name="staging", token="tok2", url="https://staging.dev", @@ -57,12 +62,12 @@ def test_save_multiple_profiles(self, config_dir): def test_save_overwrites_existing(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="old", url="https://old.dev", ) - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="new", url="https://new.dev", @@ -76,7 +81,7 @@ def test_load_defaults_when_no_file(self, config_dir): assert p.name == "default" assert p.token is None assert p.url == "" - assert p.auth_type == AuthType.JWT + assert p.auth_type == AUTH_TYPE_JWT # --------------------------------------------------------------------------- @@ -88,7 +93,7 @@ class TestLoadConfigPath: def test_load_from_explicit_path(self, tmp_path): cfg_file = tmp_path / "custom.ini" cfg = Config(path=cfg_file) - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok_custom", url="https://custom.dev", @@ -106,7 +111,7 @@ def test_load_from_explicit_path(self, tmp_path): class TestProfileResolution: def test_defaults_to_default_profile(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://test.dev", @@ -116,12 +121,12 @@ def test_defaults_to_default_profile(self, config_dir): def test_uses_switched_profile(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok1", url="https://test.dev", ) - cfg["staging"] = ConfigProfile( + cfg["staging"] = Profile( name="staging", token="tok2", url="https://staging.dev", @@ -134,12 +139,12 @@ def test_uses_switched_profile(self, config_dir): def test_env_profile_overrides_config(self, config_dir, monkeypatch): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok1", url="https://test.dev", ) - cfg["staging"] = ConfigProfile( + cfg["staging"] = Profile( name="staging", token="tok2", url="https://staging.dev", @@ -151,7 +156,7 @@ def test_env_profile_overrides_config(self, config_dir, monkeypatch): def test_env_token_does_not_override_config(self, config_dir, monkeypatch): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="cfg_token", url="https://test.dev", @@ -162,7 +167,7 @@ def test_env_token_does_not_override_config(self, config_dir, monkeypatch): def test_env_url_does_not_override_config(self, config_dir, monkeypatch): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://custom.dev", @@ -174,14 +179,14 @@ def test_env_url_does_not_override_config(self, config_dir, monkeypatch): def test_url_falls_back_for_jwt_when_missing(self, config_dir): """JWT profile with url key removed falls back to empty string.""" cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://test.dev", ) cp = configparser.ConfigParser() cp.read(config_dir / "auth.ini") - cp.remove_option(Config.PROFILE_PREFIX + "default", "url") + cp.remove_option(PROFILE_PREFIX + "default", "url") with open(config_dir / "auth.ini", "w") as f: cp.write(f) p = Config().resolve() @@ -190,19 +195,19 @@ def test_url_falls_back_for_jwt_when_missing(self, config_dir): def test_url_falls_back_for_iam_when_missing(self, config_dir): """IAM profile with url key removed falls back to IAM default.""" cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://iam.test", - auth_type=AuthType.IAM, + auth_type=AUTH_TYPE_IAM, ) cp = configparser.ConfigParser() cp.read(config_dir / "auth.ini") - cp.remove_option(Config.PROFILE_PREFIX + "default", "url") + cp.remove_option(PROFILE_PREFIX + "default", "url") with open(config_dir / "auth.ini", "w") as f: cp.write(f) p = Config().resolve() - assert p.url == Config.DEFAULT_IAM_URL + assert p.url == DEFAULT_IAM_URL def test_nonexistent_profile_returns_defaults(self, config_dir, monkeypatch): monkeypatch.setenv("CONTREE_PROFILE", "nonexistent") @@ -210,7 +215,7 @@ def test_nonexistent_profile_returns_defaults(self, config_dir, monkeypatch): assert p.name == "nonexistent" assert p.token is None assert p.url == "" - assert p.auth_type == AuthType.JWT + assert p.auth_type == AUTH_TYPE_JWT # --------------------------------------------------------------------------- @@ -221,46 +226,46 @@ def test_nonexistent_profile_returns_defaults(self, config_dir, monkeypatch): class TestAuthType: def test_default_type_is_jwt(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://test.dev", ) p = Config().resolve() - assert p.auth_type == AuthType.JWT + assert p.auth_type == AUTH_TYPE_JWT def test_iam_type_stored_and_loaded(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://iam.test", - auth_type=AuthType.IAM, + auth_type=AUTH_TYPE_IAM, project="aiproject-x", ) p = Config().resolve() - assert p.auth_type == AuthType.IAM + assert p.auth_type == AUTH_TYPE_IAM assert p.project == "aiproject-x" def test_legacy_profile_without_type_is_jwt(self, config_dir): """Profile saved without type key (legacy) defaults to jwt.""" cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://old.dev", ) cp = configparser.ConfigParser() cp.read(config_dir / "auth.ini") - cp.remove_option(Config.PROFILE_PREFIX + "default", "type") + cp.remove_option(PROFILE_PREFIX + "default", "type") with open(config_dir / "auth.ini", "w") as f: cp.write(f) p = Config().resolve() - assert p.auth_type == AuthType.JWT + assert p.auth_type == AUTH_TYPE_JWT def test_project_none_when_not_set(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://test.dev", @@ -270,11 +275,11 @@ def test_project_none_when_not_set(self, config_dir): def test_env_project_does_not_override_config(self, config_dir, monkeypatch): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://iam.test", - auth_type=AuthType.IAM, + auth_type=AUTH_TYPE_IAM, project="aiproject-cfg", ) monkeypatch.setenv("CONTREE_PROJECT", "aiproject-env") @@ -283,18 +288,18 @@ def test_env_project_does_not_override_config(self, config_dir, monkeypatch): def test_save_clears_project_when_none(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://iam.test", - auth_type=AuthType.IAM, + auth_type=AUTH_TYPE_IAM, project="aiproject-old", ) - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://test.dev", - auth_type=AuthType.JWT, + auth_type=AUTH_TYPE_JWT, ) p = Config().resolve() assert p.project is None @@ -308,7 +313,7 @@ def test_save_clears_project_when_none(self, config_dir): class TestConfigProfileDataclass: def test_frozen(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok", url="https://test.dev", @@ -319,15 +324,17 @@ def test_frozen(self, config_dir): def test_repr_masks_token(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="secret_tok", url="https://test.dev", ) p = Config().resolve() + # The library Profile hides the token from repr entirely + # (field(repr=False)). r = repr(p) assert "secret_tok" not in r - assert "***" in r + assert "token" not in r def test_repr_none_token(self, config_dir): p = Config().resolve() @@ -343,12 +350,12 @@ def test_repr_none_token(self, config_dir): class TestSwitchProfile: def test_switch_updates_default(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="tok1", url="https://test.dev", ) - cfg["staging"] = ConfigProfile( + cfg["staging"] = Profile( name="staging", token="tok2", url="https://staging.dev", @@ -372,7 +379,7 @@ def test_switch_nonexistent_raises(self, config_dir): class TestAuthFilePermissions: def test_file_mode_is_0600(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="secret-token", url="https://test.dev", @@ -383,14 +390,14 @@ def test_file_mode_is_0600(self, config_dir): def test_rewrite_keeps_0600(self, config_dir): cfg = Config() - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="t1", url="https://test.dev", ) path = config_dir / "auth.ini" os.chmod(path, 0o644) - cfg["default"] = ConfigProfile( + cfg["default"] = Profile( name="default", token="t2", url="https://test.dev", diff --git a/tests/test_cp.py b/tests/test_cp.py index b050810..193cdfd 100644 --- a/tests/test_cp.py +++ b/tests/test_cp.py @@ -1,52 +1,22 @@ from __future__ import annotations -import io import logging from contextlib import ExitStack from contextvars import copy_context from unittest.mock import patch from conftest import ContreeTestClient +from contree_client.runtime import CHUNK_SIZE from contree_cli import FORMATTER, SESSION_STORE -from contree_cli.cli.cp import CpArgs, cmd_cp, fmt_duration, fmt_size -from contree_cli.client import CHUNK_SIZE +from contree_cli.cli.cp import CpArgs, cmd_cp, fmt_size from contree_cli.output import DefaultFormatter, JSONFormatter from contree_cli.session import SessionStore -class StreamResponse: - """Response that supports chunked reading via BytesIO.""" - - def __init__( - self, - data: bytes, - *, - status: int = 200, - content_length: bool = True, - ): - self.status = status - self.reason = "OK" if status < 300 else "Error" - self._buf = io.BytesIO(data) - self._length = len(data) if content_length else None - - def read(self, amt: int | None = None) -> bytes: - return self._buf.read(amt) - - def getheader(self, name: str, default: str | None = None) -> str | None: - if name == "Content-Length" and self._length is not None: - return str(self._length) - return default - - def getheaders(self) -> list[tuple[str, str]]: - if self._length is not None: - return [("Content-Length", str(self._length))] - return [] - - def _run_cmd( tc: ContreeTestClient, - data: bytes = b"", + chunks: list[bytes] | None = None, *, store: SessionStore, image: str = "a1b2c3d4-5678-9abc-def0-111111111111", @@ -54,15 +24,12 @@ def _run_cmd( dest: str = "/tmp/out", images_response: dict | None = None, formatter=None, - content_length: bool = True, time_values: list[float] | None = None, ): """Run cmd_cp with mocked responses.""" if images_response is not None: - tc.respond_json(images_response) - tc.fake.responses.append( - StreamResponse(data, content_length=content_length), - ) + tc.mock("inspect_find_image_by_tag", images_response["images"][0]["uuid"]) + tc.mock("inspect_image_download_stream", chunks or []) FORMATTER.set(formatter or DefaultFormatter()) store.set_image(image, kind="test") @@ -84,47 +51,67 @@ def _run_cmd( class TestCmdCp: - def test_request_path(self, contree_client, session_store, tmp_path): + def test_request_args(self, contree_client, session_store, tmp_path): dest = tmp_path / "out" _run_cmd( contree_client, - b"hello", + [b"hello"], store=session_store, path="/etc/hosts", dest=str(dest), ) - paths = contree_client.request_paths - assert len(paths) == 1 - assert "/v1/inspect/a1b2c3d4-5678-9abc-def0-111111111111/download" in paths[0] - assert "path=%2Fetc%2Fhosts" in paths[0] + calls = contree_client.calls_for("inspect_image_download_stream") + assert len(calls) == 1 + assert calls[0].args == ( + "a1b2c3d4-5678-9abc-def0-111111111111", + "/etc/hosts", + ) def test_writes_file(self, contree_client, session_store, tmp_path): dest = tmp_path / "output.bin" result = _run_cmd( - contree_client, b"file contents here", store=session_store, dest=str(dest) + contree_client, + [b"file contents here"], + store=session_store, + dest=str(dest), ) assert result is None assert dest.read_bytes() == b"file contents here" + def test_dest_directory_appends_basename( + self, contree_client, session_store, tmp_path + ): + result = _run_cmd( + contree_client, + [b"content"], + store=session_store, + path="/etc/os-release", + dest=str(tmp_path), + ) + assert result is None + assert (tmp_path / "os-release").read_bytes() == b"content" + def test_tag_resolution(self, contree_client, session_store, tmp_path): dest = tmp_path / "out" images_resp = {"images": [{"uuid": "resolved-uuid", "tag": "latest"}]} _run_cmd( contree_client, - b"data", + [b"data"], store=session_store, image="tag:latest", images_response=images_resp, dest=str(dest), ) - paths = contree_client.request_paths - assert len(paths) == 2 - assert "tag=latest" in paths[0] - assert "/v1/inspect/resolved-uuid/download" in paths[1] + resolve_calls = contree_client.calls_for("inspect_find_image_by_tag") + assert len(resolve_calls) == 1 + assert resolve_calls[0].args == ("latest",) + stream_calls = contree_client.calls_for("inspect_image_download_stream") + assert len(stream_calls) == 1 + assert stream_calls[0].args[0] == "resolved-uuid" def test_empty_file(self, contree_client, session_store, tmp_path): dest = tmp_path / "empty" - result = _run_cmd(contree_client, b"", store=session_store, dest=str(dest)) + result = _run_cmd(contree_client, [], store=session_store, dest=str(dest)) assert result is None assert dest.read_bytes() == b"" @@ -135,7 +122,7 @@ def test_non_default_formatter_warns( with caplog.at_level(logging.WARNING, logger="contree_cli.cli.cp"): _run_cmd( contree_client, - b"hello", + [b"hello"], store=session_store, formatter=JSONFormatter(), dest=str(dest), @@ -146,54 +133,30 @@ def test_overwrites_existing(self, contree_client, session_store, tmp_path): dest = tmp_path / "existing.txt" dest.write_bytes(b"old content") result = _run_cmd( - contree_client, b"new content", store=session_store, dest=str(dest) + contree_client, [b"new content"], store=session_store, dest=str(dest) ) assert result is None assert dest.read_bytes() == b"new content" - def test_progress_log_with_content_length( - self, contree_client, session_store, tmp_path, caplog - ): - """After 5s elapsed, a progress line with %, ETA, and speed.""" - dest = tmp_path / "out" - data = b"A" * CHUNK_SIZE * 2 + def test_progress_log(self, contree_client, session_store, tmp_path, caplog): + """After 5s elapsed, a progress line with volume and speed. - # monotonic: start, after-chunk-1 (6s), after-chunk-2, final - time_values = [0.0, 6.0, 6.1, 6.1] - - with caplog.at_level(logging.INFO, logger="contree_cli.cli.cp"): - _run_cmd( - contree_client, - data, - store=session_store, - dest=str(dest), - time_values=time_values, - ) - - progress = [r for r in caplog.records if "downloaded" in r.message] - assert len(progress) == 1 - msg = progress[0].message - assert "50%" in msg - assert "ETA" in msg - assert "/s" in msg - - def test_progress_log_without_content_length( - self, contree_client, session_store, tmp_path, caplog - ): - """Without Content-Length, progress shows size and speed but no ETA.""" + The streaming download API exposes no response headers, so the + total size is unknown and progress carries no percent or ETA. + """ dest = tmp_path / "out" - data = b"B" * CHUNK_SIZE * 2 + chunks = [b"A" * CHUNK_SIZE, b"A" * CHUNK_SIZE] + # monotonic: start, after-chunk-1 (6s), after-chunk-2, final time_values = [0.0, 6.0, 6.1, 6.1] with caplog.at_level(logging.INFO, logger="contree_cli.cli.cp"): _run_cmd( contree_client, - data, + chunks, store=session_store, dest=str(dest), time_values=time_values, - content_length=False, ) progress = [r for r in caplog.records if "downloaded" in r.message] @@ -201,6 +164,7 @@ def test_progress_log_without_content_length( msg = progress[0].message assert "/s" in msg assert "ETA" not in msg + assert "%" not in msg def test_final_log_shows_total( self, contree_client, session_store, tmp_path, caplog @@ -209,7 +173,7 @@ def test_final_log_shows_total( dest = tmp_path / "out" with caplog.at_level(logging.INFO, logger="contree_cli.cli.cp"): _run_cmd( - contree_client, b"hello world", store=session_store, dest=str(dest) + contree_client, [b"hello world"], store=session_store, dest=str(dest) ) written = [r for r in caplog.records if "Written" in r.message] @@ -232,12 +196,3 @@ def test_fmt_size_gib(self): def test_fmt_size_tib(self): assert fmt_size(2 * 1024**4) == "2.0 TiB" - - def test_fmt_duration_seconds(self): - assert fmt_duration(45) == "45s" - - def test_fmt_duration_minutes(self): - assert fmt_duration(125) == "2m05s" - - def test_fmt_duration_hours(self): - assert fmt_duration(3723) == "1h02m03s" diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py new file mode 100644 index 0000000..4a1322e --- /dev/null +++ b/tests/test_doc_examples.py @@ -0,0 +1,236 @@ +"""Validate every documented `contree` invocation against the real parser. + +Extracts command lines from fenced bash blocks and `{terminal-shell}` +directives in README.md, docs/, the built-in manuals (agent.md, +manual.md) and the rendered skill bodies, then checks each one: + +- the subcommand path must resolve (`contree --help` exits 0); +- every flag used in the example must appear in that help text + (or in the root `contree --help` for global flags). + +This catches stale flags (`-K`, `--tagged`, `tag -d`) and renamed or +removed subcommands whenever the CLI and the docs drift apart. +""" + +from __future__ import annotations + +import re +import shlex +import subprocess +import sys +from functools import cache +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent + +DOC_FILES = sorted( + { + REPO / "README.md", + REPO / "contree_cli" / "agent.md", + REPO / "contree_cli" / "manual.md", + *(p for p in (REPO / "docs").rglob("*.md") if "_build" not in p.parts), + } +) + +FENCE_RE = re.compile(r"```(?:bash|shell|console)\n(.*?)```", re.S) +DIRECTIVE_RE = re.compile(r"```\{terminal-shell\}\s+(contree[^\n]*)") +# meta-syntax placeholders that make a line non-literal +PLACEHOLDER_RE = re.compile(r"<[a-zA-Z][^>]*>|\.\.\.|\bUUID\b|\bIMAGE\b|\bREF\b") + + +def extract_contree_lines(text: str) -> list[str]: + lines: list[str] = [] + for block in FENCE_RE.findall(text): + # join backslash continuations + block = block.replace("\\\n", " ") + for raw in block.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + # drop trailing inline comments + line = re.split(r"\s+#", line)[0] + # the payload after ` -- ` is the remote command; drop it so + # quotes with `&&` inside do not confuse the extraction + line = line.split(" -- ")[0] + (" --" if " -- " in line else "") + # unwrap eval $(...), VAR=$(...), pipes/redirects: keep the + # contree part only + for m in re.finditer(r"contree\s[^|;)&<>]*", line): + lines.append(m.group(0).strip()) + lines.extend(m.strip() for m in DIRECTIVE_RE.findall(text)) + return lines + + +def rendered_skill_bodies() -> list[tuple[str, str]]: + from contree_cli.skill import ( + AmpSkill, + ClaudeAgentSkill, + ClaudeSkill, + ClaudeSubagentSkill, + ClineSkill, + CodexSkill, + OpenCodeSkill, + ) + + out = [] + for cls in ( + ClaudeSkill, + CodexSkill, + OpenCodeSkill, + AmpSkill, + ClineSkill, + ClaudeSubagentSkill, + ClaudeAgentSkill, + ): + skill = cls(path=Path("/tmp/doc-lint")) + out.append((f"skill:{cls.__name__}", skill.render())) + return out + + +@cache +def help_text(subpath: tuple[str, ...]) -> str | None: + """Return `contree --help` output, or None if the path is invalid.""" + proc = subprocess.run( + [sys.executable, "-m", "contree_cli", *subpath, "--help"], + capture_output=True, + text=True, + timeout=30, + ) + if proc.returncode != 0: + return None + return proc.stdout + proc.stderr + + +ROOT_HELP_KEY: tuple[str, ...] = () + + +def split_invocation(tokens: list[str]) -> tuple[list[str], list[str], list[str]]: + """Split into (path, pre_flags, post_flags). + + ``pre_flags`` appear before the subcommand and must be global; + ``post_flags`` follow it and must belong to the subcommand itself + (the global parser never sees them). ``path`` greedily collects + non-flag tokens; resolve_subcommand() later shrinks it to the real + command prefix, and the leftover tokens are positional values. + """ + path: list[str] = [] + pre_flags: list[str] = [] + post_flags: list[str] = [] + i = 1 + expects_value = False + while i < len(tokens): + tok = tokens[i] + if tok == "--": + break # payload follows + if tok.startswith("-"): + flag = tok.split("=", 1)[0] + (post_flags if path else pre_flags).append(flag) + # values never start with "-" and are consumed lazily below + expects_value = "=" not in tok + i += 1 + continue + if expects_value: + expects_value = False + i += 1 + continue + if path and path[0] in ("run", "r"): + # direct-mode run: the first positional starts the remote + # command, whose own flags are not contree's business + break + if PLACEHOLDER_RE.search(tok) or tok.startswith(("$", "'", '"')): + i += 1 + continue + path.append(tok) + i += 1 + return path, pre_flags, post_flags + + +def resolve_subcommand(path: list[str]) -> tuple[tuple[str, ...], str] | None: + """Longest prefix of *path* that resolves to a real subcommand help. + + A non-empty path must resolve at depth >= 1: falling back to the + root parser would make any misspelled command "valid". + """ + lowest = 0 if not path else 1 + for n in range(len(path), lowest - 1, -1): + sub = tuple(path[:n]) + text = help_text(sub) + if text is not None: + return sub, text + return None + + +FLAG_LEXICON_RE = re.compile(r"(? frozenset[str]: + text = help_text(subpath) or "" + return frozenset(FLAG_LEXICON_RE.findall(text)) + + +def collect_cases() -> list[tuple[str, str]]: + cases: list[tuple[str, str]] = [] + for f in DOC_FILES: + for line in extract_contree_lines(f.read_text(encoding="utf-8")): + cases.append((str(f.relative_to(REPO)), line)) + for name, body in rendered_skill_bodies(): + for line in extract_contree_lines(body): + cases.append((name, line)) + # dedupe, keep first source for the message + seen: dict[str, str] = {} + for src, line in cases: + seen.setdefault(line, src) + return [(src, line) for line, src in seen.items()] + + +CASES = collect_cases() + + +def test_docs_have_examples() -> None: + assert len(CASES) > 200 + + +@pytest.mark.parametrize( + ("source", "line"), + CASES, + ids=[f"{src}::{line[:60]}" for src, line in CASES], +) +def test_documented_command_is_valid(source: str, line: str) -> None: + try: + tokens = shlex.split(line, posix=True) + except ValueError: + pytest.skip(f"unparseable shell line: {line!r}") + assert tokens and tokens[0] == "contree" + + path, pre_flags, post_flags = split_invocation(tokens) + + resolved = resolve_subcommand(path) + assert resolved is not None, f"{source}: no such command path {path!r} in {line!r}" + sub, text = resolved + + # A leftover positional that lands on a pure-namespace command + # (usage shows only a {a,b,c} choice set) is a typo, not a value. + leftover = path[len(sub) :] + choices_match = re.search(r"\{([\w,-]+)\}", text) + if leftover and choices_match: + choices = set(choices_match.group(1).split(",")) + assert leftover[0] in choices, ( + f"{source}: {leftover[0]!r} is not a subcommand of" + f" `contree {' '.join(sub)}` (from {line!r})" + ) + + for flag in pre_flags: + if PLACEHOLDER_RE.search(flag): + continue + assert flag in flag_lexicon(ROOT_HELP_KEY), ( + f"{source}: {flag!r} is not a global contree flag (from {line!r})" + ) + for flag in post_flags: + if PLACEHOLDER_RE.search(flag): + continue + assert flag in flag_lexicon(sub), ( + f"{source}: flag {flag!r} not accepted by" + f" `contree {' '.join(sub)}` (from {line!r})" + ) diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..91c4631 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import gzip +import io +from contextvars import copy_context +from unittest.mock import patch + +from conftest import ContreeTestClient +from contree_client.exceptions import NotFoundError + +from contree_cli import FORMATTER, SESSION_STORE +from contree_cli.cli.export import ExportArgs, cmd_export +from contree_cli.output import DefaultFormatter, JSONFormatter +from contree_cli.session import SessionStore + +IMG_UUID = "a1b2c3d4-5678-9abc-def0-111111111111" + + +def gzip_chunk(data: bytes) -> bytes: + return gzip.compress(data) + + +def _run_cmd( + tc: ContreeTestClient, + chunks: list[bytes] | object | None = None, + *, + store: SessionStore, + image: str = IMG_UUID, + path: str | None = "/etc", + output: str = "", + decompress: bool = False, + formatter=None, + stdout=None, +): + """Run cmd_export with mocked archive responses.""" + if isinstance(chunks, BaseException): + tc.mock("inspect_image_archive", error=chunks) + else: + tc.mock("inspect_image_archive", chunks or []) + + FORMATTER.set(formatter or DefaultFormatter()) + store.set_image(image, kind="test") + SESSION_STORE.set(store) + ctx = copy_context() + + ns_path = path if path is not None else None + args = ExportArgs( + path=ns_path if ns_path is not None else "/", + output=output, + decompress=decompress, + ) + if stdout is None: + stdout = _piped_stdout() + with patch("contree_cli.cli.export.sys.stdout", stdout): + return ctx.run(cmd_export, args) + + +def _piped_stdout(isatty: bool = False): + from unittest.mock import MagicMock + + mock = MagicMock() + mock.isatty.return_value = isatty + mock.buffer = io.BytesIO() + return mock + + +class TestCmdExport: + def test_streams_to_file(self, contree_client, session_store, tmp_path): + dest = tmp_path / "etc.tar.gz" + payload = gzip_chunk(b"tar-bytes") + rc = _run_cmd( + contree_client, + [payload], + store=session_store, + path="/etc", + output=str(dest), + ) + assert rc is None + assert dest.read_bytes() == payload + calls = contree_client.calls_for("inspect_image_archive") + assert len(calls) == 1 + assert calls[0].args == (IMG_UUID, "/etc") + assert calls[0].kwargs["compressed"] is True + + def test_default_path_is_root(self, contree_client, session_store, tmp_path): + dest = tmp_path / "rootfs.tar.gz" + _run_cmd( + contree_client, + [gzip_chunk(b"x")], + store=session_store, + path="/", + output=str(dest), + ) + calls = contree_client.calls_for("inspect_image_archive") + assert calls[0].args == (IMG_UUID, "/") + + def test_relative_path_resolves_against_cwd( + self, contree_client, session_store, tmp_path + ): + session_store.set_image(IMG_UUID, kind="test") + session_store.set_cwd("/srv") + dest = tmp_path / "out.tar.gz" + _run_cmd( + contree_client, + [gzip_chunk(b"x")], + store=session_store, + path="app", + output=str(dest), + ) + calls = contree_client.calls_for("inspect_image_archive") + assert calls[0].args == (IMG_UUID, "/srv/app") + + def test_stdout_pipe(self, contree_client, session_store): + payload = gzip_chunk(b"tar-bytes") + stdout = _piped_stdout() + rc = _run_cmd( + contree_client, + [payload], + store=session_store, + stdout=stdout, + ) + assert rc is None + assert stdout.buffer.getvalue() == payload + + def test_tty_stdout_refused_with_hint(self, contree_client, session_store, caplog): + rc = _run_cmd( + contree_client, + [gzip_chunk(b"x")], + store=session_store, + stdout=_piped_stdout(isatty=True), + ) + assert rc == 1 + assert "binary stream" in caplog.text + assert "-F rootfs.tar.gz" in caplog.text + assert "tar -tzf -" in caplog.text + # Refused before any API call. + assert contree_client.calls_for("inspect_image_archive") == [] + + def test_default_output_is_the_served_stream( + self, contree_client, session_store, tmp_path + ): + """The default output is byte-identical to what the server + serves with compressed=True; the CLI never re-encodes it.""" + dest = tmp_path / "out.tar.gz" + _run_cmd( + contree_client, + [b"served-", b"bytes"], + store=session_store, + output=str(dest), + ) + assert dest.read_bytes() == b"served-bytes" + + def test_decompress_flag(self, contree_client, session_store, tmp_path): + dest = tmp_path / "out" + rc = _run_cmd( + contree_client, + [b"tar-", b"bytes"], + store=session_store, + output=str(dest), + decompress=True, + ) + assert rc is None + assert dest.read_bytes() == b"tar-bytes" + calls = contree_client.calls_for("inspect_image_archive") + assert calls[0].kwargs["compressed"] is False + + def test_tar_extension_implies_decompress( + self, contree_client, session_store, tmp_path + ): + dest = tmp_path / "out.tar" + _run_cmd( + contree_client, + [b"tar-bytes"], + store=session_store, + output=str(dest), + ) + assert dest.read_bytes() == b"tar-bytes" + calls = contree_client.calls_for("inspect_image_archive") + assert calls[0].kwargs["compressed"] is False + + def test_tar_gz_extension_stays_gzip(self, contree_client, session_store, tmp_path): + dest = tmp_path / "out.tar.gz" + payload = gzip_chunk(b"x") + _run_cmd(contree_client, [payload], store=session_store, output=str(dest)) + calls = contree_client.calls_for("inspect_image_archive") + assert calls[0].kwargs["compressed"] is True + assert dest.read_bytes() == payload + + def test_tag_image_resolves(self, contree_client, session_store, tmp_path): + contree_client.mock("inspect_find_image_by_tag", IMG_UUID) + dest = tmp_path / "out.tar.gz" + _run_cmd( + contree_client, + [gzip_chunk(b"x")], + store=session_store, + image="tag:alpine:latest", + output=str(dest), + ) + resolve_calls = contree_client.calls_for("inspect_find_image_by_tag") + assert resolve_calls[0].args == ("alpine:latest",) + calls = contree_client.calls_for("inspect_image_archive") + assert calls[0].args == (IMG_UUID, "/etc") + + def test_missing_path_fails(self, contree_client, session_store, tmp_path, caplog): + dest = tmp_path / "out.tar.gz" + rc = _run_cmd( + contree_client, + NotFoundError(404, "path not found"), + store=session_store, + path="/nope", + output=str(dest), + ) + assert rc == 1 + assert "not found in image" in caplog.text + # No partial file is left behind. + assert not dest.exists() + + def test_format_warning(self, contree_client, session_store, tmp_path, caplog): + dest = tmp_path / "out.tar.gz" + _run_cmd( + contree_client, + [gzip_chunk(b"x")], + store=session_store, + output=str(dest), + formatter=JSONFormatter(), + ) + assert "--format is ignored" in caplog.text diff --git a/tests/test_file_cmd.py b/tests/test_file_cmd.py index 653a885..24bb5af 100644 --- a/tests/test_file_cmd.py +++ b/tests/test_file_cmd.py @@ -1,6 +1,5 @@ from __future__ import annotations -import io import json from contextvars import copy_context from dataclasses import replace @@ -9,6 +8,8 @@ import pytest from conftest import ContreeTestClient +from contree_client.exceptions import ContreeAPIError, NotFoundError +from contree_client.models import File, FileResponse, FilesListResponse from contree_cli import CLIENT, FORMATTER, SESSION_STORE from contree_cli.cli.file import ( @@ -20,43 +21,56 @@ cmd_file_edit, cmd_file_ls, ) -from contree_cli.client import ApiError from contree_cli.output import JSONFormatter from contree_cli.session import SessionStore -class StreamResponse: - """Response that supports chunked reading for stream_response().""" +def file_payload(uuid: str, *, sha256: str = "0" * 64, size: int = 1) -> dict: + """Full ``File`` payload as returned by GET /v1/files/{sha256}.""" + return { + "uuid": uuid, + "sha256": sha256, + "size": size, + "created_at": "2026-05-01T00:00:00Z", + "updated_at": "2026-05-01T00:00:00Z", + } - def __init__(self, data: bytes, *, status: int = 200): - self.status = status - self.reason = "OK" if status < 300 else "Error" - self._buf = io.BytesIO(data) - def read(self, amt: int | None = None) -> bytes: - return self._buf.read(amt) +def make_file(uuid: str, *, sha256: str = "0" * 64, size: int = 1) -> File: + return File.from_dict(file_payload(uuid, sha256=sha256, size=size)) - def getheader(self, name: str, default: str | None = None) -> str | None: - return default - def getheaders(self) -> list[tuple[str, str]]: - return [] +def mock_download(tc: ContreeTestClient, content: bytes | None) -> None: + """Mock the image file download stream; None means 404 (no file).""" + if content is None: + tc.mock( + "inspect_image_download_stream", + error=NotFoundError(404, "not found"), + ) + else: + tc.mock("inspect_image_download_stream", [content]) + + +def mock_dedup_miss(tc: ContreeTestClient) -> None: + tc.mock("get_file", error=NotFoundError(404, "not found")) -def _api_response(body: bytes | dict, *, status: int = 200) -> StreamResponse: - data = json.dumps(body).encode() if isinstance(body, dict) else body - return StreamResponse(data, status=status) +def mock_dedup_hit(tc: ContreeTestClient, uuid: str) -> None: + tc.mock("get_file", make_file(uuid)) + + +def mock_upload(tc: ContreeTestClient, uuid: str) -> None: + tc.mock("upload_file", FileResponse(uuid=uuid, sha256="0" * 64, size=1)) def _run_file_edit( tc: ContreeTestClient, args: FileEditArgs, - responses: list[StreamResponse], *, store: SessionStore, editor_content: bytes | None = None, -) -> tuple[int | None]: - """Drive ``cmd_file_edit`` with mocked editor + HTTP responses. +) -> int | None: + """Drive ``cmd_file_edit`` with a mocked editor. The editor mock writes ``editor_content`` directly to the file inside the temp dir created by ``cmd_file_edit``. This sidesteps @@ -65,8 +79,6 @@ def _run_file_edit( """ import tempfile - tc.fake.responses.extend(responses) - if not args.editor: args = replace(args, editor="fake-editor") @@ -101,11 +113,11 @@ def fake_editor(cmd: str, *, shell: bool = True) -> int: def _run_file_ls( tc: ContreeTestClient, args: FileListArgs, - responses: list[StreamResponse], + files: list[dict], *, store: SessionStore, ) -> int | None: - tc.fake.responses.extend(responses) + tc.mock("list_files", FilesListResponse.from_dict({"files": files})) CLIENT.set(tc) SESSION_STORE.set(store) FORMATTER.set(JSONFormatter()) @@ -123,21 +135,15 @@ def test_lists_with_source(self, contree_client, session_store, capsys): "uuid": "file-3", "url": "https://example.com/pkg.tgz", } - responses = [ - _api_response( - { - "files": [ - {"uuid": "file-1", "sha256": "abc", "size": 10}, - {"uuid": "file-2", "sha256": "def", "size": 20}, - {"uuid": "file-3", "sha256": "ghi", "size": 30}, - ] - } - ), + files = [ + file_payload("file-1", sha256="a" * 64, size=10), + file_payload("file-2", sha256="d" * 64, size=20), + file_payload("file-3", sha256="e" * 64, size=30), ] rc = _run_file_ls( contree_client, FileListArgs(limit=10), - responses, + files, store=session_store, ) assert rc is None @@ -151,24 +157,11 @@ def test_quiet_emits_three_columns(self, contree_client, session_store, capsys): "uuid": "file-1", "local_path": "/host/app.py", } - responses = [ - _api_response( - { - "files": [ - { - "uuid": "file-1", - "sha256": "abc", - "size": 10, - "created_at": "2026-05-01T00:00:00Z", - }, - ] - } - ), - ] + files = [file_payload("file-1", sha256="a" * 64, size=10)] _run_file_ls( contree_client, FileListArgs(limit=10, quiet=True), - responses, + files, store=session_store, ) row = json.loads(capsys.readouterr().out.strip()) @@ -194,14 +187,12 @@ class TestFileEditDownload: def test_downloads_existing_file(self, contree_client, session_store: SessionStore): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileEditArgs(path="/etc/config.ini") - download_resp = _api_response(b"original content") - dedup_miss = _api_response({"error": "not found"}, status=404) - upload_resp = _api_response({"uuid": "file-uuid-1"}, status=201) - responses = [download_resp, dedup_miss, upload_resp] + mock_download(contree_client, b"original content") + mock_dedup_miss(contree_client) + mock_upload(contree_client, "file-uuid-1") rc = _run_file_edit( contree_client, args, - responses, store=session_store, editor_content=b"modified content", ) @@ -215,14 +206,12 @@ def test_downloads_existing_file(self, contree_client, session_store: SessionSto def test_creates_empty_on_404(self, contree_client, session_store: SessionStore): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileEditArgs(path="/new/file.txt") - not_found = _api_response({"error": "not found"}, status=404) - dedup_miss = _api_response({"error": "not found"}, status=404) - upload_resp = _api_response({"uuid": "file-uuid-2"}, status=201) - responses = [not_found, dedup_miss, upload_resp] + mock_download(contree_client, None) + mock_dedup_miss(contree_client) + mock_upload(contree_client, "file-uuid-2") rc = _run_file_edit( contree_client, args, - responses, store=session_store, editor_content=b"new file content", ) @@ -236,11 +225,12 @@ def test_non_404_error_propagates( ): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileEditArgs(path="/etc/config.ini") - # 403 is non-retryable, so the caller sees it on the first hit - # without any retries or sleep. - responses = [_api_response({"error": "forbidden"}, status=403)] - with pytest.raises(ApiError) as exc_info: - _run_file_edit(contree_client, args, responses, store=session_store) + contree_client.mock( + "inspect_image_download_stream", + error=ContreeAPIError(403, "forbidden"), + ) + with pytest.raises(ContreeAPIError) as exc_info: + _run_file_edit(contree_client, args, store=session_store) assert exc_info.value.status == 403 @@ -248,19 +238,17 @@ class TestFileEditNoChanges: def test_no_changes_skips_upload(self, contree_client, session_store: SessionStore): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileEditArgs(path="/etc/config.ini") - download_resp = _api_response(b"same content") - responses = [download_resp] + mock_download(contree_client, b"same content") # editor_content=None means editor doesn't modify the file rc = _run_file_edit( contree_client, args, - responses, store=session_store, editor_content=None, ) assert rc is None - # No HTTP calls beyond the download - assert contree_client.request_count == 1 + # No API calls beyond the download + assert len(contree_client.calls) == 1 # No pending files assert session_store.pending_files() == [] @@ -269,31 +257,27 @@ class TestFileEditDedup: def test_dedup_hit_skips_upload(self, contree_client, session_store: SessionStore): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileEditArgs(path="/etc/config.ini") - download_resp = _api_response(b"original") - dedup_hit = _api_response({"uuid": "existing-uuid"}) - responses = [download_resp, dedup_hit] + mock_download(contree_client, b"original") + mock_dedup_hit(contree_client, "existing-uuid") rc = _run_file_edit( contree_client, args, - responses, store=session_store, editor_content=b"modified", ) assert rc is None files = session_store.pending_files() assert files[0].file_uuid == "existing-uuid" - # Only 2 HTTP calls: download + dedup check (no POST upload) - assert contree_client.request_count == 2 + # Only 2 API calls: download + dedup check (no upload) + assert len(contree_client.calls) == 2 + assert contree_client.calls_for("upload_file") == [] class TestFileEditEditorFailure: def test_editor_nonzero_exit(self, contree_client, session_store: SessionStore): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileEditArgs(path="/etc/config.ini") - download_resp = _api_response(b"content") - - tc = contree_client - tc.fake.responses.append(download_resp) + mock_download(contree_client, b"content") SESSION_STORE.set(session_store) ctx = copy_context() @@ -312,9 +296,9 @@ def test_editor_flag_overrides_env( ): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileEditArgs(path="/etc/config.ini", editor="nvim") - download_resp = _api_response(b"original") - dedup_miss = _api_response({"error": "not found"}, status=404) - upload_resp = _api_response({"uuid": "file-uuid-e"}, status=201) + mock_download(contree_client, b"original") + mock_dedup_miss(contree_client) + mock_upload(contree_client, "file-uuid-e") called_with: list[str] = [] @@ -326,8 +310,6 @@ def fake_editor(cmd: str, *, shell: bool = True) -> int: Path(parts[1]).write_bytes(b"modified") return 0 - tc = contree_client - tc.fake.responses.extend([download_resp, dedup_miss, upload_resp]) SESSION_STORE.set(session_store) ctx = copy_context() @@ -341,14 +323,12 @@ class TestFileEditHistoryEntry: def test_history_entry_created(self, contree_client, session_store: SessionStore): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileEditArgs(path="/etc/config.ini") - download_resp = _api_response(b"original") - dedup_miss = _api_response({"error": "not found"}, status=404) - upload_resp = _api_response({"uuid": "file-uuid"}, status=201) - responses = [download_resp, dedup_miss, upload_resp] + mock_download(contree_client, b"original") + mock_dedup_miss(contree_client) + mock_upload(contree_client, "file-uuid") _run_file_edit( contree_client, args, - responses, store=session_store, editor_content=b"modified", ) @@ -364,12 +344,9 @@ def test_history_entry_created(self, contree_client, session_store: SessionStore def _run_file_cp( tc: ContreeTestClient, args: FileCpArgs, - responses: list[StreamResponse], *, store: SessionStore, ) -> int | None: - tc.fake.responses.extend(responses) - SESSION_STORE.set(store) ctx = copy_context() @@ -388,14 +365,9 @@ def test_cp_uploads_and_records( src = tmp_path / "app.py" src.write_bytes(b"print('hello')") args = FileCpArgs(src=str(src), dest="/app/app.py") - dedup_miss = _api_response({"error": "not found"}, status=404) - upload_resp = _api_response({"uuid": "file-uuid-cp"}, status=201) - rc = _run_file_cp( - contree_client, - args, - [dedup_miss, upload_resp], - store=session_store, - ) + mock_dedup_miss(contree_client) + mock_upload(contree_client, "file-uuid-cp") + rc = _run_file_cp(contree_client, args, store=session_store) assert rc is None files = session_store.pending_files() assert len(files) == 1 @@ -412,18 +384,14 @@ def test_cp_dedup_hit( src = tmp_path / "app.py" src.write_bytes(b"print('hello')") args = FileCpArgs(src=str(src), dest="/app/app.py") - dedup_hit = _api_response({"uuid": "existing-uuid"}) - rc = _run_file_cp( - contree_client, - args, - [dedup_hit], - store=session_store, - ) + mock_dedup_hit(contree_client, "existing-uuid") + rc = _run_file_cp(contree_client, args, store=session_store) assert rc is None files = session_store.pending_files() assert files[0].file_uuid == "existing-uuid" - # Only 1 HTTP call: dedup check (no POST upload) - assert contree_client.request_count == 1 + # Only 1 API call: dedup check (no upload) + assert len(contree_client.calls) == 1 + assert contree_client.calls_for("upload_file") == [] def test_cp_missing_file_returns_error( self, @@ -432,7 +400,7 @@ def test_cp_missing_file_returns_error( ): session_store.set_image("a1b2c3d4-5678-9abc-def0-111111111111", kind="use") args = FileCpArgs(src="/nonexistent/file.py", dest="/app/file.py") - rc = _run_file_cp(contree_client, args, [], store=session_store) + rc = _run_file_cp(contree_client, args, store=session_store) assert rc == 1 assert session_store.pending_files() == [] @@ -446,14 +414,9 @@ def test_cp_history_entry_title( src = tmp_path / "data.txt" src.write_bytes(b"data") args = FileCpArgs(src=str(src), dest="/opt/data.txt") - dedup_miss = _api_response({"error": "not found"}, status=404) - upload_resp = _api_response({"uuid": "file-uuid"}, status=201) - _run_file_cp( - contree_client, - args, - [dedup_miss, upload_resp], - store=session_store, - ) + mock_dedup_miss(contree_client) + mock_upload(contree_client, "file-uuid") + _run_file_cp(contree_client, args, store=session_store) s = session_store.session assert s is not None assert s.last_kind == "file" diff --git a/tests/test_images.py b/tests/test_images.py index b0ece40..1528348 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -6,11 +6,11 @@ import pytest from conftest import ContreeTestClient +from contree_client.models import Image, OperationResponse from contree_cli import CLIENT, FORMATTER from contree_cli.cli.images import ( LIMIT_DEFAULT, - PAGE_SIZE, ImagesArgs, ImportArgs, _derive_tag, @@ -25,14 +25,8 @@ def _run_cmd(tc: ContreeTestClient, images, *, formatter=None, **kwargs): - """Run cmd_images with a single-page response.""" - return _run_cmd_pages(tc, [images], formatter=formatter, **kwargs) - - -def _run_cmd_pages(tc: ContreeTestClient, pages, *, formatter=None, **kwargs): - """Run cmd_images with multiple pages of responses.""" - for page in pages: - tc.respond_json({"images": page}) + """Run cmd_images against a mocked image stream.""" + _mock_images(tc, images) FORMATTER.set(formatter or CSVFormatter()) ctx = copy_context() @@ -60,7 +54,8 @@ def test_lists_images(self, contree_client, capsys): def test_prefix_passed_as_tag_param(self, contree_client): _run_cmd(contree_client, [], prefix="ubuntu") - assert "tag=ubuntu" in contree_client.request_paths[0] + calls = contree_client.calls_for("iter_images") + assert calls[0].kwargs["tag"] == "ubuntu" def test_null_tag_shown_as_empty(self, contree_client, capsys): images = [ @@ -101,8 +96,10 @@ def test_table_output(self, contree_client, capsys): assert len(lines) == 3 # header + 2 rows assert "UUID" in lines[0] - def test_unknown_field_passes_through(self, contree_client, capsys): - """New server fields (e.g. ``size``, ``digest``) reach the row as-is.""" + def test_unknown_field_dropped(self, contree_client, capsys): + """Fields absent from the typed Image model (e.g. ``size``, + ``digest``) are dropped by the contree-client parser; only the + model's known fields reach the row.""" images = [ { "uuid": "ggg", @@ -114,8 +111,10 @@ def test_unknown_field_passes_through(self, contree_client, capsys): ] _run_cmd(contree_client, images, formatter=JSONFormatter()) parsed = json.loads(capsys.readouterr().out.strip()) - assert parsed["size"] == 12345 - assert parsed["digest"] == "sha256:abcd" + assert parsed["uuid"] == "ggg" + assert parsed["tag"] == "v4" + assert "size" not in parsed + assert "digest" not in parsed def test_nested_fields_skipped(self, contree_client, capsys): images = [ @@ -137,156 +136,133 @@ def test_nested_fields_skipped(self, contree_client, capsys): class TestImagesParams: def test_uuid_param(self, contree_client): _run_cmd(contree_client, [], uuid="abc-123") - assert "uuid=abc-123" in contree_client.request_paths[0] + calls = contree_client.calls_for("iter_images") + assert calls[0].kwargs["uuid"] == "abc-123" def test_default_tagged_only_param(self, contree_client): _run_cmd(contree_client, []) - assert "tagged=1" in contree_client.request_paths[0] + calls = contree_client.calls_for("iter_images") + assert calls[0].kwargs["tagged"] is True def test_all_param_disables_tagged_filter(self, contree_client): _run_cmd(contree_client, [], all_images=True) - assert "tagged=1" not in contree_client.request_paths[0] + calls = contree_client.calls_for("iter_images") + assert calls[0].kwargs["tagged"] is False def test_since_param(self, contree_client): + """The parsed datetime goes to the client as-is; the library + owns the wire formatting (format_time_param).""" + from datetime import datetime + _run_cmd(contree_client, [], since="1h") - path = contree_client.request_paths[0] - assert "since=" in path + calls = contree_client.calls_for("iter_images") + assert isinstance(calls[0].kwargs["since"], datetime) def test_until_param(self, contree_client): + from datetime import datetime + _run_cmd(contree_client, [], until="2025-01-01") - path = contree_client.request_paths[0] - assert "until=" in path + calls = contree_client.calls_for("iter_images") + assert isinstance(calls[0].kwargs["until"], datetime) def _make_image(i: int) -> dict: return {"uuid": f"uuid-{i}", "tag": None, "created_at": "2025-01-01T00:00:00Z"} +def _mock_images(tc: ContreeTestClient, images: list[dict]) -> None: + tc.mock("iter_images", [Image.from_dict(image) for image in images]) + + class TestImagesPagination: - def test_single_page_partial(self, contree_client, capsys): - """A page smaller than PAGE_SIZE means no further requests.""" + """Offset pagination is the library's job (iter_images); the CLI + contract is a single iterator pass with the record budget + forwarded as limit and truncation detected via one extra item.""" + + def test_single_iterator_pass(self, contree_client, capsys): images = [_make_image(i) for i in range(5)] _run_cmd(contree_client, images) - assert contree_client.request_count == 1 - - def test_two_full_pages(self, contree_client, capsys): - """Two full pages + an empty third page.""" - page1 = [_make_image(i) for i in range(PAGE_SIZE)] - page2 = [_make_image(i) for i in range(PAGE_SIZE, PAGE_SIZE + 3)] - _run_cmd_pages(contree_client, [page1, page2]) - assert contree_client.request_count == 2 - out = capsys.readouterr().out - assert f"uuid-{PAGE_SIZE + 2}" in out - - def test_offset_increments(self, contree_client): - """Each page request increments offset by PAGE_SIZE.""" - page1 = [_make_image(i) for i in range(PAGE_SIZE)] - page2 = [] - _run_cmd_pages(contree_client, [page1, page2]) - paths = contree_client.request_paths - assert "offset=0" in paths[0] - assert f"offset={PAGE_SIZE}" in paths[1] - - def test_empty_first_page(self, contree_client, capsys): - """No output and only one request when first page is empty.""" + assert len(contree_client.calls_for("iter_images")) == 1 + + def test_limit_forwarded_with_probe(self, contree_client): + _run_cmd(contree_client, [], limit=7) + calls = contree_client.calls_for("iter_images") + assert calls[0].kwargs["limit"] == 8 + + def test_empty_stream(self, contree_client, capsys): + """No output when the stream is empty.""" _run_cmd(contree_client, []) - assert contree_client.request_count == 1 assert capsys.readouterr().out == "" def test_all_images_emitted(self, contree_client, capsys): - """All images across pages appear in output.""" - page1 = [_make_image(i) for i in range(PAGE_SIZE)] - page2 = [_make_image(i) for i in range(PAGE_SIZE, PAGE_SIZE + 5)] - _run_cmd_pages(contree_client, [page1, page2]) - out = capsys.readouterr().out - assert out.count("uuid-") == PAGE_SIZE + 5 - - def test_pages_flushed_progressively(self, contree_client, capsys): - """Each full page is flushed as it completes (streaming output).""" - page1 = [_make_image(i) for i in range(PAGE_SIZE)] - page2 = [_make_image(i) for i in range(PAGE_SIZE, PAGE_SIZE * 2)] - page3 = [_make_image(i) for i in range(PAGE_SIZE * 2, PAGE_SIZE * 2 + 3)] - _run_cmd_pages(contree_client, [page1, page2, page3]) + images = [_make_image(i) for i in range(25)] + _run_cmd(contree_client, images) out = capsys.readouterr().out - assert f"uuid-{PAGE_SIZE - 1}" in out - assert f"uuid-{PAGE_SIZE * 2 - 1}" in out - assert f"uuid-{PAGE_SIZE * 2 + 2}" in out + assert out.count("uuid-") == 25 + + def test_progress_logged_per_page(self, contree_client, caplog): + """Every consumed page reports progress, so a long listing does + not look hung while the next page loads.""" + import logging + + images = [_make_image(i) for i in range(1500)] + with caplog.at_level(logging.INFO, logger="contree_cli.cli.images"): + _run_cmd(contree_client, images, limit=3000) + progress = [ + r.getMessage() for r in caplog.records if "loading more" in r.getMessage() + ] + assert progress == ["Fetched 1000 images, loading more..."] def test_default_limit_matches_constant(self): assert LIMIT_DEFAULT > 0 assert ImagesArgs().limit == LIMIT_DEFAULT def test_limit_truncates_with_warning(self, contree_client, caplog): - """Hitting --limit triggers a probe; non-empty probe -> warning.""" + """An extra record past --limit means truncation -> warning.""" import logging - page1 = [_make_image(i) for i in range(PAGE_SIZE)] - contree_client.respond_json({"images": page1}) - contree_client.respond_json({"images": [_make_image(PAGE_SIZE)]}) + _mock_images(contree_client, [_make_image(i) for i in range(6)]) FORMATTER.set(CSVFormatter()) ctx = copy_context() with caplog.at_level(logging.WARNING, logger="contree_cli.cli.images"): - ctx.run(cmd_images, ImagesArgs(limit=PAGE_SIZE)) + ctx.run(cmd_images, ImagesArgs(limit=5)) msgs = [r.getMessage() for r in caplog.records if r.levelname == "WARNING"] - assert any("truncated" in m and f"--limit={PAGE_SIZE}" in m for m in msgs) - assert contree_client.request_count == 2 - - def test_limit_probe_uses_skip_of_one(self, contree_client): - """Probe is a single-record request, not a full page.""" - page1 = [_make_image(i) for i in range(PAGE_SIZE)] - contree_client.respond_json({"images": page1}) - contree_client.respond_json({"images": []}) - - FORMATTER.set(CSVFormatter()) - ctx = copy_context() - ctx.run(cmd_images, ImagesArgs(limit=PAGE_SIZE)) - - probe_path = contree_client.request_paths[1] - assert "limit=1" in probe_path - assert f"offset={PAGE_SIZE}" in probe_path + assert any("truncated" in m and "--limit=5" in m for m in msgs) def test_limit_warning_after_table_flush(self, contree_client, caplog, capsys): """TableFormatter buffer is flushed before the truncation warning.""" import logging - page1 = [_make_image(i) for i in range(PAGE_SIZE)] - contree_client.respond_json({"images": page1}) - contree_client.respond_json({"images": [_make_image(PAGE_SIZE)]}) + _mock_images(contree_client, [_make_image(i) for i in range(6)]) FORMATTER.set(TableFormatter()) ctx = copy_context() with caplog.at_level(logging.WARNING, logger="contree_cli.cli.images"): - ctx.run(cmd_images, ImagesArgs(limit=PAGE_SIZE)) + ctx.run(cmd_images, ImagesArgs(limit=5)) out = capsys.readouterr().out # Table content must be printed (i.e. flushed) before the handler # logs the warning. Verify the table is on stdout already. assert "uuid-0" in out - assert f"uuid-{PAGE_SIZE - 1}" in out + assert "uuid-4" in out - def test_limit_no_warning_when_no_more(self, contree_client, caplog): - """Empty probe response -> no warning.""" + def test_limit_no_warning_when_stream_fits(self, contree_client, caplog): + """Exactly --limit records -> no warning.""" import logging - page1 = [_make_image(i) for i in range(PAGE_SIZE)] - contree_client.respond_json({"images": page1}) - contree_client.respond_json({"images": []}) + _mock_images(contree_client, [_make_image(i) for i in range(5)]) FORMATTER.set(CSVFormatter()) ctx = copy_context() with caplog.at_level(logging.WARNING, logger="contree_cli.cli.images"): - ctx.run(cmd_images, ImagesArgs(limit=PAGE_SIZE)) + ctx.run(cmd_images, ImagesArgs(limit=5)) warns = [r for r in caplog.records if r.levelname == "WARNING"] assert not any("truncated" in r.getMessage() for r in warns) - def test_limit_smaller_than_page_size_emits_only_limit_records( - self, contree_client, capsys - ): - """--limit < PAGE_SIZE: caller emits exactly limit records from the page.""" - contree_client.respond_json( - {"images": [_make_image(i) for i in range(PAGE_SIZE)]} - ) + def test_limit_emits_only_limit_records(self, contree_client, capsys): + """The stream past --limit is not emitted.""" + _mock_images(contree_client, [_make_image(i) for i in range(10)]) FORMATTER.set(CSVFormatter()) ctx = copy_context() @@ -296,15 +272,6 @@ def test_limit_smaller_than_page_size_emits_only_limit_records( # 1 header row + 3 data rows. assert len(out.strip().splitlines()) == 4 - def test_progress_not_logged_for_single_short_page(self, contree_client, caplog): - """Final/only partial page does not emit progress (output covers it).""" - import logging - - images = [_make_image(i) for i in range(5)] - with caplog.at_level(logging.INFO, logger="contree_cli.cli.images"): - _run_cmd(contree_client, images) - assert not any("images so far" in r.getMessage() for r in caplog.records) - class TestImagesCreatedAtFormats: """Verify created_at parsing with various ISO 8601 formats from the API.""" @@ -489,13 +456,17 @@ def test_user_slash_image(self): # --------------------------------------------------------------------------- -def _op_response(uuid: str, status: str = "PENDING", image: str = ""): +def _op_response( + uuid: str, status: str = "PENDING", image: str = "" +) -> OperationResponse: result = {"image": image} if image else {} - return {"uuid": uuid, "status": status, "result": result} + return OperationResponse.from_dict( + {"uuid": uuid, "status": status, "result": result} + ) def _run_import(tc: ContreeTestClient, refs: list[str], *, formatter=None, **kwargs): - """Run cmd_import with mocked HTTP and time.sleep.""" + """Run cmd_import with mocked client and time.sleep.""" FORMATTER.set(formatter or CSVFormatter()) ctx = copy_context() args = ImportArgs(refs=refs, **kwargs) @@ -505,94 +476,95 @@ def _run_import(tc: ContreeTestClient, refs: list[str], *, formatter=None, **kwa class TestCmdImport: def test_single_import_success(self, contree_client, capsys): - # POST response (operation created) - contree_client.respond_json({"uuid": "op-1"}, status=201) - # GET poll response (terminal) - contree_client.respond_json( - _op_response("op-1", "SUCCESS", image="img-1"), + contree_client.mock("import_image", "op-1") + contree_client.mock( + "get_operation_status", _op_response("op-1", "SUCCESS", "img-1") ) rc = _run_import(contree_client, ["ubuntu:latest"]) assert rc is None - paths = contree_client.request_paths - assert "/v1/images/import" in paths[0] - assert "/v1/operations/op-1" in paths[1] + assert len(contree_client.calls_for("import_image")) == 1 + polls = contree_client.calls_for("get_operation_status") + assert polls[0].args == ("op-1",) out = capsys.readouterr().out assert "op-1" in out - def test_normalized_url_in_post_body(self, contree_client): - contree_client.respond_json({"uuid": "op-1"}, status=201) - contree_client.respond_json(_op_response("op-1", "SUCCESS")) + def test_normalized_url_in_request(self, contree_client): + contree_client.mock("import_image", "op-1") + contree_client.mock("get_operation_status", _op_response("op-1", "SUCCESS")) _run_import(contree_client, ["ubuntu:latest"]) - req = contree_client.get_request(0) - payload = json.loads(req.body) - assert payload["registry"]["url"] == "docker://docker.io/library/ubuntu:latest" - assert payload["tag"] == "ubuntu:latest" - assert "timeout" not in payload + call = contree_client.calls_for("import_image")[0] + registry = call.args[0] + assert registry.url == "docker://docker.io/library/ubuntu:latest" + assert registry.credentials is ... + assert call.kwargs["tag"] == "ubuntu:latest" + assert call.kwargs["timeout"] is ... - def test_timeout_included_in_post_body(self, contree_client): - contree_client.respond_json({"uuid": "op-1"}, status=201) - contree_client.respond_json(_op_response("op-1", "SUCCESS")) + def test_timeout_included_in_request(self, contree_client): + contree_client.mock("import_image", "op-1") + contree_client.mock("get_operation_status", _op_response("op-1", "SUCCESS")) _run_import(contree_client, ["ubuntu:latest"], timeout=60) - req = contree_client.get_request(0) - payload = json.loads(req.body) - assert payload["timeout"] == 60 + call = contree_client.calls_for("import_image")[0] + assert call.kwargs["timeout"] == 60 def test_brace_expansion_multiple(self, contree_client, capsys): - # 3 POST responses - contree_client.respond_json({"uuid": "op-1"}, status=201) - contree_client.respond_json({"uuid": "op-2"}, status=201) - contree_client.respond_json({"uuid": "op-3"}, status=201) + # 3 import operations + contree_client.mock("import_image", "op-1") + contree_client.mock("import_image", "op-2") + contree_client.mock("import_image", "op-3") # 3 poll responses (all terminal on first poll) - contree_client.respond_json(_op_response("op-1", "SUCCESS", "img-1")) - contree_client.respond_json(_op_response("op-2", "SUCCESS", "img-2")) - contree_client.respond_json(_op_response("op-3", "SUCCESS", "img-3")) + contree_client.mock( + "get_operation_status", _op_response("op-1", "SUCCESS", "img-1") + ) + contree_client.mock( + "get_operation_status", _op_response("op-2", "SUCCESS", "img-2") + ) + contree_client.mock( + "get_operation_status", _op_response("op-3", "SUCCESS", "img-3") + ) rc = _run_import(contree_client, ["ubuntu:{latest,noble,jammy}"]) assert rc is None - # 3 POSTs + 3 GETs = 6 requests - assert contree_client.request_count == 6 + assert len(contree_client.calls_for("import_image")) == 3 + assert len(contree_client.calls_for("get_operation_status")) == 3 out = capsys.readouterr().out assert "op-1" in out assert "op-2" in out assert "op-3" in out def test_polls_until_terminal(self, contree_client, capsys): - contree_client.respond_json({"uuid": "op-1"}, status=201) + contree_client.mock("import_image", "op-1") # First poll: still pending - contree_client.respond_json(_op_response("op-1", "EXECUTING")) + contree_client.mock("get_operation_status", _op_response("op-1", "EXECUTING")) # Second poll: done - contree_client.respond_json(_op_response("op-1", "SUCCESS", "img-1")) + contree_client.mock( + "get_operation_status", _op_response("op-1", "SUCCESS", "img-1") + ) rc = _run_import(contree_client, ["ubuntu:latest"]) assert rc is None - # 1 POST + 2 GETs - assert contree_client.request_count == 3 + assert len(contree_client.calls_for("import_image")) == 1 + assert len(contree_client.calls_for("get_operation_status")) == 2 def test_failed_import_returns_1(self, contree_client): - contree_client.respond_json({"uuid": "op-1"}, status=201) - contree_client.respond_json( - _op_response("op-1", "FAILED"), - ) + contree_client.mock("import_image", "op-1") + contree_client.mock("get_operation_status", _op_response("op-1", "FAILED")) rc = _run_import(contree_client, ["ubuntu:latest"]) assert rc == 1 def test_keyboard_interrupt_cancels_all(self, contree_client): - # POST responses - contree_client.respond_json({"uuid": "op-1"}, status=201) - contree_client.respond_json({"uuid": "op-2"}, status=201) - # DELETE responses for cancellation - contree_client.respond(status=200) - contree_client.respond(status=200) + contree_client.mock("import_image", "op-1") + contree_client.mock("import_image", "op-2") + contree_client.mock("cancel_operation", None) FORMATTER.set(CSVFormatter()) CLIENT.set(contree_client) @@ -608,30 +580,25 @@ def test_keyboard_interrupt_cancels_all(self, contree_client): ): ctx.run(cmd_import, args) - # 2 POSTs + 2 DELETEs (cancellations) - assert contree_client.request_count == 4 - paths = contree_client.request_paths - assert "/v1/operations/op-1" in paths[2] - assert "/v1/operations/op-2" in paths[3] + cancels = contree_client.calls_for("cancel_operation") + assert [c.args for c in cancels] == [("op-1",), ("op-2",)] def test_explicit_tag(self, contree_client): - contree_client.respond_json({"uuid": "op-1"}, status=201) - contree_client.respond_json(_op_response("op-1", "SUCCESS")) + contree_client.mock("import_image", "op-1") + contree_client.mock("get_operation_status", _op_response("op-1", "SUCCESS")) _run_import(contree_client, ["ubuntu:latest?tag=myubuntu:test"]) - req = contree_client.get_request(0) - payload = json.loads(req.body) - assert payload["registry"]["url"] == "docker://docker.io/library/ubuntu:latest" - assert payload["tag"] == "myubuntu:test" + call = contree_client.calls_for("import_image")[0] + assert call.args[0].url == "docker://docker.io/library/ubuntu:latest" + assert call.kwargs["tag"] == "myubuntu:test" def test_ghcr_implicit_tag(self, contree_client): - contree_client.respond_json({"uuid": "op-1"}, status=201) - contree_client.respond_json(_op_response("op-1", "SUCCESS")) + contree_client.mock("import_image", "op-1") + contree_client.mock("get_operation_status", _op_response("op-1", "SUCCESS")) _run_import(contree_client, ["ghcr.io/ubuntu/ubuntu:latest"]) - req = contree_client.get_request(0) - payload = json.loads(req.body) - assert payload["registry"]["url"] == "docker://ghcr.io/ubuntu/ubuntu:latest" - assert payload["tag"] == "ubuntu/ubuntu:latest" + call = contree_client.calls_for("import_image")[0] + assert call.args[0].url == "docker://ghcr.io/ubuntu/ubuntu:latest" + assert call.kwargs["tag"] == "ubuntu/ubuntu:latest" diff --git a/tests/test_kill.py b/tests/test_kill.py index fe3aec9..39c770c 100644 --- a/tests/test_kill.py +++ b/tests/test_kill.py @@ -3,13 +3,15 @@ from contextvars import copy_context from conftest import ContreeTestClient +from contree_client.exceptions import ContreeAPIError +from contree_client.models import OperationSummary from contree_cli import CLIENT from contree_cli.cli.operation import ACTIVE_STATUSES, CancelArgs, cmd_cancel -def _run_cmd(tc: ContreeTestClient, uuid, *, status=202): - tc.respond(status=status, body=b"") +def _run_cmd(tc: ContreeTestClient, uuid): + tc.mock("cancel_operation", None) ctx = copy_context() args = CancelArgs(uuids=[uuid]) @@ -17,11 +19,11 @@ def _run_cmd(tc: ContreeTestClient, uuid, *, status=202): class TestCmdKill: - def test_sends_delete(self, contree_client): + def test_sends_cancel(self, contree_client): _run_cmd(contree_client, "op-123") - req = contree_client.get_request(0) - assert req.method == "DELETE" - assert req.path == "/v1/operations/op-123" + calls = contree_client.calls_for("cancel_operation") + assert len(calls) == 1 + assert calls[0].args == ("op-123",) def test_logs_cancellation(self, contree_client, caplog): with caplog.at_level("INFO"): @@ -29,7 +31,7 @@ def test_logs_cancellation(self, contree_client, caplog): assert "Cancelled operation op-456" in caplog.text def test_not_found_logs_and_sets_exit(self, contree_client, caplog): - contree_client.respond(status=404, body=b"nope") + contree_client.mock("cancel_operation", error=ContreeAPIError(404, "nope")) CLIENT.set(contree_client) ctx = copy_context() with caplog.at_level("ERROR"): @@ -38,7 +40,9 @@ def test_not_found_logs_and_sets_exit(self, contree_client, caplog): assert "Failed to cancel bad-uuid" in caplog.text def test_conflict_logs_and_sets_exit(self, contree_client, caplog): - contree_client.respond(status=409, body=b"already done") + contree_client.mock( + "cancel_operation", error=ContreeAPIError(409, "already done") + ) CLIENT.set(contree_client) ctx = copy_context() with caplog.at_level("ERROR"): @@ -55,23 +59,37 @@ def _ops_for_status(status, count): return [{"uuid": f"{status.lower()}-{i}"} for i in range(count)] -def _run_kill_all(ops_by_status, *, delete_failures=None): - """Run cmd_cancel --all with mocked list + delete responses.""" - delete_failures = delete_failures or set() - tc = ContreeTestClient() +def _run_kill_all(pages, *, cancel_failures=None): + """Run cmd_cancel --all with mocked list + cancel responses. - # For each active status, one GET page (possibly empty) - for status in ACTIVE_STATUSES: - ops = ops_by_status.get(status, []) - tc.respond_json(ops) + ``list_active`` queries each ACTIVE_STATUS once; the frozenset + iteration order is nondeterministic, so ``pages`` are queued + positionally (call order), not per status. Cancellations follow + the page order, so ``cancel_failures`` matches by UUID. + """ + cancel_failures = cancel_failures or set() + tc = ContreeTestClient() - # Collect all UUIDs in order and queue delete responses - for status in ACTIVE_STATUSES: - for op in ops_by_status.get(status, []): - if op["uuid"] in delete_failures: - tc.respond(status=409, body=b"conflict") + # One page per active-status listing call; pad with empties. + queued = list(pages) + while len(queued) < len(ACTIVE_STATUSES): + queued.append([]) + for page in queued: + tc.mock( + "list_operations", + [OperationSummary.from_dict(op) for op in page], + ) + + # Queue one cancel outcome per collected UUID, in page order. + for page in queued: + for op in page: + if op["uuid"] in cancel_failures: + tc.mock( + "cancel_operation", + error=ContreeAPIError(409, "conflict"), + ) else: - tc.respond(status=202, body=b"") + tc.mock("cancel_operation", None) CLIENT.set(tc) ctx = copy_context() @@ -83,41 +101,40 @@ def _run_kill_all(ops_by_status, *, delete_failures=None): class TestKillAll: def test_kills_all_active(self, caplog): - ops = { - "PENDING": _ops_for_status("PENDING", 1), - "EXECUTING": _ops_for_status("EXECUTING", 1), - } + pages = [ + _ops_for_status("PENDING", 1), + _ops_for_status("EXECUTING", 1), + ] with caplog.at_level("INFO"): - tc, rc = _run_kill_all(ops) + tc, rc = _run_kill_all(pages) assert rc is None - # 3 GETs (one per status) + 2 DELETEs - assert tc.request_count == 5 + # 3 listing calls (one per status) + 2 cancels + assert len(tc.calls_for("list_operations")) == len(ACTIVE_STATUSES) + assert len(tc.calls_for("cancel_operation")) == 2 assert "Cancelled operation pending-0" in caplog.text assert "Cancelled operation executing-0" in caplog.text def test_no_active_operations(self, caplog): with caplog.at_level("INFO"): - tc, rc = _run_kill_all({}) + tc, rc = _run_kill_all([]) assert rc is None assert "No active operations" in caplog.text - # Only 3 GETs, no DELETEs - assert tc.request_count == 3 + # Only listings, no cancels + assert len(tc.calls_for("list_operations")) == len(ACTIVE_STATUSES) + assert tc.calls_for("cancel_operation") == [] def test_partial_failure(self, caplog): - ops = { - "PENDING": _ops_for_status("PENDING", 2), - } + pages = [_ops_for_status("PENDING", 2)] with caplog.at_level("INFO"): _, rc = _run_kill_all( - ops, - delete_failures={"pending-1"}, + pages, + cancel_failures={"pending-1"}, ) assert rc == 1 assert "Cancelled operation pending-0" in caplog.text assert "Failed to cancel pending-1" in caplog.text def test_queries_all_statuses(self): - tc, _ = _run_kill_all({}) - paths = tc.request_paths - for status in ACTIVE_STATUSES: - assert any(f"status={status}" in p for p in paths), f"{status} not queried" + tc, _ = _run_kill_all([]) + statuses = {call.kwargs["status"] for call in tc.calls_for("list_operations")} + assert statuses == set(ACTIVE_STATUSES) diff --git a/tests/test_ls.py b/tests/test_ls.py index 1298a1b..4afe576 100644 --- a/tests/test_ls.py +++ b/tests/test_ls.py @@ -3,7 +3,8 @@ import json from contextvars import copy_context -from conftest import ContreeTestClient, FakeResponse +from conftest import ContreeTestClient, make_file_item +from contree_client.models import DirectoryList from contree_cli import FORMATTER, SESSION_STORE from contree_cli.cli.ls import ( @@ -19,26 +20,8 @@ from contree_cli.session import SessionStore -def _make_file( - path: str = "/etc/hosts", - size: int = 128, - mode: int = 0o644, - owner: str = "root", - group: str = "root", - mtime: int = 1700000000, - is_dir: bool = False, - is_symlink: bool = False, -) -> dict: - return { - "path": path, - "size": size, - "mode": mode, - "owner": owner, - "group": group, - "mtime": mtime, - "is_dir": is_dir, - "is_symlink": is_symlink, - } +def _make_file(path: str = "/etc/hosts", **overrides: object) -> dict: + return make_file_item(path, **overrides) def _run_cmd( @@ -54,13 +37,16 @@ def _run_cmd( ): """Run cmd_ls with mocked responses and a session store.""" if images_response is not None: - tc.respond_json(images_response) + tc.mock("inspect_find_image_by_tag", images_response["images"][0]["uuid"]) if text is not None: - # Manually add a text response (not JSON) - tc.fake.responses.append(FakeResponse(body=text.encode())) + # The text listing goes through the raw RequestSpec path. + tc.respond_raw(body=text.encode()) else: - tc.respond_json({"path": path, "files": files or []}) + tc.mock( + "inspect_image_list", + DirectoryList.from_dict({"path": path, "files": files or []}), + ) FORMATTER.set(formatter or CSVFormatter()) store.set_image(image, kind="test") @@ -71,6 +57,10 @@ def _run_cmd( ctx.run(cmd_ls, args) +def total_requests(tc: ContreeTestClient) -> int: + return len(tc.calls) + len(tc.raw_requests) + + class TestCmdLs: def test_request_path(self, contree_client, session_store): _run_cmd( @@ -80,10 +70,9 @@ def test_request_path(self, contree_client, session_store): image="a1b2c3d4-5678-9abc-def0-111111111111", path="/etc", ) - paths = contree_client.request_paths - assert len(paths) == 1 - assert "/v1/inspect/a1b2c3d4-5678-9abc-def0-111111111111/list" in paths[0] - assert "path=%2Fetc" in paths[0] + calls = contree_client.calls_for("inspect_image_list") + assert len(calls) == 1 + assert calls[0].args == ("a1b2c3d4-5678-9abc-def0-111111111111", "/etc") def test_outputs_file_entries(self, contree_client, session_store, capsys): files = [ @@ -104,10 +93,12 @@ def test_tag_resolves_then_inspects(self, contree_client, session_store): image="tag:latest", images_response=images_resp, ) - paths = contree_client.request_paths - assert len(paths) == 2 - assert "tag=latest" in paths[0] - assert "/v1/inspect/resolved-uuid/list" in paths[1] + resolve_calls = contree_client.calls_for("inspect_find_image_by_tag") + assert len(resolve_calls) == 1 + assert resolve_calls[0].args == ("latest",) + inspect_calls = contree_client.calls_for("inspect_image_list") + assert len(inspect_calls) == 1 + assert inspect_calls[0].args[0] == "resolved-uuid" def test_empty_directory(self, contree_client, session_store, capsys): _run_cmd(contree_client, [], store=session_store) @@ -146,15 +137,13 @@ def test_json_output(self, contree_client, session_store, capsys): assert parsed["path"] == "/bin/sh" assert parsed["size"] == 42 - def test_unknown_field_passes_through(self, contree_client, session_store, capsys): - """New server fields reach the row even when not hardcoded.""" - f = _make_file() - f["future_field"] = "anything" - f["inode"] = 4242 + def test_model_fields_pass_through(self, contree_client, session_store, capsys): + """Fields not hardcoded in cmd_ls still reach the output row.""" + f = _make_file(nlink=3, uid=1042) _run_cmd(contree_client, [f], store=session_store, formatter=JSONFormatter()) parsed = json.loads(capsys.readouterr().out.strip()) - assert parsed["future_field"] == "anything" - assert parsed["inode"] == 4242 + assert parsed["nlink"] == 3 + assert parsed["uid"] == 1042 def test_table_output(self, contree_client, session_store, capsys): files = [_make_file(), _make_file(path="/etc/passwd")] @@ -166,17 +155,13 @@ def test_table_output(self, contree_client, session_store, capsys): assert "PATH" in lines[0] def test_owner_fallback_to_uid(self, contree_client, session_store, capsys): - f = _make_file() - del f["owner"] - f["uid"] = 1000 + f = _make_file(owner="", uid=1000) _run_cmd(contree_client, [f], store=session_store) out = capsys.readouterr().out assert "1000" in out def test_group_fallback_to_gid(self, contree_client, session_store, capsys): - f = _make_file() - del f["group"] - f["gid"] = 1000 + f = _make_file(group="", gid=1000) _run_cmd(contree_client, [f], store=session_store) out = capsys.readouterr().out assert "1000" in out @@ -201,10 +186,9 @@ def test_default_formatter_passes_text_param(self, contree_client, session_store store=session_store, formatter=DefaultFormatter(), ) - paths = contree_client.request_paths - assert len(paths) == 1 - assert "text=1" in paths[0] - assert "path=%2Fetc" in paths[0] + specs = contree_client.raw_requests + assert len(specs) == 1 + assert specs[0].query == {"path": "/etc", "text": "1"} def test_explicit_format_no_text_param(self, contree_client, session_store): _run_cmd( @@ -213,9 +197,9 @@ def test_explicit_format_no_text_param(self, contree_client, session_store): store=session_store, formatter=JSONFormatter(), ) - paths = contree_client.request_paths - assert len(paths) == 1 - assert "text=1" not in paths[0] + assert contree_client.raw_requests == [] + calls = contree_client.calls_for("inspect_image_list") + assert len(calls) == 1 def test_default_formatter_empty(self, contree_client, session_store, capsys): _run_cmd( @@ -235,11 +219,11 @@ def test_json_format_caches_result(self, contree_client, session_store): """Second call with same args should not hit the API.""" files = [_make_file()] _run_cmd(contree_client, files, store=session_store) - assert contree_client.request_count == 1 + assert total_requests(contree_client) == 1 # Second call -- should be served from cache _run_cmd(contree_client, files, store=session_store) - assert contree_client.request_count == 1 # no new request (cached) + assert total_requests(contree_client) == 1 # no new request (cached) def test_default_formatter_not_cached(self, contree_client, session_store): """DefaultFormatter (text=1) path always hits API.""" @@ -250,7 +234,7 @@ def test_default_formatter_not_cached(self, contree_client, session_store): store=session_store, formatter=DefaultFormatter(), ) - assert contree_client.request_count == 1 + assert total_requests(contree_client) == 1 _run_cmd( contree_client, @@ -258,4 +242,4 @@ def test_default_formatter_not_cached(self, contree_client, session_store): store=session_store, formatter=DefaultFormatter(), ) - assert contree_client.request_count == 2 # new request (not cached) + assert total_requests(contree_client) == 2 # new request (not cached) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..c2fd056 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,97 @@ +"""Entry-point wiring: resource lifecycle around command dispatch. + +The transport backend is auto-detected and session-based backends +(requests/httpx/urllib3) pool connections, so main() must drive the +client through its context manager. These tests run main() end to end +with a spy client to pin that contract. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from contree_client.profiles import Profile + +from contree_cli.__main__ import main +from contree_cli.config import Config + + +class DummyChecker: + """UpdateChecker stand-in: no PyPI traffic, always up to date.""" + + def refresh(self) -> None: + pass + + def is_latest(self) -> bool: + return True + + +class SpyClient: + """Records context-manager transitions instead of doing HTTP.""" + + def __init__(self, events: list[str]) -> None: + self.events = events + + def __enter__(self) -> SpyClient: + self.events.append("enter") + return self + + def __exit__(self, *exc: object) -> None: + self.events.append("exit") + + +def write_profile(tmp_path: Path) -> Path: + cfg_path = tmp_path / "auth.ini" + cfg = Config(cfg_path) + cfg["default"] = Profile( + name="default", + url="https://contree.dev", + token="tok", + ) + return cfg_path + + +def run_main( + monkeypatch: pytest.MonkeyPatch, + cfg_path: Path, + events: list[str], + command: str, +) -> pytest.ExceptionInfo[SystemExit]: + monkeypatch.setattr("contree_cli.__main__.UpdateChecker", DummyChecker) + monkeypatch.setattr( + "contree_cli.__main__.client_from_profile", + lambda profile, timeout=300.0: SpyClient(events), + ) + monkeypatch.setattr( + sys, + "argv", + ["contree", "--config", str(cfg_path), command], + ) + with pytest.raises(SystemExit) as exc_info: + main() + return exc_info + + +class TestClientLifecycle: + def test_client_entered_and_exited(self, tmp_path, monkeypatch, capsys): + """main() drives the client through __enter__/__exit__ so + session-based transports release pooled connections.""" + cfg_path = write_profile(tmp_path) + events: list[str] = [] + + # `session` without an active session prints an error and + # exits 1 without touching the API: the lifecycle is observed + # without mocking any API method. + run_main(monkeypatch, cfg_path, events, "session") + + assert events == ["enter", "exit"] + + def test_local_command_creates_no_client(self, tmp_path, monkeypatch, capsys): + cfg_path = write_profile(tmp_path) + events: list[str] = [] + + run_main(monkeypatch, cfg_path, events, "agent") + + assert events == [] diff --git a/tests/test_operation.py b/tests/test_operation.py index 5ed0526..d7e4acb 100644 --- a/tests/test_operation.py +++ b/tests/test_operation.py @@ -4,6 +4,8 @@ import pytest from conftest import ContreeTestClient +from contree_client.exceptions import ContreeAPIError +from contree_client.models import OperationResponse, OperationSummary from contree_cli import CLIENT, FORMATTER, SESSION_STORE from contree_cli.arguments import parser @@ -30,18 +32,34 @@ def make_op( image: str = "img-1", tag: str = "latest", ) -> dict: + if kind == "instance": + metadata = { + "command": "echo hi", + "image": "img-base", + "shell": True, + "result": None, + } + else: + metadata = { + "registry": {"url": "docker://docker.io/busybox:latest"}, + "tag": "busybox:latest", + } return { "uuid": uuid, "kind": kind, "status": status, "error": error, "duration": duration, - "metadata": {"result": None}, - "result": {"image": image, "tag": tag, "duration": None}, + "metadata": metadata, + "result": {"image": image, "tag": tag}, "created_at": "2025-06-01T00:00:00Z", } +def mock_op(tc: ContreeTestClient, op: dict) -> None: + tc.mock("get_operation_status", OperationResponse.from_dict(op)) + + def run_show_multi( tc: ContreeTestClient, ops: list[dict], @@ -50,7 +68,7 @@ def run_show_multi( store: SessionStore, ) -> int | None: for op in ops: - tc.respond_json(op) + mock_op(tc, op) FORMATTER.set(formatter or CSVFormatter()) SESSION_STORE.set(store) ctx = copy_context() @@ -64,13 +82,18 @@ def run_cancel( uuids: list[str] | None = None, all_flag: bool = False, list_pages: list[list[dict]] | None = None, - delete_statuses: list[int] | None = None, + cancel_outcomes: list[BaseException | None] | None = None, ) -> int | None: - if list_pages is not None: - for page in list_pages: - tc.respond_json(page) - for status in delete_statuses or []: - tc.respond(status=status, body=b"") + for page in list_pages or []: + tc.mock( + "list_operations", + [OperationSummary.from_dict(op) for op in page], + ) + for outcome in cancel_outcomes or []: + if isinstance(outcome, BaseException): + tc.mock("cancel_operation", error=outcome) + else: + tc.mock("cancel_operation", None) CLIENT.set(tc) ctx = copy_context() args = CancelArgs(uuids=uuids or [], all=all_flag) @@ -168,7 +191,7 @@ def test_show_single_uuid(self, contree_client, session_store, capsys): assert rc is None out = capsys.readouterr().out assert "op-a" in out - assert contree_client.request_count == 1 + assert len(contree_client.calls_for("get_operation_status")) == 1 def test_show_multiple_uuids_issues_one_get_per_uuid( self, contree_client, session_store, capsys @@ -181,23 +204,24 @@ def test_show_multiple_uuids_issues_one_get_per_uuid( store=session_store, ) assert rc is None - assert contree_client.request_count == 3 + calls = contree_client.calls_for("get_operation_status") + assert len(calls) == 3 out = capsys.readouterr().out assert "op-a" in out assert "op-b" in out assert "op-c" in out - # All three are GETs on /v1/operations/{uuid} - for i, op in enumerate(ops): - req = contree_client.get_request(i) - assert req.method == "GET" - assert req.path == f"/v1/operations/{op['uuid']}" + # One status fetch per UUID, in order + for call, op in zip(calls, ops, strict=True): + assert call.args == (op["uuid"],) def test_show_continues_on_api_error( self, contree_client, session_store, caplog, capsys ): # First UUID -> 404, then a successful one - contree_client.respond(status=404, body=b"not found") - contree_client.respond_json(make_op("op-b")) + contree_client.mock( + "get_operation_status", error=ContreeAPIError(404, "not found") + ) + mock_op(contree_client, make_op("op-b")) FORMATTER.set(JSONFormatter()) SESSION_STORE.set(session_store) @@ -224,7 +248,7 @@ def test_show_history_reference_uses_session_store( title="echo hi", operation_uuid="op-from-history", ) - contree_client.respond_json(make_op("op-from-history")) + mock_op(contree_client, make_op("op-from-history")) FORMATTER.set(CSVFormatter()) SESSION_STORE.set(session_store) @@ -233,8 +257,9 @@ def test_show_history_reference_uses_session_store( rc = ctx.run(cmd_show_multi, args) assert rc is None - assert contree_client.request_count == 1 - assert contree_client.get_request(0).path == "/v1/operations/op-from-history" + calls = contree_client.calls_for("get_operation_status") + assert len(calls) == 1 + assert calls[0].args == ("op-from-history",) def test_show_raw_multi_uuid_emits_jsonl( self, contree_client, session_store, capsys @@ -245,7 +270,7 @@ def test_show_raw_multi_uuid_emits_jsonl( ops = [make_op("op-a"), make_op("op-b"), make_op("op-c")] for op in ops: - contree_client.respond_json(op) + mock_op(contree_client, op) FORMATTER.set(JSONFormatter()) SESSION_STORE.set(session_store) ctx = copy_context() @@ -271,13 +296,12 @@ def test_cancel_single_uuid(self, contree_client, caplog): rc = run_cancel( contree_client, uuids=["op-a"], - delete_statuses=[202], + cancel_outcomes=[None], ) assert rc is None - assert contree_client.request_count == 1 - req = contree_client.get_request(0) - assert req.method == "DELETE" - assert req.path == "/v1/operations/op-a" + calls = contree_client.calls_for("cancel_operation") + assert len(calls) == 1 + assert calls[0].args == ("op-a",) assert "Cancelled operation op-a" in caplog.text def test_cancel_multiple_uuids(self, contree_client, caplog): @@ -285,21 +309,18 @@ def test_cancel_multiple_uuids(self, contree_client, caplog): rc = run_cancel( contree_client, uuids=["op-a", "op-b", "op-c"], - delete_statuses=[202, 202, 202], + cancel_outcomes=[None, None, None], ) assert rc is None - assert contree_client.request_count == 3 - for i, uuid in enumerate(["op-a", "op-b", "op-c"]): - req = contree_client.get_request(i) - assert req.method == "DELETE" - assert req.path == f"/v1/operations/{uuid}" + calls = contree_client.calls_for("cancel_operation") + assert [call.args for call in calls] == [("op-a",), ("op-b",), ("op-c",)] def test_cancel_continues_on_error(self, contree_client, caplog): with caplog.at_level("INFO"): rc = run_cancel( contree_client, uuids=["op-a", "op-b"], - delete_statuses=[409, 202], + cancel_outcomes=[ContreeAPIError(409, "conflict"), None], ) assert rc == 1 assert "Failed to cancel op-a" in caplog.text @@ -310,23 +331,29 @@ def test_cancel_requires_uuids_or_all(self, contree_client, caplog): rc = run_cancel(contree_client) assert rc == 1 assert "Provide at least one UUID" in caplog.text - assert contree_client.request_count == 0 + assert contree_client.calls == [] def test_cancel_all_iterates_active_statuses(self, contree_client, caplog): - # One op per active status, then DELETE for each - list_pages = [[{"uuid": f"{s.lower()}-0"}] for s in ACTIVE_STATUSES] + # One op per active-status listing call. ACTIVE_STATUSES is a + # frozenset, so the status <-> page pairing is nondeterministic; + # pages are queued positionally and assertions stay unordered. + list_pages = [[{"uuid": f"active-{i}"}] for i in range(len(ACTIVE_STATUSES))] with caplog.at_level("INFO"): rc = run_cancel( contree_client, all_flag=True, list_pages=list_pages, - delete_statuses=[202] * len(ACTIVE_STATUSES), + cancel_outcomes=[None] * len(ACTIVE_STATUSES), ) assert rc is None - # 3 GETs + 3 DELETEs (one per active status) - assert contree_client.request_count == 2 * len(ACTIVE_STATUSES) - for status in ACTIVE_STATUSES: - assert f"Cancelled operation {status.lower()}-0" in caplog.text + list_calls = contree_client.calls_for("list_operations") + assert {call.kwargs["status"] for call in list_calls} == set(ACTIVE_STATUSES) + cancel_calls = contree_client.calls_for("cancel_operation") + assert sorted(call.args[0] for call in cancel_calls) == [ + f"active-{i}" for i in range(len(ACTIVE_STATUSES)) + ] + for i in range(len(ACTIVE_STATUSES)): + assert f"Cancelled operation active-{i}" in caplog.text def test_cancel_all_with_no_active(self, contree_client, caplog): list_pages = [[] for _ in ACTIVE_STATUSES] @@ -337,8 +364,9 @@ def test_cancel_all_with_no_active(self, contree_client, caplog): list_pages=list_pages, ) assert rc is None - # Only GETs, no DELETEs - assert contree_client.request_count == len(ACTIVE_STATUSES) + # Only listings, no cancels + assert len(contree_client.calls_for("list_operations")) == len(ACTIVE_STATUSES) + assert contree_client.calls_for("cancel_operation") == [] assert "No active operations" in caplog.text def test_cancel_all_overrides_explicit_uuids(self, contree_client, caplog): @@ -352,14 +380,13 @@ def test_cancel_all_overrides_explicit_uuids(self, contree_client, caplog): uuids=["ignored-1", "ignored-2"], all_flag=True, list_pages=list_pages, - delete_statuses=[202], + cancel_outcomes=[None], ) assert rc is None assert "--all overrides explicit UUIDs" in caplog.text - # Only one DELETE went out -- for pending-0, not the ignored UUIDs - deletes = [r for r in contree_client.fake.requests if r.method == "DELETE"] - assert len(deletes) == 1 - assert deletes[0].path == "/v1/operations/pending-0" + # Only one cancel went out -- for pending-0, not the ignored UUIDs + cancel_calls = contree_client.calls_for("cancel_operation") + assert [call.args for call in cancel_calls] == [("pending-0",)] # ---------------------------------------------------------------------- @@ -389,20 +416,20 @@ def test_argparse_wait_default_timeout(self): def test_wait_returns_none_on_terminal_success(self, contree_client, monkeypatch): monkeypatch.setattr("contree_cli.cli.operation.time.sleep", lambda _: None) - contree_client.respond_json(_wait_op("op-1", status="SUCCESS")) + mock_op(contree_client, _wait_op("op-1", status="SUCCESS")) FORMATTER.set(JSONFormatter()) CLIENT.set(contree_client) ctx = copy_context() rc = ctx.run(cmd_wait, WaitArgs(uuids=["op-1"], timeout=60)) assert rc is None - assert contree_client.request_count == 1 + assert len(contree_client.calls_for("get_operation_status")) == 1 def test_wait_failed_op_returns_exit_code_one( self, contree_client, monkeypatch, capsys ): monkeypatch.setattr("contree_cli.cli.operation.time.sleep", lambda _: None) - contree_client.respond_json(_wait_op("op-fail", status="FAILED")) + mock_op(contree_client, _wait_op("op-fail", status="FAILED")) FORMATTER.set(JSONFormatter()) CLIENT.set(contree_client) @@ -424,8 +451,12 @@ def test_wait_success_with_nonzero_exit_code_preserves_status( `op wait && next-step` still composes correctly.""" monkeypatch.setattr("contree_cli.cli.operation.time.sleep", lambda _: None) op = _wait_op("op-false", status="SUCCESS") - op["metadata"] = {"result": {"state": {"exit_code": 1}}} - contree_client.respond_json(op) + op["metadata"] = { + "command": "false", + "image": "img-base", + "result": {"state": {"exit_code": 1}}, + } + mock_op(contree_client, op) FORMATTER.set(JSONFormatter()) CLIENT.set(contree_client) @@ -445,8 +476,12 @@ def test_wait_propagates_specific_exit_code(self, contree_client, monkeypatch): sandbox command's status.""" monkeypatch.setattr("contree_cli.cli.operation.time.sleep", lambda _: None) op = _wait_op("op-42", status="SUCCESS") - op["metadata"] = {"result": {"state": {"exit_code": 42}}} - contree_client.respond_json(op) + op["metadata"] = { + "command": "exit 42", + "image": "img-base", + "result": {"state": {"exit_code": 42}}, + } + mock_op(contree_client, op) FORMATTER.set(JSONFormatter()) CLIENT.set(contree_client) @@ -466,8 +501,8 @@ def test_wait_emits_timed_out_column( monkeypatch.setattr("contree_cli.cli.operation.time.sleep", lambda _: None) # Poll: returns EXECUTING (not terminal). Second fetch (post-deadline) # picks up the same op for the timed-out row. - contree_client.respond_json(_wait_op("op-slow", status="EXECUTING")) - contree_client.respond_json(_wait_op("op-slow", status="EXECUTING")) + mock_op(contree_client, _wait_op("op-slow", status="EXECUTING")) + mock_op(contree_client, _wait_op("op-slow", status="EXECUTING")) FORMATTER.set(JSONFormatter()) CLIENT.set(contree_client) @@ -494,8 +529,7 @@ def test_wait_no_args_no_all_errors(self, contree_client, caplog): def test_wait_all_with_no_active(self, contree_client, monkeypatch, caplog): # list_active returns no UUIDs after polling each ACTIVE_STATUS once. - for _ in ACTIVE_STATUSES: - contree_client.respond_json([]) + contree_client.mock("list_operations", []) FORMATTER.set(JSONFormatter()) CLIENT.set(contree_client) @@ -503,6 +537,7 @@ def test_wait_all_with_no_active(self, contree_client, monkeypatch, caplog): with caplog.at_level("INFO"): rc = ctx.run(cmd_wait, WaitArgs(uuids=[], all=True, timeout=60)) assert rc is None + assert len(contree_client.calls_for("list_operations")) == len(ACTIVE_STATUSES) assert "No active operations to wait for" in caplog.text diff --git a/tests/test_ps.py b/tests/test_ps.py index 0b8d69a..5b7b74d 100644 --- a/tests/test_ps.py +++ b/tests/test_ps.py @@ -5,10 +5,10 @@ import pytest from conftest import ContreeTestClient +from contree_client.models import OperationSummary from contree_cli import FORMATTER from contree_cli.cli.operation import ( - PAGE_SIZE, STATUS_CHOICES, ) from contree_cli.cli.operation import ( @@ -22,12 +22,10 @@ def _run_cmd(tc: ContreeTestClient, operations, *, formatter=None, **kwargs): - return _run_cmd_pages(tc, [operations], formatter=formatter, **kwargs) - - -def _run_cmd_pages(tc: ContreeTestClient, pages, *, formatter=None, **kwargs): - for page in pages: - tc.respond_json(page) + tc.mock( + "iter_operations", + [OperationSummary.from_dict(op) for op in operations], + ) FORMATTER.set(formatter or CSVFormatter()) ctx = copy_context() @@ -41,6 +39,10 @@ def _run_cmd_pages(tc: ContreeTestClient, pages, *, formatter=None, **kwargs): ctx.run(cmd_ps, args) +def _list_calls(tc: ContreeTestClient): + return tc.calls_for("iter_operations") + + def _make_op(i, *, status="EXECUTING", kind="instance", duration=1.5): return { "uuid": f"op-{i}", @@ -103,17 +105,17 @@ def test_table_output(self, contree_client, capsys): class TestPsDynamicFields: - """`emit_op` propagates every scalar field the API returns.""" + """List rows carry every scalar field of the OperationSummary schema.""" def test_unknown_top_level_field_appears_in_row(self, contree_client, capsys): - """Server-side additions (e.g. ``cost``) show up without code changes.""" + """Schema fields the handler does not hardcode show up in the row.""" op = _make_op(0) - op["cost"] = 0.0042 - op["project_id"] = "proj-abc" + op["consumed_cpu"] = 0.0042 + op["result_image_uuid"] = "img-result" _run_cmd(contree_client, [op], formatter=JSONFormatter()) parsed = json.loads(capsys.readouterr().out) - assert parsed["cost"] == 0.0042 - assert parsed["project_id"] == "proj-abc" + assert parsed["consumed_cpu"] == 0.0042 + assert parsed["result_image_uuid"] == "img-result" def test_nested_dict_field_skipped(self, contree_client, capsys): """Nested structures (metadata, result) are filtered out of the row.""" @@ -133,14 +135,13 @@ def test_nested_list_field_skipped(self, contree_client, capsys): parsed = json.loads(capsys.readouterr().out) assert "tags" not in parsed - def test_new_datetime_field_parsed(self, contree_client, capsys): - """``finished_at``/``updated_at`` are auto-parsed like ``created_at``.""" + def test_created_at_passes_through_iso(self, contree_client, capsys): + """``created_at`` reaches the row as the ISO instant the API sent.""" op = _make_op(0) - op["finished_at"] = "2025-06-01T01:00:00Z" + op["created_at"] = "2025-06-01T01:00:00Z" _run_cmd(contree_client, [op], formatter=JSONFormatter()) parsed = json.loads(capsys.readouterr().out) - # JSONFormatter serialises datetimes via _json_default -> isoformat. - assert parsed["finished_at"].startswith("2025-06-01T01:00:00") + assert parsed["created_at"].startswith("2025-06-01T01:00:00") def test_error_is_always_last_column(self, contree_client, capsys): """``error`` is pinned to the trailing position regardless of API order.""" @@ -160,78 +161,101 @@ def test_error_is_always_last_column(self, contree_client, capsys): assert parsed["error"] == "boom" def test_error_last_even_when_added_field_present(self, contree_client, capsys): - """A new server field appears before ``error`` in the row.""" + """A schema field the handler does not hardcode appears before ``error``.""" op = _make_op(0, status="FAILED") op["error"] = "oom" - op["cost"] = 0.01 # server field added after `error` in the response + op["image_size"] = 2048 # schema field placed after `error` in the response _run_cmd(contree_client, [op], formatter=JSONFormatter()) parsed = json.loads(capsys.readouterr().out) keys = list(parsed.keys()) assert keys[-1] == "error" - assert "cost" in keys - assert keys.index("cost") < keys.index("error") + assert "image_size" in keys + assert keys.index("image_size") < keys.index("error") class TestPsParams: def test_status_param(self, contree_client): _run_cmd(contree_client, [], status="FAILED") - assert "status=FAILED" in contree_client.request_paths[0] + assert _list_calls(contree_client)[0].kwargs["status"] == "FAILED" def test_kind_param(self, contree_client): _run_cmd(contree_client, [], kind="instance") - assert "kind=instance" in contree_client.request_paths[0] + assert _list_calls(contree_client)[0].kwargs["kind"] == "instance" def test_since_param(self, contree_client): + """The parsed datetime goes to the client as-is; the library + owns the wire formatting (format_time_param).""" + from datetime import datetime + _run_cmd(contree_client, [], since="1h") - path = contree_client.request_paths[0] - assert "since=" in path + since = _list_calls(contree_client)[0].kwargs["since"] + assert isinstance(since, datetime) def test_until_param(self, contree_client): + from datetime import datetime + _run_cmd(contree_client, [], until="2025-01-01") - path = contree_client.request_paths[0] - assert "until=" in path + until = _list_calls(contree_client)[0].kwargs["until"] + assert isinstance(until, datetime) def test_no_filters_no_extra_params(self, contree_client): _run_cmd(contree_client, []) - path = contree_client.request_paths[0] - assert "kind" not in path + kwargs = _list_calls(contree_client)[0].kwargs + assert kwargs["kind"] is None + assert kwargs["since"] is None + assert kwargs["until"] is None class TestPsPagination: - def test_single_page(self, contree_client): + """Offset pagination itself is the library's job (iter_operations); + the CLI contract is a single iterator pass with the record budget + forwarded as limit.""" + + def test_single_iterator_pass(self, contree_client): ops = [_make_op(i) for i in range(5)] _run_cmd(contree_client, ops) - assert contree_client.request_count == 1 + assert len(_list_calls(contree_client)) == 1 - def test_multi_page(self, contree_client, capsys): - page1 = [_make_op(i) for i in range(PAGE_SIZE)] - page2 = [_make_op(i) for i in range(PAGE_SIZE, PAGE_SIZE + 3)] - _run_cmd_pages(contree_client, [page1, page2]) - assert contree_client.request_count == 2 - out = capsys.readouterr().out - assert f"op-{PAGE_SIZE + 2}" in out - - def test_offset_increments(self, contree_client): - page1 = [_make_op(i) for i in range(PAGE_SIZE)] - page2 = [] - _run_cmd_pages(contree_client, [page1, page2], show_max=None) - paths = contree_client.request_paths - assert "offset=0" in paths[0] - assert f"offset={PAGE_SIZE}" in paths[1] - - def test_pages_flushed_progressively(self, contree_client, capsys): - """Each full page is flushed as it completes (streaming output).""" - page1 = [_make_op(i) for i in range(PAGE_SIZE)] - page2 = [_make_op(i) for i in range(PAGE_SIZE, PAGE_SIZE + 3)] - _run_cmd_pages( - contree_client, - [page1, page2], - show_max=None, - ) + def test_limit_forwarded_with_probe(self, contree_client): + """--show-max N asks the iterator for N+1 records so truncation + is detectable.""" + _run_cmd(contree_client, [], show_max=7) + assert _list_calls(contree_client)[0].kwargs["limit"] == 8 + + def test_no_show_max_means_unbounded(self, contree_client): + _run_cmd(contree_client, [], show_max=None) + assert _list_calls(contree_client)[0].kwargs["limit"] is None + + def test_long_stream_fully_emitted(self, contree_client, capsys): + ops = [_make_op(i) for i in range(25)] + _run_cmd(contree_client, ops, show_max=None) out = capsys.readouterr().out - # All rows from both pages should appear in output. - assert f"op-{PAGE_SIZE - 1}" in out - assert f"op-{PAGE_SIZE + 2}" in out + assert "op-0" in out + assert "op-24" in out + + def test_progress_logged_per_page(self, contree_client, caplog): + """Every consumed page reports progress, so `ps -a` over a big + history does not look hung while the next page loads.""" + import logging + + ops = [_make_op(i) for i in range(2500)] + with caplog.at_level(logging.INFO, logger="contree_cli.cli.operation"): + _run_cmd(contree_client, ops, show_max=None, all=True) + progress = [ + r.getMessage() for r in caplog.records if "loading more" in r.getMessage() + ] + assert progress == [ + "Fetched 1000 operations, loading more...", + "Fetched 2000 operations, loading more...", + ] + + def test_no_progress_log_for_short_stream(self, contree_client, caplog): + import logging + + ops = [_make_op(i) for i in range(5)] + with caplog.at_level(logging.INFO, logger="contree_cli.cli.operation"): + _run_cmd(contree_client, ops, show_max=None, all=True) + assert "loading more" not in caplog.text class TestPsActiveFilter: @@ -239,7 +263,7 @@ def test_default_sends_executing_status_to_server(self, contree_client, capsys): """Default ps sends status=EXECUTING to the server for filtering.""" ops = [_make_op(0, status="EXECUTING")] _run_cmd(contree_client, ops) - assert "status=EXECUTING" in contree_client.request_paths[0] + assert _list_calls(contree_client)[0].kwargs["status"] == "EXECUTING" out = capsys.readouterr().out assert "op-0" in out @@ -267,19 +291,19 @@ def test_default_quiet_sends_status_filter(self, contree_client, capsys): _run_cmd(contree_client, ops, quiet=True) lines = capsys.readouterr().out.strip().splitlines() assert lines == ["op-0"] - assert "status=EXECUTING" in contree_client.request_paths[0] + assert _list_calls(contree_client)[0].kwargs["status"] == "EXECUTING" def test_all_flag_no_status_param(self, contree_client): """--all flag does not send a status filter to the server.""" ops = [_make_op(0)] _run_cmd(contree_client, ops, all=True) - assert "status=" not in contree_client.request_paths[0] + assert _list_calls(contree_client)[0].kwargs["status"] is None def test_all_with_explicit_status(self, contree_client, capsys): """--all combined with --status sends the status filter.""" ops = [_make_op(0, status="FAILED")] _run_cmd(contree_client, ops, all=True, status="FAILED") - assert "status=FAILED" in contree_client.request_paths[0] + assert _list_calls(contree_client)[0].kwargs["status"] == "FAILED" assert "op-0" in capsys.readouterr().out @pytest.mark.parametrize( @@ -294,16 +318,16 @@ def test_status_shortcut_expansion( ): """Single-letter status shortcuts are expanded.""" _run_cmd(contree_client, [], status=short) - assert f"status={full}" in contree_client.request_paths[0] + assert _list_calls(contree_client)[0].kwargs["status"] == full class TestPsShowMax: def test_show_max_truncates_output(self, contree_client, capsys): - """show_max caps emitted ops; probe runs after for more.""" - page = [_make_op(i) for i in range(5)] - _run_cmd_pages( + """show_max caps emitted ops mid-page.""" + ops = [_make_op(i) for i in range(5)] + _run_cmd( contree_client, - [page, [_make_op(99)]], # main + probe + ops, show_max=3, all=True, ) @@ -314,10 +338,10 @@ def test_show_max_truncates_output(self, contree_client, capsys): assert "op-3" not in out def test_show_max_logs_warning(self, contree_client, caplog): - page = [_make_op(i) for i in range(5)] - _run_cmd_pages( + ops = [_make_op(i) for i in range(5)] + _run_cmd( contree_client, - [page, [_make_op(99)]], # probe finds more + ops, show_max=3, all=True, ) @@ -347,38 +371,34 @@ def test_show_max_no_warning_when_under_limit( assert "Output truncated" not in caplog.text def test_show_max_stops_pagination(self, contree_client): - """show_max stops mid-page; short first page is detected without a probe.""" + """show_max stops mid-page without fetching further pages.""" ops = [_make_op(i) for i in range(10)] - _run_cmd_pages( + _run_cmd( contree_client, - [ops], + ops, show_max=3, all=True, ) - # Short page (10 < PAGE_SIZE) is enough to know we've seen all data; - # no need for the historical probe request. - assert contree_client.request_count == 1 - - def test_show_max_across_pages(self, contree_client, capsys): - """show_max truncates across page boundaries.""" - page1 = [_make_op(i) for i in range(PAGE_SIZE)] - page2 = [_make_op(i) for i in range(PAGE_SIZE, PAGE_SIZE + 5)] - _run_cmd_pages( + assert len(_list_calls(contree_client)) == 1 + + def test_show_max_truncates_stream(self, contree_client, capsys): + ops = [_make_op(i) for i in range(12)] + _run_cmd( contree_client, - [page1, page2, [_make_op(99)]], # main pages + probe - show_max=PAGE_SIZE + 2, + ops, + show_max=10, all=True, ) out = capsys.readouterr().out - assert f"op-{PAGE_SIZE + 1}" in out - assert f"op-{PAGE_SIZE + 2}" not in out + assert "op-9" in out + assert "op-10" not in out def test_show_max_one_shows_one(self, contree_client, capsys): """show_max=1 emits exactly one op (no off-by-one).""" ops = [_make_op(0), _make_op(1)] - _run_cmd_pages( + _run_cmd( contree_client, - [ops, [_make_op(99)]], # main + probe + ops, show_max=1, all=True, ) @@ -386,12 +406,12 @@ def test_show_max_one_shows_one(self, contree_client, capsys): assert "op-0" in out assert "op-1" not in out - def test_show_max_no_warning_when_probe_empty(self, contree_client, caplog): - """Empty probe means we hit show_max but there's nothing more.""" - page = [_make_op(i) for i in range(3)] - _run_cmd_pages( + def test_show_max_no_warning_when_stream_fits(self, contree_client, caplog): + """Exactly show_max ops in the stream means nothing was cut.""" + ops = [_make_op(i) for i in range(3)] + _run_cmd( contree_client, - [page, []], # probe empty + ops, show_max=3, all=True, ) @@ -401,9 +421,11 @@ def test_show_max_warning_after_table_flush(self, contree_client, caplog, capsys """TableFormatter buffer is flushed before the warning is logged.""" import logging - page = [_make_op(i) for i in range(5)] - for response in (page, [_make_op(99)]): - contree_client.respond_json(response) + ops = [_make_op(i) for i in range(5)] + contree_client.mock( + "iter_operations", + [OperationSummary.from_dict(op) for op in ops], + ) FORMATTER.set(TableFormatter()) ctx = copy_context() diff --git a/tests/test_run.py b/tests/test_run.py index 04f7a34..44aa8fc 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -7,25 +7,36 @@ import os import select from contextvars import copy_context -from typing import ClassVar from unittest.mock import MagicMock, patch import pytest -from conftest import ContreeTestClient, FakeResponse +from conftest import ContreeTestClient +from contree_client.exceptions import ( + ContreeAPIError, + NotFoundError, + SSEStreamError, +) +from contree_client.models import ( + File, + FileResponse, + InstanceSpawnResponse, + OperationEvent, + OperationResponse, + StreamRepr, +) from contree_cli import CLIENT, FORMATTER, SESSION_STORE from contree_cli.cli.run import ( RunArgs, TerminalSummary, - _build_op_from_summary, _expand_mapped_files, _is_excluded, _local_file_cache_kind, _read_piped_stdin, - _stream_events_until_close, + build_op_from_summary, cmd_run, + stream_events_until_close, ) -from contree_cli.client import ApiError from contree_cli.mapped_file import MappedFile from contree_cli.output import DefaultFormatter, JSONFormatter from contree_cli.session import SessionStore @@ -35,15 +46,15 @@ IMG_NEW2 = "d4e5f6a7-89ab-cdef-0123-444444444444" IMG_SOME = "b2c3d4e5-6789-abcd-ef01-222222222222" +# A prepared mock outcome: (operation name, result model or exception). +MockSpec = tuple[str, object] -def _spawn_response(uuid: str = "op-1") -> FakeResponse: - return FakeResponse.json( - {"uuid": uuid, "status": "PENDING"}, - status=201, - ) +def _spawn_response(uuid: str = "op-1") -> MockSpec: + return ("spawn_instance", InstanceSpawnResponse.from_dict({"uuid": uuid})) -def _op_response( + +def op_body( uuid: str = "op-1", status: str = "SUCCESS", *, @@ -54,7 +65,8 @@ def _op_response( error: str | None = None, image: str = IMG_NEW, state_extra: dict | None = None, -) -> FakeResponse: +) -> dict: + """Full GET /v1/operations/{uuid} payload as the API returns it.""" state: dict = {} if exit_code is not None: state["exit_code"] = exit_code @@ -67,21 +79,97 @@ def _op_response( "stderr": stderr, "state": state or None, } - return FakeResponse.json( - { - "uuid": uuid, - "kind": "instance", - "status": status, - "error": error, - "duration": duration, - "metadata": {"result": instance_result}, - "result": {"image": image, "tag": "latest"}, - } + # OperationInstanceMetadata requires `command` and `image` on the + # wire; the API always echoes the spawn parameters back here. + return { + "uuid": uuid, + "kind": "instance", + "status": status, + "error": error, + "created_at": "2026-01-01T00:00:00+00:00", + "duration": duration, + "metadata": { + "command": "echo", + "image": IMG_UUID, + "shell": False, + "result": instance_result, + }, + "result": {"image": image, "tag": "latest"}, + } + + +def _op_response(uuid: str = "op-1", status: str = "SUCCESS", **kwargs) -> MockSpec: + return ( + "get_operation_status", + OperationResponse.from_dict(op_body(uuid, status, **kwargs)), ) -def _api_response(body: dict, *, status: int = 200) -> FakeResponse: - return FakeResponse.json(body, status=status) +def tag_lookup(uuid: str) -> MockSpec: + """GET /v1/images?tag=... resolution result.""" + return ("inspect_find_image_by_tag", uuid) + + +def file_info_response(uuid: str) -> MockSpec: + """GET /v1/files/{sha256} dedup hit: the full File record.""" + return ( + "get_file", + File.from_dict( + { + "uuid": uuid, + "sha256": "0" * 64, + "size": 7, + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + } + ), + ) + + +def file_missing_response() -> MockSpec: + """GET /v1/files/{sha256} dedup miss: 404.""" + return ("get_file", NotFoundError(404, "not found")) + + +def file_upload_response(uuid: str, sha256: str = "0" * 64) -> MockSpec: + """POST /v1/files success: uuid + sha256 + size.""" + return ("upload_file", FileResponse(uuid=uuid, sha256=sha256, size=7)) + + +def apply_mocks(tc: ContreeTestClient, mocks: list[MockSpec]) -> None: + """Queue mock outcomes; auto-mock the SSE stream as empty when the + test doesn't care about it (the CLI always opens the event stream + before falling back to the terminal GET).""" + for name, value in mocks: + if isinstance(value, BaseException): + tc.mock(name, error=value) + else: + tc.mock(name, value) + if all(name != "iter_operation_events" for name, _ in mocks): + tc.mock("iter_operation_events", []) + + +def call_ops(tc: ContreeTestClient) -> list[str]: + """Operation names of every recorded API call, in order.""" + return [c.operation for c in tc.calls] + + +def spawn_payload(tc: ContreeTestClient, index: int = 0) -> dict: + """Reassemble the spawn_instance call into the wire-payload shape + the old tests asserted against: positional (command, image) merged + with the kwargs, FileSpec/StreamRepr models rendered as dicts.""" + call = tc.calls_for("spawn_instance")[index] + payload: dict = {"command": call.args[0], "image": call.args[1], **call.kwargs} + files = payload.get("files") + if isinstance(files, dict): + payload["files"] = { + path: spec.to_dict() if hasattr(spec, "to_dict") else spec + for path, spec in files.items() + } + stdin = payload.get("stdin") + if stdin is not None and hasattr(stdin, "to_dict"): + payload["stdin"] = stdin.to_dict() + return payload def _tty_stdin() -> MagicMock: @@ -94,19 +182,19 @@ def _tty_stdin() -> MagicMock: def _run_cmd( tc: ContreeTestClient, args: RunArgs, - responses: list[FakeResponse], + mocks: list[MockSpec], *, store: SessionStore, formatter=None, stdin_mock: MagicMock | None = None, ): - """Run cmd_run with mocked HTTP responses and mocked sleep. + """Run cmd_run with prepared method-level mocks and mocked sleep. - The fake connection auto-serves empty SSE responses for any - GET /events path so existing tests can stay shaped as - `[spawn, op]` without knowing the CLI now opens an SSE first. + Tests that invoke cmd_run twice on the same client must queue all + mocks in the first call (outcome queues are FIFO with a sticky + tail) and pass [] on the second. """ - tc.fake.responses.extend(responses) + apply_mocks(tc, mocks) FORMATTER.set(formatter or JSONFormatter()) SESSION_STORE.set(store) @@ -141,12 +229,11 @@ def test_detach_exits_after_spawn(self, contree_client, session_store, capsys): assert "op-1" in out def test_detach_no_poll_request(self, contree_client, session_store): - """Only 1 HTTP request (the POST spawn), no GET poll.""" + """Only the spawn call is made, no status poll.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args(detach=True) _run_cmd(contree_client, args, [_spawn_response()], store=session_store) - assert contree_client.request_count == 1 - assert contree_client.get_request(0).method == "POST" + assert call_ops(contree_client) == ["spawn_instance"] def test_detach_shows_status(self, contree_client, session_store, capsys): session_store.set_image(IMG_UUID, kind="test") @@ -168,36 +255,43 @@ def test_detach_shows_status(self, contree_client, session_store, capsys): class TestPollLoop: def test_poll_until_success(self, contree_client, session_store, capsys): - """SSE follow=1 makes the API serve the terminal state directly — - the CLI no longer polls through intermediate PENDING snapshots.""" + """The event stream closes without a completion frame; the + terminal-check GET serves the final state directly.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 0 parsed = json.loads(capsys.readouterr().out) assert parsed["status"] == "SUCCESS" - def test_unknown_field_passes_through(self, contree_client, session_store, capsys): - """New server fields on the operation reach JSON output as-is.""" + def test_unknown_field_filtered_by_typed_client( + self, contree_client, session_store, capsys + ): + """Typed response models keep only spec fields: unknown server + fields are dropped by contree-client instead of passing through + to JSON output, while known fields still land as-is.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args() - op_body = json.loads(_op_response(status="SUCCESS", exit_code=0).body) - op_body["session_key"] = "sess-1" - op_body["future_field"] = "anything" - responses = [_spawn_response(), FakeResponse.json(op_body)] - _run_cmd(contree_client, args, responses, store=session_store) + body = op_body(status="SUCCESS", exit_code=0) + body["future_field"] = "anything" + mocks: list[MockSpec] = [ + _spawn_response(), + ("get_operation_status", OperationResponse.from_dict(body)), + ] + _run_cmd(contree_client, args, mocks, store=session_store) parsed = json.loads(capsys.readouterr().out) - assert parsed["session_key"] == "sess-1" - assert parsed["future_field"] == "anything" + assert "future_field" not in parsed + assert parsed["status"] == "SUCCESS" + assert parsed["uuid"] == "op-1" def test_poll_default_shows_stdout(self, contree_client, session_store, capsys): session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response( status="SUCCESS", @@ -208,7 +302,7 @@ def test_poll_default_shows_stdout(self, contree_client, session_store, capsys): rc = _run_cmd( contree_client, args, - responses, + mocks, store=session_store, formatter=DefaultFormatter(), ) @@ -219,32 +313,32 @@ def test_poll_default_shows_stdout(self, contree_client, session_store, capsys): def test_poll_until_failed(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="FAILED", exit_code=None, error="timeout"), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 1 def test_poll_until_cancelled(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="CANCELLED", exit_code=None), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 1 def test_failed_with_exit_code(self, contree_client, session_store): """FAILED with exit code (e.g. timeout kill) returns the exit code.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="FAILED", exit_code=137, error="timeout"), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 137 def test_timed_out_logs_warning(self, contree_client, session_store, caplog): @@ -255,7 +349,7 @@ def test_timed_out_logs_warning(self, contree_client, session_store, caplog): """ session_store.set_image(IMG_UUID, kind="test") args = _default_args(timeout=60) - responses = [ + mocks = [ _spawn_response(), _op_response( status="SUCCESS", @@ -264,7 +358,7 @@ def test_timed_out_logs_warning(self, contree_client, session_store, caplog): ), ] with caplog.at_level(logging.WARNING, logger="contree_cli.cli.run"): - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) records = [r for r in caplog.records if "timed out" in r.getMessage()] assert len(records) == 1 @@ -277,12 +371,12 @@ def test_failed_without_timeout_still_fatal( """A non-timeout FAILED keeps emitting at FATAL severity.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="FAILED", exit_code=1, error="oom"), ] with caplog.at_level(logging.WARNING, logger="contree_cli.cli.run"): - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) ended = [r for r in caplog.records if "ended with status" in r.getMessage()] assert len(ended) == 1 @@ -294,32 +388,32 @@ def test_success_without_timeout_logs_nothing( """Plain SUCCESS does not emit a timeout warning.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] with caplog.at_level(logging.WARNING, logger="contree_cli.cli.run"): - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert [r for r in caplog.records if "timed out" in r.getMessage()] == [] def test_exit_code_propagated(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=42), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 42 def test_success_no_exit_code(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=None), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc is None @@ -328,16 +422,14 @@ def test_success_no_exit_code(self, contree_client, session_store): class TestCtrlC: @staticmethod - def _run_ctrl_c( - responses: list[FakeResponse], store: SessionStore - ) -> ContreeTestClient: - """Run cmd_run with the events stream raising KeyboardInterrupt - — simulates the user hitting Ctrl-C while the CLI was waiting + def _run_ctrl_c(mocks: list[MockSpec], store: SessionStore) -> ContreeTestClient: + """Run cmd_run with the events stream raising KeyboardInterrupt, + simulating the user hitting Ctrl-C while the CLI was waiting on SSE for the operation to terminate.""" store.set_image(IMG_UUID, kind="test") args = _default_args() tc = ContreeTestClient() - tc.fake.responses.extend(responses) + apply_mocks(tc, mocks) CLIENT.set(tc) FORMATTER.set(JSONFormatter()) @@ -346,7 +438,7 @@ def _run_ctrl_c( with ( patch( - "contree_cli.cli.run._stream_events_until_close", + "contree_cli.cli.run.stream_events_until_close", side_effect=KeyboardInterrupt, ), patch("contree_cli.cli.run.sys.stdin", _tty_stdin()), @@ -356,25 +448,24 @@ def _run_ctrl_c( return tc def test_ctrl_c_cancels_operation(self, session_store): - """On KeyboardInterrupt during the wait, DELETE is sent.""" + """On KeyboardInterrupt during the wait, the op is cancelled.""" tc = self._run_ctrl_c( [ _spawn_response(), - _api_response({}, status=202), + ("cancel_operation", None), ], session_store, ) - methods = [r.method for r in tc.fake.requests] - assert "DELETE" in methods - delete_req = next(r for r in tc.fake.requests if r.method == "DELETE") - assert "/v1/operations/op-1" in delete_req.path + cancels = tc.calls_for("cancel_operation") + assert len(cancels) == 1 + assert cancels[0].args == ("op-1",) def test_ctrl_c_delete_failure_still_raises(self, session_store): - """If DELETE fails, KeyboardInterrupt is still re-raised.""" + """If the cancel fails, KeyboardInterrupt is still re-raised.""" self._run_ctrl_c( [ _spawn_response(), - _api_response({"error": "not found"}, status=404), + ("cancel_operation", NotFoundError(404, "not found")), ], session_store, ) @@ -388,12 +479,12 @@ class TestBrokenPipe: @staticmethod def _run_broken_pipe( - responses: list[FakeResponse], store: SessionStore + mocks: list[MockSpec], store: SessionStore ) -> ContreeTestClient: store.set_image(IMG_UUID, kind="test") args = _default_args() tc = ContreeTestClient() - tc.fake.responses.extend(responses) + apply_mocks(tc, mocks) CLIENT.set(tc) FORMATTER.set(JSONFormatter()) @@ -402,7 +493,7 @@ def _run_broken_pipe( with ( patch( - "contree_cli.cli.run._stream_events_until_close", + "contree_cli.cli.run.stream_events_until_close", side_effect=BrokenPipeError, ), patch("contree_cli.cli.run.sys.stdin", _tty_stdin()), @@ -422,19 +513,21 @@ def _run_broken_pipe( def test_broken_pipe_cancels_operation(self, session_store): tc = self._run_broken_pipe( - [_spawn_response(), _api_response({}, status=202)], + [_spawn_response(), ("cancel_operation", None)], session_store, ) - methods = [r.method for r in tc.fake.requests] - assert "DELETE" in methods - delete_req = next(r for r in tc.fake.requests if r.method == "DELETE") - assert "/v1/operations/op-1" in delete_req.path + cancels = tc.calls_for("cancel_operation") + assert len(cancels) == 1 + assert cancels[0].args == ("op-1",) def test_broken_pipe_delete_failure_still_exits_141(self, session_store): - """Even if the DELETE fails, we still exit 141 rather than + """Even if the cancel fails, we still exit 141 rather than re-raising BrokenPipeError.""" self._run_broken_pipe( - [_spawn_response(), _api_response({"error": "not found"}, status=404)], + [ + _spawn_response(), + ("cancel_operation", NotFoundError(404, "not found")), + ], session_store, ) @@ -480,7 +573,7 @@ def test_expand_directory_custom_excludes(self, tmp_path): class TestFileUpload: def test_file_upload(self, contree_client, session_store, tmp_path): - """POST /v1/files is called for each attached file.""" + """upload_file is called for each attached file after dedup miss.""" session_store.set_image(IMG_UUID, kind="test") host_file = tmp_path / "data.txt" host_file.write_text("content") @@ -493,26 +586,21 @@ def test_file_upload(self, contree_client, session_store, tmp_path): ) args = _default_args(file=[mf]) - file_resp = _api_response( - {"uuid": "file-uuid-1", "sha256": "abc"}, - status=201, - ) - responses = [ - _api_response({"error": "not found"}, status=404), # GET dedup miss - file_resp, # POST /v1/files + mocks = [ + file_missing_response(), # GET dedup miss + file_upload_response("file-uuid-1"), # POST /v1/files _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 0 - # Verify dedup check then file upload - req0 = contree_client.get_request(0) - assert req0.method == "GET" - assert "/v1/files/" in req0.path - req1 = contree_client.get_request(1) - assert req1.method == "POST" - assert "/v1/files" in req1.path + # Verify dedup check then file upload, before the spawn + assert call_ops(contree_client)[:3] == [ + "get_file", + "upload_file", + "spawn_instance", + ] def test_file_uuid_in_spawn_payload(self, contree_client, session_store, tmp_path): """Uploaded file UUID appears in the spawn payload.""" @@ -528,27 +616,21 @@ def test_file_uuid_in_spawn_payload(self, contree_client, session_store, tmp_pat ) args = _default_args(file=[mf]) - file_resp = _api_response( - {"uuid": "file-42", "sha256": "def"}, - status=201, - ) - responses = [ - _api_response({"error": "not found"}, status=404), # GET dedup miss - file_resp, + mocks = [ + file_missing_response(), # GET dedup miss + file_upload_response("file-42"), _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) - # GET dedup, POST /v1/files, then POST /v1/instances - spawn_req = contree_client.get_request(2) - body = json.loads(spawn_req.body) + body = spawn_payload(contree_client) assert "/app/script.sh" in body["files"] assert body["files"]["/app/script.sh"]["uuid"] == "file-42" assert body["files"]["/app/script.sh"]["uid"] == 1000 def test_file_dedup_skips_upload(self, contree_client, session_store, tmp_path): - """GET /v1/files/... returns 200 -> no POST upload, UUID reused.""" + """get_file returns the record -> no upload, UUID reused.""" session_store.set_image(IMG_UUID, kind="test") host_file = tmp_path / "data.txt" host_file.write_text("content") @@ -561,24 +643,19 @@ def test_file_dedup_skips_upload(self, contree_client, session_store, tmp_path): ) args = _default_args(file=[mf]) - responses = [ - _api_response({"uuid": "existing-uuid"}), # GET dedup hit + mocks = [ + file_info_response("existing-uuid"), # GET dedup hit _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 0 - methods = [r.method for r in contree_client.fake.requests] - # Only GET (dedup), POST (spawn), GET (poll) -- no POST /v1/files - req0 = contree_client.get_request(0) - assert req0.method == "GET" - assert "/v1/files/" in req0.path - assert methods.count("POST") == 1 # only the spawn POST + assert call_ops(contree_client)[:2] == ["get_file", "spawn_instance"] + assert contree_client.calls_for("upload_file") == [] # Spawn uses the existing UUID - spawn_req = contree_client.get_request(1) - body = json.loads(spawn_req.body) + body = spawn_payload(contree_client) assert body["files"]["/app/data.txt"]["uuid"] == "existing-uuid" def test_file_dedup_logs_reuse( @@ -597,18 +674,18 @@ def test_file_dedup_logs_reuse( ) args = _default_args(file=[mf]) - responses = [ - _api_response({"uuid": "existing-uuid"}), # GET dedup hit + mocks = [ + file_info_response("existing-uuid"), # GET dedup hit _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] with caplog.at_level(logging.INFO): - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert "File reused:" in caplog.text assert "existing-uuid" in caplog.text def test_file_dedup_non_404_raises(self, contree_client, session_store, tmp_path): - """Non-404 error from GET /v1/files propagates.""" + """Non-404 error from get_file propagates.""" session_store.set_image(IMG_UUID, kind="test") host_file = tmp_path / "data.txt" host_file.write_text("content") @@ -621,10 +698,9 @@ def test_file_dedup_non_404_raises(self, contree_client, session_store, tmp_path ) args = _default_args(file=[mf]) - # 403 is non-retryable, so the caller sees it on the first hit. - responses = [_api_response({"error": "forbidden"}, status=403)] - with pytest.raises(ApiError) as exc_info: - _run_cmd(contree_client, args, responses, store=session_store) + mocks: list[MockSpec] = [("get_file", ContreeAPIError(403, "forbidden"))] + with pytest.raises(ContreeAPIError) as exc_info: + _run_cmd(contree_client, args, mocks, store=session_store) assert exc_info.value.status == 403 def test_local_file_cache_skips_api_file_lookup( @@ -649,17 +725,17 @@ def test_local_file_cache_skips_api_file_lookup( } args = _default_args(file=[mf]) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) - # spawn + poll only, no GET/POST /v1/files - req0 = contree_client.get_request(0) - assert req0.method == "POST" - assert "/v1/instances" in req0.path - body = json.loads(req0.body) + # spawn + poll only, no get_file/upload_file + assert call_ops(contree_client)[0] == "spawn_instance" + assert contree_client.calls_for("get_file") == [] + assert contree_client.calls_for("upload_file") == [] + body = spawn_payload(contree_client) assert body["files"]["/app/cached.txt"]["uuid"] == "cached-uuid" def test_local_file_cache_invalidated_when_file_changes( @@ -684,19 +760,16 @@ def test_local_file_cache_invalidated_when_file_changes( os.utime(host_file, ns=(st.st_atime_ns, st.st_mtime_ns + 1)) args = _default_args(file=[mf]) - responses = [ - _api_response({"uuid": "new-uuid"}), # GET dedup hit for new content + mocks = [ + file_info_response("new-uuid"), # GET dedup hit for new content _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) - req0 = contree_client.get_request(0) - assert req0.method == "GET" - assert "/v1/files/" in req0.path - spawn_req = contree_client.get_request(1) - spawn_body = json.loads(spawn_req.body) - assert spawn_body["files"]["/app/cached-change.txt"]["uuid"] == "new-uuid" + assert call_ops(contree_client)[0] == "get_file" + body = spawn_payload(contree_client) + assert body["files"]["/app/cached-change.txt"]["uuid"] == "new-uuid" class TestParallelUpload: @@ -771,13 +844,12 @@ def _get_payload( self, tc: ContreeTestClient, args: RunArgs, store: SessionStore ) -> dict: store.set_image(IMG_UUID, kind="test") - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(tc, args, responses, store=store) - spawn_req = tc.get_request(0) - return json.loads(spawn_req.body) + _run_cmd(tc, args, mocks, store=store) + return spawn_payload(tc) def test_basic_fields(self, contree_client, session_store): args = _default_args() @@ -836,36 +908,32 @@ class TestTagResolution: def test_tag_resolved_before_spawn(self, contree_client, session_store): session_store.set_image("tag:latest", kind="test") args = _default_args() - tag_resp = _api_response( - {"images": [{"uuid": "resolved-uuid"}]}, - ) - responses = [ - tag_resp, # GET /v1/images?tag=latest + mocks = [ + tag_lookup("resolved-uuid"), # GET /v1/images?tag=latest _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) - # First request is the tag lookup - req0 = contree_client.get_request(0) - assert req0.method == "GET" - assert "tag=latest" in req0.path + # First call is the tag lookup + assert call_ops(contree_client)[0] == "inspect_find_image_by_tag" + lookup = contree_client.calls_for("inspect_find_image_by_tag")[0] + assert lookup.args == ("latest",) # Spawn uses resolved UUID - spawn_req = contree_client.get_request(1) - body = json.loads(spawn_req.body) + body = spawn_payload(contree_client) assert body["image"] == "resolved-uuid" def test_uuid_passthrough(self, contree_client, session_store): session_store.set_image(IMG_SOME, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + assert contree_client.calls_for("inspect_find_image_by_tag") == [] + body = spawn_payload(contree_client) assert body["image"] == IMG_SOME @@ -876,50 +944,46 @@ class TestEnvParsing: def test_env_key_value(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args(env=["FOO=bar", "BAZ=qux"]) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["env"] == {"FOO": "bar", "BAZ": "qux"} def test_env_empty_value(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args(env=["KEY="]) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["env"] == {"KEY": ""} def test_no_env_omitted(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args(env=[]) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert "env" not in body def test_session_env_no_auto_preserve(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") session_store.set_env("PATH", "/usr/bin:/bin") args = _default_args(env=[]) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["env"] == {"PATH": "/usr/bin:/bin"} assert "preserve_env" not in body @@ -927,13 +991,12 @@ def test_session_env_with_per_run_override(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") session_store.set_env("PATH", "/usr/bin:/bin") args = _default_args(env=["DEBUG=1"]) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["env"] == {"PATH": "/usr/bin:/bin", "DEBUG": "1"} assert "preserve_env" not in body @@ -941,38 +1004,35 @@ def test_session_env_with_preserve_flag(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") session_store.set_env("PATH", "/usr/bin:/bin") args = _default_args(preserve_env=True) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["env"] == {"PATH": "/usr/bin:/bin"} assert body["preserve_env"] is True def test_preserve_env_flag(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args(preserve_env=True) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["preserve_env"] is True def test_preserve_env_with_per_run_env(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") args = _default_args(env=["FOO=bar"], preserve_env=True) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["env"] == {"FOO": "bar"} assert body["preserve_env"] is True @@ -982,26 +1042,21 @@ def test_preserved_env_not_resent(self, contree_client, session_store): session_store.set_env("PATH", "/usr/bin") new_img = "00000000-0000-0000-0000-000000000099" - # First run: preserve env + # First run: preserve env. Mocks for both runs are queued up + # front (outcome queues pop in FIFO order across invocations). args1 = _default_args(preserve_env=True) - responses1 = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=new_img), + _spawn_response(), + _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args1, responses1, store=session_store) - - # Clear recorded requests for second run - contree_client.fake.requests.clear() + _run_cmd(contree_client, args1, mocks, store=session_store) # Second run: same env, should skip sending it args2 = _default_args() - responses2 = [ - _spawn_response(), - _op_response(status="SUCCESS", exit_code=0), - ] - _run_cmd(contree_client, args2, responses2, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args2, [], store=session_store) + body = spawn_payload(contree_client, index=1) assert "env" not in body def test_preserved_env_resent_after_rollback(self, contree_client, session_store): @@ -1010,29 +1065,23 @@ def test_preserved_env_resent_after_rollback(self, contree_client, session_store session_store.set_env("PATH", "/usr/bin") new_img = "00000000-0000-0000-0000-000000000099" - # Run with preserve + # Run with preserve (mocks for both runs queued up front) args1 = _default_args(preserve_env=True) - responses1 = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=new_img), + _spawn_response(), + _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args1, responses1, store=session_store) + _run_cmd(contree_client, args1, mocks, store=session_store) # Rollback to original image (no preserved env) session_store.rollback(1) - # Clear recorded requests for next run - contree_client.fake.requests.clear() - # Run again: env must be sent because original image has no preserved env args2 = _default_args() - responses2 = [ - _spawn_response(), - _op_response(status="SUCCESS", exit_code=0), - ] - _run_cmd(contree_client, args2, responses2, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args2, [], store=session_store) + body = spawn_payload(contree_client, index=1) assert body["env"] == {"PATH": "/usr/bin"} @@ -1046,13 +1095,12 @@ def test_shell_joins_command(self, contree_client, session_store): command_args=["echo", "hello", "world"], shell=True, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["command"] == "echo hello world" assert body["shell"] is True assert "args" not in body @@ -1063,13 +1111,12 @@ def test_non_shell_splits_command(self, contree_client, session_store): command_args=["echo", "hello", "world"], shell=False, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["command"] == "echo" assert body["args"] == ["hello", "world"] assert body["shell"] is False @@ -1085,13 +1132,12 @@ def test_non_shell_passes_args_raw(self, contree_client, session_store): command_args=["python3", "-c", "print('hello world')"], shell=False, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["command"] == "python3" assert body["args"] == ["-c", "print('hello world')"] @@ -1101,13 +1147,12 @@ def test_shell_quotes_arg_with_spaces(self, contree_client, session_store): command_args=["python3", "-c", "print('hello world')"], shell=True, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) # shlex.join must round-trip back through shlex.split to the # original argv when the remote shell parses the command. import shlex @@ -1126,13 +1171,12 @@ def test_shell_does_not_overquote_simple_tokens( command_args=["ls", "-la", "/etc"], shell=True, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["command"] == "ls -la /etc" def test_shell_passes_single_expression_verbatim( @@ -1149,13 +1193,12 @@ def test_shell_passes_single_expression_verbatim( command_args=["echo 1 ; echo 2"], shell=True, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["command"] == "echo 1 ; echo 2" assert body["shell"] is True @@ -1173,14 +1216,13 @@ def test_skips_unready_stdin(self, contree_client, session_store, monkeypatch): monkeypatch.setattr(select, "select", lambda *args, **kwargs: ([], [], [])) args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - _run_cmd(contree_client, args, responses, store=session_store, stdin_mock=fake) + _run_cmd(contree_client, args, mocks, store=session_store, stdin_mock=fake) # ensure request sent without stdin field - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + body = spawn_payload(contree_client) assert "stdin" not in body def test_reads_ready_stdin(self, contree_client, session_store, monkeypatch): @@ -1192,13 +1234,12 @@ def test_reads_ready_stdin(self, contree_client, session_store, monkeypatch): monkeypatch.setattr(select, "select", lambda *args, **kwargs: ([0], [], [])) args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - _run_cmd(contree_client, args, responses, store=session_store, stdin_mock=fake) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store, stdin_mock=fake) + body = spawn_payload(contree_client) assert "stdin" in body assert body["stdin"]["value"] @@ -1208,11 +1249,11 @@ def test_success_updates_session(self, contree_client, session_store): """On SUCCESS with new image, session is updated.""" session_store.set_image(IMG_UUID, kind="use") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert session_store.current_image == IMG_NEW s = session_store.session assert s is not None @@ -1224,11 +1265,11 @@ def test_disposable_creates_branch_no_image_update( """Disposable runs create disposable branch without changing image.""" session_store.set_image(IMG_UUID, kind="use") args = _default_args(disposable=True) - responses = [ + mocks = [ _spawn_response("op-dispose"), _op_response("op-dispose", status="SUCCESS", exit_code=0, image=IMG_NEW), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert session_store.current_image == IMG_UUID branches = dict(session_store.list_branches()) assert "disposable-op-dispose" in branches @@ -1239,8 +1280,12 @@ def test_disposable_detach_creates_branch( ) -> None: session_store.set_image(IMG_UUID, kind="use") args = _default_args(disposable=True, detach=True) - responses = [_spawn_response("op-dispose-det")] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd( + contree_client, + args, + [_spawn_response("op-dispose-det")], + store=session_store, + ) branches = dict(session_store.list_branches()) assert "disposable-op-dispose-det" in branches assert branches["disposable-op-dispose-det"] is False @@ -1249,22 +1294,22 @@ def test_disposable_does_not_update_session(self, contree_client, session_store) """Disposable runs do not update the session image.""" session_store.set_image(IMG_UUID, kind="use") args = _default_args(disposable=True) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert session_store.current_image == IMG_UUID def test_failed_does_not_update_session(self, contree_client, session_store): """Failed runs do not update the session image.""" session_store.set_image(IMG_UUID, kind="use") args = _default_args() - responses = [ + mocks = [ _spawn_response(), - _op_response(status="FAILED", error="timeout"), + _op_response(status="FAILED", exit_code=None, error="timeout"), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert session_store.current_image == IMG_UUID @@ -1276,11 +1321,11 @@ def test_terminal_op_cached_after_run(self, contree_client, session_store): """Completed run caches the operation so `show` skips the API.""" session_store.set_image(IMG_UUID, kind="use") args = _default_args() - responses = [ + mocks = [ _spawn_response("op-cached"), _op_response("op-cached", status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) cached = session_store.cache.get(("op-cached", "operation")) assert cached is not None assert cached["status"] == "SUCCESS" @@ -1289,21 +1334,22 @@ def test_failed_op_cached_after_run(self, contree_client, session_store): """Failed runs also cache the terminal operation.""" session_store.set_image(IMG_UUID, kind="use") args = _default_args() - responses = [ + mocks = [ _spawn_response("op-fail"), - _op_response("op-fail", status="FAILED", error="boom"), + _op_response("op-fail", status="FAILED", exit_code=None, error="boom"), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) cached = session_store.cache.get(("op-fail", "operation")) assert cached is not None assert cached["status"] == "FAILED" def test_detach_does_not_cache(self, contree_client, session_store): - """Detached runs exit before terminal state — nothing to cache.""" + """Detached runs exit before terminal state, nothing to cache.""" session_store.set_image(IMG_UUID, kind="use") args = _default_args(detach=True) - responses = [_spawn_response("op-detach")] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd( + contree_client, args, [_spawn_response("op-detach")], store=session_store + ) assert session_store.cache.get(("op-detach", "operation")) is None @@ -1321,13 +1367,12 @@ def test_pending_files_in_spawn_payload(self, contree_client, session_store): ) session_store.add_pending_file(hid, "/app/config.ini", "pf-uuid-1") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert "/app/config.ini" in body["files"] assert body["files"]["/app/config.ini"]["uuid"] == "pf-uuid-1" @@ -1353,14 +1398,13 @@ def test_explicit_file_overrides_pending( mode=0o644, ) args = _default_args(file=[mf]) - responses = [ - _api_response({"uuid": "explicit-uuid"}), # GET dedup hit + mocks = [ + file_info_response("explicit-uuid"), # GET dedup hit _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(1) # after GET dedup - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) # Explicit file should win assert body["files"]["/app/data.txt"]["uuid"] == "explicit-uuid" @@ -1373,23 +1417,19 @@ def test_not_included_after_run(self, contree_client, session_store): title="Change file /a.txt", ) session_store.add_pending_file(hid, "/a.txt", "pf-uuid") - # First run -- includes pending file + # First run -- includes pending file. Mocks for both runs are + # queued up front (FIFO outcome queues, sticky tail). args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), - ] - _run_cmd(contree_client, args, responses, store=session_store) - # Clear recorded requests before second run - contree_client.fake.requests.clear() - # Second run -- pending file should NOT be included (last entry is run) - responses2 = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW2), ] - _run_cmd(contree_client, args, responses2, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + # Second run -- pending file should NOT be included (last entry is run) + _run_cmd(contree_client, args, [], store=session_store) + body = spawn_payload(contree_client, index=1) assert "files" not in body def test_reappears_after_rollback(self, contree_client, session_store): @@ -1401,27 +1441,22 @@ def test_reappears_after_rollback(self, contree_client, session_store): title="Change file /a.txt", ) session_store.add_pending_file(hid, "/a.txt", "pf-uuid") - # Run -- bakes the file in + # Run -- bakes the file in (mocks for both runs queued up front) args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), + _spawn_response(), + _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW2), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert session_store.pending_files() == [] # Rollback past the run session_store.rollback(1) assert len(session_store.pending_files()) == 1 - # Clear recorded requests before next run - contree_client.fake.requests.clear() # Next run should include the pending file again - responses2 = [ - _spawn_response(), - _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW2), - ] - _run_cmd(contree_client, args, responses2, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, [], store=session_store) + body = spawn_payload(contree_client, index=1) assert "/a.txt" in body["files"] @@ -1440,7 +1475,7 @@ def test_stdin_piped(self, contree_client, session_store): """Piped stdin is included in payload as base64 StreamRepr.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] @@ -1448,46 +1483,45 @@ def test_stdin_piped(self, contree_client, session_store): _run_cmd( contree_client, args, - responses, + mocks, store=session_store, stdin_mock=self._piped_stdin(stdin_content), ) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + body = spawn_payload(contree_client) assert "stdin" in body - assert body["stdin"]["encoding"] == "base64" - assert base64.b64decode(body["stdin"]["value"]) == stdin_content + # Printable payloads travel verbatim; the encoding rule lives + # in StreamRepr.from_bytes. + assert body["stdin"]["encoding"] == "ascii" + assert StreamRepr.from_dict(body["stdin"]).as_bytes() == stdin_content def test_stdin_tty_not_included(self, contree_client, session_store): """When stdin is a TTY, no stdin key in payload.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert "stdin" not in body def test_stdin_empty_not_included(self, contree_client, session_store): """Piped but empty stdin does not add stdin key.""" session_store.set_image(IMG_UUID, kind="test") args = _default_args() - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] _run_cmd( contree_client, args, - responses, + mocks, store=session_store, stdin_mock=self._piped_stdin(b""), ) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + body = spawn_payload(contree_client) assert "stdin" not in body @@ -1504,20 +1538,17 @@ def test_script_sent_as_stdin(self, contree_client, session_store, tmp_path): command_args=[str(script)], interpreter=True, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 0 - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + body = spawn_payload(contree_client) assert body["command"] == "/bin/sh" assert body["shell"] is True assert body["args"] == ["-s"] - assert body["stdin"]["encoding"] == "base64" - decoded = base64.b64decode(body["stdin"]["value"]) - assert decoded == b"echo hello\n" + assert StreamRepr.from_dict(body["stdin"]).as_bytes() == b"echo hello\n" def test_extra_args(self, contree_client, session_store, tmp_path): """Extra args after script path are passed as -s -- args.""" @@ -1528,13 +1559,12 @@ def test_extra_args(self, contree_client, session_store, tmp_path): command_args=[str(script), "arg1", "arg2"], interpreter=True, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["command"] == "/bin/sh" assert body["args"] == ["-s", "--", "arg1", "arg2"] @@ -1544,13 +1574,12 @@ def test_without_flag_no_magic(self, contree_client, session_store, tmp_path): script.write_text("#!/usr/bin/env -S contree run -I\necho hello\n") session_store.set_image(IMG_UUID, kind="test") args = _default_args(command_args=[str(script)]) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] - _run_cmd(contree_client, args, responses, store=session_store) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) + _run_cmd(contree_client, args, mocks, store=session_store) + body = spawn_payload(contree_client) assert body["command"] == str(script) assert "stdin" not in body @@ -1563,7 +1592,7 @@ def test_skips_piped_stdin(self, contree_client, session_store, tmp_path): command_args=[str(script)], interpreter=True, ) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0), ] @@ -1573,14 +1602,13 @@ def test_skips_piped_stdin(self, contree_client, session_store, tmp_path): _run_cmd( contree_client, args, - responses, + mocks, store=session_store, stdin_mock=piped, ) - spawn_req = contree_client.get_request(0) - body = json.loads(spawn_req.body) - decoded = base64.b64decode(body["stdin"]["value"]) - assert decoded == b"echo from script\n" + body = spawn_payload(contree_client) + stdin = StreamRepr.from_dict(body["stdin"]) + assert stdin.as_bytes() == b"echo from script\n" # ── Escape sequence sanitization ───────────────────────────────────── @@ -1593,7 +1621,7 @@ def test_colors_preserved(self, contree_client, session_store, capsys): session_store.set_image(IMG_UUID, kind="test") args = _default_args() colored = "\033[1;32mgreen\033[0m normal" - responses = [ + mocks = [ _spawn_response(), _op_response( status="SUCCESS", @@ -1604,7 +1632,7 @@ def test_colors_preserved(self, contree_client, session_store, capsys): _run_cmd( contree_client, args, - responses, + mocks, store=session_store, formatter=DefaultFormatter(), ) @@ -1615,7 +1643,7 @@ def test_cursor_movement_stripped(self, contree_client, session_store, capsys): session_store.set_image(IMG_UUID, kind="test") args = _default_args() raw = "\033[2;5Htext\033[Aup\033[Bdown\033[10Gcol" - responses = [ + mocks = [ _spawn_response(), _op_response( status="SUCCESS", @@ -1626,7 +1654,7 @@ def test_cursor_movement_stripped(self, contree_client, session_store, capsys): _run_cmd( contree_client, args, - responses, + mocks, store=session_store, formatter=DefaultFormatter(), ) @@ -1639,7 +1667,7 @@ def test_alternate_screen_stripped(self, contree_client, session_store, capsys): session_store.set_image(IMG_UUID, kind="test") args = _default_args() raw = "\033[?1049hhtop output\033[?1049l" - responses = [ + mocks = [ _spawn_response(), _op_response( status="SUCCESS", @@ -1650,7 +1678,7 @@ def test_alternate_screen_stripped(self, contree_client, session_store, capsys): _run_cmd( contree_client, args, - responses, + mocks, store=session_store, formatter=DefaultFormatter(), ) @@ -1663,7 +1691,7 @@ def test_clear_screen_stripped(self, contree_client, session_store, capsys): session_store.set_image(IMG_UUID, kind="test") args = _default_args() raw = "\033[2Jcontent\033[K" - responses = [ + mocks = [ _spawn_response(), _op_response( status="SUCCESS", @@ -1674,7 +1702,7 @@ def test_clear_screen_stripped(self, contree_client, session_store, capsys): _run_cmd( contree_client, args, - responses, + mocks, store=session_store, formatter=DefaultFormatter(), ) @@ -1687,7 +1715,7 @@ def test_stderr_also_sanitized(self, contree_client, session_store, capsys): session_store.set_image(IMG_UUID, kind="test") args = _default_args() raw = "\033[?25lerror msg\033[?25h" - responses = [ + mocks = [ _spawn_response(), _op_response( status="SUCCESS", @@ -1698,7 +1726,7 @@ def test_stderr_also_sanitized(self, contree_client, session_store, capsys): _run_cmd( contree_client, args, - responses, + mocks, store=session_store, formatter=DefaultFormatter(), ) @@ -1711,7 +1739,7 @@ def test_json_formatter_not_sanitized(self, contree_client, session_store, capsy session_store.set_image(IMG_UUID, kind="test") args = _default_args() raw = "\033[2Jcontent" - responses = [ + mocks = [ _spawn_response(), _op_response( status="SUCCESS", @@ -1722,7 +1750,7 @@ def test_json_formatter_not_sanitized(self, contree_client, session_store, capsy _run_cmd( contree_client, args, - responses, + mocks, store=session_store, formatter=JSONFormatter(), ) @@ -1930,9 +1958,15 @@ def test_detach_appends_to_existing_cache(self, contree_client, session_store): """Multiple detach runs append to cache.""" session_store.set_image(IMG_UUID, kind="test") args1 = _default_args(detach=True) - _run_cmd(contree_client, args1, [_spawn_response("op-1")], store=session_store) + # Queue both spawn outcomes up front (FIFO with sticky tail) + _run_cmd( + contree_client, + args1, + [_spawn_response("op-1"), _spawn_response("op-2")], + store=session_store, + ) args2 = _default_args(detach=True) - _run_cmd(contree_client, args2, [_spawn_response("op-2")], store=session_store) + _run_cmd(contree_client, args2, [], store=session_store) pending_key = ("", f"ops:{session_store.session_key}") cached = session_store.cache.get(pending_key) assert isinstance(cached, list) @@ -1958,38 +1992,37 @@ class TestUseFlag: def test_use_resolves_tag_and_runs(self, contree_client, session_store): """--use resolves a tag, sets session image, then runs.""" args = _default_args(use="tag:ubuntu:latest") - responses = [ - _api_response({"images": [{"uuid": IMG_UUID}]}), + mocks = [ + tag_lookup(IMG_UUID), _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 0 assert session_store.current_image == IMG_NEW def test_use_with_uuid(self, contree_client, session_store): """--use with a UUID skips tag resolution.""" args = _default_args(use=IMG_SOME) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 0 - req0 = contree_client.get_request(0) - assert req0.method == "POST" - body = json.loads(req0.body) + assert contree_client.calls_for("inspect_find_image_by_tag") == [] + body = spawn_payload(contree_client) assert body["image"] == IMG_SOME def test_use_works_without_existing_session(self, contree_client, session_store): """--use creates a session even when none exists.""" assert session_store.session is None args = _default_args(use=IMG_UUID) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - rc = _run_cmd(contree_client, args, responses, store=session_store) + rc = _run_cmd(contree_client, args, mocks, store=session_store) assert rc == 0 assert session_store.session is not None assert session_store.current_image == IMG_NEW @@ -1997,7 +2030,7 @@ def test_use_works_without_existing_session(self, contree_client, session_store) def test_use_disposable(self, contree_client, session_store): """--use + --disposable sets session to use-image, run doesn't advance.""" args = _default_args(use=IMG_UUID, disposable=True) - responses = [ + mocks = [ _spawn_response("op-use-disp"), _op_response( "op-use-disp", @@ -2006,18 +2039,18 @@ def test_use_disposable(self, contree_client, session_store): image=IMG_NEW, ), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert session_store.current_image == IMG_UUID def test_use_creates_history_entry(self, contree_client, session_store): """--use creates a 'use' kind history entry before the run.""" args = _default_args(use="tag:myimage") - responses = [ - _api_response({"images": [{"uuid": IMG_UUID}]}), + mocks = [ + tag_lookup(IMG_UUID), _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) s = session_store.session assert s is not None assert s.last_kind == "run" @@ -2029,421 +2062,365 @@ def test_use_without_command_still_sets_image( ): """--use with no command args still switches session image.""" args = RunArgs(use=IMG_UUID) - responses = [ + mocks = [ _spawn_response(), _op_response(status="SUCCESS", exit_code=0, image=IMG_NEW), ] - _run_cmd(contree_client, args, responses, store=session_store) + _run_cmd(contree_client, args, mocks, store=session_store) assert session_store.session is not None # --------------------------------------------------------------------------- -# SSE streaming (`_stream_events_until_close` and `_build_op_from_summary`) +# SSE streaming (`stream_events_until_close` and `build_op_from_summary`) # --------------------------------------------------------------------------- -class _SSEResponse: - """Minimal HTTPResponse-shaped object backed by an in-memory buffer. - - `_stream_events_until_close` only touches `.readline()` (via - `iter_sse_events`) and `.close()`; nothing else is needed. - """ - - def __init__(self, body: bytes | str) -> None: - payload = body.encode("utf-8") if isinstance(body, str) else body - self.buf = io.BytesIO(payload) - self.closed = False - - def readline(self, size: int = -1) -> bytes: - return self.buf.readline() - - def close(self) -> None: - self.closed = True - - -class _JSONResponse: - """Minimal HTTPResponse-shape for the streamer's between-attempt - ``GET /operations/{uuid}`` terminal check. Only ``.read()`` and - ``.close()`` are used.""" - - def __init__(self, payload: dict) -> None: - self.payload = json.dumps(payload).encode("utf-8") - self.closed = False - - def read(self, size: int = -1) -> bytes: - return self.payload - - def close(self) -> None: - self.closed = True +EVENT_TS = "2026-01-01T00:00:00.000000+00:00" + + +def full_resources() -> dict: + """The complete per-process resource usage block every real `exit` + event carries (all counters are required by the API schema).""" + counters = ( + "user_time_us", + "sys_time_us", + "max_rss_kb", + "shared_memory", + "unshared_memory", + "swaps", + "minor_faults", + "major_faults", + "voluntary_ctx_switches", + "involuntary_ctx_switches", + "block_input_ops", + "block_output_ops", + "ipc_msgs_sent", + "ipc_msgs_received", + "signals_received", + ) + return dict.fromkeys(counters, 0) + + +def exit_data(code: int = 0, *, timed_out: bool = False, signal: int = -1) -> dict: + """Full `exit` event payload as the API emits it.""" + return { + "pid": 4242, + "code": code, + "signal": signal, + "timed_out": timed_out, + "duration_ms": 1500, + "resources": full_resources(), + } -class _StubStreamClient: - """Stand-in for `ContreeClient` that queues responses for both the - SSE `follow=1` endpoint and the between-attempt terminal-status - GET. +def completion_data(status: str = "SUCCESS", **overrides) -> dict: + """Full `completion` event payload as the API emits it.""" + data: dict = { + "status": status, + "duration_ms": 1500, + "result_image_uuid": IMG_NEW, + "error": None, + "image_size_bytes": 4096, + } + data.update(overrides) + return data - `responses` feeds `GET /events?follow=1` calls. `get_responses` - feeds `GET /operations/{uuid}` terminal checks; when it's empty - the stub falls back to a non-terminal op (`status=EXECUTING`) so - tests that don't care about the check don't have to prime it. - A queued item may be an `_SSEResponse` / `_JSONResponse` (served - as-is), an `Exception` subclass or instance (raised), or `None` - (equivalent to an empty response). All calls are recorded so - tests can inspect them via `.calls`, `.sse_calls`, `.get_calls`. - """ +def make_event( + event_type: str, + data: dict, + *, + event_id: int = 1, + spid: int | None = None, +) -> OperationEvent: + """Build a typed OperationEvent the way the SSE decoder does: + payloads matching the schema become models, sparse payloads stay + raw dicts.""" + payload: dict = {"id": event_id, "ts": EVENT_TS, "type": event_type, "data": data} + if spid is not None: + payload["spid"] = spid + return OperationEvent.from_dict(payload) + + +def stream_event( + kind: str, + value: str, + *, + event_id: int = 1, + encoding: str = "ascii", +) -> OperationEvent: + """A stdout/stderr chunk event for the main process (spid=1).""" + return make_event( + kind, + {"value": value, "encoding": encoding}, + event_id=event_id, + spid=1, + ) - NON_TERMINAL: ClassVar[dict[str, str]] = {"status": "EXECUTING"} - def __init__( - self, - responses: list, - get_responses: list | None = None, - ) -> None: - self.responses = list(responses) - self.get_responses = list(get_responses or []) - self.calls: list[tuple[str, str, dict[str, str] | None]] = [] - - @property - def sse_calls(self) -> list[tuple[str, str, dict[str, str] | None]]: - return [c for c in self.calls if "/events" in c[1]] +def completion_event( + status: str = "SUCCESS", *, event_id: int = 2, **overrides +) -> OperationEvent: + return make_event( + "completion", completion_data(status, **overrides), event_id=event_id + ) - @property - def get_calls(self) -> list[tuple[str, str, dict[str, str] | None]]: - return [c for c in self.calls if "/events" not in c[1]] - def request( - self, - method: str, - path: str, - *, - headers: dict[str, str] | None = None, - **_: object, - ): - self.calls.append((method, path, headers)) - is_sse = "/events" in path - if is_sse: - item = self.responses.pop(0) - elif self.get_responses: - item = self.get_responses.pop(0) - else: - return _JSONResponse(self.NON_TERMINAL) - if isinstance(item, type) and issubclass(item, BaseException): - raise item("stub") - if isinstance(item, BaseException): - raise item - if item is None: - return _SSEResponse(b"") if is_sse else _JSONResponse(self.NON_TERMINAL) - return item - - -def _frame(*, id: int, event: str, data: dict | str) -> str: - """Build one SSE frame. Mirrors the API contract by embedding `id` - inside the JSON `data:` payload as well as the SSE `id:` line - (the CLI reads the id from the JSON, not the SSE header).""" - if isinstance(data, dict) and "id" not in data: - data = {"id": id, **data} - data_str = data if isinstance(data, str) else json.dumps(data) - return f"id: {id}\nevent: {event}\ndata: {data_str}\n\n" +def executing_response(uuid: str = "op-1") -> OperationResponse: + """A non-terminal GET /v1/operations/{uuid} snapshot.""" + return OperationResponse.from_dict({"uuid": uuid, "status": "EXECUTING"}) class TestStreamEventsUntilClose: - def test_url_uses_follow_1(self, session_store): - """Verifies the endpoint spelling matches the OpenAPI spec (`?follow=1`).""" - completion = _frame( - id=1, - event="completion", - data={"type": "completion", "data": {"status": "SUCCESS"}}, - ) - client = _StubStreamClient([_SSEResponse(completion)]) - _stream_events_until_close(client, "op-1", DefaultFormatter()) - assert client.calls[0][0] == "GET" - assert client.calls[0][1] == "/v1/operations/op-1/events?follow=1" - - def test_first_call_has_no_last_event_id_header(self, session_store): - client = _StubStreamClient( - [ - _SSEResponse( - _frame( - id=1, - event="completion", - data={"type": "completion", "data": {}}, - ) - ) - ] - ) - _stream_events_until_close(client, "op-x", DefaultFormatter()) - assert client.calls[0][2] is None + def test_stream_opened_with_follow(self): + """The streamer subscribes with follow=True so the server keeps + the stream open for a running operation.""" + tc = ContreeTestClient() + tc.mock("iter_operation_events", [completion_event()]) + stream_events_until_close(tc, "op-1", DefaultFormatter()) + call = tc.calls_for("iter_operation_events")[0] + assert call.args == ("op-1",) + assert call.kwargs["follow"] is True + + def test_first_call_has_no_last_event_id(self): + """The first subscribe passes last_event_id=None (nothing to + resume from).""" + tc = ContreeTestClient() + tc.mock("iter_operation_events", [completion_event()]) + stream_events_until_close(tc, "op-x", DefaultFormatter()) + call = tc.calls_for("iter_operation_events")[0] + assert call.kwargs["last_event_id"] is None def test_stdout_streamed_live_for_default_formatter(self, capsys): - chunk = _frame( - id=1, - event="stdout", - data={ - "type": "stdout", - "spid": 1, - "data": {"value": "hello", "encoding": "ascii"}, - }, - ) - completion = _frame( - id=2, - event="completion", - data={"type": "completion", "data": {"status": "SUCCESS"}}, + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + [stream_event("stdout", "hello", event_id=1), completion_event()], ) - client = _StubStreamClient([_SSEResponse(chunk + completion)]) - summary = _stream_events_until_close(client, "op-1", DefaultFormatter()) + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) out = capsys.readouterr() assert out.out == "hello" assert bytes(summary.stdout) == b"hello" def test_stderr_streamed_live_for_default_formatter(self, capsys): - chunk = _frame( - id=1, - event="stderr", - data={ - "type": "stderr", - "spid": 1, - "data": {"value": "oops\n", "encoding": "ascii"}, - }, - ) - completion = _frame( - id=2, - event="completion", - data={"type": "completion", "data": {"status": "FAILED"}}, + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + [ + stream_event("stderr", "oops\n", event_id=1), + completion_event("FAILED", error="boom", result_image_uuid=None), + ], ) - client = _StubStreamClient([_SSEResponse(chunk + completion)]) - summary = _stream_events_until_close(client, "op-1", DefaultFormatter()) + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) out = capsys.readouterr() assert out.err == "oops\n" assert bytes(summary.stderr) == b"oops\n" def test_json_formatter_accumulates_without_printing(self, capsys): - chunk = _frame( - id=1, - event="stdout", - data={ - "type": "stdout", - "spid": 1, - "data": {"value": "hi", "encoding": "ascii"}, - }, - ) - completion = _frame( - id=2, - event="completion", - data={"type": "completion", "data": {"status": "SUCCESS"}}, + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + [stream_event("stdout", "hi", event_id=1), completion_event()], ) - client = _StubStreamClient([_SSEResponse(chunk + completion)]) - summary = _stream_events_until_close(client, "op-1", JSONFormatter()) + summary = stream_events_until_close(tc, "op-1", JSONFormatter()) out = capsys.readouterr() assert out.out == "" assert bytes(summary.stdout) == b"hi" def test_base64_chunk_decoded(self, capsys): payload = base64.b64encode(b"\x00\xff bin").decode("ascii") - chunk = _frame( - id=1, - event="stdout", - data={ - "type": "stdout", - "spid": 1, - "data": {"value": payload, "encoding": "base64"}, - }, - ) - completion = _frame( - id=2, - event="completion", - data={"type": "completion", "data": {"status": "SUCCESS"}}, + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + [ + stream_event("stdout", payload, event_id=1, encoding="base64"), + completion_event(), + ], ) - client = _StubStreamClient([_SSEResponse(chunk + completion)]) - summary = _stream_events_until_close(client, "op-1", JSONFormatter()) + summary = stream_events_until_close(tc, "op-1", JSONFormatter()) assert bytes(summary.stdout) == b"\x00\xff bin" def test_exit_event_for_spid_1_stored(self): - exit_frame = _frame( - id=1, - event="exit", - data={"type": "exit", "spid": 1, "data": {"code": 0, "timed_out": False}}, - ) - completion = _frame( - id=2, - event="completion", - data={"type": "completion", "data": {"status": "SUCCESS"}}, + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + [ + make_event("exit", exit_data(0), event_id=1, spid=1), + completion_event(), + ], ) - client = _StubStreamClient([_SSEResponse(exit_frame + completion)]) - summary = _stream_events_until_close(client, "op-1", DefaultFormatter()) + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) assert summary.exit_event is not None - assert summary.exit_event["data"]["code"] == 0 + assert summary.exit_event.data.code == 0 def test_exit_event_for_non_main_spid_ignored(self): """Only spid=1 drives CLI exit code; child spid exits stay unrecorded.""" - exit_frame = _frame( - id=1, - event="exit", - data={"type": "exit", "spid": 2, "data": {"code": 3, "timed_out": False}}, - ) - completion = _frame( - id=2, - event="completion", - data={"type": "completion", "data": {"status": "SUCCESS"}}, + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + [ + make_event("exit", exit_data(3), event_id=1, spid=2), + completion_event(), + ], ) - client = _StubStreamClient([_SSEResponse(exit_frame + completion)]) - summary = _stream_events_until_close(client, "op-1", DefaultFormatter()) + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) assert summary.exit_event is None - def test_completion_frame_breaks_loop(self): - completion = _frame( - id=1, - event="completion", - data={"type": "completion", "data": {"status": "SUCCESS"}}, - ) - client = _StubStreamClient([_SSEResponse(completion)]) - summary = _stream_events_until_close(client, "op-1", DefaultFormatter()) + def test_completion_event_breaks_loop(self): + tc = ContreeTestClient() + tc.mock("iter_operation_events", [completion_event(event_id=1)]) + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) assert summary.completion is not None - assert summary.completion["data"]["status"] == "SUCCESS" + assert summary.completion.data.status == "SUCCESS" + assert len(tc.calls_for("iter_operation_events")) == 1 def test_stream_ends_without_completion_falls_back_to_terminal_get(self): - """SSE closes cleanly without a `completion` frame — the + """SSE closes cleanly without a `completion` event: the between-attempt GET check detects the op is terminal and parks it on `summary.fallback_op` so the caller doesn't need to GET again.""" - op = {"uuid": "op-1", "status": "SUCCESS", "result": {"image": None}} - client = _StubStreamClient( - [_SSEResponse(b"")], get_responses=[_JSONResponse(op)] + tc = ContreeTestClient() + tc.mock("iter_operation_events", []) + op = OperationResponse.from_dict( + {"uuid": "op-1", "status": "SUCCESS", "result": {"image": None}} ) - summary = _stream_events_until_close(client, "op-1", DefaultFormatter()) + tc.mock("get_operation_status", op) + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) assert summary.completion is None - assert summary.fallback_op == op + assert summary.fallback_op == op.to_dict() assert bytes(summary.stdout) == b"" def test_sse_connect_errors_then_terminal_via_get(self): """SSE keeps failing to connect while the op runs; once the between-attempt GET reports terminal status the streamer stops retrying and returns without ever seeing a completion - frame.""" - op = {"uuid": "op-1", "status": "SUCCESS"} - client = _StubStreamClient( - [ApiError(500, "srv", "boom")] * 3, - get_responses=[ - _JSONResponse({"status": "EXECUTING"}), - _JSONResponse({"status": "EXECUTING"}), - _JSONResponse(op), - ], - ) - with patch("contree_cli.cli.run.time.sleep"): - summary = _stream_events_until_close(client, "op-1", DefaultFormatter()) + event.""" + tc = ContreeTestClient() + tc.mock("iter_operation_events", error=ContreeAPIError(502, "boom")) + tc.mock("get_operation_status", executing_response()) + tc.mock("get_operation_status", executing_response()) + tc.mock( + "get_operation_status", + OperationResponse.from_dict({"uuid": "op-1", "status": "SUCCESS"}), + ) + # Reconnect sleeps happen inside the library's + # follow_operation_events loop. + with patch("contree_client.base.time.sleep"): + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) assert summary.completion is None - assert summary.fallback_op == op - assert len(client.sse_calls) == 3 - assert len(client.get_calls) == 3 - - def test_sse_error_frame_triggers_reconnect_with_last_event_id(self): - first = ( - _frame( - id=1, - event="stdout", - data={ - "type": "stdout", - "spid": 1, - "data": {"value": "a", "encoding": "ascii"}, - }, - ) - + "event: sse_error\ndata: boom\n\n" - ) - completion = _frame( - id=2, - event="completion", - data={"type": "completion", "data": {"status": "SUCCESS"}}, - ) - client = _StubStreamClient([_SSEResponse(first), _SSEResponse(completion)]) - with patch("contree_cli.cli.run.time.sleep"): - summary = _stream_events_until_close(client, "op-1", DefaultFormatter()) + assert summary.fallback_op == {"uuid": "op-1", "status": "SUCCESS"} + assert len(tc.calls_for("iter_operation_events")) == 3 + # Three terminal probes inside the library loop plus one final + # fetch of the terminal payload for fallback_op. + assert len(tc.calls_for("get_operation_status")) == 4 + + def test_sse_error_triggers_reconnect_with_last_event_id(self): + """A mid-stream server error (SSEStreamError after some events) + makes the streamer reconnect, resuming from the last received + event id.""" + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + [stream_event("stdout", "a", event_id=1)], + error=SSEStreamError("boom", last_event_id=1), + ) + tc.mock("iter_operation_events", [completion_event(event_id=2)]) + # The op is still running when the terminal check fires between + # the two attempts. + tc.mock("get_operation_status", executing_response()) + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) + assert summary.completion is not None + calls = tc.calls_for("iter_operation_events") + assert len(calls) == 2 + assert calls[0].kwargs["last_event_id"] is None + assert calls[1].kwargs["last_event_id"] == 1 + + def test_retry_after_honored_on_api_error(self): + """A 425/410-style ContreeAPIError with retry_after sleeps for + exactly that delay before reconnecting.""" + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + error=ContreeAPIError(425, "too early", retry_after=7), + ) + tc.mock("iter_operation_events", [completion_event()]) + tc.mock("get_operation_status", executing_response()) + with patch("contree_cli.cli.run.time.sleep") as sleep_mock: + summary = stream_events_until_close(tc, "op-1", DefaultFormatter()) assert summary.completion is not None - assert len(client.sse_calls) == 2 - assert client.sse_calls[1][2] == {"Last-Event-Id": "1"} + assert sleep_mock.call_args_list[0].args == (7,) def test_broken_pipe_from_stdout_propagates(self, monkeypatch): """`BrokenPipeError` from local stdio write must propagate - unchanged — retrying can't fix a closed local pipe and it + unchanged: retrying can't fix a closed local pipe and it would be misinterpreted as a remote network error otherwise.""" - chunk = _frame( - id=1, - event="stdout", - data={ - "type": "stdout", - "spid": 1, - "data": {"value": "hi", "encoding": "ascii"}, - }, + tc = ContreeTestClient() + tc.mock( + "iter_operation_events", + [stream_event("stdout", "hi", event_id=1)], ) - client = _StubStreamClient([_SSEResponse(chunk)]) - def raise_broken_pipe(*_args: object, **_kw: object) -> int: + def raise_broken_pipe(*args: object, **kw: object) -> int: raise BrokenPipeError monkeypatch.setattr("sys.stdout.buffer.write", raise_broken_pipe) with pytest.raises(BrokenPipeError): - _stream_events_until_close(client, "op-1", DefaultFormatter()) + stream_events_until_close(tc, "op-1", DefaultFormatter()) # Only the initial SSE attempt is made; no retry, no terminal-check # GET (BrokenPipeError bypasses the retry path entirely). - assert len(client.sse_calls) == 1 - assert len(client.get_calls) == 0 + assert len(tc.calls_for("iter_operation_events")) == 1 + assert tc.calls_for("get_operation_status") == [] class TestBuildOpFromSummary: - def _completion(self, **overrides) -> dict: - base = { - "type": "completion", - "data": { - "status": "SUCCESS", - "error": None, - "duration_ms": 1500, - "image_size_bytes": 4096, - "result_image_uuid": IMG_NEW, - }, - } - base["data"].update(overrides) - return base + def _completion(self, **overrides) -> OperationEvent: + return make_event("completion", completion_data(**overrides)) def test_shape_carries_status_and_uuid(self): summary = TerminalSummary(completion=self._completion()) - op = _build_op_from_summary("op-1", summary) + op = build_op_from_summary("op-1", summary) assert op["uuid"] == "op-1" assert op["kind"] == "instance" assert op["status"] == "SUCCESS" def test_duration_ms_converted_to_seconds(self): summary = TerminalSummary(completion=self._completion(duration_ms=2500)) - op = _build_op_from_summary("op-1", summary) + op = build_op_from_summary("op-1", summary) assert op["duration"] == 2.5 def test_duration_missing_falls_back_to_zero(self): + """A completion payload that doesn't match the schema is kept + as a raw dict by the decoder; missing duration_ms then falls + back to zero.""" summary = TerminalSummary( - completion={"type": "completion", "data": {"status": "SUCCESS"}} + completion=make_event("completion", {"status": "SUCCESS"}) ) - op = _build_op_from_summary("op-1", summary) + op = build_op_from_summary("op-1", summary) assert op["duration"] == 0.0 def test_result_image_uuid_propagates(self): summary = TerminalSummary(completion=self._completion()) - op = _build_op_from_summary("op-1", summary) + op = build_op_from_summary("op-1", summary) assert op["result_image_uuid"] == IMG_NEW assert op["result"] == {"image": IMG_NEW, "tag": None} def test_exit_event_drives_state(self): summary = TerminalSummary( completion=self._completion(), - exit_event={ - "type": "exit", - "spid": 1, - "data": {"code": 42, "timed_out": True}, - }, + exit_event=make_event( + "exit", + exit_data(42, timed_out=True), + event_id=2, + spid=1, + ), ) - op = _build_op_from_summary("op-1", summary) + op = build_op_from_summary("op-1", summary) state = op["metadata"]["result"]["state"] assert state == {"exit_code": 42, "timed_out": True} def test_state_is_none_without_exit_event(self): summary = TerminalSummary(completion=self._completion()) - op = _build_op_from_summary("op-1", summary) + op = build_op_from_summary("op-1", summary) assert op["metadata"]["result"]["state"] is None def test_stdout_stderr_reassembled_from_bytearrays(self): @@ -2452,7 +2429,7 @@ def test_stdout_stderr_reassembled_from_bytearrays(self): stdout=bytearray(b"hello\n"), stderr=bytearray(b"warn\n"), ) - op = _build_op_from_summary("op-1", summary) + op = build_op_from_summary("op-1", summary) result = op["metadata"]["result"] assert result["stdout"]["value"] == "hello\n" assert result["stderr"]["value"] == "warn\n" @@ -2460,8 +2437,10 @@ def test_stdout_stderr_reassembled_from_bytearrays(self): def test_error_field_passthrough(self): summary = TerminalSummary( - completion=self._completion(status="FAILED", error="boom") + completion=self._completion( + status="FAILED", error="boom", result_image_uuid=None + ) ) - op = _build_op_from_summary("op-1", summary) + op = build_op_from_summary("op-1", summary) assert op["status"] == "FAILED" assert op["error"] == "boom" diff --git a/tests/test_session.py b/tests/test_session.py index ce26899..9458335 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -6,7 +6,7 @@ import pytest -from contree_cli.config import ConfigProfile +from contree_cli.config import session_db_path from contree_cli.session import ( PendingFile, SessionStore, @@ -426,15 +426,15 @@ def test_without_env_var(self): class TestGetDbPath: def test_default_profile(self): - p = ConfigProfile(name="default", url="", token=None) - assert p.session_db_path.name == "default.db" - assert p.session_db_path.parent.name == "sessions" - assert p.session_db_path.parent.parent.name == "cli" + path = session_db_path("default") + assert path.name == "default.db" + assert path.parent.name == "sessions" + assert path.parent.parent.name == "cli" def test_named_profile(self): - p = ConfigProfile(name="staging", url="", token=None) - assert p.session_db_path.name == "staging.db" - assert p.session_db_path.parent.name == "sessions" + path = session_db_path("staging") + assert path.name == "staging.db" + assert path.parent.name == "sessions" class TestPendingFiles: diff --git a/tests/test_session_cmd.py b/tests/test_session_cmd.py index 1223f56..06f85fa 100644 --- a/tests/test_session_cmd.py +++ b/tests/test_session_cmd.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +from contree_client.models import OperationResponse from contree_cli import FORMATTER, SESSION_STORE from contree_cli.cli.session import ( @@ -32,6 +33,21 @@ Capsys = pytest.CaptureFixture[str] +def respond_status(tc, payload: dict) -> None: + """Queue one typed get_operation_status poll result.""" + tc.mock("get_operation_status", OperationResponse.from_dict(payload)) + + +def respond_listing(tc, ops: list[dict]) -> None: + """Queue the raw GET /operations listing used by no-arg `session wait`. + + The handler needs the per-op ``session_key`` field, which the typed + OperationSummary model does not carry, so it fetches the raw payload + via ``client.request(RequestSpec(...))``. + """ + tc.respond_raw(body=json.dumps(ops).encode()) + + @pytest.fixture() def store(tmp_path: Path) -> SessionStore: s = SessionStore(tmp_path / "cmd.db", "test_session") @@ -444,14 +460,15 @@ def test_wait_specific_ops(self, contree_client, capsys, session_store): SESSION_STORE.set(session_store) session_store.set_image("img-1", kind="use") FORMATTER.set(JSONFormatter()) - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-1", "status": "SUCCESS", "kind": "instance", "duration": 2, "error": "", - } + }, ) rc = cmd_wait(WaitArgs(op_ids=["op-1"])) assert rc is None @@ -465,7 +482,8 @@ def test_wait_active_when_none_provided( SESSION_STORE.set(session_store) FORMATTER.set(JSONFormatter()) session_store.set_image("img-1", kind="use") - contree_client.respond_json( + respond_listing( + contree_client, [ { "uuid": "op-2", @@ -473,16 +491,17 @@ def test_wait_active_when_none_provided( "kind": "instance", "session_key": "test", } - ] + ], ) - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-2", "status": "SUCCESS", "kind": "instance", "duration": 1, "error": "", - } + }, ) rc = cmd_wait(WaitArgs(op_ids=[])) assert rc is None @@ -496,7 +515,8 @@ def test_wait_active_none_for_other_session( SESSION_STORE.set(session_store) FORMATTER.set(JSONFormatter()) session_store.set_image("img-1", kind="use") - contree_client.respond_json( + respond_listing( + contree_client, [ { "uuid": "op-3", @@ -504,7 +524,7 @@ def test_wait_active_none_for_other_session( "kind": "instance", "session_key": "other", } - ] + ], ) rc = cmd_wait(WaitArgs(op_ids=[])) assert rc is None @@ -523,7 +543,8 @@ def test_wait_active_uses_cached_when_api_has_none( {"op": "op-4", "title": "sleep 1", "disposable": False} ] - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-4", "status": "SUCCESS", @@ -531,7 +552,7 @@ def test_wait_active_uses_cached_when_api_has_none( "duration": 1, "error": "", "result": {"image": "img-new"}, - } + }, ) rc = cmd_wait(WaitArgs(op_ids=[])) assert rc is None @@ -551,7 +572,8 @@ def test_wait_active_disposable_does_not_advance_session( {"op": "op-5", "title": "sleep 1", "disposable": True} ] - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-5", "status": "SUCCESS", @@ -559,7 +581,7 @@ def test_wait_active_disposable_does_not_advance_session( "duration": 1, "error": "", "result": {"image": "img-disposable"}, - } + }, ) rc = cmd_wait(WaitArgs(op_ids=[])) assert rc is None @@ -577,14 +599,15 @@ def test_wait_active_skips_missing_session_key( SESSION_STORE.set(session_store) FORMATTER.set(JSONFormatter()) session_store.set_image("img-1", kind="use") - contree_client.respond_json( + respond_listing( + contree_client, [ { "uuid": "op-4", "status": "PENDING", "kind": "instance", } - ] + ], ) rc = cmd_wait(WaitArgs(op_ids=[])) assert rc is None @@ -597,16 +620,21 @@ def test_wait_exit_code_failure_sets_status_and_rc( SESSION_STORE.set(session_store) FORMATTER.set(JSONFormatter()) session_store.set_image("img-1", kind="use") - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-err", "status": "SUCCESS", "kind": "instance", "duration": 1, "error": "", - "metadata": {"result": {"state": {"exit_code": 1}}}, + "metadata": { + "command": "false", + "image": "img-1", + "result": {"state": {"exit_code": 1}}, + }, "result": {"image": "img-new"}, - } + }, ) rc = cmd_wait(WaitArgs(op_ids=["op-err"])) assert rc == 1 @@ -630,16 +658,21 @@ def test_wait_outputs_title_and_exit_code_from_cached_meta( {"op": "op-7", "title": "sleep 1", "disposable": False} ] - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-7", "status": "SUCCESS", "kind": "instance", "duration": 1, "error": "", - "metadata": {"result": {"state": {"exit_code": 2}}}, + "metadata": { + "command": "sleep 1", + "image": "img-1", + "result": {"state": {"exit_code": 2}}, + }, "result": {"image": "img-new"}, - } + }, ) rc = cmd_wait(WaitArgs(op_ids=[])) assert rc == 2 @@ -653,27 +686,37 @@ def test_wait_outputs_title_and_exit_code_from_cached_meta( def test_wait_unknown_field_passes_through( self, contree_client, session_store, capsys ) -> None: - """New server fields reach the row even when not hardcoded.""" + """Schema fields the handler does not hardcode reach the row. + + The poll round-trips through the typed OperationResponse model, + so only fields the API schema declares survive; the handler + itself must not need a code change for them. + """ SESSION_STORE.set(session_store) FORMATTER.set(JSONFormatter()) session_store.set_image("img-1", kind="use") - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-x", "status": "SUCCESS", "kind": "instance", "duration": 1, - "session_key": "sess-1", - "future_field": "anything", - "metadata": {"result": {"state": {"exit_code": 0}}}, + "consumed_cpu": 0.5, + "result_image_uuid": "img-new", + "metadata": { + "command": "true", + "image": "img-1", + "result": {"state": {"exit_code": 0}}, + }, "result": {"image": "img-new"}, - } + }, ) cmd_wait(WaitArgs(op_ids=["op-x"])) out = capsys.readouterr().out.strip().splitlines() data = json.loads(out[0]) - assert data["session_key"] == "sess-1" - assert data["future_field"] == "anything" + assert data["consumed_cpu"] == 0.5 + assert data["result_image_uuid"] == "img-new" def test_show_defaults_to_last_20_and_logs_info( self, @@ -956,7 +999,8 @@ def test_wait_cached_string_items( pending_key = ("", f"ops:{session_store.session_key}") session_store.cache[pending_key] = ["op-str"] - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-str", "status": "SUCCESS", @@ -964,7 +1008,7 @@ def test_wait_cached_string_items( "duration": 1, "error": "", "result": {"image": "img-str"}, - } + }, ) rc = cmd_wait(WaitArgs(op_ids=[])) assert rc is None @@ -978,14 +1022,15 @@ def test_wait_failed_op_returns_exit_code( SESSION_STORE.set(session_store) FORMATTER.set(JSONFormatter()) session_store.set_image("img-1", kind="use") - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-fail", "status": "FAILED", "kind": "instance", "duration": 1, "error": "timeout", - } + }, ) rc = cmd_wait(WaitArgs(op_ids=["op-fail"])) assert rc == 1 @@ -997,14 +1042,15 @@ def test_wait_cancelled_op_returns_exit_code( SESSION_STORE.set(session_store) FORMATTER.set(JSONFormatter()) session_store.set_image("img-1", kind="use") - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-cancel", "status": "CANCELLED", "kind": "instance", "duration": 0, "error": "", - } + }, ) rc = cmd_wait(WaitArgs(op_ids=["op-cancel"])) assert rc == 1 @@ -1016,21 +1062,23 @@ def test_wait_polls_until_terminal( SESSION_STORE.set(session_store) FORMATTER.set(JSONFormatter()) session_store.set_image("img-1", kind="use") - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-poll", "status": "EXECUTING", "kind": "instance", - } + }, ) - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-poll", "status": "SUCCESS", "kind": "instance", "duration": 2, "error": "", - } + }, ) from unittest.mock import patch @@ -1051,7 +1099,8 @@ def test_wait_cleans_pending_cache( session_store.cache[pending_key] = [ {"op": "op-c1", "title": "t1", "disposable": False} ] - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-c1", "status": "SUCCESS", @@ -1059,7 +1108,7 @@ def test_wait_cleans_pending_cache( "duration": 1, "error": "", "result": {"image": "img-c1"}, - } + }, ) cmd_wait(WaitArgs(op_ids=[])) # Cache should be cleaned @@ -1077,7 +1126,8 @@ def test_wait_multiple_ops_partial_cache_update( {"op": "op-m1", "title": "t1", "disposable": False}, {"op": "op-m2", "title": "t2", "disposable": False}, ] - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-m1", "status": "SUCCESS", @@ -1085,9 +1135,10 @@ def test_wait_multiple_ops_partial_cache_update( "duration": 1, "error": "", "result": {"image": "img-m1"}, - } + }, ) - contree_client.respond_json( + respond_status( + contree_client, { "uuid": "op-m2", "status": "SUCCESS", @@ -1095,7 +1146,7 @@ def test_wait_multiple_ops_partial_cache_update( "duration": 2, "error": "", "result": {"image": "img-m2"}, - } + }, ) cmd_wait(WaitArgs(op_ids=[])) # All done -> cache cleaned diff --git a/tests/test_shell_completer.py b/tests/test_shell_completer.py index 5303050..ce71448 100644 --- a/tests/test_shell_completer.py +++ b/tests/test_shell_completer.py @@ -4,7 +4,13 @@ from pathlib import PurePosixPath from unittest.mock import MagicMock, patch -from conftest import ContreeTestClient +from conftest import ContreeTestClient, make_file_item +from contree_client.exceptions import ContreeAPIError +from contree_client.models import ( + DirectoryList, + ImageListResponse, + OperationSummary, +) from contree_cli.session import ImageCache, Session from contree_cli.shell.completer import ShellCompleter @@ -22,16 +28,7 @@ def _make_file( is_dir: bool = False, is_symlink: bool = False, ) -> dict: - return { - "path": path, - "size": 128, - "mode": 0o644, - "owner": "root", - "group": "root", - "mtime": 1700000000, - "is_dir": is_dir, - "is_symlink": is_symlink, - } + return make_file_item(path, is_dir=is_dir, is_symlink=is_symlink) def _path_completer( @@ -41,7 +38,10 @@ def _path_completer( ) -> tuple[ShellCompleter, ContreeTestClient]: """Build a completer with ContreeTestClient that returns given files.""" client = ContreeTestClient() - client.respond_json({"path": "/etc", "files": files}) + client.mock( + "inspect_image_list", + DirectoryList.from_dict({"path": "/etc", "files": files}), + ) store = MagicMock() store.session = Session( @@ -445,18 +445,8 @@ def test_api_error_returns_empty(self): last_title="test", updated_at="2025-01-01", ) - # Queue a response that will cause an error when parsed - client.fake.responses.append( - type( - "ErrorResp", - (), - { - "status": 500, - "reason": "Error", - "read": lambda self, amt=None: b"error", - }, - )() - ) + # Make the listing call fail with an API error + client.mock("inspect_image_list", error=ContreeAPIError(500, "error")) completer = _make_completer(client=client, store=store) results = _complete_line( @@ -646,7 +636,7 @@ def _image_completer( ) -> tuple[ShellCompleter, ContreeTestClient]: """Build a completer with ContreeTestClient that returns given images.""" client = ContreeTestClient() - client.respond_json({"images": images}) + client.mock("list_images", ImageListResponse.from_dict({"images": images})) store = MagicMock() store.cache = cache @@ -724,6 +714,70 @@ def test_tag_completes_images(self, image_cache): assert "tag:common/rust/ubuntu:noble " in results assert "aaaa-1111 " in results + def test_tag_prefix_forwarded_to_api(self, image_cache): + """The typed tag prefix must reach the server-side filter, not + just the client-side startswith pass over the first page.""" + images = [_make_image("aaaa-1111", tag="ubuntu:latest")] + completer, client = _image_completer(images, image_cache) + + _complete_line( + completer, + "tag:ubu", + "contree use tag:ubu", + begidx=12, + ) + + calls = client.calls_for("list_images") + assert len(calls) == 1 + assert calls[0].kwargs["tag"] == "ubu" + + def test_bare_tag_prefix_forwarded_to_api(self, image_cache): + images = [_make_image("aaaa-1111", tag="ubuntu:latest")] + completer, client = _image_completer(images, image_cache) + + _complete_line( + completer, + "ubu", + "contree use ubu", + begidx=12, + ) + + calls = client.calls_for("list_images") + assert len(calls) == 1 + assert calls[0].kwargs["tag"] == "ubu" + + def test_empty_text_lists_without_filter(self, image_cache): + images = [_make_image("aaaa-1111", tag="ubuntu:latest")] + completer, client = _image_completer(images, image_cache) + + _complete_line( + completer, + "", + "contree use ", + begidx=12, + ) + + calls = client.calls_for("list_images") + assert len(calls) == 1 + assert calls[0].kwargs["tag"] is None + + def test_uuid_prefix_probes_unfiltered_page(self, image_cache): + """A hex-looking text cannot be tag-filtered server-side; the + completer also matches UUIDs from the unfiltered first page.""" + images = [_make_image("a1b2c3d4-5678-9abc-def0-111111111111")] + completer, client = _image_completer(images, image_cache) + + results = _complete_line( + completer, + "a1b2", + "contree use a1b2", + begidx=12, + ) + + assert "a1b2c3d4-5678-9abc-def0-111111111111 " in results + tags = [call.kwargs["tag"] for call in client.calls_for("list_images")] + assert tags == ["a1b2", None] + def test_image_cache_persists(self, image_cache): """Second call returns cached data without hitting the API.""" images = [_make_image("aaaa-1111", tag="old/tag")] @@ -736,12 +790,9 @@ def test_image_cache_persists(self, image_cache): begidx=12, ) assert "tag:old/tag " in results1 + assert len(client.calls_for("list_images")) == 1 # Second call -- should NOT call the API again (cached) - # Queue a different response that should NOT be used - client.respond_json( - {"images": [_make_image("bbbb-2222", tag="new/tag")]}, - ) results2 = _complete_line( completer, "tag:", @@ -749,9 +800,9 @@ def test_image_cache_persists(self, image_cache): begidx=12, ) - # Still returns cached data + # Still returns cached data, no new API call recorded assert "tag:old/tag " in results2 - assert "tag:new/tag " not in results2 + assert len(client.calls_for("list_images")) == 1 def test_no_client_returns_empty(self): completer = _make_completer(client=None) @@ -765,8 +816,7 @@ def test_no_client_returns_empty(self): def test_api_error_returns_empty(self): client = ContreeTestClient() - # Queue an error response - client.respond(status=500, body=b"error") + client.mock("list_images", error=ContreeAPIError(500, "error")) completer = _make_completer(client=client) results = _complete_line( @@ -831,11 +881,11 @@ def test_untagged_images_only_return_uuid(self, image_cache): def _make_operation( uuid: str, - state: str = "SUCCESS", + status: str = "SUCCESS", ) -> dict[str, object]: return { "uuid": uuid, - "state": state, + "status": status, "kind": "instance", "created_at": "2025-01-01", } @@ -847,7 +897,10 @@ def _op_completer( ) -> tuple[ShellCompleter, ContreeTestClient]: """Build a completer with ContreeTestClient that returns given operations.""" client = ContreeTestClient() - client.respond_json({"operations": operations}) + client.mock( + "list_operations", + [OperationSummary.from_dict(op) for op in operations], + ) store = MagicMock() store.cache = cache @@ -894,7 +947,7 @@ def test_kill_completes_operation_uuid(self, image_cache): def test_operation_empty_on_api_failure(self): client = ContreeTestClient() - client.respond(status=500, body=b"error") + client.mock("list_operations", error=ContreeAPIError(500, "error")) completer = _make_completer(client=client) results = _complete_line( @@ -1119,7 +1172,7 @@ def test_short_flag_completes_formats(self): results = _complete_line( completer, "", - "-f ", + "-o ", begidx=3, ) from contree_cli.output import FORMATTERS @@ -1127,13 +1180,13 @@ def test_short_flag_completes_formats(self): for name in FORMATTERS: assert name + " " in results - def test_contree_f_flag_completes_formats(self): - """'contree -f ' completes format names.""" + def test_contree_o_flag_completes_formats(self): + """'contree -o ' completes format names.""" completer = _make_completer() results = _complete_line( completer, "", - "contree -f ", + "contree -o ", begidx=11, ) from contree_cli.output import FORMATTERS @@ -1302,7 +1355,7 @@ def test_choices_auto_bind_via_format_flag(self): results = _complete_line( completer, "", - "contree -f ", + "contree -o ", begidx=11, ) names = [r.rstrip(" ") for r in results] diff --git a/tests/test_shell_parser.py b/tests/test_shell_parser.py index 7b39efd..e1892c0 100644 --- a/tests/test_shell_parser.py +++ b/tests/test_shell_parser.py @@ -119,9 +119,9 @@ class TestFormatFlag: """``-f``/``--format`` on the root shell parser.""" def test_format_flag_with_command(self): - """-f json ls parses correctly.""" + """-o json ls parses correctly.""" parser, _ = build_shell_parser() - ns = parser.parse_args(["-f", "json", "ls", "/etc"]) + ns = parser.parse_args(["-o", "json", "ls", "/etc"]) assert ns.output_format == "json" assert ns.command == "ls" assert ns.path == "/etc" @@ -143,7 +143,7 @@ def test_invalid_format_raises(self): """Invalid format name raises ShellParseError.""" parser, _ = build_shell_parser() with pytest.raises(ShellParseError): - parser.parse_args(["-f", "nonexistent", "ls"]) + parser.parse_args(["-o", "nonexistent", "ls"]) class TestGetCommandNames: diff --git a/tests/test_shell_repl.py b/tests/test_shell_repl.py index 182dd4f..e60f9bd 100644 --- a/tests/test_shell_repl.py +++ b/tests/test_shell_repl.py @@ -9,9 +9,9 @@ import pytest from conftest import ContreeTestClient +from contree_client.exceptions import ContreeAPIError from contree_cli import CLIENT, FORMATTER, SESSION_STORE -from contree_cli.client import ApiError from contree_cli.output import ( DefaultFormatter, JSONFormatter, @@ -128,7 +128,7 @@ def test_contree_api_error_caught(self, capsys, session_store): with patch.object(shell.parser, "parse_args") as mock_parse: ns = MagicMock() - ns.handler = MagicMock(side_effect=ApiError(404, "Not Found", "gone")) + ns.handler = MagicMock(side_effect=ContreeAPIError(404, "gone")) ns.load_args = MagicMock() ns.load_args.from_args.return_value = "args" ns.output_format = None @@ -476,7 +476,9 @@ def test_bare_command_api_error_caught(self, capsys): with ( _mock_session(), - patch("contree_cli.cli.run.cmd_run", side_effect=ApiError(500, "err", "")), + patch( + "contree_cli.cli.run.cmd_run", side_effect=ContreeAPIError(500, "err") + ), ): shell.dispatch_run("echo hello") @@ -675,7 +677,7 @@ class TestFormatOverride: """Per-command ``-f``/``--format`` override and persistent switching.""" def test_per_command_override_uses_json(self): - """'contree -f json ls' should use JSONFormatter for that command.""" + """'contree -o json ls' should use JSONFormatter for that command.""" shell = _make_shell() original = DefaultFormatter() FORMATTER.set(original) @@ -692,12 +694,12 @@ def fake_handler(args): ns.load_args.from_args.return_value = "args" ns.output_format = "json" mock_parse.return_value = ns - shell.dispatch_contree(["-f", "json", "ls"]) + shell.dispatch_contree(["-o", "json", "ls"]) assert captured_formatter["type"] is JSONFormatter def test_per_command_override_restores_original(self): - """After -f json, the original formatter is restored.""" + """After -o json, the original formatter is restored.""" shell = _make_shell() original = DefaultFormatter() FORMATTER.set(original) @@ -709,7 +711,7 @@ def test_per_command_override_restores_original(self): ns.load_args.from_args.return_value = "args" ns.output_format = "json" mock_parse.return_value = ns - shell.dispatch_contree(["-f", "json", "ls"]) + shell.dispatch_contree(["-o", "json", "ls"]) assert FORMATTER.get() is original @@ -721,12 +723,12 @@ def test_per_command_override_restores_on_exception(self): with patch.object(shell.parser, "parse_args") as mock_parse: ns = MagicMock() - ns.handler = MagicMock(side_effect=ApiError(500, "err", "")) + ns.handler = MagicMock(side_effect=ContreeAPIError(500, "err")) ns.load_args = MagicMock() ns.load_args.from_args.return_value = "args" ns.output_format = "json" mock_parse.return_value = ns - shell.dispatch_contree(["-f", "json", "ls"]) + shell.dispatch_contree(["-o", "json", "ls"]) assert FORMATTER.get() is original @@ -840,9 +842,9 @@ def test_help_unknown_delegates_to_contree(self): mock.assert_called_once_with(["nonexistent", "--help"]) def test_help_f_shows_format_help(self, capsys): - """'help -f' resolves to 'help --format' via alias.""" + """'help -o' resolves to 'help --format' via alias.""" shell = _make_shell() - shell.execute("help -f") + shell.execute("help -o") out = capsys.readouterr().out assert "--format" in out assert "format" in out.lower() @@ -888,17 +890,17 @@ def test_format_switches_to_table(self): assert type(FORMATTER.get()) is TableFormatter def test_format_short_flag(self): - """'-f json' works the same as '--format json'.""" + """'-o json' works the same as '--format json'.""" shell = _make_shell() FORMATTER.set(DefaultFormatter()) - shell.execute("-f json") + shell.execute("-o json") assert type(FORMATTER.get()) is JSONFormatter def test_format_short_flag_prints_current(self, capsys): """'-f' with no args prints current format name.""" shell = _make_shell() FORMATTER.set(DefaultFormatter()) - shell.execute("-f") + shell.execute("-o") out = capsys.readouterr().out.strip() assert out == "default" @@ -1033,9 +1035,9 @@ def test_help_dash_h_prints_usage(self, capsys): assert "TOPIC" in out def test_help_with_flag_like_topic_resolves(self, capsys): - """``help -f`` reaches BUILTIN_HELP via the HELP_ALIASES table.""" + """``help -o`` reaches BUILTIN_HELP via the HELP_ALIASES table.""" shell = _make_shell() - shell.execute("help -f") + shell.execute("help -o") out = capsys.readouterr().out assert "--format" in out diff --git a/tests/test_show.py b/tests/test_show.py index 0c0689f..c817b76 100644 --- a/tests/test_show.py +++ b/tests/test_show.py @@ -5,10 +5,10 @@ from contextvars import copy_context from conftest import ContreeTestClient +from contree_client.models import OperationResponse, decode_stream from contree_cli import FORMATTER, SESSION_STORE from contree_cli.cli.show import ShowArgs, cmd_show -from contree_cli.client import decode_stream from contree_cli.output import ( CSVFormatter, DefaultFormatter, @@ -18,7 +18,7 @@ def _run_cmd(tc: ContreeTestClient, op, *, formatter=None, store): - tc.respond_json(op) + tc.mock("get_operation_status", OperationResponse.from_dict(op)) FORMATTER.set(formatter or CSVFormatter()) SESSION_STORE.set(store) @@ -41,7 +41,18 @@ def _make_op( stderr=None, exit_code=None, ): - metadata = {"result": None} + if kind == "instance": + metadata = { + "command": "echo hi", + "image": "img-base", + "shell": True, + "result": None, + } + else: + metadata = { + "registry": {"url": "docker://docker.io/busybox:latest"}, + "tag": "busybox:latest", + } if stdout is not None or stderr is not None or exit_code is not None: state = {} if exit_code is not None: @@ -58,7 +69,7 @@ def _make_op( "error": error, "duration": duration, "metadata": metadata, - "result": {"image": image, "tag": tag, "duration": None}, + "result": {"image": image, "tag": tag}, } @@ -105,10 +116,11 @@ def test_shows_operation(self, contree_client, capsys, session_store): assert "SUCCESS" in out assert "instance" in out - def test_request_path(self, contree_client, session_store): + def test_request_uuid(self, contree_client, session_store): _run_cmd(contree_client, _make_op(), store=session_store) - req = contree_client.get_request(0) - assert req.path == "/v1/operations/op-abc" + calls = contree_client.calls_for("get_operation_status") + assert len(calls) == 1 + assert calls[0].args == ("op-abc",) def test_null_duration(self, contree_client, capsys, session_store): _run_cmd(contree_client, _make_op(duration=None), store=session_store) @@ -146,14 +158,19 @@ def test_json_output(self, contree_client, capsys, session_store): assert parsed["duration"] == 5.0 def test_unknown_field_passes_through(self, contree_client, capsys, session_store): - """New server fields reach the row even when not hardcoded.""" + """Schema fields the handler does not hardcode reach the row. + + The payload round-trips through the typed OperationResponse + model, so only fields the API schema declares survive; the + handler itself must not need a code change for them. + """ op = _make_op() - op["session_key"] = "sess-1" - op["future_field"] = "anything" + op["image_uuid"] = "img-src" + op["consumed_cpu"] = 0.25 _run_cmd(contree_client, op, formatter=JSONFormatter(), store=session_store) parsed = json.loads(capsys.readouterr().out) - assert parsed["session_key"] == "sess-1" - assert parsed["future_field"] == "anything" + assert parsed["image_uuid"] == "img-src" + assert parsed["consumed_cpu"] == 0.25 def test_table_output(self, contree_client, capsys, session_store): fmt = TableFormatter() @@ -178,7 +195,7 @@ def test_history_entry_id_resolution(self, contree_client, session_store, capsys session_store.set_image("img-2", kind="run", operation_uuid="op-run") op = _make_op(uuid="op-run") - contree_client.respond_json(op) + contree_client.mock("get_operation_status", OperationResponse.from_dict(op)) SESSION_STORE.set(session_store) FORMATTER.set(CSVFormatter()) ctx = copy_context() @@ -188,8 +205,8 @@ def test_history_entry_id_resolution(self, contree_client, session_store, capsys out = capsys.readouterr().out assert "op-run" in out - req = contree_client.get_request(0) - assert req.path == "/v1/operations/op-run" + calls = contree_client.calls_for("get_operation_status") + assert calls[0].args == ("op-run",) class TestShowStdout: @@ -277,28 +294,31 @@ def test_terminal_op_cached(self, contree_client, session_store): """SUCCESS op is cached; second call skips API.""" op = _make_op(status="SUCCESS") _run_cmd(contree_client, op, store=session_store) - assert contree_client.request_count == 1 + assert len(contree_client.calls_for("get_operation_status")) == 1 _run_cmd(contree_client, op, store=session_store) - assert contree_client.request_count == 1 # no new request (cached) + # no new request (cached) + assert len(contree_client.calls_for("get_operation_status")) == 1 def test_failed_op_cached(self, contree_client, session_store): """FAILED op is also terminal and should be cached.""" op = _make_op(status="FAILED", error="boom") _run_cmd(contree_client, op, store=session_store) - assert contree_client.request_count == 1 + assert len(contree_client.calls_for("get_operation_status")) == 1 _run_cmd(contree_client, op, store=session_store) - assert contree_client.request_count == 1 # no new request (cached) + # no new request (cached) + assert len(contree_client.calls_for("get_operation_status")) == 1 def test_non_terminal_op_not_cached(self, contree_client, session_store): """EXECUTING op should not be cached; second call hits API.""" op = _make_op(status="EXECUTING") _run_cmd(contree_client, op, store=session_store) - assert contree_client.request_count == 1 + assert len(contree_client.calls_for("get_operation_status")) == 1 _run_cmd(contree_client, op, store=session_store) - assert contree_client.request_count == 2 # new request (not cached) + # new request (not cached) + assert len(contree_client.calls_for("get_operation_status")) == 2 class TestStatusVerbatim: @@ -340,11 +360,12 @@ def test_raw_prints_server_payload_as_jsonl( self, contree_client, capsys, session_store ): # --raw skips formatter routing and derived columns and emits - # JSONL: one operation per line, the full server JSON, so multi- - # UUID `op show --raw` streams cleanly into `jq -c` / `awk`. + # JSONL: one operation per line, the server payload as the + # typed OperationResponse model round-trips it, so multi-UUID + # `op show --raw` streams cleanly into `jq -c` / `awk`. op = _make_op(status="SUCCESS", exit_code=1) - op["server_only_field"] = "preserved" - contree_client.respond_json(op) + op["consumed_memory"] = 4096 + contree_client.mock("get_operation_status", OperationResponse.from_dict(op)) FORMATTER.set(JSONFormatter()) SESSION_STORE.set(session_store) ctx = copy_context() @@ -354,9 +375,9 @@ def test_raw_prints_server_payload_as_jsonl( assert len(lines) == 1 parsed = json.loads(lines[0]) # No derived columns (no `exit_code` flattening, no `image`/`tag` - # promotion); the entire server payload is what came back. + # promotion); every schema field of the payload is kept. assert parsed["status"] == "SUCCESS" - assert parsed["server_only_field"] == "preserved" + assert parsed["consumed_memory"] == 4096 assert "metadata" in parsed # full nested structure preserved assert parsed["metadata"]["result"]["state"]["exit_code"] == 1 # JSONL contract: exactly one line of compact JSON per op. diff --git a/tests/test_skill.py b/tests/test_skill.py index 54cf7e6..90414c4 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -1,8 +1,12 @@ from __future__ import annotations +import re import shutil from pathlib import Path +import pytest + +import contree_cli.config as config_mod from contree_cli.cli.skill import ( SkillInstallArgs, SkillListArgs, @@ -14,6 +18,7 @@ cmd_skill_upgrade, ) from contree_cli.skill import ( + ALL_SKILL_TYPES, SKILL_NAME, AmpSkill, ClaudeAgentSkill, @@ -28,6 +33,7 @@ parse_version, skill_from_spec, skill_version, + skills_from_spec, ) @@ -98,19 +104,21 @@ def test_install_claude_skill_dir(self, tmp_path: Path, config_dir: Path) -> Non def test_install_default_specs( self, tmp_path: Path, config_dir: Path, monkeypatch ) -> None: - codex_home = tmp_path / ".codex" + agents_home = tmp_path / ".agents" claude_home = tmp_path / ".claude" claude_home.mkdir(parents=True) - monkeypatch.setattr("contree_cli.skill.default_codex_home", lambda: codex_home) + monkeypatch.setattr( + "contree_cli.skill.default_agents_home", lambda: agents_home + ) monkeypatch.setattr( "contree_cli.skill.default_claude_home", lambda: claude_home ) rc = cmd_skill_install(SkillInstallArgs(specs=())) assert rc is None - # Non-claude types installed unconditionally - assert (codex_home / "skills" / SKILL_NAME / "SKILL.md").is_file() + # Non-claude types installed unconditionally; codex under ~/.agents + assert (agents_home / "skills" / SKILL_NAME / "SKILL.md").is_file() # Claude types require ~/.claude to exist assert (claude_home / "skills" / SKILL_NAME / "SKILL.md").is_file() assert (claude_home / "agents" / f"{SKILL_NAME}.md").is_file() @@ -218,6 +226,254 @@ def test_remove_empty_registry(self, config_dir: Path, caplog) -> None: assert rc == 1 +class TestNonDestructiveInstall: + """Installing into a populated directory must never delete foreign files.""" + + def populated_dir(self, tmp_path: Path) -> Path: + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + (repo / "src" / "main.py").write_text("print('keep me')", encoding="utf-8") + (repo / "README.md").write_text("keep me too", encoding="utf-8") + return repo + + def test_install_keeps_foreign_files(self, tmp_path: Path) -> None: + repo = self.populated_dir(tmp_path) + ClaudeSkill(path=repo).install() + assert (repo / "SKILL.md").is_file() + assert (repo / "src" / "main.py").read_text(encoding="utf-8") == ( + "print('keep me')" + ) + assert (repo / "README.md").read_text(encoding="utf-8") == "keep me too" + + def test_install_without_force_works_when_no_skill_md(self, tmp_path: Path) -> None: + repo = self.populated_dir(tmp_path) + skill = ClaudeSkill(path=repo) + assert not skill.exists + skill.install() + assert skill.exists + + def test_install_refuses_when_skill_md_present(self, tmp_path: Path) -> None: + repo = self.populated_dir(tmp_path) + skill = ClaudeSkill(path=repo) + skill.install() + with pytest.raises(FileExistsError): + skill.install() + + def test_force_reinstall_replaces_only_skill_files(self, tmp_path: Path) -> None: + repo = self.populated_dir(tmp_path) + skill = ClaudeSkill(path=repo) + skill.install() + (repo / "SKILL.md").write_text("stale", encoding="utf-8") + skill.install(force=True) + assert "stale" not in (repo / "SKILL.md").read_text(encoding="utf-8") + assert (repo / "src" / "main.py").read_text(encoding="utf-8") == ( + "print('keep me')" + ) + assert (repo / "README.md").read_text(encoding="utf-8") == "keep me too" + + def test_remove_keeps_foreign_files_and_dir(self, tmp_path: Path) -> None: + repo = self.populated_dir(tmp_path) + skill = ClaudeSkill(path=repo) + skill.install() + skill.remove() + assert not (repo / "SKILL.md").exists() + assert not (repo / ".version").exists() + assert not (repo / "agents").exists() + assert (repo / "src" / "main.py").is_file() + assert (repo / "README.md").is_file() + + def test_remove_clean_install_removes_dir(self, tmp_path: Path) -> None: + dest = tmp_path / "skills" / SKILL_NAME + skill = ClaudeSkill(path=dest) + skill.install() + skill.remove() + assert not dest.exists() + + def test_remove_keeps_sibling_skills_and_agents( + self, tmp_path: Path, config_dir: Path, monkeypatch + ) -> None: + claude_home = tmp_path / ".claude" + other_skill = claude_home / "skills" / "other-skill" / "SKILL.md" + other_skill.parent.mkdir(parents=True) + other_skill.write_text("other skill", encoding="utf-8") + other_agent = claude_home / "agents" / "reviewer.md" + other_agent.parent.mkdir(parents=True) + other_agent.write_text("other agent", encoding="utf-8") + monkeypatch.setattr( + "contree_cli.skill.default_claude_home", lambda: claude_home + ) + monkeypatch.setattr( + "contree_cli.skill.default_codex_home", lambda: tmp_path / ".codex" + ) + + assert cmd_skill_install(SkillInstallArgs(specs=())) is None + assert (claude_home / "skills" / SKILL_NAME / "SKILL.md").is_file() + assert (claude_home / "agents" / f"{SKILL_NAME}.md").is_file() + + assert cmd_skill_remove(SkillRemoveArgs(force=True)) is None + assert not (claude_home / "skills" / SKILL_NAME).exists() + assert not (claude_home / "agents" / f"{SKILL_NAME}.md").exists() + assert other_skill.read_text(encoding="utf-8") == "other skill" + assert other_agent.read_text(encoding="utf-8") == "other agent" + + def test_remove_shared_dir_keeps_foreign_agents(self, tmp_path: Path) -> None: + claude_home = tmp_path / ".claude" + other_agent = claude_home / "agents" / "reviewer.md" + other_agent.parent.mkdir(parents=True) + other_agent.write_text("other agent", encoding="utf-8") + + skill = ClaudeSkill(path=claude_home) + skill.install() + skill.remove() + assert other_agent.read_text(encoding="utf-8") == "other agent" + assert other_agent.parent.is_dir() + + +class TestProjectRootSpecs: + """A directory spec is a project root, not the skill directory itself.""" + + def test_raw_dir_expands_to_all_kinds(self, tmp_path: Path, monkeypatch) -> None: + claude_home = tmp_path / ".claude-home" + claude_home.mkdir() + monkeypatch.setattr( + "contree_cli.skill.default_claude_home", lambda: claude_home + ) + root = tmp_path / "proj" + root.mkdir() + + skills = skills_from_spec(str(root)) + paths = {s.path for s in skills} + assert len(skills) == len(ALL_SKILL_TYPES) + assert root / ".claude" / "skills" / SKILL_NAME in paths + assert root / ".agents" / "skills" / SKILL_NAME in paths + assert root / ".claude" / "agents" / f"{SKILL_NAME}.md" in paths + assert root / ".claude" / "agents" / f"{SKILL_NAME}-subagent.md" in paths + assert all(str(s.path).startswith(str(root)) for s in skills) + + def test_raw_dir_without_claude_home_skips_claude_kinds( + self, tmp_path: Path, monkeypatch + ) -> None: + monkeypatch.setattr( + "contree_cli.skill.default_claude_home", + lambda: tmp_path / "missing", + ) + root = tmp_path / "proj" + root.mkdir() + + kinds = {s.kind for s in skills_from_spec(str(root))} + assert "claude" not in kinds + assert "codex" in kinds + + def test_kind_path_resolves_project_root(self, tmp_path: Path) -> None: + root = tmp_path / "proj" + skill = skill_from_spec(f"claude:{root}") + assert skill.path == root / ".claude" / "skills" / SKILL_NAME + + def test_codex_kind_path_uses_agents_dir(self, tmp_path: Path) -> None: + root = tmp_path / "proj" + skill = skill_from_spec(f"codex:{root}") + assert skill.path == root / ".agents" / "skills" / SKILL_NAME + + def test_codex_global_under_agents_home(self, tmp_path: Path, monkeypatch) -> None: + agents_home = tmp_path / ".agents" + monkeypatch.setattr( + "contree_cli.skill.default_agents_home", lambda: agents_home + ) + skill = skill_from_spec("codex:~") + assert skill.path == agents_home / "skills" / SKILL_NAME + + def test_dir_with_skill_md_stays_literal(self, tmp_path: Path) -> None: + dest = tmp_path / "somewhere" + dest.mkdir() + (dest / "SKILL.md").write_text("installed", encoding="utf-8") + assert skills_from_spec(str(dest)) == (ClaudeSkill(path=dest),) + + def test_contree_basename_stays_literal(self, tmp_path: Path) -> None: + dest = tmp_path / "anywhere" / SKILL_NAME + assert skills_from_spec(str(dest)) == (ClaudeSkill(path=dest),) + + def test_md_path_stays_literal(self, tmp_path: Path) -> None: + dest = tmp_path / "somewhere" / "custom.md" + assert skills_from_spec(str(dest)) == (ClaudeSubagentSkill(path=dest),) + + def test_installed_path_round_trips_for_removal(self, tmp_path: Path) -> None: + root = tmp_path / "proj" + installed = root / ".claude" / "skills" / SKILL_NAME + skills = skills_from_spec(str(installed)) + assert skills == (ClaudeSkill(path=installed),) + + def test_project_root_inside_agent_home_expands( + self, tmp_path: Path, monkeypatch + ) -> None: + """A project living inside .claude/.codex/.agents is still a root.""" + claude_home = tmp_path / ".claude-home" + claude_home.mkdir() + monkeypatch.setattr( + "contree_cli.skill.default_claude_home", lambda: claude_home + ) + root = tmp_path / ".claude" / "projects" / "proj" + root.mkdir(parents=True) + skills = skills_from_spec(str(root)) + assert len(skills) == len(ALL_SKILL_TYPES) + assert all(str(s.path).startswith(str(root)) for s in skills) + + def test_install_remove_cycle_leaves_project_clean( + self, tmp_path: Path, config_dir: Path, monkeypatch + ) -> None: + claude_home = tmp_path / ".claude-home" + claude_home.mkdir() + monkeypatch.setattr( + "contree_cli.skill.default_claude_home", lambda: claude_home + ) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex-home")) + root = tmp_path / "proj" + root.mkdir() + (root / "app.py").write_text("app", encoding="utf-8") + + specs = frozenset(skills_from_spec(str(root))) + assert cmd_skill_install(SkillInstallArgs(specs=specs)) is None + assert cmd_skill_remove(SkillRemoveArgs(specs=specs, force=True)) is None + assert [p.name for p in root.iterdir()] == ["app.py"] + + +class TestContreeHomeInSkill: + """Rendered skills and the install registry must respect CONTREE_HOME.""" + + def test_codex_sandbox_lists_contree_home_writable_root( + self, tmp_path: Path, monkeypatch + ) -> None: + data_home = tmp_path / "contree-data" + monkeypatch.setattr(config_mod, "CONTREE_HOME", data_home) + body = CodexSkill(path=tmp_path / "codex").body() + assert re.search(r'writable_roots = \["[^"]*contree-data"\]', body) + # TOML basic strings treat backslash as an escape; the path must + # be rendered with forward slashes on every platform + assert "\\" not in body + + def test_codex_sandbox_contracts_home_prefix(self, monkeypatch) -> None: + monkeypatch.setattr(config_mod, "CONTREE_HOME", Path.home() / "custom-contree") + body = CodexSkill(path=Path("unused")).body() + assert 'writable_roots = ["~/custom-contree"]' in body + + def test_installed_skill_md_reflects_contree_home( + self, tmp_path: Path, config_dir: Path, monkeypatch + ) -> None: + data_home = tmp_path / "contree-data" + monkeypatch.setattr(config_mod, "CONTREE_HOME", data_home) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex-home")) + dest = tmp_path / ".codex" / "skills" / SKILL_NAME + assert cmd_skill_install(SkillInstallArgs(specs=_specs(dest))) is None + text = (dest / "SKILL.md").read_text(encoding="utf-8") + assert re.search(r'writable_roots = \["[^"]*contree-data"\]', text) + + def test_registry_db_lives_under_contree_home( + self, tmp_path: Path, config_dir: Path + ) -> None: + dest = _installed(tmp_path) + assert cmd_skill_install(SkillInstallArgs(specs=_specs(dest))) is None + assert (config_mod.CONTREE_HOME / "cli" / "skills.db").is_file() + + class TestSkillList: def test_list_shows_remembered(self, tmp_path: Path, config_dir: Path) -> None: dest1 = tmp_path / "codex" / SKILL_NAME @@ -286,8 +542,8 @@ def test_claude_global(self, monkeypatch) -> None: assert skill.path == home / "skills" / SKILL_NAME def test_codex_global(self, monkeypatch) -> None: - home = Path("/tmp/test-codex") - monkeypatch.setattr("contree_cli.skill.default_codex_home", lambda: home) + home = Path("/tmp/test-agents") + monkeypatch.setattr("contree_cli.skill.default_agents_home", lambda: home) skill = skill_from_spec("codex:~") assert isinstance(skill, CodexSkill) assert skill.path == home / "skills" / SKILL_NAME @@ -347,6 +603,10 @@ def test_agents_marker(self) -> None: p = Path("/home/user/.config/agents/skills/contree") assert isinstance(guess_skill(p), AmpSkill) + def test_dot_agents_marker_is_codex(self) -> None: + p = Path("/home/user/.agents/skills/contree") + assert isinstance(guess_skill(p), CodexSkill) + def test_unknown_defaults_anthropic(self) -> None: p = Path("/some/random/path") assert isinstance(guess_skill(p), ClaudeSkill) @@ -406,7 +666,31 @@ def test_subagent_install_remove(self, tmp_path: Path, config_dir: Path) -> None def test_subagent_no_fallback(self, tmp_path: Path) -> None: s = ClaudeSubagentSkill(path=tmp_path / "sub.md") assert s.fallback() == "" - assert s.references() == "" + + def test_subagent_frontmatter_uses_tools_field(self, tmp_path: Path) -> None: + fm = ClaudeSubagentSkill(path=tmp_path / "sub.md").frontmatter() + assert "tools: Bash, Read, Grep" in fm + assert "allowed-tools" not in fm + + def test_codex_body_has_sandbox_section(self, tmp_path: Path) -> None: + assert "## Codex Sandbox" in CodexSkill(path=tmp_path / "codex").body() + + def test_claude_body_has_no_sandbox_section(self, tmp_path: Path) -> None: + assert "## Codex Sandbox" not in ClaudeSkill(path=tmp_path / "claude").body() + + def test_workflow_numbering_is_sequential(self, tmp_path: Path) -> None: + for skill in ( + ClaudeSkill(path=tmp_path / "claude"), + ClaudeSubagentSkill(path=tmp_path / "sub.md"), + ): + body = skill.body() + workflow = body.split("## Required Workflow", 1)[1].split("\n## ", 1)[0] + numbers = [ + int(line.split(".", 1)[0]) + for line in workflow.splitlines() + if line[:1].isdigit() + ] + assert numbers == list(range(1, len(numbers) + 1)) def test_skill_hash_eq(self, tmp_path: Path) -> None: a = ClaudeSkill(path=tmp_path / "a") @@ -422,9 +706,10 @@ def test_resolve_path_empty(self) -> None: assert p.name == SKILL_NAME assert p.is_absolute() - def test_resolve_path_explicit(self, tmp_path: Path) -> None: + def test_resolve_path_treats_dir_as_project_root(self, tmp_path: Path) -> None: p = ClaudeSkill.resolve_path(str(tmp_path / "custom")) - assert p == (tmp_path / "custom").resolve() + root = (tmp_path / "custom").resolve() + assert p == root / ".claude" / "skills" / SKILL_NAME def test_installed_version_missing(self, tmp_path: Path) -> None: s = ClaudeSkill(path=tmp_path / "noexist") @@ -481,8 +766,7 @@ def test_render_has_skills_frontmatter(self, tmp_path: Path) -> None: rendered = s.render() assert "skills:" in rendered assert "- contree" in rendered - assert "tools:" in rendered - assert "- Bash" in rendered + assert "tools: Bash, Read, Grep" in rendered def test_install_remove(self, tmp_path: Path, config_dir: Path) -> None: md = tmp_path / "agents" / "contree.md" @@ -517,11 +801,13 @@ def test_cmd_install_includes_agent( ) -> None: claude_home = tmp_path / ".claude" claude_home.mkdir(parents=True) - codex_home = tmp_path / ".codex-fresh" + agents_home = tmp_path / ".agents-fresh" monkeypatch.setattr( "contree_cli.skill.default_claude_home", lambda: claude_home ) - monkeypatch.setattr("contree_cli.skill.default_codex_home", lambda: codex_home) + monkeypatch.setattr( + "contree_cli.skill.default_agents_home", lambda: agents_home + ) rc = cmd_skill_install(SkillInstallArgs(specs=())) assert rc is None @@ -529,23 +815,25 @@ def test_cmd_install_includes_agent( assert (claude_home / "skills" / SKILL_NAME / "SKILL.md").is_file() assert (claude_home / "agents" / f"{SKILL_NAME}.md").is_file() # Non-claude types: installed even without pre-existing home - assert (codex_home / "skills" / SKILL_NAME / "SKILL.md").is_file() + assert (agents_home / "skills" / SKILL_NAME / "SKILL.md").is_file() def test_no_claude_types_without_home( self, tmp_path: Path, config_dir: Path, monkeypatch ) -> None: claude_home = tmp_path / ".claude-nonexist" - codex_home = tmp_path / ".codex" + agents_home = tmp_path / ".agents" monkeypatch.setattr( "contree_cli.skill.default_claude_home", lambda: claude_home ) - monkeypatch.setattr("contree_cli.skill.default_codex_home", lambda: codex_home) + monkeypatch.setattr( + "contree_cli.skill.default_agents_home", lambda: agents_home + ) rc = cmd_skill_install(SkillInstallArgs(specs=())) assert rc is None assert not (claude_home / "skills" / SKILL_NAME).exists() assert not (claude_home / "agents").exists() - assert (codex_home / "skills" / SKILL_NAME / "SKILL.md").is_file() + assert (agents_home / "skills" / SKILL_NAME / "SKILL.md").is_file() def test_render_mentions_subagents(self, tmp_path: Path) -> None: s = ClaudeAgentSkill(path=tmp_path / "agent.md") diff --git a/tests/test_tag.py b/tests/test_tag.py index e78e05e..f69392b 100644 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -1,14 +1,14 @@ from __future__ import annotations -import json from contextvars import copy_context import pytest from conftest import ContreeTestClient +from contree_client.exceptions import ContreeAPIError, NotFoundError +from contree_client.models import Image from contree_cli import SESSION_STORE from contree_cli.cli.tag import TagArgs, cmd_tag -from contree_cli.client import ApiError from contree_cli.session import SessionStore IMG_UUID = "a1b2c3d4-5678-9abc-def0-111111111111" @@ -28,10 +28,12 @@ def _run_cmd( tag="latest", *, delete=False, - status=200, body=None, ): - tc.respond_json(body or _DEFAULT_TAG_BODY, status=status) + if delete: + tc.mock("delete_image_tag", None) + else: + tc.mock("update_image_tag", Image.from_dict(body or _DEFAULT_TAG_BODY)) ctx = copy_context() args = TagArgs(tag=tag, image_ref=image_ref, delete=delete) result = ctx.run(cmd_tag, args) @@ -39,16 +41,17 @@ def _run_cmd( class TestCmdTag: - def test_sends_patch(self, contree_client): + def test_sends_update(self, contree_client): _run_cmd(contree_client, IMG_UUID, "v1.0") - req = contree_client.get_request(0) - assert req.method == "PATCH" - assert req.path == f"/v1/images/{IMG_UUID}/tag" + calls = contree_client.calls_for("update_image_tag") + assert len(calls) == 1 + assert calls[0].args == (IMG_UUID, "v1.0") - def test_request_body(self, contree_client): + def test_request_args(self, contree_client): _run_cmd(contree_client, IMG_UUID, "latest") - req = contree_client.get_request(0) - assert json.loads(req.body) == {"tag": "latest"} + call = contree_client.calls_for("update_image_tag")[0] + assert call.args == (IMG_UUID, "latest") + assert call.kwargs == {} def test_returns_none_on_success(self, contree_client): result = _run_cmd(contree_client) @@ -60,10 +63,15 @@ def test_logs_success(self, contree_client, caplog): assert f"Tagged image {IMG_UUID_2} as prod" in caplog.text def test_not_found_raises(self, contree_client): - contree_client.respond(status=404, body=b"image not found") + # "bad-uuid" is not a UUID, so it resolves as a tag; the tag + # lookup answers 404 for an unknown tag. + contree_client.mock( + "inspect_find_image_by_tag", + error=NotFoundError(404, "no such tag"), + ) ctx = copy_context() args = TagArgs(tag="latest", image_ref="bad-uuid") - with pytest.raises(ApiError) as exc_info: + with pytest.raises(ContreeAPIError) as exc_info: ctx.run(cmd_tag, args) assert exc_info.value.status == 404 @@ -71,9 +79,10 @@ def test_not_found_raises(self, contree_client): class TestCmdTagDelete: def test_sends_delete(self, contree_client): _run_cmd(contree_client, IMG_UUID, delete=True) - req = contree_client.get_request(0) - assert req.method == "DELETE" - assert req.path == f"/v1/images/{IMG_UUID}/tag?tag=latest" + calls = contree_client.calls_for("delete_image_tag") + assert len(calls) == 1 + assert calls[0].args == (IMG_UUID,) + assert calls[0].kwargs == {"tag": "latest"} def test_returns_none_on_success(self, contree_client): result = _run_cmd(contree_client, delete=True) @@ -84,26 +93,38 @@ def test_logs_removal(self, contree_client, caplog): _run_cmd(contree_client, IMG_UUID_3, delete=True) assert f"Removed tag 'latest' from image {IMG_UUID_3}" in caplog.text - def test_delete_includes_tag_in_query(self, contree_client): + def test_delete_includes_tag_kwarg(self, contree_client): result = _run_cmd(contree_client, IMG_UUID, "mytag", delete=True) - req = contree_client.get_request(0) - assert req.method == "DELETE" - assert req.path == f"/v1/images/{IMG_UUID}/tag?tag=mytag" + call = contree_client.calls_for("delete_image_tag")[0] + assert call.args == (IMG_UUID,) + assert call.kwargs == {"tag": "mytag"} assert result is None + def test_delete_without_image_resolves_tag(self, contree_client): + contree_client.mock("inspect_find_image_by_tag", IMG_UUID_2) + contree_client.mock("delete_image_tag", None) + ctx = copy_context() + args = TagArgs(tag="mytag", image_ref=None, delete=True) + result = ctx.run(cmd_tag, args) + assert result is None + resolve = contree_client.calls_for("inspect_find_image_by_tag") + assert resolve[0].args == ("mytag",) + call = contree_client.calls_for("delete_image_tag")[0] + assert call.args == (IMG_UUID_2,) + assert call.kwargs == {"tag": "mytag"} + class TestCmdTagCurrentImage: def test_tags_current_session_image(self, contree_client, session_store): session_store.set_image(IMG_UUID, kind="test") SESSION_STORE.set(session_store) - contree_client.respond_json(_DEFAULT_TAG_BODY) + contree_client.mock("update_image_tag", Image.from_dict(_DEFAULT_TAG_BODY)) ctx = copy_context() args = TagArgs(tag="my-tag", image_ref=None) result = ctx.run(cmd_tag, args) assert result is None - req = contree_client.get_request(0) - assert req.method == "PATCH" - assert req.path == f"/v1/images/{IMG_UUID}/tag" + call = contree_client.calls_for("update_image_tag")[0] + assert call.args == (IMG_UUID, "my-tag") def test_no_session_returns_error(self, contree_client, tmp_path): store = SessionStore(tmp_path / "empty.db", "no-image") diff --git a/tests/test_url_fetch.py b/tests/test_url_fetch.py index d65d562..f774eff 100644 --- a/tests/test_url_fetch.py +++ b/tests/test_url_fetch.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from contree_client.models import FileResponse import contree_cli.docker.url_fetch as url_fetch from contree_cli.docker.url_fetch import ( @@ -69,7 +70,11 @@ def test_first_fetch_uploads_and_caches(self, contree_client, session_store): head_headers = {"etag": '"first"'} get_headers = {"etag": '"first"', "content-length": str(len(body))} - contree_client.respond_json({"uuid": "remote-1", "sha256": "x"}) + # POST /v1/files replies with the full FileResponse payload. + contree_client.mock( + "ensure_file", + FileResponse(uuid="remote-1", sha256="x", size=len(body)), + ) with ( patch.object(url_fetch, "http_head", return_value=head_headers), @@ -90,9 +95,9 @@ def test_first_fetch_uploads_and_caches(self, contree_client, session_store): assert cached["url"] == self.URL assert cached["etag"] == '"first"' - upload_req = contree_client.get_request(0) - assert upload_req.method == "POST" - assert "/v1/files" in upload_req.path + upload_calls = contree_client.calls_for("ensure_file") + assert len(upload_calls) == 1 + assert upload_calls[0].args == (body,) def test_head_validators_skip_download(self, contree_client, session_store): cache_key = url_cache_key(self.URL) @@ -112,7 +117,7 @@ def test_head_validators_skip_download(self, contree_client, session_store): assert result.file_uuid == "remote-cached" assert result.sha256 == "cached-sha" get_mock.assert_not_called() - assert contree_client.request_count == 0 + assert contree_client.calls == [] def test_get_304_skips_upload(self, contree_client, session_store): cache_key = url_cache_key(self.URL) @@ -135,7 +140,7 @@ def test_get_304_skips_upload(self, contree_client, session_store): result = fetch_and_upload(self.URL, contree_client, session_store) assert result.file_uuid == "remote-cached" - assert contree_client.request_count == 0 + assert contree_client.calls == [] class TestHttpHelpers: @@ -149,6 +154,7 @@ def test_http_head_returns_empty_on_http_error(self): ) with patch("urllib.request.urlopen", side_effect=err): assert url_fetch.http_head("https://x") == {} + assert err.closed def test_http_get_stream_translates_304_to_status_only(self): class FakeHeaders: diff --git a/tests/test_use.py b/tests/test_use.py index 98a5ec8..ad57636 100644 --- a/tests/test_use.py +++ b/tests/test_use.py @@ -4,7 +4,7 @@ from contextvars import copy_context from unittest.mock import patch -from conftest import ContreeTestClient, FakeResponse +from conftest import ContreeTestClient from contree_cli import FORMATTER, SESSION_STORE from contree_cli.cli.use import UseArgs, cmd_use @@ -17,30 +17,17 @@ def _run_cmd( args: UseArgs, *, store: SessionStore, - responses: list[FakeResponse] | None = None, + images_response: dict | None = None, formatter=None, ): - """Run cmd_use with mocked HTTP client and real SessionStore.""" - if responses: - tc.fake.responses.extend(responses) + """Run cmd_use with mocked client and real SessionStore.""" + if images_response is not None: + tc.mock("inspect_find_image_by_tag", images_response["images"][0]["uuid"]) SESSION_STORE.set(store) FORMATTER.set(formatter or DefaultFormatter()) ctx = copy_context() - - created: list[SessionStore] = [] - _orig = SessionStore.__init__ - - def _tracking_init(self, db_path, session_key): - _orig(self, db_path, session_key) - created.append(self) - - with patch.object(SessionStore, "__init__", _tracking_init): - rc = ctx.run(cmd_use, args) - - for s in created: - s.close() - return rc + return ctx.run(cmd_use, args) class TestUseWithImage: @@ -66,16 +53,15 @@ def test_outputs_fish_syntax(self, contree_client, session_store, capsys): def test_resolves_tag(self, contree_client, session_store): args = UseArgs(image="tag:latest", new=False) - images_resp = FakeResponse.json( - {"images": [{"uuid": "resolved-uuid"}]}, - ) _run_cmd( contree_client, args, store=session_store, - responses=[images_resp], + images_response={"images": [{"uuid": "resolved-uuid"}]}, ) assert session_store.current_image == "resolved-uuid" + calls = contree_client.calls_for("inspect_find_image_by_tag") + assert calls[0].args == ("latest",) class TestUseNoArgs: @@ -113,6 +99,8 @@ def test_new_creates_different_session( assert "CONTREE_SESSION" in out # Key should NOT be the original "test" key assert "test" not in out + assert session_store.session_key != "test" + assert session_store.current_image == "a1b2c3d4-5678-9abc-def0-111111111111" def test_new_warns_on_tty(self, contree_client, session_store, profile, caplog): args = UseArgs(image="a1b2c3d4-5678-9abc-def0-111111111111", new=True) diff --git a/uv.lock b/uv.lock index aa5cb8b..cd851fb 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] @@ -28,6 +29,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -39,113 +81,111 @@ wheels = [ [[package]] name = "beautifulsoup4" -version = "4.14.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +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/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { 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 = "charset-normalizer" -version = "3.4.4" +version = "3.4.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -161,6 +201,9 @@ wheels = [ name = "contree-cli" version = "0.6.0" source = { editable = "." } +dependencies = [ + { name = "contree-client" }, +] [package.dev-dependencies] dev = [ @@ -172,7 +215,7 @@ dev = [ docs = [ { name = "furo" }, { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -182,6 +225,7 @@ docs = [ ] [package.metadata] +requires-dist = [{ name = "contree-client", specifier = ">=0.1.3" }] [package.metadata.requires-dev] dev = [ @@ -198,117 +242,111 @@ docs = [ { name = "sphinx-mintlify-output" }, ] +[[package]] +name = "contree-client" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/23/c8ac6db7872eda1ede7b6ae76c5445faefae2a34d9a35e2b1544e8233930/contree_client-0.1.3.tar.gz", hash = "sha256:c5dd328cc400803311a7bdfcdd98db3e4035febc94303bfc141b5f1c7cb782d2", size = 130188, upload-time = "2026-07-17T10:33:53.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/36/e28aa3e5172a5eb9a166fcc8c9e85a1c00415d834fdee43bc2a51d0a7cfe/contree_client-0.1.3-py3-none-any.whl", hash = "sha256:a2bc43b10f5eaee7c489d07425d00dac89f30ecf22509a2401272a0c20970534", size = 96388, upload-time = "2026-07-17T10:33:52.405Z" }, +] + [[package]] name = "coverage" -version = "7.13.4" +version = "7.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/83/6051c2a2feab48ae5bd27c84ef047191d2d4a3172f689e38eaa48ed17db1/coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67", size = 927640, upload-time = "2026-07-12T20:58:19.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, - { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, - { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, - { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, - { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, - { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, - { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, - { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, - { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, - { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, + { url = "https://files.pythonhosted.org/packages/ef/76/eef242d6bb95fb737fefd9bbf3a318897e4e82cf543b53a61c2a6e8588eb/coverage-7.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:05d87c2a43373ad6b976d0a99ad58c48633633bcdeb896dc645a006472cc4a71", size = 221195, upload-time = "2026-07-12T20:55:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/89/73/41cd50da59d513a30fd3a34b5516b962ac17514defdb6705948446a5c0b1/coverage-7.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2afce82f2cf8f4c9002746a42755e1dc61baff33d9f7ab5569b4c9101f8f4d1d", size = 221714, upload-time = "2026-07-12T20:55:58.104Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/6df6ac6677719a087c839208186525b7b1f3653c50196c43246482ada65e/coverage-7.15.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a545ef5384d787d0fcac6c349afc2c5f99dcc39e13ed3c191b2c06305f64c04", size = 248454, upload-time = "2026-07-12T20:55:59.336Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f5/86f92c429fbf942986d01c3bb7b0b6c3ad06b09f7fa745023877413dde83/coverage-7.15.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:afaa144b8f5b3bc69fe0ce50d401c46b01ab264782553bfd05a3f98804524ecb", size = 250282, upload-time = "2026-07-12T20:56:00.589Z" }, + { url = "https://files.pythonhosted.org/packages/47/c6/99d0f1c1dd1583aaf8c1deca447e44426be54a76027a8c55e29970f22b15/coverage-7.15.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b6099490e5f88569c46b18605f556c3b30acc9a0a219cf7ef8fab8f7161ec4", size = 252149, upload-time = "2026-07-12T20:56:01.853Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c3/5d30740ec96f5fd8a659e46b6ee9224197f87aa66ce4a20e93dcf46221ac/coverage-7.15.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb5f6c2cf1ffd0bf2bd925c7cdcae9b4f208e9696d453ed51eb1f5fa0cc5b45b", size = 254061, upload-time = "2026-07-12T20:56:03.277Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/3afb2950673ca6cd22bc9ad6a9d6058c853bef8fe27a6d1df3adc274fd88/coverage-7.15.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81572011fc1fc271317da35da593944daef7bfd507085e35751abbe702b74f69", size = 249152, upload-time = "2026-07-12T20:56:04.618Z" }, + { url = "https://files.pythonhosted.org/packages/ca/53/25ab3c0a7cf517ed4d30cd4dd82331e005d4b57fec8c19c0ffd94fb50baa/coverage-7.15.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f765d13c08497687d0780cca66115c6aa4ba6703ad43b61e94fab9db689e3a3", size = 250187, upload-time = "2026-07-12T20:56:06Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2a/8afb5f994e26e919e5dca5164f1c7b6b7ce6090aab1aac32b1ae1782f047/coverage-7.15.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:771caba880fee96493d18dfc465c318e08ab74e3bc2a3e4089e52514be6a6e54", size = 248193, upload-time = "2026-07-12T20:56:07.376Z" }, + { url = "https://files.pythonhosted.org/packages/d4/62/b6616e15288c9a7b628f7745e832fd8af2c03ec1f7dc11da25947f5ab16f/coverage-7.15.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:433a73200848e80f27712fc113b6ff5311f29b479a7d3bd4b1106138a77f9674", size = 252004, upload-time = "2026-07-12T20:56:08.787Z" }, + { url = "https://files.pythonhosted.org/packages/9e/21/5d80d65e4df3018dedb23a44f276f20ce26e429ffd76df03de03bcf9a767/coverage-7.15.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5d6fa45079db9fbeba0a69e3d91189f05301d6ac918162a53179d32fc9ed4910", size = 248463, upload-time = "2026-07-12T20:56:10.245Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/04a22708691561c44d77e1d5a60857b351310fdfbf253533780bb31c8b6f/coverage-7.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:00b6703c6640075cdce5124e9335dfdf9167272475301828acfdd09c0e5ee731", size = 249064, upload-time = "2026-07-12T20:56:11.614Z" }, + { url = "https://files.pythonhosted.org/packages/82/c9/a5c1e8954e657559de478acc1ef73e9393d3fc9b4a2ab4427501d765aca2/coverage-7.15.1-cp310-cp310-win32.whl", hash = "sha256:3ad9a0eac4728327fd870d52f74d2e631d176c5f178eaea2d9983ab5b9755a55", size = 223248, upload-time = "2026-07-12T20:56:13.068Z" }, + { url = "https://files.pythonhosted.org/packages/54/49/5f6566e14611a58e93a926f3a8a7b22e4e0702ebb4c467ac38f452c7f4cf/coverage-7.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:eb5fa75dc3d30e3a1b75da97973479b20ffa9b0641ff56d6e94b5f3e210daa54", size = 223874, upload-time = "2026-07-12T20:56:14.396Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/5095e07a0986930174fc153fd0bb115f7f2724f5344cc672e016d4f23a4e/coverage-7.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6506330b4a8dcf53b95bd84d8d0e817107cdb3fc1438e835029cdf0bc6612eb0", size = 221320, upload-time = "2026-07-12T20:56:16.036Z" }, + { url = "https://files.pythonhosted.org/packages/43/7a/7e2598ce98433a214020a61c0755d5961f9f26df0c630dc73e06b86d3416/coverage-7.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8a51a8ec382c39d939cba0ab07ae949077ae4e842343bd4eed22d432358cea9", size = 221823, upload-time = "2026-07-12T20:56:17.54Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4f/67dac6a69139b490a239d91b6d5ef127982ffb89159769241656ab879ee5/coverage-7.15.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18ea20e3922d7f8ca9e0ef1084408d08c4ad62d5e531cb9c1f6896a99297ebea", size = 252242, upload-time = "2026-07-12T20:56:18.868Z" }, + { url = "https://files.pythonhosted.org/packages/05/37/5e439725a96de592309725550c8df639c359c209dcb4b152ed46032d4c0d/coverage-7.15.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aab9902a64b8390e3b56e539fddae1d79a267807fe5cb0c18d7d2f544ce867e2", size = 254153, upload-time = "2026-07-12T20:56:20.118Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/671ba9e15f9651a99962fb588a1fe4fb267cae4bb85f9bb4ac4413c6f7ef/coverage-7.15.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cc316264317b07a9e90d7f2b4188a15e36e9b54e651081b791b0515fa612a29", size = 256261, upload-time = "2026-07-12T20:56:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3c/88305322b4d015b4536b7174b8f9b87f850f01af9d89008cdbf984b65ff2/coverage-7.15.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d4e47e7eea81a8ccf060a07627654151d929da62c7b715738387c200905cec89", size = 258222, upload-time = "2026-07-12T20:56:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/d02e42333a57f266ded61ed4fb3821da1f2dc143734aaa3dcc9c117204c5/coverage-7.15.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6829d9a3b55ad2b73ef5fda8302e5be03683789e88b1a079dcf4a773229c21d", size = 252368, upload-time = "2026-07-12T20:56:24.475Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d0/94ebc010926a191d83e83b1f1236f8bd0d14d0eb98b5c324aa4be2589a8e/coverage-7.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:764e045811f9c8cda436641f3f088283351d331a519b5807f19041cd0a68da1c", size = 253953, upload-time = "2026-07-12T20:56:26.036Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/2c8553191da0c2da2c43ff294fb9bab57a5b0fae03560304a82a8b5addc3/coverage-7.15.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:09b3b088aa24489c4082bcc35fcc8224281ab94a653dfb6d3f0c8165b0d628ab", size = 252016, upload-time = "2026-07-12T20:56:27.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/20f47986d8360fa1a31d9858dd2ba0be009d44ecfa8d02120b1eb93c028b/coverage-7.15.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7911b02f57053adf8164ae63edb1c26574d24dfccabadc5268cf69310a69a358", size = 255783, upload-time = "2026-07-12T20:56:28.921Z" }, + { url = "https://files.pythonhosted.org/packages/17/36/fb61983b5259f78fa5e62ee74e91fe4de4c556fcbc9a3b9c9021c2f8b57b/coverage-7.15.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:664e279ed40599b8ed16f4db18d92a7e212c73129672bec8f5d96d4da48d2404", size = 251735, upload-time = "2026-07-12T20:56:30.465Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2e/d109d8d2d6c726ba2e78125297073918a2a91464f0ea777e5398fa3cf75c/coverage-7.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34fe7cf79d5f1f87f2e8ce7dc1c32950841f50e10d0120f263856acfad66de34", size = 252645, upload-time = "2026-07-12T20:56:32.076Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/5b3219cbb46135e14673427f2bf5890c4da4e6f655243692f3448aa3f42c/coverage-7.15.1-cp311-cp311-win32.whl", hash = "sha256:5e2d2536d2f57a354aa382ed303ac0e2e5c9522a508c05b998d26181b94163a7", size = 223414, upload-time = "2026-07-12T20:56:33.432Z" }, + { url = "https://files.pythonhosted.org/packages/5b/80/24e13ade0b6df8090e2492f9c55044bf1423f7ef28c76fc1af8c827a61cb/coverage-7.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:c337da8fca7ea93ab43f3868cfcde6cf6dad32c3906b273cfbad5d7390bc423b", size = 223888, upload-time = "2026-07-12T20:56:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9e/34659930ae380491af48e2f5f5f9a2e99cd9fb7de3d30f27be5ac5425137/coverage-7.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:db3403fdb7a94d5eb73e099befad8104d2a7d110a0f0d99df0de61c5d1fa756c", size = 223435, upload-time = "2026-07-12T20:56:36.302Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/32c1826309beaf4604c54accef108fdd611e5e5e93f2f5192f050cd5f6bd/coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80", size = 221497, upload-time = "2026-07-12T20:56:37.628Z" }, + { url = "https://files.pythonhosted.org/packages/db/5c/b88ce0d68fa550c7f3b58617fbf363bce64df5bf8295a01b627e4696e022/coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189", size = 221854, upload-time = "2026-07-12T20:56:39.033Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/8509fd2a66fc4e0a829f76a0f0b1dc3cc163368352435b5f243168658077/coverage-7.15.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:23214bdbe226f2b0e9c66a7d6a1d59d4a88045dcf86e702cf0fe0d0935e3d615", size = 253359, upload-time = "2026-07-12T20:56:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/e5/81/c7009ed7ea9765adb2b9d095054d748266fae5f07ac6c5f925f33715fcde/coverage-7.15.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df164be93b46b4825cc39339440a05edc54c4d1d865ba4a60fd43d151a2a1cd3", size = 256096, upload-time = "2026-07-12T20:56:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/21/52/dc8ee03968a5ba86e2da5aa48ddc9e3747bd65d63825fdce2d96acb9c5ff/coverage-7.15.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a524fca1a6f08927d9dc2d4c873cfb7bd7202c247f08b14bdc02424071b8b304", size = 257211, upload-time = "2026-07-12T20:56:43.513Z" }, + { url = "https://files.pythonhosted.org/packages/b8/27/95d7623908da8937deb53d48efcdbf423907a47540e63c62fa21372c652b/coverage-7.15.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d70f3542cd38de85a9e257dcb1ac4c1ab4b6d7d2c2a645809207556628755d1c", size = 259473, upload-time = "2026-07-12T20:56:44.974Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3b/730d761928de97d585465680b568ae69622fb40716babadeabffe75cb51b/coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b", size = 253759, upload-time = "2026-07-12T20:56:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fc/6b9277acff1f9484b6c12857af5774689d1a6a95e13265f7405329d2f5da/coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61", size = 255131, upload-time = "2026-07-12T20:56:48.073Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/c704f86129594ba34e25a64695d2068c71d51c2b98907184d716c94f4aec/coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e", size = 253275, upload-time = "2026-07-12T20:56:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/29/80fee8af47de4a6dce71ccf2938491f444687a756af258a56d8469b8f1b0/coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5", size = 257345, upload-time = "2026-07-12T20:56:51.038Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/a1e7d7ed1b48a8adf8fd5154d9e83fcc5ad8e6ff20ae00e44865057dce8d/coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d", size = 252844, upload-time = "2026-07-12T20:56:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8c/a4bc26e6ee207d412f3678f04d74be1550e83140563ca0e4997510579712/coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2", size = 254716, upload-time = "2026-07-12T20:56:53.968Z" }, + { url = "https://files.pythonhosted.org/packages/11/9d/8ad0266ecfada6353cf6627a1a02294cf55a907521b6ee0bd7b770cfd659/coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76", size = 223554, upload-time = "2026-07-12T20:56:55.583Z" }, + { url = "https://files.pythonhosted.org/packages/81/6d/24224929e06c6e05a93f738bc5f9e8e6ab658f8f1d9b823e7b85430e28b8/coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374", size = 224087, upload-time = "2026-07-12T20:56:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/35/23/f81441dd01de88e53c97842e706907b307d9078918c3f4998b11e9ac7250/coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a", size = 223472, upload-time = "2026-07-12T20:56:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/6fa289d7993a2a39f1b283ddb58c4bfec80f7800be654b8ba8a9f6a07c63/coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a", size = 221519, upload-time = "2026-07-12T20:57:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/0db4902a0588234a70ab0218073c0b20fbc5c740aa35f91d360160a2ebc9/coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b", size = 221895, upload-time = "2026-07-12T20:57:01.867Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/3719783865092dac5e08df842730305ee9ab1973ae7ddb6fbdf27d401f30/coverage-7.15.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4c5a5eff4ad4f9f7088fd3fc7a66d98d06566ee294b3b053309fb0a3b45be1e", size = 252882, upload-time = "2026-07-12T20:57:03.459Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5e/caf3abbdbb22629626160ffc9c017eb995b7cb11c0be46b974834cef1792/coverage-7.15.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:962aa56c1c9b016d681265880eb6acc9966029d2c4c559319cc43a1abbb9b59a", size = 255479, upload-time = "2026-07-12T20:57:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f1/d60f375bfe095fef944f0f19427aefdbf9bdd5a9571c41a4bf6e2f5fdb81/coverage-7.15.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1678eb2dc57a8ce67601b029582ef6d41e9e6ca22692aaeccd4107e40f27386c", size = 256715, upload-time = "2026-07-12T20:57:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/d7/17/8b0cbc90d02dc5adad4d9034c1824ec3fa567771b4c39d9c1e3f9b1431b8/coverage-7.15.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1174900a43f6f8c425fee10d7dbddc308adefcdc78aaced32357f5ab750a0e90", size = 258845, upload-time = "2026-07-12T20:57:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/c5e69f5fb75c322e9a3e4ef64d02eebfc3d66efceccc8514ff80a3c13a56/coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f", size = 253098, upload-time = "2026-07-12T20:57:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/57/21144252fdd0c01d707d48fbcea13a80b0b7c42ced3f299f885ab8978c3a/coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8", size = 254844, upload-time = "2026-07-12T20:57:11.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/2a/499a28a322b0ce6768328e6c5bb2e2ad00ac068a7c7adb2ecd8533c8c5d9/coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a", size = 252807, upload-time = "2026-07-12T20:57:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/928a95da5da8b60f2b00e1482c7787b3316188e6d2d227fb8e124ada43a1/coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31", size = 256965, upload-time = "2026-07-12T20:57:14.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/10/889adbc1b8c9f866ed51e18a98bcafc0259fb9d29b81f50a719407c64ea8/coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad", size = 252628, upload-time = "2026-07-12T20:57:15.892Z" }, + { url = "https://files.pythonhosted.org/packages/1a/30/a5e1871e5d93416511f8e359d1ccebfe0cbb050a1bbf7dd20228533ec0cf/coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a", size = 254399, upload-time = "2026-07-12T20:57:17.703Z" }, + { url = "https://files.pythonhosted.org/packages/2d/26/c36fbffd549dadbdd1a75827528fb00a4c46aa3187b007b750b1e2cebbf2/coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e", size = 223564, upload-time = "2026-07-12T20:57:19.253Z" }, + { url = "https://files.pythonhosted.org/packages/16/fc/becbb9d2c4206d242b9b1e1e8e24a42f7926c0200dd3c788b9fab4bb96d5/coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7", size = 224106, upload-time = "2026-07-12T20:57:21.108Z" }, + { url = "https://files.pythonhosted.org/packages/d3/30/1cfc641461369b6858799fca61c0a8b5edc490c519bf7c636ffa6bbf556f/coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49", size = 223497, upload-time = "2026-07-12T20:57:22.734Z" }, + { url = "https://files.pythonhosted.org/packages/f0/46/81961952e7aebfb38ad0ae4264e8954cc607a7af9e7ac111f9fa986595cc/coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8", size = 221560, upload-time = "2026-07-12T20:57:24.282Z" }, + { url = "https://files.pythonhosted.org/packages/13/d2/ee14d715889f216baf47301d9f469e08fff6995552aaf67e897b282865f6/coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4", size = 221894, upload-time = "2026-07-12T20:57:25.87Z" }, + { url = "https://files.pythonhosted.org/packages/f3/38/f830bc6e6c2c5f23f43847125e6c650d378872f7eeba8d49f1d42193e8a9/coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81", size = 252938, upload-time = "2026-07-12T20:57:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/0d3dd963631259d794c898735d5436e68d6a8d40749c419a07ff7c171469/coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca", size = 255445, upload-time = "2026-07-12T20:57:29.234Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/aabed228557565c958259251b89bab8c5669b31291fa63b3e2154ebb017a/coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74", size = 256790, upload-time = "2026-07-12T20:57:30.826Z" }, + { url = "https://files.pythonhosted.org/packages/bc/aa/1cc888e5d3623e603c4e5399653cb25728bb2b40d7519188a3e293d24620/coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047", size = 259104, upload-time = "2026-07-12T20:57:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/fc16d5f5e53098dae41efa21e8ccc611a9b4fe922750dd03dc56db552182/coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9", size = 252956, upload-time = "2026-07-12T20:57:34.316Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f3/52384668c3de4519ca770bf1975a89e4d6eb5aa2faf0da0577a14008cba4/coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652", size = 254797, upload-time = "2026-07-12T20:57:35.947Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/54b807e7c1868178e902fd8360b5d4e559394462f97285c50edf1c4608db/coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4", size = 252762, upload-time = "2026-07-12T20:57:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/dde8adf0338e3ace738757dccf1ce817e5fdcadfae77e1b48a77e5a3b265/coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c", size = 257037, upload-time = "2026-07-12T20:57:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/179dd88cf60a0aeeee16a970ffe250dccea8b80ed4beab4c5d3f6c41ad4b/coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633", size = 252577, upload-time = "2026-07-12T20:57:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/2c/37/8a593d69ab521beb6a105a2017cac4ba94425ee0a8349e29c3c0b522d24f/coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41", size = 254235, upload-time = "2026-07-12T20:57:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/bc9b3bced66f2cdad4bf5e57ae51c54ea226e8aaaebfc9370a9a11877bf3/coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b", size = 223771, upload-time = "2026-07-12T20:57:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/4d20337bed61915d14349e62b88d5e4144d5a9872b64adbe90e9906db6db/coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a", size = 224257, upload-time = "2026-07-12T20:57:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/7b/df/bbfeae4948f3ded516f92b32f2d57952427fc5ecfc0924487bb6ee6a5f38/coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74", size = 223683, upload-time = "2026-07-12T20:57:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/35/65/0b431856064e387d1f5cf474625e4a0465e907024d42f35de6af19ced0be/coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b", size = 222298, upload-time = "2026-07-12T20:57:49.882Z" }, + { url = "https://files.pythonhosted.org/packages/a6/96/50eac9bd49df8a3df5f3d38746d1bf332299dffb554486c94ebd55c9dc49/coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49", size = 222561, upload-time = "2026-07-12T20:57:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5b/6ba1c4a27e10b8816fd2622b98162c83d3bdf1185097360373611bf96364/coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864", size = 263923, upload-time = "2026-07-12T20:57:53.392Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/fe03ade97a3ca2d890e98c572cf48a99fda9adba85757c34b823f41efe1e/coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c", size = 266043, upload-time = "2026-07-12T20:57:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/e0/55c4b1217a572a43e13b39e1eb78d0da29fb23679003bd0cdf22c50b1978/coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07", size = 268465, upload-time = "2026-07-12T20:57:57.017Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/ee47944f76afc03909119b036fe9e0da8cbd274a5141287de79791a0fb6d/coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e", size = 269584, upload-time = "2026-07-12T20:57:58.958Z" }, + { url = "https://files.pythonhosted.org/packages/cb/8a/6b4d9779c7b2e21c3d12c3425e3261aa7411399319e27aa402dfec4db5d0/coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e", size = 263019, upload-time = "2026-07-12T20:58:00.979Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1e/db5c7fa0c8ba5ece390a1e1a3f30db71d440240a80589df28e66a7503c40/coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf", size = 265916, upload-time = "2026-07-12T20:58:03.005Z" }, + { url = "https://files.pythonhosted.org/packages/83/53/fe5176682b00709b13fab36addd26883139d0dea430816fea412e69255e2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82", size = 263520, upload-time = "2026-07-12T20:58:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e7/16f15127be93fbc70c667df5ec5dce934fc76c9b0888d84969a5d5341e2c/coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82", size = 267254, upload-time = "2026-07-12T20:58:06.824Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/e5119111f6f065376395a525f7ce6e9174d83f3db6d217ea0211a61cca4d/coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7", size = 262366, upload-time = "2026-07-12T20:58:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9c/6d0a81182df18a73b081e7a8630f0e2a52b12dfd7898c6ab839551a454d2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594", size = 264680, upload-time = "2026-07-12T20:58:10.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a2/bac0cbd4450638f1be2041a464b1766c8cc94abf705a2df6f1c8d4be870d/coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070", size = 224077, upload-time = "2026-07-12T20:58:12.065Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b2/d83c5403155172a43ba47c08641bad3f89822d8405102423a41339d2c857/coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f", size = 224908, upload-time = "2026-07-12T20:58:13.956Z" }, + { url = "https://files.pythonhosted.org/packages/cc/41/442b74cad832cc77712080585455482e7cc4f4a9a13192f65731dcd18231/coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b", size = 224219, upload-time = "2026-07-12T20:58:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/34/98/07a67cf1a26e795d617ed5c540c042b0ac87b72f810c30c07f076cf334f3/coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c", size = 213284, upload-time = "2026-07-12T20:58:18.079Z" }, ] [package.optional-dependencies] @@ -333,7 +371,8 @@ name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } @@ -373,20 +412,20 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +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/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { 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 = "imagesize" -version = "1.4.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] @@ -412,87 +451,89 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, - { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, - { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, - { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, - { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, - { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, - { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, - { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, - { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, - { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, - { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, - { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, ] [[package]] @@ -512,18 +553,19 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", "python_full_version == '3.11.*'", ] dependencies = [ { name = "mdurl", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +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/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { 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]] @@ -613,15 +655,15 @@ wheels = [ [[package]] name = "mdit-py-plugins" -version = "0.5.0" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +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/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, + { 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]] @@ -635,48 +677,62 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, ] [[package]] @@ -710,42 +766,43 @@ wheels = [ [[package]] name = "myst-parser" -version = "5.0.0" +version = "5.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", "python_full_version == '3.11.*'", ] dependencies = [ { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, { name = "pyyaml", marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, ] [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +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/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { 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 = "pathspec" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -768,7 +825,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -779,23 +836,23 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { 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-cov" -version = "7.0.0" +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/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +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/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { 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]] @@ -864,7 +921,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -872,9 +929,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +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/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { 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]] @@ -888,45 +945,45 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.2" +version = "0.15.21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, - { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, - { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, - { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, - { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] [[package]] name = "snowballstemmer" -version = "3.0.1" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, ] [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] @@ -996,7 +1053,8 @@ name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", ] dependencies = [ { name = "alabaster", marker = "python_full_version >= '3.12'" }, @@ -1056,7 +1114,8 @@ name = "sphinx-design" version = "0.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", "python_full_version == '3.11.*'", ] dependencies = [ @@ -1070,16 +1129,16 @@ wheels = [ [[package]] name = "sphinx-mintlify-output" -version = "0.1.0" +version = "0.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/9f/7f5cd9a8bf3b205f35cb899355fd9588661bf07c99a1ffa7c5173f796d05/sphinx_mintlify_output-0.1.0.tar.gz", hash = "sha256:1c9d3bc2c9d0b208a2f284ee3796ffc15311bd83e64b780bb3ba1d343a5b70be", size = 40281, upload-time = "2026-06-18T08:17:49.217Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/39/1e21f30983dcacbf8734d79aa54317f5ea9f5b0ad5e6bd3567587e5818b0/sphinx_mintlify_output-0.1.1.tar.gz", hash = "sha256:0b9f4d8c37a78d6fc7de61438730a57891b20347bf66a05a4a5cbf037c524a47", size = 40290, upload-time = "2026-06-19T07:55:36.874Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/b5/f8b20789f9648c144258efb15e599709acb2f0e03eb6b5b1392ec631b48a/sphinx_mintlify_output-0.1.0-py3-none-any.whl", hash = "sha256:d290ba358fbf8c48b1f12ea810ebe768cc2dd03e7afead93f9ff4ce6b62305a5", size = 53856, upload-time = "2026-06-18T08:17:50.373Z" }, + { url = "https://files.pythonhosted.org/packages/fe/06/166308c8f0f67d2553381b36597cbe4688b6ed74242ad0edbdfc9bf59865/sphinx_mintlify_output-0.1.1-py3-none-any.whl", hash = "sha256:ff2287e5a4f35bccc5930f50d34099f9733ef6ceb6455e3ad92399adc618163e", size = 53845, upload-time = "2026-06-19T07:55:35.822Z" }, ] [[package]] @@ -1138,72 +1197,72 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +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/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, + { 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 = "typing-extensions" -version = "4.15.0" +version = "4.16.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" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +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/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { 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" }, ]