Ctrl K
ComunidadGuías…

dev.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Cross-platform dev task runner for agami-core.

Run a task with:  uv run dev.py <task>   (works the same on macOS, Linux, Windows).

The ONLY prerequisite is `uv` (https://docs.astral.sh/uv/). Every task shells out to
`uvx`, which fetches ruff / pre-commit / pytest on demand — so there is nothing else to
install globally. These tasks mirror the CI gate (.github/workflows/ci.yml); CI is the
unbypassable version that runs on every PR.

Tasks:
  setup   wire the local pre-commit hooks (ruff + gitleaks on commit; tests on push)
  check   the full local gate: ruff lint + tests + gitleaks (what CI runs)
  test    just the test suite
  lint    just ruff (lint + format check)
  fmt     apply ruff's auto-formatter to the tree
  cover   patch coverage — are the lines you changed covered by a test?
  sync-lib  regenerate plugins/agami/lib/ (the vendored slice the plugin scripts import)
"""

from __future__ import annotations

import filecmp
import shutil
import subprocess
import sys
from pathlib import Path

RUFF = ["uvx", "ruff@0.15.19"]
# The suite imports the agami-core library, so install it editable with the [model]
# extra (pydantic/pyyaml/sqlglot). DB drivers are omitted on purpose — those tests skip without a DB.
# `build` is here for ONE test — `test_wheel_contents.py`, which builds a wheel and looks
# inside it. That test is the suite's only cover on packaging, because every other test runs
# against an editable install where the data directories resolve into the source tree
# whatever the wheel contains (#122). Without this dependency it would skip, and a guard that
# skips is the same as no guard.
TEST_DEPS = ["--with", "pytest-cov", "--with", "pytest-xdist", "--with", "build", "--with-editable", "packages/agami-core[model,server]"]
# One worker per core, as CI runs it — the suite is a long tail of short tests, not a few slow ones.
PARALLEL = ["-n", "auto"]
# `dev/` is here because it holds gate logic CI executes (changelog_gate.py), not just local
# helpers — code the build depends on should be linted like the code it guards.
TARGETS = ["plugins", "packages", "tests", "dev.py", "dev"]

_ROOT = Path(__file__).resolve().parent
# The plugin's runtime scripts import a small stdlib-only slice of the agami-core library. The
# marketplace ships only plugins/agami/ (no packages/, no pip install), so that slice is vendored —
# drift-checked — into plugins/agami/lib/ so the scripts resolve it there. Source of truth stays the
# package; `sync-lib` regenerates the copy and `check` fails on drift.
_LIB_SRC = _ROOT / "packages" / "agami-core" / "src"
_LIB_DST = _ROOT / "plugins" / "agami" / "lib"
# The module-load import closure of the 4 scripts (all stdlib-only), as source path -> path under lib/.
# tests/test_plugin_lib_resolution.py fails if a script gains a module-level import that's missing here; a
# new *lazy* import from the package would also need adding. `units.py` lands under a private top-level
# name: a partial `lib/semantic_model/` package would shadow the installed one whenever lib/ is first on
# sys.path (#539).
_VENDORED = {
    "agami_paths.py": "agami_paths.py",
    "execute_sql.py": "execute_sql.py",
    "guardrail.py": "guardrail.py",
    "sql_guard.py": "sql_guard.py",
    "sql_dialect_rules.py": "sql_dialect_rules.py",
    "semantic_model/units.py": "_semantic_model_units.py",
}


def run(cmd: list[str], *, allow_fail: bool = False) -> int:
    """Echo and run a command; return its exit code (0 == ok)."""
    print(f"\n$ {' '.join(cmd)}")
    code = subprocess.run(cmd).returncode
    if code and not allow_fail:
        print(f"  -> failed (exit {code})")
    return code


def lint() -> int:
    rc = run([*RUFF, "check", *TARGETS])
    # format is informational for now (the tree has an unformatted backlog) — report, don't fail.
    run([*RUFF, "format", "--check", *TARGETS], allow_fail=True)
    return rc


def fmt() -> int:
    return run([*RUFF, "format", *TARGETS])


def test() -> int:
    return run(["uvx", *TEST_DEPS, "pytest", "tests/", "-q", *PARALLEL])


def secrets() -> int:
    return run(["uvx", "pre-commit", "run", "gitleaks", "--all-files"])


def sync_lib() -> int:
    """Regenerate plugins/agami/lib/ from packages/agami-core/src (the vendored closure the scripts import)."""
    for src, rel in _VENDORED.items():
        dst = _LIB_DST / rel
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(_LIB_SRC / src, dst)
        print(f"  synced {dst.relative_to(_ROOT)}")
    return 0


def _lib_drift() -> int:
    """Fail if plugins/agami/lib/ has drifted from packages/agami-core/src (someone edited the source)."""
    drifted = [
        rel
        for src, rel in _VENDORED.items()
        if not (_LIB_DST / rel).exists() or not filecmp.cmp(_LIB_SRC / src, _LIB_DST / rel, shallow=False)
    ]
    if drifted:
        print(f"\n$ lib drift check\n  ✗ plugins/agami/lib is stale: {', '.join(drifted)}"
              "\n    run: uv run dev.py sync-lib")
        return 1
    return 0


def check() -> int:
    """ruff lint + tests + gitleaks + the vendored-lib drift check — the same checks CI gates on."""
    rc = lint()
    rc |= test()
    rc |= secrets()
    rc |= _lib_drift()
    print("\n✓ all checks passed" if rc == 0 else "\n✗ some checks failed")
    return rc


def cover() -> int:
    """Coverage of the lines THIS branch changed (fails on untested changed lines)."""
    # Make sure origin/main exists locally (fresh clones / worktrees may not have it).
    run(["git", "fetch", "--quiet", "origin", "main"], allow_fail=True)
    rc = run(["uvx", *TEST_DEPS, "pytest", "tests/", "-q", *PARALLEL,
              "--cov=plugins", "--cov=packages/agami-core/src", "--cov-report=xml"])
    return rc or run(["uvx", "diff-cover", "coverage.xml", "--compare-branch=origin/main"])


def setup() -> int:
    """Install the local pre-commit hooks (convenience; CI is the real gate)."""
    return run(
        ["uvx", "pre-commit", "install", "--hook-type", "pre-commit", "--hook-type", "pre-push"]
    )


TASKS = {"setup": setup, "check": check, "test": test, "lint": lint, "fmt": fmt, "cover": cover,
         "sync-lib": sync_lib}


def main() -> int:
    if shutil.which("uvx") is None:
        print(
            "uv is required and was not found on PATH.\n"
            "  macOS / Linux : curl -LsSf https://astral.sh/uv/install.sh | sh\n"
            '  Windows       : powershell -c "irm https://astral.sh/uv/install.ps1 | iex"\n'
            "  docs          : https://docs.astral.sh/uv/getting-started/installation/"
        )
        return 1
    task = sys.argv[1] if len(sys.argv) > 1 else ""
    if task not in TASKS:
        print(f"usage: uv run dev.py [{' | '.join(TASKS)}]")
        return 2
    return TASKS[task]()


if __name__ == "__main__":
    # Normalize to a clean 0/1 (a signal-killed child returns a negative code).
    raise SystemExit(0 if main() == 0 else 1)