release: prepare v1.3.0
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
"""Auditable drawing delivery with artifact hashes and DXF reopen parity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from version import __version__
|
||||
|
||||
from .scoring import combine
|
||||
from .validator import DrawingValidator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import AutoCADBackend
|
||||
|
||||
|
||||
_FORMAT_FILENAMES = {
|
||||
"dxf": "drawing.dxf",
|
||||
"pdf": "drawing.pdf",
|
||||
"png": "preview.png",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeliveryResult:
|
||||
status: str
|
||||
output_dir: str
|
||||
manifest_path: str
|
||||
artifacts: list[dict]
|
||||
score: dict
|
||||
parity: dict
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"status": self.status,
|
||||
"output_dir": self.output_dir,
|
||||
"manifest_path": self.manifest_path,
|
||||
"artifacts": list(self.artifacts),
|
||||
"score": dict(self.score),
|
||||
"parity": dict(self.parity),
|
||||
}
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _git_sha() -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
return result.stdout.strip() or None
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
async def drawing_snapshot(backend: AutoCADBackend) -> dict[str, Any]:
|
||||
"""Return the stable structural fields used for save/reopen comparison."""
|
||||
await backend._ensure_document_state()
|
||||
entities = await backend.entity_list(limit=100_000)
|
||||
info = await backend.drawing_info()
|
||||
listed_entity_count = len(entities)
|
||||
reported_entity_count = int(info.entity_count)
|
||||
return {
|
||||
"entity_count": reported_entity_count,
|
||||
"listed_entity_count": listed_entity_count,
|
||||
"inventory_complete": listed_entity_count == reported_entity_count,
|
||||
"types": dict(sorted(Counter(item.type for item in entities).items())),
|
||||
"layers": dict(sorted(Counter(item.layer for item in entities).items())),
|
||||
"extents_min": [float(value) for value in info.extents_min],
|
||||
"extents_max": [float(value) for value in info.extents_max],
|
||||
}
|
||||
|
||||
|
||||
def compare_drawing_snapshots(
|
||||
source: dict[str, Any],
|
||||
reopened: dict[str, Any],
|
||||
*,
|
||||
tolerance: float = 1e-6,
|
||||
) -> dict:
|
||||
"""Compare counts exactly and drawing extents within ``tolerance``."""
|
||||
differences: list[dict] = []
|
||||
for label, snapshot in (("source", source), ("reopened", reopened)):
|
||||
if snapshot.get("inventory_complete") is False:
|
||||
differences.append(
|
||||
{
|
||||
"field": f"{label}_inventory_complete",
|
||||
"entity_count": snapshot.get("entity_count"),
|
||||
"listed_entity_count": snapshot.get("listed_entity_count"),
|
||||
}
|
||||
)
|
||||
for field in ("entity_count", "types", "layers"):
|
||||
if source.get(field) != reopened.get(field):
|
||||
differences.append(
|
||||
{"field": field, "source": source.get(field), "reopened": reopened.get(field)}
|
||||
)
|
||||
for field in ("extents_min", "extents_max"):
|
||||
left = source.get(field, [])
|
||||
right = reopened.get(field, [])
|
||||
equal = len(left) == len(right) and all(
|
||||
abs(float(a) - float(b)) <= tolerance for a, b in zip(left, right, strict=True)
|
||||
)
|
||||
if not equal:
|
||||
differences.append({"field": field, "source": left, "reopened": right})
|
||||
return {
|
||||
"ok": not differences,
|
||||
"tolerance": tolerance,
|
||||
"source": source,
|
||||
"reopened": reopened,
|
||||
"differences": differences,
|
||||
}
|
||||
|
||||
|
||||
def _artifact_record(fmt: str, path: Path, *, status: str, error: str | None = None) -> dict:
|
||||
record = {
|
||||
"format": fmt,
|
||||
"filename": path.name,
|
||||
"status": status,
|
||||
"size_bytes": path.stat().st_size if path.is_file() else 0,
|
||||
"sha256": _sha256(path) if path.is_file() else None,
|
||||
}
|
||||
if error:
|
||||
record["error"] = error
|
||||
return record
|
||||
|
||||
|
||||
async def _reopen_parity(source: dict, dxf_path: Path) -> dict:
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
reopened_backend = EzdxfBackend()
|
||||
await reopened_backend.connect()
|
||||
try:
|
||||
await reopened_backend.drawing_open(str(dxf_path))
|
||||
reopened = await drawing_snapshot(reopened_backend)
|
||||
finally:
|
||||
await reopened_backend.disconnect()
|
||||
return compare_drawing_snapshots(source, reopened)
|
||||
|
||||
|
||||
async def deliver_drawing(
|
||||
backend: AutoCADBackend,
|
||||
output_dir: str | Path,
|
||||
*,
|
||||
formats: list[str] | None = None,
|
||||
min_score: float = 95.0,
|
||||
strict_critique: bool = True,
|
||||
expected: dict | None = None,
|
||||
) -> DeliveryResult:
|
||||
"""Export, validate, hash and re-open a release-ready drawing bundle."""
|
||||
requested = list(dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"])))
|
||||
unknown = [item for item in requested if item not in _FORMAT_FILENAMES]
|
||||
if unknown:
|
||||
raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}")
|
||||
if not 0 <= float(min_score) <= 100:
|
||||
raise ValueError("min_score must be between 0 and 100")
|
||||
|
||||
destination = Path(output_dir).resolve()
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
if not destination.is_dir():
|
||||
raise ValueError(f"Delivery output is not a directory: {destination}")
|
||||
|
||||
started = time.perf_counter()
|
||||
timings: dict[str, float] = {}
|
||||
artifacts: list[dict] = []
|
||||
capabilities = backend.capabilities().to_dict()
|
||||
feature_caps = capabilities["features"]
|
||||
source = await drawing_snapshot(backend)
|
||||
|
||||
# DXF is always emitted: it is the canonical, re-openable parity artifact.
|
||||
export_formats = ["dxf", *(item for item in requested if item != "dxf")]
|
||||
for fmt in export_formats:
|
||||
artifact_path = destination / _FORMAT_FILENAMES[fmt]
|
||||
capability = feature_caps.get(fmt, {"supported": False, "reason": "not_declared"})
|
||||
export_started = time.perf_counter()
|
||||
if not capability.get("supported"):
|
||||
artifacts.append(
|
||||
_artifact_record(
|
||||
fmt,
|
||||
artifact_path,
|
||||
status="unsupported",
|
||||
error=str(capability.get("reason") or "backend does not support format"),
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
if fmt == "dxf":
|
||||
await backend.drawing_export_dxf(str(artifact_path))
|
||||
elif fmt == "pdf":
|
||||
await backend.drawing_export_pdf(str(artifact_path))
|
||||
else:
|
||||
png = await backend.view_screenshot()
|
||||
if not png:
|
||||
raise RuntimeError("backend returned no PNG bytes")
|
||||
artifact_path.write_bytes(png)
|
||||
artifacts.append(_artifact_record(fmt, artifact_path, status="created"))
|
||||
except Exception as exc:
|
||||
artifacts.append(
|
||||
_artifact_record(fmt, artifact_path, status="failed", error=str(exc))
|
||||
)
|
||||
finally:
|
||||
timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2)
|
||||
|
||||
validation_started = time.perf_counter()
|
||||
validation = await DrawingValidator().run(backend, expected=expected or {})
|
||||
critique_issues = await backend.drawing_critique(focus=None)
|
||||
critique_summary = {severity: 0 for severity in ("error", "warning", "info")}
|
||||
for issue in critique_issues:
|
||||
critique_summary[issue.severity] += 1
|
||||
|
||||
dxf_record = next(item for item in artifacts if item["format"] == "dxf")
|
||||
if dxf_record["status"] == "created":
|
||||
try:
|
||||
parity = await _reopen_parity(source, destination / dxf_record["filename"])
|
||||
except Exception as exc:
|
||||
parity = {
|
||||
"ok": False,
|
||||
"tolerance": 1e-6,
|
||||
"source": source,
|
||||
"reopened": None,
|
||||
"differences": [{"field": "reopen", "error": str(exc)}],
|
||||
}
|
||||
else:
|
||||
parity = {
|
||||
"ok": False,
|
||||
"tolerance": 1e-6,
|
||||
"source": source,
|
||||
"reopened": None,
|
||||
"differences": [{"field": "dxf", "error": "canonical DXF was not created"}],
|
||||
}
|
||||
|
||||
score = combine(
|
||||
validation.summary,
|
||||
critique_summary,
|
||||
{"error": 0 if parity["ok"] else 1},
|
||||
)
|
||||
timings["validation_ms"] = round((time.perf_counter() - validation_started) * 1000, 2)
|
||||
|
||||
failed_export = any(item["status"] != "created" for item in artifacts)
|
||||
critique_blocked = any(
|
||||
issue.severity == "error" or (strict_critique and issue.severity != "info")
|
||||
for issue in critique_issues
|
||||
)
|
||||
validation_blocked = (
|
||||
not validation.ok
|
||||
or not parity["ok"]
|
||||
or score["score"] < float(min_score)
|
||||
or critique_blocked
|
||||
)
|
||||
if failed_export:
|
||||
status = "failed_export"
|
||||
elif validation_blocked:
|
||||
status = "failed_validation"
|
||||
else:
|
||||
status = "success"
|
||||
|
||||
validation_payload = {
|
||||
"validator": validation.to_dict(),
|
||||
"critique": [issue.to_dict() for issue in critique_issues],
|
||||
"critique_summary": critique_summary,
|
||||
"score": score,
|
||||
"parity": parity,
|
||||
}
|
||||
validation_path = destination / "validation.json"
|
||||
validation_path.write_text(
|
||||
json.dumps(validation_payload, ensure_ascii=False, indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
plan = backend.get_plan_spec()
|
||||
timings["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"status": status,
|
||||
"version": __version__,
|
||||
"git_sha": _git_sha(),
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"backend": backend.name,
|
||||
"capabilities": capabilities,
|
||||
"spec_hash": plan.get("spec_hash") if plan else None,
|
||||
"requested_formats": requested,
|
||||
"min_score": float(min_score),
|
||||
"strict_critique": strict_critique,
|
||||
"score": score,
|
||||
"validator": validation.to_dict(),
|
||||
"critique": validation_payload["critique"],
|
||||
"critique_summary": critique_summary,
|
||||
"parity": parity,
|
||||
"artifacts": artifacts,
|
||||
"validation_artifact": _artifact_record("json", validation_path, status="created"),
|
||||
"timings_ms": timings,
|
||||
}
|
||||
manifest_path = destination / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return DeliveryResult(
|
||||
status=status,
|
||||
output_dir=str(destination),
|
||||
manifest_path=str(manifest_path),
|
||||
artifacts=artifacts,
|
||||
score=score,
|
||||
parity=parity,
|
||||
)
|
||||
Reference in New Issue
Block a user