Files
labyricorn-site/labyricorn_mcp/validation.py
T
Labyricorn fe6025fa63
Deploy production / deploy (push) Successful in 21s
Add local repository MCP server
2026-08-23 10:48:33 -07:00

150 lines
5.8 KiB
Python

"""Structured wrappers around the repository's supported validation commands."""
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import sys
import time
from typing import Any
import uuid
from .errors import LabyricornMcpError
from .gitops import GitOperations
from .safety import RepositoryGuard
class ValidationOperations:
BUILD_SOURCE_DIRECTORIES = ("assets", "configs", "content", "models", "scripts", "templates")
def __init__(self, guard: RepositoryGuard, git: GitOperations):
self.guard = guard
self.git = git
def _run(self, command: list[str], *, timeout: int) -> dict[str, Any]:
started = time.monotonic()
try:
result = subprocess.run(
command,
cwd=self.guard.root,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
return {
"passed": False,
"returncode": None,
"duration_seconds": round(time.monotonic() - started, 3),
"stdout": (exc.stdout or "")[-100_000:] if isinstance(exc.stdout, str) else "",
"stderr": "Validation timed out.",
"timed_out": True,
}
except OSError as exc:
raise LabyricornMcpError("VALIDATION_FAILED", f"Validation could not start: {exc}") from exc
return {
"passed": result.returncode == 0,
"returncode": result.returncode,
"duration_seconds": round(time.monotonic() - started, 3),
"stdout": result.stdout[-100_000:],
"stderr": result.stderr[-100_000:],
"truncated": len(result.stdout) > 100_000 or len(result.stderr) > 100_000,
}
def _lektor_executable(self) -> str:
adjacent = Path(sys.executable).with_name("lektor.exe" if sys.platform == "win32" else "lektor")
if adjacent.is_file():
return str(adjacent)
discovered = shutil.which("lektor")
if discovered:
return discovered
raise LabyricornMcpError(
"RUNTIME_MISSING",
"Lektor is unavailable. Launch the MCP with the existing Lektor pipx Python environment or put lektor on PATH.",
)
def build_site(self) -> dict[str, Any]:
cache = self.guard.resolve(".cache")
cache.mkdir(exist_ok=True)
temporary = cache / f"labyricorn-mcp-build-{uuid.uuid4().hex}"
source = temporary / "source"
output = temporary / "output"
source.mkdir(parents=True)
try:
for name in self.BUILD_SOURCE_DIRECTORIES:
origin = self.guard.resolve(name, must_exist=True)
if origin.is_dir():
self._validate_build_tree(origin)
shutil.copytree(
origin,
source / name,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
)
shutil.copy2(
self.guard.root / "Labyricorn.lektorproject",
source / "Labyricorn.lektorproject",
)
command = [
sys.executable,
str(source / "scripts" / "build_with_projects.py"),
"--site-root",
str(source),
"--cache-path",
str(self.guard.resolve(".cache/project-sources")),
"--output-path",
str(output),
"--lektor",
self._lektor_executable(),
]
result = self._run(command, timeout=600)
result["index_created"] = (output / "index.html").is_file()
result["output_retained"] = False
result["passed"] = result["passed"] and result["index_created"]
return result
finally:
shutil.rmtree(temporary, ignore_errors=True)
def _validate_build_tree(self, root: Path) -> None:
if not root.resolve().is_relative_to(self.guard.root):
raise LabyricornMcpError("PATH_OUTSIDE_REPOSITORY", "A build source root resolves outside the repository.")
for path in root.rglob("*"):
if not path.resolve().is_relative_to(self.guard.root):
raise LabyricornMcpError(
"PATH_OUTSIDE_REPOSITORY", "A build source path resolves outside the repository."
)
if path.is_symlink() or getattr(path, "is_junction", lambda: False)():
if not path.resolve().is_relative_to(self.guard.root):
raise LabyricornMcpError(
"PATH_OUTSIDE_REPOSITORY", "A linked build source path resolves outside the repository."
)
def run_tests(self) -> dict[str, Any]:
return self._run(
[sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"], timeout=600
)
def check_diff(self) -> dict[str, Any]:
result = self._run(["git", "diff", "--check", "HEAD"], timeout=60)
result["changed_files"] = [entry["path"] for entry in self.git.status()["changes"]]
return result
def validate_changes(self) -> dict[str, Any]:
build = self.build_site()
tests = self.run_tests()
diff = self.check_diff()
return {
"passed": build["passed"] and tests["passed"] and diff["passed"],
"build": build,
"tests": tests,
"diff_check": diff,
"warnings": [
line for result in (build, tests, diff) for line in result.get("stderr", "").splitlines()
if "warning" in line.casefold()
],
"changed_files": diff["changed_files"],
}