diff --git a/.gitignore b/.gitignore index 4f8ca2d8..9ca6892b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ monkeytype.sqlite3 # Test related data temp/ + +# Python packaging +*.egg-info/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..58784e5b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "sp-cli" +version = "0.1.0" +description = "AI-friendly CLI for the CCExtractor CI / Sample Platform" +requires-python = ">=3.10" +dependencies = ["click", "requests"] + +[project.scripts] +sp = "sp_cli.main:cli" + +[tool.setuptools] +packages = ["sp_cli", "sp_cli.commands"] diff --git a/run.py b/run.py index e277c6d9..a27ea2a3 100755 --- a/run.py +++ b/run.py @@ -60,10 +60,14 @@ app.config['DEBUG']) log = log_configuration.create_logger("Platform") -# Create bucket objext using GCS storage client -sa_file = os.path.join(app.config.get('INSTALL_FOLDER', ''), app.config.get('SERVICE_ACCOUNT_FILE', '')) -storage_client = Client.from_service_account_json(sa_file) -storage_client_bucket = storage_client.bucket(app.config.get('GCS_BUCKET_NAME', '')) +# Create bucket object using GCS storage client, unless explicitly disabled (local dev) +if os.environ.get('DISABLE_GCS', '0') == '1': + storage_client = None + storage_client_bucket = None +else: + sa_file = os.path.join(app.config.get('INSTALL_FOLDER', ''), app.config.get('SERVICE_ACCOUNT_FILE', '')) + storage_client = Client.from_service_account_json(sa_file) + storage_client_bucket = storage_client.bucket(app.config.get('GCS_BUCKET_NAME', '')) # Save build commit repo = git.Repo(app.config.get('INSTALL_FOLDER', '')) diff --git a/sp_cli/__init__.py b/sp_cli/__init__.py new file mode 100644 index 00000000..c3179aac --- /dev/null +++ b/sp_cli/__init__.py @@ -0,0 +1,9 @@ +"""``sp`` — an AI-friendly command-line client for the CCExtractor Sample Platform. + +The CLI is a thin layer over the Sample Platform JSON API (``/api/v1``). It is +designed to be driven by AI agents as well as humans: it emits machine-readable +JSON by default and uses non-zero exit codes plus a consistent error envelope on +failure, so it can be scripted without screen-scraping the web UI. +""" + +__version__ = "0.1.0" diff --git a/sp_cli/__main__.py b/sp_cli/__main__.py new file mode 100644 index 00000000..d5e9b2eb --- /dev/null +++ b/sp_cli/__main__.py @@ -0,0 +1,6 @@ +"""Allow the CLI to be run as ``python -m sp_cli``.""" + +from sp_cli.main import cli + +if __name__ == '__main__': + cli() diff --git a/sp_cli/banner.py b/sp_cli/banner.py new file mode 100644 index 00000000..d0cb6de4 --- /dev/null +++ b/sp_cli/banner.py @@ -0,0 +1,54 @@ +"""Branded welcome screen for the ``sp`` CLI. + +Shown only when ``sp`` is invoked with no subcommand. Never emitted on command +output, so machine consumers (agents parsing JSON) are unaffected. Colors are +applied via :func:`click.style` and are auto-stripped when output is piped. +""" + +import click + +from sp_cli import __version__ + +#: Figlet-style "sp" wordmark. +LOGO = r""" ___ _ __ + / __| '_ \ + \__ \ |_) | + |___/ .__/ + |_|""" + +_GROUPS = [ + ('TRIAGE', 'sp investigate ← one-shot: what failed and why'), + ('RUNS', 'sp run ls · show · summary · failures · results · result · diff · artifacts · logs · errors'), + ('SAMPLES', 'sp sample ls · show · history'), + ('TESTS', 'sp regression ls'), + ('SYSTEM', 'sp health · queue'), + ('AUTH', 'sp auth login · logout'), +] + +_EXAMPLES = [ + ('sp investigate 9299', 'triage a run end-to-end'), + ('sp run failures 9299', 'failing tests, each labeled with why'), + ('sp run diff 9299 137', 'expected-vs-actual diff (ids auto-resolved)'), +] + + +def show_welcome() -> None: + """Print the branded welcome screen (banner, command map, examples).""" + click.echo() + click.echo(click.style(LOGO, fg='cyan')) + click.echo(f" {click.style('CCExtractor CI', bold=True)} · AI-friendly CLI · v{__version__}") + click.echo(" drive CI investigations from the terminal — no UI, no HTML scraping") + click.echo() + + for name, line in _GROUPS: + click.echo(f" {click.style(name.ljust(8), fg='green', bold=True)} {line}") + click.echo() + + click.echo(f" {click.style('Examples', bold=True)}") + for command, note in _EXAMPLES: + click.echo(f" {command.ljust(28)} {click.style('# ' + note, fg='bright_black')}") + click.echo() + + click.echo(f" {click.style('Help', bold=True)} sp COMMAND --help" + f" {click.style('Config', bold=True)} SP_BASE_URL · SP_API_TOKEN") + click.echo() diff --git a/sp_cli/classifier.py b/sp_cli/classifier.py new file mode 100644 index 00000000..ec67ef9d --- /dev/null +++ b/sp_cli/classifier.py @@ -0,0 +1,110 @@ +"""Rule-based classification of regression-test failures into stable codes. + +Deterministic, no ML: maps the raw signals a test run exposes (exit code, +expected return code, output presence, pass-history) onto a small, stable +taxonomy so an agent can branch on *why* a test failed instead of parsing +prose. Platform differences are normalized — e.g. a segfault surfaces as ``139`` +on Linux and ``-1073741819`` (0xC0000005) on Windows; both classify as +``SEGFAULT``. + +Each classification returns a ``code`` (stable, machine-readable), a +``confidence`` (``high`` for unambiguous exit-code rules, ``medium`` for +output-based ones), a human ``reason``, and ``regression`` (True if the test was +passing before — a real regression; False if it never passed; None if unknown). +""" + +from typing import Any, Dict, Optional + +# --- Failure codes (stable; downstream tools may pin on these) --------------- +CODE_PASS = "PASS" +CODE_SEGFAULT = "SEGFAULT" +CODE_ABORT = "ABORT" +CODE_TIMEOUT = "TIMEOUT" +CODE_MISSING_OUTPUT = "MISSING_OUTPUT" +CODE_EXIT_CODE_MISMATCH = "EXIT_CODE_MISMATCH" +CODE_OUTPUT_DIFF = "OUTPUT_DIFF" +CODE_UNKNOWN = "UNKNOWN" + +# --- Exit codes that denote a crash, normalized across platforms ------------- +#: SIGSEGV (128+11) on Linux, raw -11, and 0xC0000005 access violation on Windows. +_SEGFAULT_CODES = frozenset({139, -11, -1073741819}) +#: SIGABRT (128+6) on Linux and raw -6. +_ABORT_CODES = frozenset({134, -6}) +#: `timeout` exit (124) and SIGTERM (143 / -15). +_TIMEOUT_CODES = frozenset({124, 143, -15}) + + +def classify(exit_code: Optional[int], expected_rc: Optional[int], *, + has_output_diff: bool = False, missing_output: bool = False, + has_ever_passed: Optional[bool] = None) -> Dict[str, Any]: + """ + Classify a single regression-test result into a stable failure code. + + Rules are evaluated most-severe first (crash > timeout > missing output > + exit-code mismatch > output diff), so the most actionable signal wins. + + :param exit_code: The process exit code observed for the test. + :type exit_code: Optional[int] + :param expected_rc: The exit code the test was expected to return. + :type expected_rc: Optional[int] + :param has_output_diff: True if a differing output file was recorded. + :type has_output_diff: bool + :param missing_output: True if output was expected but none was produced. + :type missing_output: bool + :param has_ever_passed: Whether this test has ever passed (history), if known. + :type has_ever_passed: Optional[bool] + :return: ``{code, confidence, reason, regression}``. + :rtype: Dict[str, Any] + """ + regression = _regression_state(has_ever_passed) + + if exit_code in _SEGFAULT_CODES: + return _result(CODE_SEGFAULT, "high", + f"Crash (segfault / access violation), exit {exit_code}", regression) + if exit_code in _ABORT_CODES: + return _result(CODE_ABORT, "high", f"Aborted (SIGABRT), exit {exit_code}", regression) + if exit_code in _TIMEOUT_CODES: + return _result(CODE_TIMEOUT, "high", f"Timed out / terminated, exit {exit_code}", regression) + if missing_output: + return _result(CODE_MISSING_OUTPUT, "high", + "No output was produced but one was expected", regression) + if exit_code != expected_rc: + return _result(CODE_EXIT_CODE_MISMATCH, "high", + f"Exited {exit_code}, expected {expected_rc}", regression) + if has_output_diff: + return _result(CODE_OUTPUT_DIFF, "medium", + "Exit code matched but output differs from expected", regression) + + return _result(CODE_PASS, "high", "Exit code matched and no output diff recorded", regression) + + +def _regression_state(has_ever_passed: Optional[bool]) -> Optional[bool]: + """ + Translate pass-history into the ``regression`` flag. + + :param has_ever_passed: Whether the test has ever passed, if known. + :type has_ever_passed: Optional[bool] + :return: True if a real regression, False if never worked, None if unknown. + :rtype: Optional[bool] + """ + if has_ever_passed is None: + return None + return bool(has_ever_passed) + + +def _result(code: str, confidence: str, reason: str, regression: Optional[bool]) -> Dict[str, Any]: + """ + Assemble a classification result dict. + + :param code: The stable failure code. + :type code: str + :param confidence: ``high`` or ``medium``. + :type confidence: str + :param reason: Human-readable explanation. + :type reason: str + :param regression: Regression flag (see :func:`_regression_state`). + :type regression: Optional[bool] + :return: The assembled result. + :rtype: Dict[str, Any] + """ + return {"code": code, "confidence": confidence, "reason": reason, "regression": regression} diff --git a/sp_cli/client.py b/sp_cli/client.py new file mode 100644 index 00000000..2719deeb --- /dev/null +++ b/sp_cli/client.py @@ -0,0 +1,168 @@ +"""HTTP client for the CCExtractor CI System API (`/api/v1`).""" + +from typing import Any, Dict, List, Optional + +import requests # type: ignore[import-untyped] + + +class ApiError(Exception): + """Raised when an API request fails, carrying the structured error envelope.""" + + def __init__(self, code: str, message: str, status: Optional[int] = None, + details: Optional[Dict[str, Any]] = None) -> None: + """ + Build an API error. + + :param code: Stable machine-readable error code (e.g. ``not_found``). + :type code: str + :param message: Human-readable explanation. + :type message: str + :param status: HTTP status code, if the failure was an HTTP response. + :type status: Optional[int] + :param details: Optional structured context echoed from the API. + :type details: Optional[Dict[str, Any]] + """ + super().__init__(message) + self.code = code + self.message = message + self.status = status + self.details = details + + @property + def exit_code(self) -> int: + """ + Map the error to a process exit code so callers can branch on it. + + :return: 3 connection · 4 not-found · 5 validation · 6 auth · 7 rate-limited · 1 other. + :rtype: int + """ + if self.code == 'connection_error': + return 3 + if self.status == 404: + return 4 + if self.status in (400, 422): + return 5 + if self.status in (401, 403): + return 6 + if self.status == 429: + return 7 + return 1 + + +class ApiClient: + """Minimal client over the JSON API. Sends a bearer token when configured.""" + + def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 30) -> None: + """ + Configure the client. + + :param base_url: Root URL of the platform (without the ``/api/v1`` prefix). + :type base_url: str + :param token: Optional opaque bearer token sent on every request. + :type token: Optional[str] + :param timeout: Per-request timeout in seconds. + :type timeout: int + """ + self.base_url = base_url.rstrip('/') + self.token = token + self.timeout = timeout + self.session = requests.Session() + + def _headers(self) -> Dict[str, str]: + """ + Build request headers, including the bearer token when set. + + :return: Header mapping. + :rtype: Dict[str, str] + """ + headers = {'Accept': 'application/json'} + if self.token: + headers['Authorization'] = f'Bearer {self.token}' + return headers + + def request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None) -> Any: + """ + Perform a request against an API path and return the decoded JSON body. + + :param method: HTTP method (``GET``, ``POST``, ``DELETE`` …). + :type method: str + :param path: API path below ``/api/v1`` (e.g. ``/runs``). + :type path: str + :param params: Optional query-string parameters. + :type params: Optional[Dict[str, Any]] + :param json_body: Optional JSON request body. + :type json_body: Optional[Dict[str, Any]] + :raises ApiError: on connection failure, a non-JSON body, or an HTTP error. + :return: The decoded JSON response body (or ``None`` for ``204``). + :rtype: Any + """ + url = f"{self.base_url}{path}" + try: + response = self.session.request(method, url, params=params, json=json_body, + headers=self._headers(), timeout=self.timeout) + except requests.RequestException as exc: + raise ApiError('connection_error', f'Could not reach {url}: {exc}') + + if response.status_code == 204: + return None + + try: + payload = response.json() + except ValueError: + raise ApiError('invalid_response', + f'Expected JSON but got HTTP {response.status_code}', response.status_code) + + if response.status_code >= 400: + error = payload if isinstance(payload, dict) else {} + raise ApiError( + error.get('code', 'http_error'), + error.get('message', f'Request failed with HTTP {response.status_code}'), + response.status_code, + error.get('details'), + ) + + return payload + + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: + """ + Perform a GET and return the decoded body. + + :param path: API path below ``/api/v1``. + :type path: str + :param params: Optional query-string parameters. + :type params: Optional[Dict[str, Any]] + :return: The decoded JSON body. + :rtype: Any + """ + return self.request('GET', path, params=params) + + def get_paginated(self, path: str, params: Optional[Dict[str, Any]] = None, + max_items: int = 1000) -> List[Any]: + """ + Follow offset pagination and return the combined ``data`` list. + + :param path: API path below ``/api/v1``. + :type path: str + :param params: Optional query-string parameters (``limit``/``offset`` are managed). + :type params: Optional[Dict[str, Any]] + :param max_items: Safety cap on total items collected. + :type max_items: int + :return: All items across pages. + :rtype: List[Any] + """ + merged = dict(params or {}) + merged.setdefault('limit', 100) + offset = 0 + items: List[Any] = [] + while True: + merged['offset'] = offset + payload = self.get(path, params=merged) + data = payload.get('data', []) if isinstance(payload, dict) else [] + items.extend(data) + pagination = payload.get('pagination', {}) if isinstance(payload, dict) else {} + next_offset = pagination.get('next_offset') + if not data or next_offset is None or len(items) >= max_items: + break + offset = next_offset + return items diff --git a/sp_cli/commands/__init__.py b/sp_cli/commands/__init__.py new file mode 100644 index 00000000..38d12262 --- /dev/null +++ b/sp_cli/commands/__init__.py @@ -0,0 +1 @@ +"""Command groups for the ``sp`` CLI, grouped by resource (noun-verb).""" diff --git a/sp_cli/commands/auth.py b/sp_cli/commands/auth.py new file mode 100644 index 00000000..3de56a83 --- /dev/null +++ b/sp_cli/commands/auth.py @@ -0,0 +1,47 @@ +"""``sp auth`` — obtain and revoke API tokens.""" + +import click + +from sp_cli.client import ApiError +from sp_cli.output import render, render_error + + +@click.group() +def auth() -> None: + """Obtain and revoke API tokens.""" + + +@auth.command('login') +@click.option('--email', prompt=True, help='Account email.') +@click.option('--password', prompt=True, hide_input=True, help='Account password (never stored).') +@click.option('--name', 'token_name', default='sp-cli', show_default=True, help='Token label.') +@click.option('--days', 'expires_in_days', type=int, default=30, show_default=True, + help='Token lifetime in days (max 90).') +@click.pass_context +def auth_login(ctx: click.Context, email: str, password: str, + token_name: str, expires_in_days: int) -> None: + """Create an API token; store the printed value in SP_API_TOKEN.""" + client = ctx.obj['client'] + output = ctx.obj['output'] + body = {'email': email, 'password': password, + 'token_name': token_name, 'expires_in_days': expires_in_days} + try: + result = client.request('POST', '/auth/tokens', json_body=body) + except ApiError as error: + render_error(error, output) + raise SystemExit(error.exit_code) + render(result, output) + + +@auth.command('logout') +@click.pass_context +def auth_logout(ctx: click.Context) -> None: + """Revoke the current API token.""" + client = ctx.obj['client'] + output = ctx.obj['output'] + try: + client.request('DELETE', '/auth/tokens/current') + except ApiError as error: + render_error(error, output) + raise SystemExit(error.exit_code) + click.echo('Token revoked.') diff --git a/sp_cli/commands/investigate.py b/sp_cli/commands/investigate.py new file mode 100644 index 00000000..16c2a243 --- /dev/null +++ b/sp_cli/commands/investigate.py @@ -0,0 +1,72 @@ +"""``sp investigate`` — one-shot triage of a run (status + counts + classified failures).""" + +from typing import Any, Dict, List + +import click + +from sp_cli.client import ApiError +from sp_cli.output import render, render_error +from sp_cli.triage import classify_sample, group_by_code, is_failure + +_RUN_FIELDS = ('run_id', 'pr_number', 'platform', 'commit_sha', 'branch', 'status', 'github_link') + + +@click.command('investigate') +@click.argument('run_id', type=int) +@click.pass_context +def investigate(ctx: click.Context, run_id: int) -> None: + """Triage a run in one shot: run info, pass/fail counts, and classified failures. + + Combines the run detail, summary, and per-result classification into a single + report — the whole "what failed and why" investigation in one command. + """ + client = ctx.obj['client'] + output = ctx.obj['output'] + try: + run = client.get(f'/runs/{run_id}') + summary = client.get(f'/runs/{run_id}/summary') + samples = client.get_paginated(f'/runs/{run_id}/samples') + except ApiError as error: + render_error(error, output) + raise SystemExit(error.exit_code) + + failures = [classify_sample(s) for s in samples if is_failure(s)] + report = { + 'run': {field: run.get(field) for field in _RUN_FIELDS}, + 'summary': summary, + 'by_code': group_by_code(failures), + 'failures': failures, + } + + if output == 'json': + render(report, 'json') + else: + _print_digest(report) + + +def _print_digest(report: Dict[str, Any]) -> None: + """ + Print a human-readable investigation digest. + + :param report: The assembled investigation report. + :type report: Dict[str, Any] + """ + run = report['run'] + summary = report['summary'] + header = (f"Run {run.get('run_id')} · PR {run.get('pr_number')} · {run.get('platform')} · " + f"{run.get('commit_sha')} · {str(run.get('status')).upper()}") + click.echo(header) + click.echo(f" {summary.get('fail_count')} failed / {summary.get('total_samples')} total" + f" ({summary.get('pass_count')} pass)") + + by_code = report['by_code'] + if by_code: + click.echo() + click.echo(" by code:") + for code, count in by_code.items(): + click.echo(f" {str(count).rjust(4)} {code}") + + failures: List[Dict[str, Any]] = report['failures'] + if failures: + click.echo() + render({'data': failures}, 'table') diff --git a/sp_cli/commands/regression.py b/sp_cli/commands/regression.py new file mode 100644 index 00000000..fac9e37b --- /dev/null +++ b/sp_cli/commands/regression.py @@ -0,0 +1,29 @@ +"""``sp regression`` — list regression-test definitions.""" + +from typing import Optional + +import click + +from sp_cli.runner import clean_params, fetch_and_render + + +@click.group() +def regression() -> None: + """List regression-test definitions.""" + + +@regression.command('ls') +@click.option('--category', default=None, help='Filter by category name.') +@click.option('--tag', default=None, help='Filter by tag.') +@click.option('--active/--all', 'active', default=None, help='Only active tests (default: all).') +@click.option('--sample-id', type=int, default=None, help='Filter by sample id.') +@click.option('--limit', type=int, default=None, help='Page size (max 100).') +@click.option('--offset', type=int, default=None, help='Pagination offset.') +@click.pass_context +def regression_ls(ctx: click.Context, category: Optional[str], tag: Optional[str], + active: Optional[bool], sample_id: Optional[int], + limit: Optional[int], offset: Optional[int]) -> None: + """List regression-test definitions.""" + params = clean_params({'category': category, 'tag': tag, 'active': active, + 'sample_id': sample_id, 'limit': limit, 'offset': offset}) + fetch_and_render(ctx, '/regression-tests', params) diff --git a/sp_cli/commands/run.py b/sp_cli/commands/run.py new file mode 100644 index 00000000..b304248e --- /dev/null +++ b/sp_cli/commands/run.py @@ -0,0 +1,236 @@ +"""``sp run`` — list, inspect, and triage CI runs.""" + +from typing import Any, Dict, List, Optional, Tuple + +import click + +from sp_cli.client import ApiError +from sp_cli.output import render, render_error +from sp_cli.runner import clean_params, fetch_and_render +from sp_cli.triage import classify_sample, is_failure + + +@click.group() +def run() -> None: + """List, inspect, and triage CI runs.""" + + +@run.command('ls') +@click.option('--status', default=None, help='queued|running|pass|fail|canceled|error|incomplete') +@click.option('--platform', default=None, help='linux|windows') +@click.option('--branch', default=None, help='Filter by branch name.') +@click.option('--commit', 'commit_sha', default=None, help='Full 40-char commit SHA.') +@click.option('--limit', type=int, default=None, help='Page size (max 100).') +@click.option('--offset', type=int, default=None, help='Pagination offset.') +@click.pass_context +def run_ls(ctx: click.Context, status: Optional[str], platform: Optional[str], branch: Optional[str], + commit_sha: Optional[str], limit: Optional[int], offset: Optional[int]) -> None: + """List CI runs (newest first).""" + params = clean_params({'status': status, 'platform': platform, 'branch': branch, + 'commit_sha': commit_sha, 'limit': limit, 'offset': offset}) + fetch_and_render(ctx, '/runs', params) + + +@run.command('show') +@click.argument('run_id', type=int) +@click.pass_context +def run_show(ctx: click.Context, run_id: int) -> None: + """Show a single run's details.""" + fetch_and_render(ctx, f'/runs/{run_id}') + + +@run.command('summary') +@click.argument('run_id', type=int) +@click.pass_context +def run_summary(ctx: click.Context, run_id: int) -> None: + """Show a run's pass/fail summary.""" + fetch_and_render(ctx, f'/runs/{run_id}/summary') + + +@run.command('failures') +@click.argument('run_id', type=int) +@click.pass_context +def run_failures(ctx: click.Context, run_id: int) -> None: + """Show a run's failing tests, each labeled with a classification code.""" + client = ctx.obj['client'] + output = ctx.obj['output'] + try: + samples = client.get_paginated(f'/runs/{run_id}/samples') + except ApiError as error: + render_error(error, output) + raise SystemExit(error.exit_code) + + rows = [classify_sample(s) for s in samples if is_failure(s)] + render({'data': rows, 'summary': {'failures': len(rows), 'of_total': len(samples)}}, output) + + +@run.command('results') +@click.argument('run_id', type=int) +@click.option('--status', default=None, help='pass|fail|skipped|missing_output|running|not_started') +@click.option('--limit', type=int, default=None, help='Page size (max 100).') +@click.option('--offset', type=int, default=None, help='Pagination offset.') +@click.pass_context +def run_results(ctx: click.Context, run_id: int, status: Optional[str], + limit: Optional[int], offset: Optional[int]) -> None: + """List all regression-test results in a run.""" + params = clean_params({'status': status, 'limit': limit, 'offset': offset}) + fetch_and_render(ctx, f'/runs/{run_id}/samples', params) + + +@run.command('result') +@click.argument('run_id', type=int) +@click.argument('sample_id', type=int) +@click.pass_context +def run_result(ctx: click.Context, run_id: int, sample_id: int) -> None: + """Show full details for a single regression-test result in a run.""" + fetch_and_render(ctx, f'/runs/{run_id}/samples/{sample_id}') + + +@run.command('diff') +@click.argument('run_id', type=int) +@click.argument('sample_id', type=int) +@click.option('--regression', 'regression_id', type=int, default=None, + help='Regression test id (auto-resolved if omitted).') +@click.option('--output', 'output_id', type=int, default=None, + help='Output file id (auto-resolved if omitted).') +@click.option('--context', 'context_lines', type=int, default=None, help='Diff context lines.') +@click.pass_context +def run_diff(ctx: click.Context, run_id: int, sample_id: int, regression_id: Optional[int], + output_id: Optional[int], context_lines: Optional[int]) -> None: + """Show the expected-vs-actual diff for a failing result. + + Resolves the (regression, output) ids automatically when omitted, so you + don't need the hidden ids the web UI requires to build a diff URL. + """ + client = ctx.obj['client'] + output = ctx.obj['output'] + try: + targets = _resolve_diff_targets(client, run_id, sample_id, regression_id, output_id) + if not targets: + raise ApiError('not_found', 'No differing output to diff for this result', 404) + diffs = [] + for media_sample_id, reg_id, out_id in targets: + params = clean_params({'context_lines': context_lines}) + diffs.append(client.get( + f'/runs/{run_id}/samples/{media_sample_id}' + f'/regression-tests/{reg_id}/outputs/{out_id}/diff', + params=params)) + except ApiError as error: + render_error(error, output) + raise SystemExit(error.exit_code) + + render(diffs[0] if len(diffs) == 1 else {'data': diffs}, output) + + +@run.command('approve-baseline') +@click.argument('run_id', type=int) +@click.argument('sample_id', type=int) +@click.option('--regression', 'regression_id', type=int, required=True, + help='Regression test id of the result to approve.') +@click.option('--output', 'output_id', type=int, required=True, + help="Output file id whose actual output becomes the new baseline.") +@click.option('--remove-variants', is_flag=True, default=False, + help='Remove all platform-specific variants (see WARNING below).') +@click.pass_context +def run_approve_baseline(ctx: click.Context, run_id: int, sample_id: int, + regression_id: int, output_id: int, remove_variants: bool) -> None: + """Approve a result's actual output as the new expected baseline. + + Requires admin privileges (the ``baselines:write`` scope). + + WARNING: --remove-variants makes this output the single source of truth + across ALL platforms by deleting every platform-specific variant. This + applies globally and cannot be undone from the CLI -- use with care. + + --regression and --output are required (no auto-resolution): approving a + baseline is destructive, so the exact target must be stated explicitly. + """ + client = ctx.obj['client'] + output = ctx.obj['output'] + try: + # The endpoint's path slot is the *media* sample id, which + # differs from the regression-result id passed on the command line. + # Resolve it from the result detail (same contract as `run diff`). + detail = client.get(f'/runs/{run_id}/samples/{sample_id}') + media_sample_id = detail.get('sample_id') + if media_sample_id is None: + raise ApiError('not_found', 'Could not resolve the media sample for this result', 404) + body = {'regression_id': regression_id, 'output_id': output_id, + 'remove_variants': remove_variants} + result = client.request( + 'POST', f'/runs/{run_id}/samples/{media_sample_id}/baseline-approval', + json_body=body) + except ApiError as error: + render_error(error, output) + raise SystemExit(error.exit_code) + + render(result, output) + + +@run.command('artifacts') +@click.argument('run_id', type=int) +@click.pass_context +def run_artifacts(ctx: click.Context, run_id: int) -> None: + """List downloadable artifacts for a run (signed URLs).""" + fetch_and_render(ctx, f'/runs/{run_id}/artifacts') + + +@run.command('logs') +@click.argument('run_id', type=int) +@click.pass_context +def run_logs(ctx: click.Context, run_id: int) -> None: + """Show raw logs for a run (requires contributor or admin privileges).""" + fetch_and_render(ctx, f'/runs/{run_id}/logs') + + +@run.command('errors') +@click.argument('run_id', type=int) +@click.option('--type', 'error_type', default=None, + help='test_failure|exit_code_mismatch|missing_output|diff_mismatch') +@click.pass_context +def run_errors(ctx: click.Context, run_id: int, error_type: Optional[str]) -> None: + """Show structured test errors for a run.""" + fetch_and_render(ctx, f'/runs/{run_id}/errors', clean_params({'type': error_type})) + + +def _resolve_diff_targets(client: Any, run_id: int, sample_id: int, + regression_id: Optional[int], + output_id: Optional[int]) -> List[Tuple[int, int, int]]: + """ + Resolve the (media_sample_id, regression_id, output_id) triples to diff. + + The diff endpoint is keyed by the *media* sample id, the regression test + id, and the output file id -- three different numbers. The CLI's + ``sample_id`` argument is the result id within the run (the regression test + id), so we always fetch the result detail to recover the media sample id + (``detail['sample_id']``) and the differing output(s), and let the caller + pass explicit ids only to narrow which output(s) to diff. + + :param client: The API client. + :type client: Any + :param run_id: The run id. + :type run_id: int + :param sample_id: The result id within the run (the regression test id). + :type sample_id: int + :param regression_id: Optional explicit regression id override. + :type regression_id: Optional[int] + :param output_id: Optional explicit output id to diff. + :type output_id: Optional[int] + :return: A list of ``(media_sample_id, regression_id, output_id)`` triples. + :rtype: List[Tuple[int, int, int]] + """ + detail = client.get(f'/runs/{run_id}/samples/{sample_id}') + media_sample_id = detail.get('sample_id') + reg_id = regression_id if regression_id is not None else detail.get('regression_test_id') + if media_sample_id is None or reg_id is None: + return [] + + outputs = detail.get('outputs') or [] + # The API reports per-output status as pass|fail|missing_output; anything + # not 'pass' is worth diffing. Fall back to all outputs if none qualify. + differing = [o for o in outputs if o.get('status') not in (None, 'pass')] or outputs + + if output_id is not None: + return [(media_sample_id, reg_id, output_id)] + return [(media_sample_id, reg_id, o.get('output_id')) for o in differing + if o.get('output_id') is not None] diff --git a/sp_cli/commands/sample.py b/sp_cli/commands/sample.py new file mode 100644 index 00000000..d8bd8ae6 --- /dev/null +++ b/sp_cli/commands/sample.py @@ -0,0 +1,47 @@ +"""``sp sample`` — list and inspect media samples.""" + +from typing import Optional + +import click + +from sp_cli.runner import clean_params, fetch_and_render + + +@click.group() +def sample() -> None: + """List and inspect media samples.""" + + +@sample.command('ls') +@click.option('--name', default=None, help='Filter by sample name.') +@click.option('--tag', default=None, help='Filter by tag.') +@click.option('--extension', default=None, help='Filter by file extension.') +@click.option('--limit', type=int, default=None, help='Page size (max 100).') +@click.option('--offset', type=int, default=None, help='Pagination offset.') +@click.pass_context +def sample_ls(ctx: click.Context, name: Optional[str], tag: Optional[str], + extension: Optional[str], limit: Optional[int], offset: Optional[int]) -> None: + """List known media samples.""" + params = clean_params({'name': name, 'tag': tag, 'extension': extension, + 'limit': limit, 'offset': offset}) + fetch_and_render(ctx, '/samples', params) + + +@sample.command('show') +@click.argument('sample_id', type=int) +@click.pass_context +def sample_show(ctx: click.Context, sample_id: int) -> None: + """Show metadata for a single sample.""" + fetch_and_render(ctx, f'/samples/{sample_id}') + + +@sample.command('history') +@click.argument('sample_id', type=int) +@click.option('--platform', default=None, help='linux|windows') +@click.option('--limit', type=int, default=None, help='Page size (max 100).') +@click.pass_context +def sample_history(ctx: click.Context, sample_id: int, platform: Optional[str], + limit: Optional[int]) -> None: + """Show this sample's result history across runs.""" + params = clean_params({'platform': platform, 'limit': limit}) + fetch_and_render(ctx, f'/samples/{sample_id}/history', params) diff --git a/sp_cli/commands/system.py b/sp_cli/commands/system.py new file mode 100644 index 00000000..cb913a2e --- /dev/null +++ b/sp_cli/commands/system.py @@ -0,0 +1,23 @@ +"""``sp health`` and ``sp queue`` — system status commands.""" + +from typing import Optional + +import click + +from sp_cli.runner import clean_params, fetch_and_render + + +@click.command('health') +@click.pass_context +def health(ctx: click.Context) -> None: + """Show CI system health and dependency status.""" + fetch_and_render(ctx, '/system/health') + + +@click.command('queue') +@click.option('--platform', default=None, help='linux|windows') +@click.option('--status', default=None, help='queued|running') +@click.pass_context +def queue(ctx: click.Context, platform: Optional[str], status: Optional[str]) -> None: + """Show queue depth and currently running jobs.""" + fetch_and_render(ctx, '/system/queue', clean_params({'platform': platform, 'status': status})) diff --git a/sp_cli/main.py b/sp_cli/main.py new file mode 100644 index 00000000..47d8c14b --- /dev/null +++ b/sp_cli/main.py @@ -0,0 +1,51 @@ +"""Entry point and root command group for the ``sp`` CLI.""" + +from typing import Optional + +import click + +from sp_cli import __version__ +from sp_cli.client import ApiClient +from sp_cli.commands.auth import auth +from sp_cli.commands.investigate import investigate +from sp_cli.commands.regression import regression +from sp_cli.commands.run import run +from sp_cli.commands.sample import sample +from sp_cli.commands.system import health, queue + +DEFAULT_BASE_URL = 'http://localhost:5000/api/v1' + + +@click.group(invoke_without_command=True) +@click.option('--base-url', envvar='SP_BASE_URL', default=DEFAULT_BASE_URL, show_default=True, + help='API base URL incl. the /api/v1 prefix. Env: SP_BASE_URL.') +@click.option('--token', envvar='SP_API_TOKEN', default=None, + help='Bearer token sent with each request. Env: SP_API_TOKEN.') +@click.option('--output', '-o', type=click.Choice(['json', 'table']), default='json', show_default=True, + help='Output format.') +@click.option('--timeout', type=int, default=30, show_default=True, help='Per-request timeout (seconds).') +@click.version_option(__version__, prog_name='sp') +@click.pass_context +def cli(ctx: click.Context, base_url: str, token: Optional[str], output: str, timeout: int) -> None: + """AI-friendly CLI for the CCExtractor CI / Sample Platform. + + Emits JSON by default so it can be driven by agents and scripts. Point it at + a running platform with --base-url or the SP_BASE_URL environment variable, + and authenticate with a token via --token / SP_API_TOKEN (see `sp auth login`). + """ + ctx.obj = { + 'client': ApiClient(base_url, token=token, timeout=timeout), + 'output': output, + } + if ctx.invoked_subcommand is None: + from sp_cli.banner import show_welcome + show_welcome() + + +cli.add_command(investigate) +cli.add_command(run) +cli.add_command(sample) +cli.add_command(regression) +cli.add_command(auth) +cli.add_command(health) +cli.add_command(queue) diff --git a/sp_cli/output.py b/sp_cli/output.py new file mode 100644 index 00000000..cd5e0d21 --- /dev/null +++ b/sp_cli/output.py @@ -0,0 +1,140 @@ +"""Render API responses to the terminal as JSON (default) or a simple table.""" + +import json +from typing import Any, Dict, List + +import click + +from sp_cli.client import ApiError + +#: Value types rendered as plain table columns; nested structures are skipped. +_SCALAR = (str, int, float, bool, type(None)) + + +def render(payload: Any, output: str) -> None: + """ + Render a successful API payload in the requested format. + + Handles the API's three shapes: a list wrapper (``{data, pagination}``), a + flat single object (run/summary/health), and bare values. + + :param payload: The decoded JSON body returned by the API. + :type payload: Any + :param output: Either ``json`` or ``table``. + :type output: str + """ + if output == 'json': + click.echo(json.dumps(payload, indent=2)) + return + + if isinstance(payload, dict) and isinstance(payload.get('data'), list): + _print_rows(payload['data']) + footer = _footer(payload) + if footer: + click.echo(f"\n{footer}") + elif isinstance(payload, dict): + _print_kv(payload) + else: + click.echo(json.dumps(payload, indent=2)) + + +def render_error(error: ApiError, output: str) -> None: + """ + Render an API error as a JSON envelope on stderr, regardless of output mode. + + :param error: The error to render. + :type error: ApiError + :param output: The selected output mode (unused; kept for symmetry). + :type output: str + """ + envelope: Dict[str, Any] = {'error': {'code': error.code, 'message': error.message}} + if error.status is not None: + envelope['error']['status'] = error.status + if error.details: + envelope['error']['details'] = error.details + click.echo(json.dumps(envelope, indent=2), err=True) + + +def _footer(payload: Dict[str, Any]) -> str: + """ + Build a one-line footer from a ``summary`` or ``pagination`` block. + + :param payload: The full response payload. + :type payload: Dict[str, Any] + :return: A footer string (possibly empty). + :rtype: str + """ + summary = payload.get('summary') + if isinstance(summary, dict): + return ' · '.join(f"{k}: {v}" for k, v in summary.items()) + pagination = payload.get('pagination') + if isinstance(pagination, dict): + parts = [] + if pagination.get('total') is not None: + parts.append(f"{pagination['total']} total") + if pagination.get('next_offset') is not None: + parts.append(f"more at offset {pagination['next_offset']}") + return ' · '.join(parts) + return '' + + +def _print_rows(rows: List[Any]) -> None: + """ + Print a list of flat dicts as an aligned table of their scalar fields. + + :param rows: The list of row dicts to render. + :type rows: List[Any] + """ + if not rows: + click.echo('(no results)') + return + if not all(isinstance(row, dict) for row in rows): + click.echo(json.dumps(rows, indent=2)) + return + + columns: List[str] = [] + for row in rows: + for key, value in row.items(): + if key not in columns and isinstance(value, _SCALAR): + columns.append(key) + + widths = {col: len(col) for col in columns} + for row in rows: + for col in columns: + widths[col] = max(widths[col], len(_cell(row.get(col)))) + + click.echo(' '.join(col.ljust(widths[col]) for col in columns)) + click.echo(' '.join('-' * widths[col] for col in columns)) + for row in rows: + click.echo(' '.join(_cell(row.get(col)).ljust(widths[col]) for col in columns)) + + +def _print_kv(record: Dict[str, Any]) -> None: + """ + Print a single record as ``key: value`` lines, JSON-encoding nested values. + + :param record: The record to render. + :type record: Dict[str, Any] + """ + width = max((len(key) for key in record), default=0) + for key, value in record.items(): + rendered = _cell(value) if isinstance(value, _SCALAR) else json.dumps(value) + click.echo(f"{key.ljust(width)} : {rendered}") + + +def _cell(value: Any) -> str: + """ + Format a scalar cell value for table display. + + :param value: The value to format. + :type value: Any + :return: A string representation (empty string for ``None``). + :rtype: str + """ + if value is None: + return '' + if isinstance(value, bool): + return 'true' if value else 'false' + if isinstance(value, (list, tuple)): + return ', '.join(str(item) for item in value) + return str(value) diff --git a/sp_cli/runner.py b/sp_cli/runner.py new file mode 100644 index 00000000..52767722 --- /dev/null +++ b/sp_cli/runner.py @@ -0,0 +1,41 @@ +"""Shared helper that fetches from the API and renders the result or error.""" + +from typing import Any, Dict, Optional + +import click + +from sp_cli.client import ApiError +from sp_cli.output import render, render_error + + +def fetch_and_render(ctx: click.Context, path: str, params: Optional[Dict[str, Any]] = None) -> None: + """ + Fetch a path via the configured client and render it, exiting on error. + + :param ctx: The active Click context (carries the client and output mode). + :type ctx: click.Context + :param path: API path below ``/api/v1``. + :type path: str + :param params: Optional query-string parameters. + :type params: Optional[Dict[str, Any]] + """ + client = ctx.obj['client'] + output = ctx.obj['output'] + try: + payload = client.get(path, params=params) + except ApiError as error: + render_error(error, output) + raise SystemExit(error.exit_code) + render(payload, output) + + +def clean_params(params: Dict[str, Any]) -> Dict[str, Any]: + """ + Drop ``None`` values so unset options are not sent as query parameters. + + :param params: Raw mapping of option names to values. + :type params: Dict[str, Any] + :return: The mapping without ``None`` values. + :rtype: Dict[str, Any] + """ + return {key: value for key, value in params.items() if value is not None} diff --git a/sp_cli/triage.py b/sp_cli/triage.py new file mode 100644 index 00000000..4eaec6e3 --- /dev/null +++ b/sp_cli/triage.py @@ -0,0 +1,85 @@ +"""Triage helpers: turn raw RunSample results into classified failure rows. + +Shared by ``sp run failures`` and ``sp investigate`` so both label failures the +same way. The classification itself lives in :mod:`sp_cli.classifier`; this module +adapts a ``RunSample`` (from ``/runs/{id}/samples``) into a flat, agent-friendly row. +""" + +from typing import Any, Dict, List + +from sp_cli.classifier import classify + +#: RunSample statuses that count as a failure worth triaging. +FAILURE_STATUSES = ('fail', 'missing_output') + + +def is_failure(sample: Dict[str, Any]) -> bool: + """ + Report whether a RunSample result is a failure. + + :param sample: One ``RunSample`` object. + :type sample: Dict[str, Any] + :return: True if the result failed or produced no output. + :rtype: bool + """ + return sample.get('status') in FAILURE_STATUSES + + +def has_output_diff(sample: Dict[str, Any]) -> bool: + """ + Decide whether a failing sample recorded a differing output file. + + Prefers the per-output ``status`` when present; otherwise falls back to + "failed but the exit code matched, so the failure must be an output diff." + + :param sample: One ``RunSample`` object. + :type sample: Dict[str, Any] + :return: True if an output differed from expected. + :rtype: bool + """ + outputs = sample.get('outputs') or [] + if outputs: + return any(item.get('status') not in (None, 'pass') for item in outputs) + return sample.get('status') == 'fail' and sample.get('exit_code') == sample.get('expected_rc') + + +def classify_sample(sample: Dict[str, Any]) -> Dict[str, Any]: + """ + Map a RunSample result onto a classified failure row. + + :param sample: One ``RunSample`` object from ``/runs/{id}/samples``. + :type sample: Dict[str, Any] + :return: A flat row with ids plus the classification code, confidence and reason. + :rtype: Dict[str, Any] + """ + label = classify( + sample.get('exit_code'), sample.get('expected_rc'), + missing_output=(sample.get('status') == 'missing_output'), + has_output_diff=has_output_diff(sample), + ) + return { + 'regression_test_id': sample.get('regression_test_id'), + 'sample_id': sample.get('sample_id'), + 'sample_name': sample.get('sample_name'), + 'categories': sample.get('categories') or [], + 'exit_code': sample.get('exit_code'), + 'expected_rc': sample.get('expected_rc'), + 'code': label['code'], + 'confidence': label['confidence'], + 'reason': label['reason'], + } + + +def group_by_code(failures: List[Dict[str, Any]]) -> Dict[str, int]: + """ + Count classified failures by their code. + + :param failures: Classified failure rows. + :type failures: List[Dict[str, Any]] + :return: A mapping of code → count, highest first. + :rtype: Dict[str, int] + """ + counts: Dict[str, int] = {} + for failure in failures: + counts[failure['code']] = counts.get(failure['code'], 0) + 1 + return dict(sorted(counts.items(), key=lambda item: item[1], reverse=True)) diff --git a/tests/test_cli/__init__.py b/tests/test_cli/__init__.py new file mode 100644 index 00000000..ccccefe2 --- /dev/null +++ b/tests/test_cli/__init__.py @@ -0,0 +1 @@ +"""Tests for the sp CLI (sp_cli).""" diff --git a/tests/test_cli/test_classifier.py b/tests/test_cli/test_classifier.py new file mode 100644 index 00000000..34df46cf --- /dev/null +++ b/tests/test_cli/test_classifier.py @@ -0,0 +1,69 @@ +"""Tests for the rule-based failure classifier, using real examples from run #9299.""" + +import unittest + +from sp_cli import classifier + + +class ClassifierTests(unittest.TestCase): + """Each case is grounded in a real failure observed in the friction study.""" + + def test_exit_code_mismatch(self): + """`10 (Expected 0)` — the common CEA-708 failure in run #9299.""" + result = classifier.classify(10, 0) + self.assertEqual(result["code"], classifier.CODE_EXIT_CODE_MISMATCH) + self.assertEqual(result["confidence"], "high") + self.assertIn("10", result["reason"]) + + def test_windows_segfault_normalized(self): + """`-1073741819` (0xC0000005) on Windows — the DVB failure in #9299.""" + result = classifier.classify(-1073741819, 0) + self.assertEqual(result["code"], classifier.CODE_SEGFAULT) + self.assertEqual(result["confidence"], "high") + + def test_linux_segfault_normalized(self): + """`139` on Linux is the same crash — must map to the same code.""" + self.assertEqual(classifier.classify(139, 0)["code"], classifier.CODE_SEGFAULT) + + def test_abort(self): + """`134` (SIGABRT) classifies as ABORT.""" + self.assertEqual(classifier.classify(134, 0)["code"], classifier.CODE_ABORT) + + def test_timeout(self): + """`124` (timeout) classifies as TIMEOUT.""" + self.assertEqual(classifier.classify(124, 0)["code"], classifier.CODE_TIMEOUT) + + def test_missing_output(self): + """'No output generated but there should be' — exit matches but output missing.""" + result = classifier.classify(0, 0, missing_output=True) + self.assertEqual(result["code"], classifier.CODE_MISSING_OUTPUT) + + def test_output_diff(self): + """Exit code matches but the output file differs.""" + result = classifier.classify(0, 0, has_output_diff=True) + self.assertEqual(result["code"], classifier.CODE_OUTPUT_DIFF) + self.assertEqual(result["confidence"], "medium") + + def test_pass(self): + """Exit matches, no diff, nothing missing → PASS.""" + self.assertEqual(classifier.classify(0, 0)["code"], classifier.CODE_PASS) + + def test_crash_beats_exit_mismatch(self): + """A segfault is reported as SEGFAULT, not a generic exit mismatch.""" + self.assertEqual(classifier.classify(139, 0)["code"], classifier.CODE_SEGFAULT) + + def test_regression_flag_true_when_previously_passed(self): + """A failure on a test that has passed before is a real regression.""" + self.assertTrue(classifier.classify(10, 0, has_ever_passed=True)["regression"]) + + def test_regression_flag_false_when_never_passed(self): + """A failure on a test that never passed is pre-existing (never worked).""" + self.assertFalse(classifier.classify(10, 0, has_ever_passed=False)["regression"]) + + def test_regression_flag_none_when_unknown(self): + """Without history, the regression flag is None (unknown).""" + self.assertIsNone(classifier.classify(10, 0)["regression"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli/test_cli.py b/tests/test_cli/test_cli.py new file mode 100644 index 00000000..4836c575 --- /dev/null +++ b/tests/test_cli/test_cli.py @@ -0,0 +1,186 @@ +"""Tests for the sp CLI command surface, mocking the API client.""" + +import json +import unittest +from unittest import mock + +from click.testing import CliRunner + +from sp_cli.client import ApiError +from sp_cli.main import cli + +RUNS_PAGE = { + 'data': [{'run_id': 9299, 'status': 'fail', 'platform': 'windows', 'commit_sha': 'e6cd34e'}], + 'pagination': {'limit': 50, 'offset': 0, 'total': 1, 'next_offset': None}, +} + +# A run's results: a segfault, an exit mismatch, a missing output, and a pass. +RUN_SAMPLES = [ + {'regression_test_id': 18, 'sample_name': 'dvb', 'categories': ['DVB'], + 'status': 'fail', 'exit_code': -1073741819, 'expected_rc': 0, 'outputs': []}, + {'regression_test_id': 137, 'sample_name': 'cea708', 'categories': ['CEA-708'], + 'status': 'fail', 'exit_code': 10, 'expected_rc': 0, 'outputs': []}, + {'regression_test_id': 7, 'sample_name': 'broken', 'categories': ['Broken'], + 'status': 'missing_output', 'exit_code': 0, 'expected_rc': 0, 'outputs': []}, + {'regression_test_id': 1, 'sample_name': 'ok', 'categories': ['General'], + 'status': 'pass', 'exit_code': 0, 'expected_rc': 0, 'outputs': []}, +] + + +class CliCommandTests(unittest.TestCase): + """Exercise the CLI commands with a mocked client.""" + + def setUp(self): + """Create a runner for each test.""" + self.runner = CliRunner() + + @mock.patch('sp_cli.client.ApiClient.get') + def test_run_ls_calls_runs_with_filters(self, mock_get): + """`run ls` hits /runs and forwards set filters only.""" + mock_get.return_value = RUNS_PAGE + result = self.runner.invoke(cli, ['run', 'ls', '--platform', 'windows']) + + self.assertEqual(result.exit_code, 0) + mock_get.assert_called_once_with('/runs', params={'platform': 'windows'}) + + @mock.patch('sp_cli.client.ApiClient.get') + def test_run_show(self, mock_get): + """`run show ` targets the run detail path.""" + mock_get.return_value = {'run_id': 9299, 'status': 'fail'} + result = self.runner.invoke(cli, ['run', 'show', '9299']) + + self.assertEqual(result.exit_code, 0) + mock_get.assert_called_once_with('/runs/9299', params=None) + + @mock.patch('sp_cli.client.ApiClient.get_paginated') + def test_run_failures_classifies(self, mock_paginated): + """`run failures` keeps only failures and labels each with a code.""" + mock_paginated.return_value = RUN_SAMPLES + result = self.runner.invoke(cli, ['run', 'failures', '9299']) + + self.assertEqual(result.exit_code, 0) + mock_paginated.assert_called_once_with('/runs/9299/samples') + data = json.loads(result.output) + codes = {row['regression_test_id']: row['code'] for row in data['data']} + self.assertEqual(codes, {18: 'SEGFAULT', 137: 'EXIT_CODE_MISMATCH', 7: 'MISSING_OUTPUT'}) + self.assertEqual(data['summary'], {'failures': 3, 'of_total': 4}) + + @mock.patch('sp_cli.client.ApiClient.get_paginated') + def test_run_failures_table_output(self, mock_paginated): + """Table mode renders the classification columns.""" + mock_paginated.return_value = RUN_SAMPLES + result = self.runner.invoke(cli, ['-o', 'table', 'run', 'failures', '9299']) + + self.assertEqual(result.exit_code, 0) + self.assertIn('SEGFAULT', result.output) + self.assertIn('code', result.output) + + @mock.patch('sp_cli.client.ApiClient.get') + def test_sample_ls(self, mock_get): + """`sample ls` hits /samples.""" + mock_get.return_value = {'data': [], 'pagination': {'total': 0, 'next_offset': None}} + result = self.runner.invoke(cli, ['sample', 'ls', '--tag', 'teletext']) + + self.assertEqual(result.exit_code, 0) + mock_get.assert_called_once_with('/samples', params={'tag': 'teletext'}) + + @mock.patch('sp_cli.client.ApiClient.get') + def test_health(self, mock_get): + """`sp health` hits /system/health.""" + mock_get.return_value = {'status': 'ok', 'dependencies': []} + result = self.runner.invoke(cli, ['health']) + + self.assertEqual(result.exit_code, 0) + mock_get.assert_called_once_with('/system/health', params=None) + + @mock.patch('sp_cli.client.ApiClient.get') + def test_not_found_maps_to_exit_code_and_stderr(self, mock_get): + """A not-found error exits 4 with a JSON envelope on stderr.""" + mock_get.side_effect = ApiError('not_found', 'Run 9 not found', 404) + result = self.runner.invoke(cli, ['run', 'show', '9']) + + self.assertEqual(result.exit_code, 4) + self.assertEqual(json.loads(result.stderr)['error']['code'], 'not_found') + + @mock.patch('sp_cli.client.ApiClient.get') + def test_run_result(self, mock_get): + """`run result ` targets the result-detail path.""" + mock_get.return_value = {'regression_test_id': 137, 'status': 'fail'} + result = self.runner.invoke(cli, ['run', 'result', '9299', '5']) + + self.assertEqual(result.exit_code, 0) + mock_get.assert_called_once_with('/runs/9299/samples/5', params=None) + + @mock.patch('sp_cli.client.ApiClient.get') + def test_run_diff_auto_resolves_hidden_ids(self, mock_get): + """`run diff` resolves the media sample id + regression/output ids from detail.""" + mock_get.side_effect = [ + {'regression_test_id': 137, 'sample_id': 42, + 'outputs': [{'output_id': 2, 'status': 'fail'}]}, + {'status': 'different', 'hunks': []}, + ] + result = self.runner.invoke(cli, ['run', 'diff', '9299', '5']) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(mock_get.call_count, 2) + args, kwargs = mock_get.call_args + self.assertEqual(args[0], '/runs/9299/samples/42/regression-tests/137/outputs/2/diff') + self.assertEqual(kwargs['params'], {}) + + @mock.patch('sp_cli.client.ApiClient.get') + def test_run_diff_with_explicit_ids_uses_media_sample_from_detail(self, mock_get): + """Explicit --regression/--output still fetch detail for the media sample id.""" + mock_get.side_effect = [ + {'regression_test_id': 137, 'sample_id': 42, 'outputs': []}, + {'status': 'different', 'hunks': []}, + ] + result = self.runner.invoke(cli, ['run', 'diff', '9299', '5', '--regression', '137', '--output', '2']) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(mock_get.call_count, 2) + args, kwargs = mock_get.call_args + self.assertEqual(args[0], '/runs/9299/samples/42/regression-tests/137/outputs/2/diff') + self.assertEqual(kwargs['params'], {}) + + @mock.patch('sp_cli.client.ApiClient.request') + @mock.patch('sp_cli.client.ApiClient.get') + def test_run_approve_baseline_resolves_media_sample_and_posts(self, mock_get, mock_request): + """`run approve-baseline` POSTs to the media-sample path resolved from detail.""" + mock_get.return_value = {'regression_test_id': 137, 'sample_id': 42, 'outputs': []} + mock_request.return_value = {'status': 'approved', 'run_id': 9299, 'sample_id': 42, + 'regression_id': 137, 'output_id': 2} + result = self.runner.invoke(cli, ['run', 'approve-baseline', '9299', '5', + '--regression', '137', '--output', '2', '--remove-variants']) + + self.assertEqual(result.exit_code, 0) + mock_get.assert_called_once_with('/runs/9299/samples/5') + mock_request.assert_called_once_with( + 'POST', '/runs/9299/samples/42/baseline-approval', + json_body={'regression_id': 137, 'output_id': 2, 'remove_variants': True}) + + @mock.patch('sp_cli.client.ApiClient.get') + def test_run_approve_baseline_requires_regression_and_output(self, mock_get): + """Approving a baseline refuses to run without the explicit target ids.""" + result = self.runner.invoke(cli, ['run', 'approve-baseline', '9299', '5']) + + self.assertNotEqual(result.exit_code, 0) + mock_get.assert_not_called() + + @mock.patch('sp_cli.client.ApiClient.get_paginated') + @mock.patch('sp_cli.client.ApiClient.get') + def test_investigate_combines_run_summary_and_failures(self, mock_get, mock_paginated): + """`investigate` merges run detail, summary, and classified failures.""" + mock_get.side_effect = [ + {'run_id': 9299, 'pr_number': 2264, 'platform': 'windows', 'status': 'fail'}, + {'run_id': 9299, 'total_samples': 4, 'pass_count': 1, 'fail_count': 3}, + ] + mock_paginated.return_value = RUN_SAMPLES + result = self.runner.invoke(cli, ['investigate', '9299']) + + self.assertEqual(result.exit_code, 0) + report = json.loads(result.output) + self.assertEqual(report['run']['pr_number'], 2264) + self.assertEqual(report['summary']['fail_count'], 3) + self.assertEqual(report['by_code'], + {'SEGFAULT': 1, 'EXIT_CODE_MISMATCH': 1, 'MISSING_OUTPUT': 1}) + self.assertEqual(len(report['failures']), 3) diff --git a/tests/test_cli/test_client.py b/tests/test_cli/test_client.py new file mode 100644 index 00000000..5eeeb5ce --- /dev/null +++ b/tests/test_cli/test_client.py @@ -0,0 +1,90 @@ +"""Tests for the CLI's HTTP client, mocking the requests session.""" + +import unittest +from unittest import mock + +import requests # type: ignore[import-untyped] + +from sp_cli.client import ApiClient, ApiError + + +class FakeResponse: + """Minimal stand-in for a requests Response.""" + + def __init__(self, status_code, json_data=None, raise_json=False): + """Store the canned status and body.""" + self.status_code = status_code + self._json = json_data + self._raise_json = raise_json + + def json(self): + """Return the canned JSON body or raise like requests does on non-JSON.""" + if self._raise_json: + raise ValueError('No JSON could be decoded') + return self._json + + +class ApiClientTests(unittest.TestCase): + """Exercise request building and error mapping in the client.""" + + @mock.patch('requests.Session.request') + def test_get_returns_payload_and_builds_url(self, mock_request): + """A 2xx response is returned and the API prefix is applied.""" + mock_request.return_value = FakeResponse(200, {'data': []}) + client = ApiClient('https://host/api/v1') + + self.assertEqual(client.get('/runs'), {'data': []}) + args, _ = mock_request.call_args + self.assertEqual(args[0], 'GET') + self.assertEqual(args[1], 'https://host/api/v1/runs') + + @mock.patch('requests.Session.request') + def test_204_returns_none(self, mock_request): + """A 204 (e.g. token revoke) returns None, not a parse error.""" + mock_request.return_value = FakeResponse(204) + self.assertIsNone(ApiClient('https://host').request('DELETE', '/auth/tokens/current')) + + @mock.patch('requests.Session.request') + def test_error_codes_map_to_exit_codes(self, mock_request): + """Each HTTP error maps to its documented exit code.""" + cases = {404: 4, 422: 5, 400: 5, 401: 6, 403: 6, 429: 7} + client = ApiClient('https://host') + for status, expected_exit in cases.items(): + mock_request.return_value = FakeResponse(status, {'code': 'x', 'message': 'm'}) + with self.assertRaises(ApiError) as caught: + client.get('/runs/9') + self.assertEqual(caught.exception.exit_code, expected_exit, f'status {status}') + + @mock.patch('requests.Session.request') + def test_token_is_sent_as_bearer_header(self, mock_request): + """A configured token is sent as an Authorization header.""" + mock_request.return_value = FakeResponse(200, {}) + ApiClient('https://host', token='secret').get('/runs') + _, kwargs = mock_request.call_args + self.assertEqual(kwargs['headers']['Authorization'], 'Bearer secret') + + @mock.patch('requests.Session.request', side_effect=requests.RequestException('boom')) + def test_connection_failure(self, mock_request): + """A transport failure maps to a connection_error with exit code 3.""" + with self.assertRaises(ApiError) as caught: + ApiClient('https://host').get('/runs') + self.assertEqual(caught.exception.code, 'connection_error') + self.assertEqual(caught.exception.exit_code, 3) + + @mock.patch('requests.Session.request') + def test_non_json_body_raises_invalid_response(self, mock_request): + """A non-JSON body raises invalid_response rather than crashing.""" + mock_request.return_value = FakeResponse(500, raise_json=True) + with self.assertRaises(ApiError) as caught: + ApiClient('https://host').get('/runs') + self.assertEqual(caught.exception.code, 'invalid_response') + + @mock.patch('requests.Session.request') + def test_get_paginated_follows_offset(self, mock_request): + """Pagination is followed across pages until next_offset is null.""" + mock_request.side_effect = [ + FakeResponse(200, {'data': [1, 2, 3], 'pagination': {'next_offset': 3}}), + FakeResponse(200, {'data': [4, 5], 'pagination': {'next_offset': None}}), + ] + items = ApiClient('https://host').get_paginated('/runs/9/samples') + self.assertEqual(items, [1, 2, 3, 4, 5]) diff --git a/tests/test_cli/test_triage.py b/tests/test_cli/test_triage.py new file mode 100644 index 00000000..5a570c96 --- /dev/null +++ b/tests/test_cli/test_triage.py @@ -0,0 +1,99 @@ +"""Tests for the triage helpers that adapt RunSample results into failure rows.""" + +import unittest + +from sp_cli import triage + + +class IsFailureTests(unittest.TestCase): + """``is_failure`` keys off the RunSample status.""" + + def test_fail_status_is_failure(self): + """A 'fail' status counts as a failure worth triaging.""" + self.assertTrue(triage.is_failure({"status": "fail"})) + + def test_missing_output_is_failure(self): + """A 'missing_output' status counts as a failure.""" + self.assertTrue(triage.is_failure({"status": "missing_output"})) + + def test_pass_status_is_not_failure(self): + """A 'pass' status is not a failure.""" + self.assertFalse(triage.is_failure({"status": "pass"})) + + def test_missing_status_is_not_failure(self): + """A result with no status is not treated as a failure.""" + self.assertFalse(triage.is_failure({})) + + +class HasOutputDiffTests(unittest.TestCase): + """``has_output_diff`` prefers per-output status, matching the API's 'pass'.""" + + def test_passing_output_is_not_a_diff(self): + """A per-output status of 'pass' must not be reported as a diff.""" + sample = {"status": "fail", "outputs": [{"status": "pass"}]} + self.assertFalse(triage.has_output_diff(sample)) + + def test_failing_output_is_a_diff(self): + """A per-output status other than 'pass' is a differing output.""" + sample = {"status": "fail", "outputs": [{"status": "fail"}]} + self.assertTrue(triage.has_output_diff(sample)) + + def test_mixed_outputs_report_a_diff(self): + """If any output differs, the sample has an output diff.""" + sample = {"status": "fail", + "outputs": [{"status": "pass"}, {"status": "fail"}]} + self.assertTrue(triage.has_output_diff(sample)) + + def test_no_outputs_falls_back_to_matching_exit_code(self): + """Without per-output data, a fail whose exit code matched is a diff.""" + sample = {"status": "fail", "exit_code": 0, "expected_rc": 0} + self.assertTrue(triage.has_output_diff(sample)) + + def test_no_outputs_and_exit_mismatch_is_not_a_diff(self): + """Without per-output data, a fail with a mismatched exit code is not a diff.""" + sample = {"status": "fail", "exit_code": 1, "expected_rc": 0} + self.assertFalse(triage.has_output_diff(sample)) + + +class ClassifySampleTests(unittest.TestCase): + """``classify_sample`` flattens a RunSample into an agent-friendly row.""" + + def test_carries_ids_and_classification(self): + """The row carries the result ids plus the classification code.""" + sample = { + "regression_test_id": 42, + "sample_id": 7, + "sample_name": "dvb_subtitles", + "categories": ["DVB"], + "exit_code": 10, + "expected_rc": 0, + "status": "fail", + "outputs": [{"status": "pass"}], + } + row = triage.classify_sample(sample) + self.assertEqual(row["regression_test_id"], 42) + self.assertEqual(row["sample_id"], 7) + self.assertEqual(row["sample_name"], "dvb_subtitles") + self.assertEqual(row["categories"], ["DVB"]) + self.assertIn("code", row) + self.assertIn("confidence", row) + self.assertIn("reason", row) + + +class GroupByCodeTests(unittest.TestCase): + """``group_by_code`` counts failures by code, highest first.""" + + def test_counts_sorted_descending(self): + """Codes are returned ordered by frequency, most common first.""" + failures = [ + {"code": "EXIT_CODE_MISMATCH"}, + {"code": "SEGFAULT"}, + {"code": "EXIT_CODE_MISMATCH"}, + ] + counts = triage.group_by_code(failures) + self.assertEqual(counts, {"EXIT_CODE_MISMATCH": 2, "SEGFAULT": 1}) + self.assertEqual(next(iter(counts)), "EXIT_CODE_MISMATCH") + + +if __name__ == "__main__": + unittest.main() diff --git a/utility.py b/utility.py index 98eeec53..085d5114 100644 --- a/utility.py +++ b/utility.py @@ -11,6 +11,7 @@ import requests import werkzeug from flask import abort, g, redirect, request +from werkzeug.exceptions import ServiceUnavailable ROOT_DIR = path.dirname(path.abspath(__file__)) @@ -30,6 +31,9 @@ def serve_file_download(file_name, file_folder, file_sub_folder='') -> werkzeug. """ from run import config, storage_client_bucket + if storage_client_bucket is None: + raise ServiceUnavailable('File storage backend is not configured.') + file_path = path.join(file_folder, file_sub_folder, file_name) blob = storage_client_bucket.blob(file_path) blob.content_disposition = f'attachment; filename="{file_name}"' diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..e24b0da6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,186 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "sp-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "requests" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]