diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..93a3082 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: +- package-ecosystem: uv + directory: / + schedule: + interval: cron + cronjob: 15 5 * * * + timezone: Europe/Warsaw + open-pull-requests-limit: 1 + versioning-strategy: increase-if-necessary + allow: + - dependency-name: costs + dependency-type: all + - dependency-name: goal + dependency-type: all + - dependency-name: pfix + dependency-type: all + - dependency-name: clickmd + dependency-type: all + - dependency-name: code2llm + dependency-type: all + groups: + internal-packages: + patterns: + - costs + - goal + - pfix + - clickmd + - code2llm + commit-message: + prefix: "chore(deps)" diff --git a/.github/internal-dependencies.json b/.github/internal-dependencies.json new file mode 100644 index 0000000..ce582b0 --- /dev/null +++ b/.github/internal-dependencies.json @@ -0,0 +1,35 @@ +{ + "schema": "goal.internal-dependencies/v1", + "packages": [ + { + "name": "costs", + "repository": "semcod/costs", + "registry": "pypi", + "versioning": "semver" + }, + { + "name": "goal", + "repository": "semcod/goal", + "registry": "pypi", + "versioning": "semver" + }, + { + "name": "pfix", + "repository": "semcod/pfix", + "registry": "pypi", + "versioning": "semver" + }, + { + "name": "clickmd", + "repository": "semcod/clickmd", + "registry": "pypi", + "versioning": "semver" + }, + { + "name": "code2llm", + "repository": "semcod/code2llm", + "registry": "pypi", + "versioning": "semver" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad6128e..0506e79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,29 +5,48 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: + +permissions: + contents: read jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: + fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13"] use-op3: [0, 1] steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - - - name: Install uv - uses: astral-sh/setup-uv@v5 - - - name: Install dependencies - run: uv sync --frozen - - - name: Run tests (REDEPLOY_USE_OP3=${{ matrix.use-op3 }}) + - name: Check out immutable documentation standard + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: wellmanifest/docs + ref: ebe7501063ef4f3e63ded610c2d3183010ca636e # v0.1.1 + path: .docs-standard + token: ${{ secrets.ORG_SYNC_PAT }} + persist-credentials: false + - name: Validate documentation placement + shell: bash + env: + DOCS_BASE: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + args=(--root . --standard-revision ebe7501063ef4f3e63ded610c2d3183010ca636e) + if [[ -n "$DOCS_BASE" ]]; then args+=(--base "$DOCS_BASE"); fi + python .docs-standard/docs/standard/check.py "${args[@]}" + - run: python -m pip install uv + - name: Install locked test dependencies + run: uv sync --locked --extra dev --extra op3 --python "${{ matrix.python-version }}" + - name: Test supported Python versions env: REDEPLOY_USE_OP3: ${{ matrix.use-op3 }} - run: uv run pytest tests/ tests/contract/ tests/integration/ -v --tb=short + run: uv run --no-sync python -m pytest tests redeploy/tests -q diff --git a/.github/workflows/internal-dependency-freshness.yml b/.github/workflows/internal-dependency-freshness.yml new file mode 100644 index 0000000..1f0eaf2 --- /dev/null +++ b/.github/workflows/internal-dependency-freshness.yml @@ -0,0 +1,36 @@ +name: Internal dependency freshness + +on: + schedule: + - cron: "24 6 * * *" + workflow_dispatch: + pull_request: + paths: + - pyproject.toml + - uv.lock + - .github/internal-dependencies.json + - .github/workflows/internal-dependency-freshness.yml + +permissions: + contents: read + +jobs: + freshness: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Install released checker + run: python -m pip install "goal==2.2.0" + - name: Verify published stable targets + run: goal dependencies --catalog .github/internal-dependencies.json --check > dependency-freshness.json + - name: Retain freshness evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: dependency-freshness + path: dependency-freshness.json + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 346b99a..83e035f 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,6 @@ apply-results.yaml # Environment variables .env + +# Linked development checkouts +/worktrees/ diff --git a/.governance/docs.json b/.governance/docs.json new file mode 100644 index 0000000..908b39e --- /dev/null +++ b/.governance/docs.json @@ -0,0 +1,7 @@ +{ + "schema": "wellmanifest.docs/adoption/v1", + "repository": "semcod/redeploy", + "standard": "wellmanifest/docs", + "source_revision": "ebe7501063ef4f3e63ded610c2d3183010ca636e", + "policy_sha256": "f6ba9c011ea1d9260e7fac3a1638a767d5ebc9f7d9b32ed51cc3aea22fe95d8c" +} diff --git a/docs/README.md b/docs/README.md index 645f57e..170c37a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1232,4 +1232,5 @@ pytest | `README.md` | Project overview (this file) | — | | `examples` | Usage examples and code samples | [View](./examples) | - \ No newline at end of file + +- [Aktualizacja i kontrola zależności wewnętrznych](information/internal-dependencies.md) diff --git a/docs/information/internal-dependencies.md b/docs/information/internal-dependencies.md new file mode 100644 index 0000000..3e73b85 --- /dev/null +++ b/docs/information/internal-dependencies.md @@ -0,0 +1,83 @@ +--- +{ + "schema": "wellmanifest.docs/document/v1", + "id": "internal-dependencies", + "kind": "information", + "version": 1, + "title": "Aktualizacja i kontrola zależności wewnętrznych", + "status": "implemented", + "owner": "semcod/redeploy", + "created": "2026-09-06", + "updated": "2026-09-06", + "review_after": "2026-09-13", + "source_revision": "91671e551248177894de1fdc57dfef453e00d85d", + "affected_repositories": [ + "semcod/redeploy" + ], + "evidence": [ + "https://github.com/semcod/redeploy/blob/91671e551248177894de1fdc57dfef453e00d85d/pyproject.toml", + "https://pypi.org/project/goal/2.2.0/", + "https://docs.astral.sh/uv/concepts/projects/dependencies/", + "https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference" + ] +} +--- + +# Aktualizacja i kontrola zależności wewnętrznych + + +## Cel + +Utrzymywać aktualne, przetestowane zależności projektu `semcod/redeploy` przy zachowaniu Pythona 3.11 jako minimalnej wersji aplikacji. + + +## Zakres + +Właścicielem lockfile i CI jest `semcod/redeploy`. Katalog [.github/internal-dependencies.json](../../.github/internal-dependencies.json) obejmuje pięć jawnie wskazanych dystrybucji PyPI: costs, goal, pfix, clickmd i code2llm. Audyt sprawdza te z nich, które występują w uv.lock. Nie obejmuje dowolnego pakietu tylko na podstawie nazwy organizacji. + + +## Dowody + +Przed zmianą workflow CI używał uv sync --frozen bez dodatku dev, a późniejsze uv run mogło ponownie rozwiązać zależności. Teraz CI instaluje uv sync --locked --extra dev --extra op3 i uruchamia testy przez uv run --no-sync. Zachowano macierz Python 3.11/3.12/3.13 i oba ustawienia REDEPLOY_USE_OP3. W odczycie PyPI z 2026-09-06 aktualnymi stabilnymi wersjami były costs 0.2.0, goal 2.2.0, pfix 0.1.79 i clickmd 1.1.15; te wersje zapisano w uv.lock. + +Testy obejmują tests/ i redeploy/tests/, w tym regresje wykonawcy. Lokalna macierz dała 1568 passed, 17 skipped w każdym z sześciu wariantów; pominięcia obejmują integracje wymagające zewnętrznych usług. Wyniki publikacji i kolejnych kontroli są dostępne w [GitHub Actions](https://github.com/semcod/redeploy/actions). + +Przywrócono pełną implementację redeploy/apply/handlers.py z [ostatniej wersji przed wyzerowaniem](https://github.com/semcod/redeploy/blob/d9c7dd873cd2cedb6bb082bf2757661f4f3f3da9/redeploy/apply/handlers.py). [Commit wydania 0.2.80](https://github.com/semcod/redeploy/commit/257a616) usunął jej treść, blokując import aplikacji. Testy CLI korzystają teraz z izolowanego katalogu i jawnych plików wejściowych; testy schematu nie wymagają prywatnego checkoutu c2004. + + +## Obsługa + +Goal jest narzędziem automatyzacji, bez importów w kodzie aplikacji. Usunięto go z runtime i dodatku dev. Grupa `automation` wymaga Pythona >=3.12 i nie jest domyślnie instalowana z aplikacją ani dodatkiem `dev`. Zastosowano [oddzielny zakres Pythona grupy uv](https://docs.astral.sh/uv/concepts/projects/dependencies/#group-requires-python). + +```bash +uv sync --locked --extra dev --extra op3 --python 3.11 +uv run --no-sync python -m pytest tests redeploy/tests -q +``` + +Dependabot codziennie proponuje aktualizację wewnętrznych pakietów w jednym PR. Testy uruchamiają zatwierdzony lockfile na Pythonie 3.11, 3.12 i 3.13. Osobny workflow codziennie oraz po zmianie zależności w PR porównuje lockfile z najwyższymi stabilnymi wydaniami w katalogu; zachowuje raport jako artefakt. Obie automatyzacje można uruchomić ręcznie. + +Ręczny audyt używa grupy narzędziowej: + +```bash +uv run --locked --group automation --python 3.12 goal dependencies --catalog .github/internal-dependencies.json --check +``` + +Aktualizacja wybranych pakietów: + +```bash +uv lock --upgrade-package costs --upgrade-package goal --upgrade-package pfix --upgrade-package clickmd --upgrade-package code2llm +``` + +Po aktualizacji uruchom testy, opublikuj PR, a po jego sprawdzeniu i scaleniu zsynchronizuj środowisko. CI kontroluje też położenie i metadane dokumentacji według wellmanifest/docs 0.1.1, przypiętego do `ebe7501063ef4f3e63ded610c2d3183010ca636e` w `.governance/docs.json`. + + +## Ograniczenia + +Ograniczenie `>=` dopuszcza nowszą wersję, lecz nie zmienia istniejącego środowiska. `uv sync --locked` odtwarza wersje z lockfile. Codzienny PR nie jest automatycznie scalany ani wdrażany. Opóźnienie zależy od harmonogramu, testów i procesu publikacji. + +Checker w CI jest przypięty do Goal 2.2.0. Jego aktualizację trzeba wykonać jawnie. Audyt tej wersji porównuje stabilne wersje x.y.z i nie jest pełnym resolverem wszystkich formatów wersji ani kontrolą pochodzenia każdego pakietu. Dokładne przypięcie standardu dokumentacji podlega osobnej, sprawdzanej aktualizacji. + + +## Utrzymanie + +Sprawdzaj nieudane harmonogramy i zaległe PR-y. Nową dystrybucję dodaj do katalogu dopiero po potwierdzeniu jej właściciela i sposobu wersjonowania. Wyniki przekrojowe rozwijaj w [kanonicznym raporcie subactor/docs](https://github.com/subactor/docs/blob/main/architecture/analysis/internal-dependencies.md). Zwiększ wersję tego dokumentu po zmianie mechanizmu. diff --git a/pyproject.toml b/pyproject.toml index 2d74ebc..116f4a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,8 @@ dependencies = [ "httpx>=0.25", "rich>=13.0", "jmespath>=1.0", - "goal>=2.1.0", - "costs>=0.1.20", - "pfix>=0.1.60", + "costs>=0.2.0", + "pfix>=0.1.79", ] license = "Apache-2.0" @@ -30,9 +29,8 @@ redeploy-mcp = "redeploy.mcp_server:serve" [project.optional-dependencies] dev = ["pytest>=8.0", "ruff>=0.4", - "goal>=2.1.0", - "costs>=0.1.20", - "pfix>=0.1.60", + "costs>=0.2.0", + "pfix>=0.1.79", ] op3 = ["op3>=0.1.8"] mcp = ["mcp>=1.0"] @@ -82,3 +80,9 @@ max_commits = 500 # Cost thresholds for badge colors (USD) badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 } + +[dependency-groups] +automation = ["goal>=2.2.0"] + +[tool.uv.dependency-groups.automation] +requires-python = ">=3.12" diff --git a/redeploy/apply/handlers.py b/redeploy/apply/handlers.py index e69de29..80ca4d3 100644 --- a/redeploy/apply/handlers.py +++ b/redeploy/apply/handlers.py @@ -0,0 +1,803 @@ +"""Step action handlers for migration execution.""" +from __future__ import annotations + +import base64 +import json +import os +import shlex +import subprocess +import threading +import time +from pathlib import Path +from typing import TYPE_CHECKING + +from loguru import logger + +from ..models import MigrationStep, StepAction, StepStatus +from .exceptions import StepError + +if TYPE_CHECKING: + from ..detect.remote import RemoteProbe + from ..models import MigrationPlan + from .progress import ProgressEmitter + + +def _format_step_output(stdout: str, stderr: str, max_chars: int = 8000) -> str: + out = str(stdout or "").strip() + err = str(stderr or "").strip() + chunks: list[str] = [] + if out: + chunks.append("stdout:\n" + out) + if err: + chunks.append("stderr:\n" + err) + if not chunks: + return "ok" + joined = "\n\n".join(chunks) + return joined[:max_chars] + + +def run_ssh(step: MigrationStep, probe: RemoteProbe) -> None: + """Execute SSH command on remote host.""" + cmd = step.command + if not cmd: + raise StepError(step, "No command specified") + timeout = step.timeout or 300 + r = probe.run(cmd, timeout=timeout) + step.result = _format_step_output(r.out, r.stderr) + if not r.ok: + raise StepError(step, f"exit={r.exit_code}: {_format_step_output(r.out, r.stderr, 400)}") + step.status = StepStatus.DONE + + +def run_scp(step: MigrationStep, probe: RemoteProbe, plan: MigrationPlan) -> None: + """Copy file via SCP.""" + if not step.src or not step.dst: + raise StepError(step, "scp requires src and dst") + if probe.is_local and Path(step.src).resolve() == Path(step.dst).resolve(): + step.status = StepStatus.DONE + step.result = "skipped (same file)" + return + if probe.is_local: + Path(step.dst).parent.mkdir(parents=True, exist_ok=True) + cmd = ["cp", step.src, step.dst] + else: + _ensure_remote_parent_dir(probe, step.dst) + cmd = ["scp", "-o", "StrictHostKeyChecking=no", + step.src, f"{plan.host}:{step.dst}"] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode != 0: + raise StepError(step, f"scp failed: {_format_step_output(result.stdout, result.stderr, 400)}") + step.status = StepStatus.DONE + step.result = _format_step_output(result.stdout, result.stderr) + + +def run_rsync(step: MigrationStep, probe: RemoteProbe, plan: MigrationPlan) -> None: + """Sync files via rsync. + + W trybie FROZEN (env ``REDEPLOY_FROZEN_COMMIT``, ustawiany przez + ``redeploy run --frozen-commit``) źródła względne trackowane w zamrożonym + commicie jadą z eksportu ``git archive`` zamiast żywego working tree — + inaczej kroki spec-a przemycają WIP mimo zamrożonego syncu projektu. + """ + if not step.src or not step.dst: + raise StepError(step, "rsync requires src and dst") + + src = step.src + frozen_note = "" + frozen_commit = os.environ.get("REDEPLOY_FROZEN_COMMIT", "").strip() + export_ctx = None + if frozen_commit: + import tempfile + + from ..gitsync import GitSyncError, frozen_rsync_src + + export_ctx = tempfile.TemporaryDirectory(prefix="redeploy-frozen-rsync-") + try: + src, frozen_note = frozen_rsync_src( + Path.cwd(), frozen_commit, step.src, Path(export_ctx.name) + ) + except GitSyncError as exc: + export_ctx.cleanup() + raise StepError(step, f"frozen rsync src: {exc}") from exc + logger.info(f" [{step.id}] {frozen_note}") + + try: + if probe.is_local: + dst = step.dst + Path(dst).mkdir(parents=True, exist_ok=True) + else: + _ensure_remote_parent_dir(probe, step.dst) + dst = f"{plan.host}:{step.dst}" + cmd = [ + "rsync", + "-az", + "--delete", + "--filter", + ":- .gitignore", + "--filter", + ":- .redeployignore", + ] + for exc in step.excludes: + cmd += ["--exclude", exc] + cmd += [src, dst] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + if result.returncode != 0: + raise StepError(step, f"rsync failed: {_format_step_output(result.stdout, result.stderr, 400)}") + step.status = StepStatus.DONE + parts = [p for p in (frozen_note, _format_step_output(result.stdout, result.stderr)) if p] + step.result = "\n".join(parts) + finally: + if export_ctx is not None: + export_ctx.cleanup() + + +def _ensure_remote_parent_dir(probe: RemoteProbe, remote_dst: str) -> None: + """Best-effort mkdir -p for remote transfer destinations.""" + target = remote_dst.strip() + if not target: + return + + mkdir_target = target if target.endswith("/") else os.path.dirname(target) + if not mkdir_target or mkdir_target == ".": + return + + cmd = f"mkdir -p {shlex.quote(mkdir_target)}" + r = probe.run(cmd, timeout=30) + if not r.ok: + # Non-fatal here; scp/rsync call will still provide concrete error if needed. + logger.debug("mkdir preflight failed for {}: {}", remote_dst, (r.stderr or "")[:200]) + + +def run_docker_build( + step: MigrationStep, + probe: RemoteProbe, + emitter: ProgressEmitter | None, +) -> None: + """Run docker compose build on remote with periodic progress polling.""" + from .utils import run_container_build + run_container_build(step, probe, emitter, engine="docker") + + +def run_podman_build( + step: MigrationStep, + probe: RemoteProbe, + emitter: ProgressEmitter | None, +) -> None: + """Run podman build on remote with periodic progress polling.""" + from .utils import run_container_build + run_container_build(step, probe, emitter, engine="podman") + + +def run_docker_health_wait( + step: MigrationStep, + probe: RemoteProbe, +) -> None: + """Wait until all containers reach 'healthy' or 'running' status.""" + cmd = step.command # should be: "cd && docker compose -f ... ps --format json" + if not cmd: + raise StepError(step, "No command specified for docker_health_wait") + + timeout = step.timeout or 120 + poll_interval = 8 + elapsed = 0 + last_status = "" + + while elapsed < timeout: + r = probe.run(cmd, timeout=20) + if r.ok and r.out.strip(): + statuses = _parse_container_statuses(r.out) + status_str = ", ".join(f"{n}:{s}" for n, s in statuses) + + if status_str != last_status: + logger.info(f" [{elapsed}s] containers: {status_str}") + last_status = status_str + + if _all_containers_healthy(statuses): + step.status = StepStatus.DONE + step.result = f"all containers healthy after {elapsed}s: {status_str}" + logger.debug(f" ✓ all containers healthy ({elapsed}s)") + return + else: + logger.debug(f" [{elapsed}s] waiting for containers (no output yet)...") + + time.sleep(poll_interval) + elapsed += poll_interval + + # timeout — log final state and continue (don't fail hard, http_check will catch it) + step.status = StepStatus.DONE + step.result = f"timeout {timeout}s reached, last: {last_status or 'unknown'}" + logger.warning(f" docker_health_wait timed out after {timeout}s — proceeding to health check") + + +def _parse_container_statuses(output: str) -> list[tuple[str, str]]: + """Parse docker compose ps output into (name, status) tuples.""" + lines = output.strip().splitlines() + statuses = [] + for line in lines: + if line.startswith("NAME") or not line.strip(): + continue + parts = line.split(None, 1) + name = parts[0] if parts else "?" + status = parts[1].strip() if len(parts) > 1 else "?" + statuses.append((name, status)) + return statuses + + +def _all_containers_healthy(statuses: list[tuple[str, str]]) -> bool: + """Check if all containers are in a healthy/running state.""" + if not statuses: + return False + unhealthy = [ + n for n, s in statuses + if not any(kw in s.lower() for kw in ("up", "running", "healthy")) + ] + return not unhealthy + + +def run_container_log_tail(step: MigrationStep, probe: RemoteProbe) -> None: + """Fetch and log the last N lines from each container after start.""" + cmd = step.command + if not cmd: + raise StepError(step, "No command specified for container_log_tail") + + r = probe.run(cmd, timeout=30) + if r.ok and r.out.strip(): + for line in r.out.strip().splitlines(): + logger.debug(f" log: {line}") + step.result = f"{len(r.out.splitlines())} log lines fetched" + else: + step.result = "no log output (containers may still be starting)" + step.status = StepStatus.DONE + + +def run_http_check( + step: MigrationStep, + probe: RemoteProbe, + retries: int = 5, + delay: int = 8, +) -> None: + """HTTP check via SSH curl on the remote host (avoids local network/firewall issues).""" + if not step.url: + raise StepError(step, "http_check requires url") + last_err = "" + for attempt in range(retries): + if step.expect: + cmd = f"curl -skf --max-time 10 '{step.url}' | grep -F '{step.expect}'" + else: + cmd = f"curl -skf --max-time 10 '{step.url}'" + r = probe.run(cmd, timeout=20) + if r.ok and (not step.expect or step.expect in r.out): + step.status = StepStatus.DONE + step.result = f"OK (expect='{step.expect}' found)" if step.expect else r.out[:200] + return + last_err = f"expected '{step.expect}' not found in: {r.out[:80]}" if r.ok else (r.stderr[:100] or f"curl exit={r.exit_code}") + logger.debug(f" retry {attempt + 1}/{retries}: {last_err}") + time.sleep(delay) + raise StepError(step, f"HTTP check failed after {retries} retries: {last_err}") + + +# ── Query-language post-deploy tests (testql / oql / aql) ───────────────────── +# These run LOCALLY on the controller (where the interpreters live) and target +# the freshly deployed host via `step.url`. Each supports an inline command +# override via `step.command` with {url}/{source}/{mode}/{locale}/{context} +# placeholders; otherwise a sensible default argv is built per language. + +def _run_local_query(step: MigrationStep, label: str, default_argv: list[str]) -> subprocess.CompletedProcess: + """Run a query-language runner locally; command-template override wins.""" + timeout = step.timeout or 300 + if step.command: + # Podmieniamy TYLKO znane placeholdery (nie .format() — literalne {} + # w komendzie, np. JSON, nie mogą być traktowane jako pola formatu). + cmd = step.command + for key, val in { + "url": step.url or "", + "source": step.query_source or "", + "mode": step.query_mode or "", + "locale": step.query_locale or "en", + "context": step.query_context or "", + }.items(): + cmd = cmd.replace("{" + key + "}", val) + logger.info(f"{label}: {cmd}") + shell, argv = True, cmd + else: + argv = [a for a in default_argv if a != ""] + logger.info(f"{label}: {' '.join(shlex.quote(a) for a in argv)}") + shell = False + try: + return subprocess.run(argv, shell=shell, capture_output=True, text=True, timeout=timeout) + except FileNotFoundError: + raise StepError(step, f"{label}: runner not found on PATH — {default_argv[0]!r} (ustaw query_runner albo command)") + except subprocess.TimeoutExpired: + raise StepError(step, f"{label}: timeout po {timeout}s") + + +def _query_output(r: subprocess.CompletedProcess) -> str: + return ((r.stdout or "") + ("\n" + r.stderr if r.stderr else "")).strip() + + +def run_testql(step: MigrationStep, probe: RemoteProbe) -> None: + """TestQL smoke przeciw wdrożonemu hostowi: `testql run --url `.""" + if not step.query_source and not step.command: + raise StepError(step, "testql wymaga query_source (plik .testql.toon.yaml) albo command") + runner = step.query_runner or "testql" + default_argv = [runner, "run"] + (["--url", step.url] if step.url else []) \ + + list(step.flags or []) + ([step.query_source] if step.query_source else []) + r = _run_local_query(step, "testql", default_argv) + out = _query_output(r) + if r.returncode == 0 and (not step.expect or step.expect in out): + step.status = StepStatus.DONE + step.result = f"testql OK: {step.query_source or step.command}" + (f" (expect '{step.expect}')" if step.expect else "") + return + raise StepError(step, f"testql FAILED (rc={r.returncode}): {out[-500:]}") + + +def run_oql(step: MigrationStep, probe: RemoteProbe) -> None: + """OQL scenariusz przeciw wdrożonemu runtime: `oqlctl -m --json`. + + Zwykle celuje w firmware (`--firmware-url `). Werdykt z pola JSON `ok`; + fallback: kod wyjścia 0. + """ + if not step.query_source and not step.command: + raise StepError(step, "oql wymaga query_source (plik .oql) albo command") + runner = step.query_runner or "oqlctl" + mode = step.query_mode or "execute" + default_argv = [runner, step.query_source or "", "-m", mode, "--json", "-q"] \ + + (["--firmware-url", step.url] if step.url else []) + list(step.flags or []) + r = _run_local_query(step, "oql", default_argv) + out = _query_output(r) + verdict, detail = _parse_oql_verdict(r.stdout, r.returncode) + if verdict: + step.status = StepStatus.DONE + step.result = f"oql OK: {step.query_source or step.command} ({detail})" + return + raise StepError(step, f"oql FAILED ({detail}): {out[-500:]}") + + +def _parse_oql_verdict(stdout: str, returncode: int) -> tuple[bool, str]: + """Zwróć (ok, opis) z JSON-a oqlctl (`ok`, `errors`); fallback na exit code.""" + text = (stdout or "").strip() + for candidate in (text, text.splitlines()[-1] if text else ""): + try: + data = json.loads(candidate) + except (json.JSONDecodeError, ValueError): + continue + ok = bool(data.get("ok")) + errs = data.get("errors") or [] + return ok, ("ok=true" if ok else f"ok=false errors={errs[:2]}") + return returncode == 0, f"exit={returncode} (brak JSON)" + + +def run_aql(step: MigrationStep, probe: RemoteProbe) -> None: + """AQL model decyzyjny: rozwiąż i sprawdź werdykt (wariant/plan). + + Domyślnie `aql [locale]`; asercja: exit 0 i (jeśli podano) `expect` + w wyjściu (np. nazwa wariantu). Dla resolve z kontekstem ustaw `command`, + np. `node relcom/central-node/aql/resolve.mjs {source} {context}`. + """ + if not step.query_source and not step.command: + raise StepError(step, "aql wymaga query_source (plik .aql) albo command") + runner = step.query_runner or "aql" + default_argv = [runner, step.query_source or ""] \ + + ([step.query_context] if step.query_context else []) \ + + ([step.query_locale] if step.query_locale else []) + list(step.flags or []) + r = _run_local_query(step, "aql", default_argv) + out = _query_output(r) + if r.returncode == 0 and (not step.expect or step.expect in out): + step.status = StepStatus.DONE + step.result = f"aql OK: {step.query_source or step.command}" + (f" (expect '{step.expect}')" if step.expect else "") + return + raise StepError(step, f"aql FAILED (rc={r.returncode}): {out[-500:]}") + + +def run_version_check(step: MigrationStep, probe: RemoteProbe) -> None: + """Version check via SSH curl on the remote host.""" + if not step.url or not step.expect: + raise StepError(step, "version_check requires url and expect") + cmd = f"curl -skf --max-time 10 '{step.url}'" + r = probe.run(cmd, timeout=20) + if not r.ok: + raise StepError(step, f"curl failed: {r.stderr[:100]}") + if step.expect not in r.out: + raise StepError(step, f"version '{step.expect}' not found in response: {r.out[:100]}") + step.status = StepStatus.DONE + step.result = f"version {step.expect} confirmed" + + +def run_plugin( + step: MigrationStep, + probe: RemoteProbe, + plan: MigrationPlan, + emitter: ProgressEmitter | None, + dry_run: bool, +) -> None: + """Dispatch to a registered plugin handler.""" + from ..plugins import PluginContext, registry as _plugin_registry + + plugin_type = step.plugin_type + if not plugin_type: + raise StepError(step, "plugin action requires plugin_type field") + handler = _plugin_registry.get(plugin_type) + if not handler: + available = ", ".join(_plugin_registry.names()) or "(none loaded)" + raise StepError(step, f"unknown plugin_type '{plugin_type}'. Available: {available}") + ctx = PluginContext( + step=step, + host=plan.host, + probe=probe, + emitter=emitter, + params=step.plugin_params, + dry_run=dry_run, + ) + handler(ctx) + + +def run_wait(step: MigrationStep) -> None: + """Wait for specified number of seconds.""" + total = step.seconds + if total <= 0: + step.status = StepStatus.DONE + step.result = "waited 0s" + return + tick = min(10, max(5, total // 6)) # log every 5–10s + elapsed = 0 + while elapsed < total: + chunk = min(tick, total - elapsed) + time.sleep(chunk) + elapsed += chunk + if elapsed < total: + logger.debug(f" waiting... {elapsed}/{total}s") + step.status = StepStatus.DONE + step.result = f"waited {total}s" + + +def run_inline_script( + step: MigrationStep, + probe: RemoteProbe, + plan: MigrationPlan, +) -> None: + """Execute multiline bash script via SSH using base64 encoding.""" + script = step.command + + # If command_ref is set, extract script from markdown file + if step.command_ref: + script = _resolve_command_ref(step.command_ref, step, plan) + + if not script: + raise StepError(step, "inline_script requires command field or command_ref with script content") + + # Base64 encode the script to safely pass it through SSH + encoded = base64.b64encode(script.encode()).decode() + timeout = step.timeout or 300 + + # Create temp file, decode script, run it, then clean up + cmd = ( + f"tmpfile=$(mktemp) && " + f"echo '{encoded}' | base64 -d > \"$tmpfile\" && " + f"chmod +x \"$tmpfile\" && " + f"\"$tmpfile\"; " + f"rc=$?; " + f"rm -f \"$tmpfile\"; " + f"exit $rc" + ) + + r = probe.run(cmd, timeout=timeout) + step.result = _format_step_output(r.out, r.stderr, 500) or "script executed" + if not r.ok: + raise StepError( + step, + f"script failed with exit={r.exit_code}: {_format_step_output(r.out, r.stderr, 800)}", + ) + step.status = StepStatus.DONE + + +def _resolve_command_ref(command_ref: str, step: MigrationStep, plan: MigrationPlan) -> str: + """Resolve command_ref to script content from markdown file. + + command_ref formats: + - "./file.md#section-id" - script from section in specific file + - "#section-id" - script from section in current spec file + - "#kiosk-browser-configuration-script" - markpact:ref block + """ + from ..markpact import resolve_script_ref + + # Parse command_ref + if "#" in command_ref: + file_part, section_id = command_ref.split("#", 1) + file_path = file_part if file_part else getattr(plan, 'spec_path', None) + else: + section_id = command_ref + file_path = getattr(plan, 'spec_path', None) + + if not file_path: + raise StepError(step, f"Cannot resolve command_ref '{command_ref}': no file path available") + + file_path = Path(file_path) + if not file_path.exists(): + raise StepError(step, f"Command ref file not found: {file_path}") + + markdown_content = file_path.read_text(encoding="utf-8") + result = resolve_script_ref(markdown_content, section_id, language="bash") + + if result is None: + raise StepError( + step, + f"Could not find bash script for ref '{section_id}' in {file_path} (tried markpact:ref and section heading)" + ) + script, _ = result + return script + + +# ── hardware-specific handlers ──────────────────────────────────────────────── + +def run_ensure_config_line(step: MigrationStep, probe: "RemoteProbe") -> None: + """Idempotent add/replace a line in a remote config.txt.""" + from ..hardware.config_txt import ensure_line + + if not step.config_file or not step.config_line: + raise StepError(step, "ensure_config_line requires config_file and config_line") + + config_path = step.config_file + r = probe.run(f"sudo cat {config_path}", timeout=10) + if not r.ok: + raise StepError(step, f"Cannot read {config_path}: {r.stderr[:200]}") + + edit = ensure_line( + r.out, + step.config_line, + section=step.config_section or "all", + replaces_pattern=step.config_replaces_pattern, + ) + + if not edit.changed: + step.status = StepStatus.DONE + step.result = f"no-op: {edit.diff_summary}" + return + + # Write atomically: base64-encode to avoid shell quoting issues + encoded = base64.b64encode(edit.new_content.encode()).decode() + tmp = f"/tmp/redeploy-cfg-{step.id}.txt" + write_r = probe.run( + f"echo '{encoded}' | base64 -d | sudo tee {tmp} > /dev/null && sudo mv {tmp} {config_path}", + timeout=15, + ) + if not write_r.ok: + raise StepError(step, f"Cannot write {config_path}: {write_r.stderr[:200]}") + + step.status = StepStatus.DONE + step.result = edit.diff_summary + + +def run_raspi_config(step: MigrationStep, probe: "RemoteProbe") -> None: + """Run raspi-config nonint to enable/disable an interface.""" + from ..hardware.raspi_config import build_raspi_config_command + + if not step.raspi_interface or not step.raspi_state: + raise StepError(step, "raspi_config requires raspi_interface and raspi_state") + + try: + cmd = build_raspi_config_command(step.raspi_interface, step.raspi_state) + except ValueError as exc: + raise StepError(step, str(exc)) + + r = probe.run(cmd, timeout=30) + if not r.ok: + raise StepError(step, f"raspi-config failed: {r.stderr[:200]}") + + step.status = StepStatus.DONE + step.result = f"applied: {cmd}" + + +# ── kiosk handlers ──────────────────────────────────────────────────────────── + +def run_ensure_kanshi_profile(step: MigrationStep, probe: "RemoteProbe") -> None: + """Idempotently write or replace a named kanshi output profile. + + Declarative mode (preferred):: + + profile_name: waveshare-only + outputs_on: [DSI-2] + outputs_off: [HDMI-A-2] + + Legacy mode (pre-rendered block):: + + step.command — the profile block to write (rendered kanshi syntax) + step.config_file — kanshi config path (default: ~/.config/kanshi/config) + """ + from ..hardware.kiosk.output_profiles import OutputProfile + + # Declarative mode: build profile from semantic fields + if step.profile_name: + profile = OutputProfile( + name=step.profile_name, + enabled=list(step.outputs_on), + disabled=list(step.outputs_off), + ) + profile_block = profile.to_kanshi_config() + else: + profile_block = step.command + + if not profile_block: + raise StepError( + step, + "ensure_kanshi_profile requires profile_name+outputs_on/outputs_off " + "or step.command with pre-rendered profile block", + ) + + config_path = step.config_file or "~/.config/kanshi/config" + mkdir_cmd = f"mkdir -p $(dirname {config_path})" + probe.run(mkdir_cmd, timeout=10) + + r = probe.run(f"cat {config_path} 2>/dev/null || echo ''", timeout=10) + existing = r.out if r.ok else "" + + # Extract profile name from block (first line: "profile {") + first_line = profile_block.strip().splitlines()[0] + import re + m = re.match(r"profile\s+(\S+)\s*\{", first_line) + if not m: + raise StepError(step, f"Cannot parse profile name from block: {first_line!r}") + profile_name = m.group(1) + + # Replace existing profile block or append + pattern = re.compile( + rf"^profile\s+{re.escape(profile_name)}\s*\{{[^}}]*\}}", re.MULTILINE | re.DOTALL + ) + if pattern.search(existing): + if profile_block.strip() in existing: + step.status = StepStatus.DONE + step.result = f"no-op: profile '{profile_name}' already correct" + return + new_content = pattern.sub(profile_block.strip(), existing) + changed = True + else: + sep = "\n" if existing and not existing.endswith("\n") else "" + new_content = existing + sep + profile_block.strip() + "\n" + changed = True + + encoded = base64.b64encode(new_content.encode()).decode() + tmp = f"/tmp/redeploy-kanshi-{step.id}.conf" + write_r = probe.run( + f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null && mv {tmp} {config_path}", + timeout=15, + ) + if not write_r.ok: + raise StepError(step, f"Cannot write {config_path}: {write_r.stderr[:200]}") + + # Reload kanshi if running + probe.run("pkill -SIGUSR1 kanshi 2>/dev/null || true", timeout=5) + + step.status = StepStatus.DONE + step.result = f"profile '{profile_name}' written to {config_path}" + + +def run_ensure_autostart_entry(step: MigrationStep, probe: "RemoteProbe") -> None: + """Idempotently add or replace keyed entries in a compositor autostart file. + + Declarative mode (preferred):: + + compositor: labwc + entries: + - "kanshid &" + - "sleep 3" + - "bash /home/pi/c2004/scripts/kiosk-launch.sh &" + + Legacy mode (single entry):: + + step.config_file — path to autostart file + step.config_line — the line to write (the entry body) + step.config_section — used as the entry key for idempotent marker + """ + from ..hardware.kiosk.autostart import AutostartEntry, ensure_autostart_entry + from ..hardware.kiosk.compositors import COMPOSITORS + + # Determine autostart path + config_file = step.config_file + if not config_file and step.compositor: + comp = COMPOSITORS.get(step.compositor) + if not comp: + raise StepError(step, f"Unknown compositor '{step.compositor}'") + config_file = comp.autostart_abs() + if not config_file: + raise StepError(step, "ensure_autostart_entry requires config_file or compositor") + + # Collect entries to apply + entries: list[AutostartEntry] = [] + if step.entries: + for i, line in enumerate(step.entries): + key = f"redeploy-{i}-{line.split()[0] if line else 'entry'}" + entries.append(AutostartEntry(key=key, line=line)) + elif step.config_line: + key = step.config_section or "redeploy" + entries.append(AutostartEntry(key=key, line=step.config_line)) + + if not entries: + raise StepError(step, "ensure_autostart_entry requires entries or config_line") + + r = probe.run(f"cat {config_file} 2>/dev/null || echo ''", timeout=10) + existing = r.out if r.ok else "" + + changed_any = False + new_content = existing + for entry in entries: + new_content, changed = ensure_autostart_entry(new_content, entry) + if changed: + changed_any = True + + if not changed_any: + step.status = StepStatus.DONE + step.result = f"no-op: {len(entries)} autostart entry(ies) already correct" + return + + mkdir_cmd = f"mkdir -p $(dirname {config_file})" + probe.run(mkdir_cmd, timeout=10) + + encoded = base64.b64encode(new_content.encode()).decode() + tmp = f"/tmp/redeploy-autostart-{step.id}.txt" + write_r = probe.run( + f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null && mv {tmp} {config_file}", + timeout=15, + ) + if not write_r.ok: + raise StepError(step, f"Cannot write {config_file}: {write_r.stderr[:200]}") + + step.status = StepStatus.DONE + step.result = f"{len(entries)} autostart entry(ies) written to {config_file}" + + +def run_ensure_browser_kiosk_script(step: MigrationStep, probe: "RemoteProbe") -> None: + """Write a kiosk-launch.sh script to the remote device. + + Declarative mode (preferred):: + + browser_profile: chromium_wayland_kiosk + url: "http://localhost:8100/connect-id?font=xlarge&theme=dark" + kiosk_script_path: /home/pi/c2004/scripts/kiosk-launch.sh + + Legacy mode (pre-rendered script):: + + step.command — the full script content to write + step.dst — destination path (default: ~/kiosk-launch.sh) + """ + from ..hardware.kiosk.browsers import CHROMIUM_WAYLAND_KIOSK + + # Declarative mode: build script from profile + URL + if step.browser_profile: + # Registry lookup (extend when more profiles exist) + if step.browser_profile == "chromium_wayland_kiosk": + profile = CHROMIUM_WAYLAND_KIOSK + else: + raise StepError(step, f"Unknown browser_profile '{step.browser_profile}'") + if not step.url: + raise StepError(step, "browser_profile mode requires 'url' field") + script_content = profile.build_launch_cmd(step.url) + else: + script_content = step.command + + if not script_content: + raise StepError( + step, + "ensure_browser_kiosk_script requires browser_profile+url " + "or step.command with pre-rendered script body", + ) + + dst = step.kiosk_script_path or step.dst or "~/kiosk-launch.sh" + + r = probe.run(f"cat {dst} 2>/dev/null || echo ''", timeout=10) + existing = r.out if r.ok else "" + if existing.strip() == script_content.strip(): + step.status = StepStatus.DONE + step.result = f"no-op: {dst} already correct" + return + + encoded = base64.b64encode(script_content.encode()).decode() + tmp = f"/tmp/redeploy-kiosk-{step.id}.sh" + write_r = probe.run( + f"echo '{encoded}' | base64 -d | tee {tmp} > /dev/null" + f" && chmod +x {tmp} && mv {tmp} {dst}", + timeout=15, + ) + if not write_r.ok: + raise StepError(step, f"Cannot write {dst}: {write_r.stderr[:200]}") + + step.status = StepStatus.DONE + step.result = f"kiosk script written to {dst}" diff --git a/redeploy/tests/test_cli.py b/redeploy/tests/test_cli.py index 25b5c24..8732877 100644 --- a/redeploy/tests/test_cli.py +++ b/redeploy/tests/test_cli.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest.mock import patch import yaml +import pytest from click.testing import CliRunner from redeploy.cli import cli @@ -96,6 +97,12 @@ def _runner(): class TestRunPlanOnly: + @pytest.fixture(autouse=True) + def isolated_project(self, tmp_path, monkeypatch): + """Provide only the local artifacts declared by these test specs.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("TEST_MODE=1\n") + def test_plan_only_exit_zero(self, tmp_path): spec = tmp_path / "migration.yaml" spec.write_text(_migration_yaml()) diff --git a/tests/test_prompt_llm.py b/tests/test_prompt_llm.py index 737853f..3a16c93 100644 --- a/tests/test_prompt_llm.py +++ b/tests/test_prompt_llm.py @@ -74,7 +74,17 @@ def call_llm(instruction: str, schema: dict | None = None) -> dict: # Schema sanity (no LLM required) # --------------------------------------------------------------------------- -def test_schema_discovers_c2004_specs(): +@pytest.fixture +def schema_workspace(tmp_path, monkeypatch): + """Exercise discovery without relying on a developer's sibling checkout.""" + target = tmp_path / "redeploy" / "pi109" + target.mkdir(parents=True) + (target / "migration.yaml").write_text("name: fixture-app\nversion: 1.0.0\n") + monkeypatch.setattr(__name__ + ".C2004_ROOT", tmp_path) + return tmp_path + + +def test_schema_discovers_c2004_specs(schema_workspace): """Schema build must find pi109 specs in c2004.""" schema = build_c2004_schema() assert "specs" in schema @@ -83,7 +93,7 @@ def test_schema_discovers_c2004_specs(): assert any("pi109" in p for p in paths), f"No pi109 spec found in schema: {paths}" -def test_schema_has_command_catalogue(): +def test_schema_has_command_catalogue(schema_workspace): schema = build_c2004_schema() assert "commands" in schema assert "run" in schema["commands"] @@ -91,13 +101,13 @@ def test_schema_has_command_catalogue(): assert "import" in schema["commands"] -def test_schema_has_version_and_cwd(): +def test_schema_has_version_and_cwd(schema_workspace): schema = build_c2004_schema() assert "version" in schema - assert "cwd" in schema + assert Path(schema["cwd"]) == schema_workspace -def test_schema_has_iac_metadata(): +def test_schema_has_iac_metadata(schema_workspace): schema = build_c2004_schema() assert "iac" in schema assert "parsers" in schema["iac"] diff --git a/uv.lock b/uv.lock index 6a92d94..af014a5 100644 --- a/uv.lock +++ b/uv.lock @@ -5,9 +5,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -502,16 +505,16 @@ wheels = [ [[package]] name = "clickmd" -version = "1.1.14" +version = "1.1.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "costs" }, { name = "goal" }, { name = "pfix" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/1b/84df87d056991c2fb0789ae47b49d5afb8496f2c45541760e81553d00b43/clickmd-1.1.14.tar.gz", hash = "sha256:a3625777736ba5d3d43f7de8ff81bc01d2a4d77f6c252ce6ea7d55b860279f43", size = 41134, upload-time = "2026-04-08T09:11:28.657Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/12/82b3b80bb2751e0d6c120c1beb11116527cca9322202b5a1924811b58376/clickmd-1.1.15.tar.gz", hash = "sha256:0a2971b952e471c4726bc2f88ed397abd395283c79e4885e435921426493d01c", size = 41145, upload-time = "2026-06-29T07:35:58.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/29/625bc2004029ce7297d0709e6486447c4a0f0d832c2ddd962cada14d32c7/clickmd-1.1.14-py3-none-any.whl", hash = "sha256:005bbc7cd97d8a58ba25b4fc0018b7970e37b57ee50d53aa33df5f445b4ff46b", size = 45540, upload-time = "2026-04-08T09:11:27.228Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5c/398fafad25e456d86fe8fe7bf9a3873f2077564d42dcdd1426b76d01e915/clickmd-1.1.15-py3-none-any.whl", hash = "sha256:b191c0c26be5ef009ea27a3e88c1c7d633d95a6da2fc737f04f6727481bfeb5e", size = 45514, upload-time = "2026-06-29T07:35:56.53Z" }, ] [[package]] @@ -525,7 +528,7 @@ wheels = [ [[package]] name = "costs" -version = "0.1.50" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, @@ -538,9 +541,9 @@ dependencies = [ { name = "tiktoken" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/25/0778be9c7c3ef2cd5b9f8c59ac2c49af885099edbc41aaeef5312c6c2fe0/costs-0.1.50.tar.gz", hash = "sha256:2a452e126ada7597a0a6134d3d185afdcad65ad08f9d4ed7e1502199c6c7320e", size = 30275, upload-time = "2026-04-20T14:11:06.415Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/6f/7f3c72d6d4b5264bd5a3447e18a3b5184bffee143f72c8d023bb759afd1a/costs-0.2.0.tar.gz", hash = "sha256:3782bae859725dae30f697b5f3e5aa5d84e06feadebc5782ae706e21d46437af", size = 44606, upload-time = "2026-09-05T15:49:55.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/83/1dca023a2fdccc2082ee22fc9848b6b453e4d673c4218c323e9f24882efa/costs-0.1.50-py3-none-any.whl", hash = "sha256:3224dab8005b9765219c0bd224731554aa67878b86e67f9afa8edecfd991d8a8", size = 35506, upload-time = "2026-04-20T14:11:04.754Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7e/fc91d9fdc7a1ceb9116daffab27124a54f6e8887c9d59d906df43dcbfa6a/costs-0.2.0-py3-none-any.whl", hash = "sha256:4798b8535cbbcbb354e440ebce91365f17c910414bf5831a0610998ae6e679af", size = 48625, upload-time = "2026-09-05T15:49:54.054Z" }, ] [[package]] @@ -872,7 +875,7 @@ wheels = [ [[package]] name = "goal" -version = "2.1.218" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -882,9 +885,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/d3/c6d31f1087257adddaac23b243d133f49061d155b6c7f26804b9846d64a4/goal-2.1.218.tar.gz", hash = "sha256:209e403df9d44a9d533f6784d564af1e1622965ca76900ee9ee5ae08fff57d77", size = 257431, upload-time = "2026-04-26T12:00:47.589Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/9c/135e3f67a7324c3c49ef4246f5518b59ad90226e62c3d30ae20cba005c1d/goal-2.2.0.tar.gz", hash = "sha256:e9e2c1ac9493dae3b887b16d503f7c1f83d02ddcbdd3c5cf58d44a458bb2662f", size = 399786, upload-time = "2026-09-05T19:41:55.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/71/26e4e65d187ba5c2e1f0885ec135f2442d18f52f70c6f2d4ac5e17df2a3a/goal-2.1.218-py3-none-any.whl", hash = "sha256:752f77c5160c929bdbf2e5ab3d07b327a81d8d4dddc8691ba4d80895886f36db", size = 251843, upload-time = "2026-04-26T12:00:45.61Z" }, + { url = "https://files.pythonhosted.org/packages/24/69/8a4cf5710683bb777f1f6e4cd44dd3907befe97b94c6ea6e97d4305b4e44/goal-2.2.0-py3-none-any.whl", hash = "sha256:0d8f93aa317ac92a66b64b797e210a5d66044ce8db2cb05b012b03d42aced95e", size = 349709, upload-time = "2026-09-05T19:41:53.615Z" }, ] [[package]] @@ -1815,7 +1818,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1824,7 +1827,7 @@ wheels = [ [[package]] name = "pfix" -version = "0.1.72" +version = "0.1.79" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "litellm" }, @@ -1833,9 +1836,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/e7/5f5ed8d95da0205422dea693f5b68077665540c3fcddab448f17f58c8304/pfix-0.1.72.tar.gz", hash = "sha256:c2dbcb29ef4f037bf5d31d33a872476dee669e092a68de9e8dd324952c0e9574", size = 2538020, upload-time = "2026-03-29T19:26:46.122Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/7e/252be5e1d55321b947013010f45b02d781de87e25b9a524c0ee72c1ad3c0/pfix-0.1.79.tar.gz", hash = "sha256:6bc4c572e22e0f0d952bc5e2df78f52f8f999cab64e22138b097b2eab4d83092", size = 2845663, upload-time = "2026-07-05T17:00:57.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/b0/4c68ab0b9e784eb9685805f46523af0875c9cb0c88072c047e5c38ed600a/pfix-0.1.72-py3-none-any.whl", hash = "sha256:608c33964aed46d0a7c02417463513e5ab2fea0f4ee6309ab6769d7f837e1d6e", size = 152417, upload-time = "2026-03-29T19:26:41.235Z" }, + { url = "https://files.pythonhosted.org/packages/39/bf/548982294e795d79003680b2214c7f5dd00ca4b0ea856064bf9cb6869ba7/pfix-0.1.79-py3-none-any.whl", hash = "sha256:bb7ab83b99c0bfbaae5e6e95cb801a27d4fd68749485600fdfe49dad00607461", size = 151754, upload-time = "2026-07-05T17:00:52.816Z" }, ] [[package]] @@ -2392,7 +2395,6 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "costs" }, - { name = "goal" }, { name = "httpx" }, { name = "jmespath" }, { name = "loguru" }, @@ -2407,7 +2409,6 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "costs" }, - { name = "goal" }, { name = "pfix" }, { name = "pytest" }, { name = "ruff" }, @@ -2419,13 +2420,16 @@ op3 = [ { name = "op3" }, ] +[package.dev-dependencies] +automation = [ + { name = "goal", marker = "python_full_version >= '3.12'" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0" }, - { name = "costs", specifier = ">=0.1.20" }, - { name = "costs", marker = "extra == 'dev'", specifier = ">=0.1.20" }, - { name = "goal", specifier = ">=2.1.0" }, - { name = "goal", marker = "extra == 'dev'", specifier = ">=2.1.0" }, + { name = "costs", specifier = ">=0.2.0" }, + { name = "costs", marker = "extra == 'dev'", specifier = ">=0.2.0" }, { name = "httpx", specifier = ">=0.25" }, { name = "jmespath", specifier = ">=1.0" }, { name = "loguru", specifier = ">=0.7" }, @@ -2433,8 +2437,8 @@ requires-dist = [ { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "op3", marker = "extra == 'op3'", specifier = ">=0.1.8" }, { name = "paramiko", specifier = ">=3.0" }, - { name = "pfix", specifier = ">=0.1.60" }, - { name = "pfix", marker = "extra == 'dev'", specifier = ">=0.1.60" }, + { name = "pfix", specifier = ">=0.1.79" }, + { name = "pfix", marker = "extra == 'dev'", specifier = ">=0.1.79" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -2443,6 +2447,9 @@ requires-dist = [ ] provides-extras = ["dev", "op3", "mcp"] +[package.metadata.requires-dev] +automation = [{ name = "goal", marker = "python_full_version >= '3.12'", specifier = ">=2.2.0" }] + [[package]] name = "referencing" version = "0.37.0" @@ -3006,8 +3013,8 @@ name = "uvicorn" version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform != 'emscripten'" }, - { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "click" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } wheels = [