dev.py
Rama main · réplica de lectura del repositorio original.
#!/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)