generated from coulomb/repo-seed
Implement infospace scaffold and service baseline
This commit is contained in:
23
src/info_tech_canon/__init__.py
Normal file
23
src/info_tech_canon/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""InfoTechCanon service package."""
|
||||
|
||||
from .service import (
|
||||
CanonServiceError,
|
||||
artifact_graph,
|
||||
inspect_canon,
|
||||
list_artifacts,
|
||||
list_models,
|
||||
list_standards,
|
||||
profile_inspect,
|
||||
validate_canon,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CanonServiceError",
|
||||
"artifact_graph",
|
||||
"inspect_canon",
|
||||
"list_artifacts",
|
||||
"list_models",
|
||||
"list_standards",
|
||||
"profile_inspect",
|
||||
"validate_canon",
|
||||
]
|
||||
3
src/info_tech_canon/__main__.py
Normal file
3
src/info_tech_canon/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
104
src/info_tech_canon/api.py
Normal file
104
src/info_tech_canon/api.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .service import (
|
||||
CanonServiceError,
|
||||
artifact_graph,
|
||||
inspect_canon,
|
||||
list_artifacts,
|
||||
list_models,
|
||||
list_standards,
|
||||
profile_inspect,
|
||||
validate_canon,
|
||||
)
|
||||
|
||||
|
||||
def serve(host: str = "127.0.0.1", port: int = 8765, root: Path | None = None) -> None:
|
||||
handler = _build_handler(root)
|
||||
server = ThreadingHTTPServer((host, port), handler)
|
||||
print(f"InfoTechCanon API listening on http://{host}:{port}", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _build_handler(root: Path | None) -> type[BaseHTTPRequestHandler]:
|
||||
class CanonRequestHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
query = parse_qs(parsed.query)
|
||||
try:
|
||||
status, payload = _route(parsed.path, query, root)
|
||||
except CanonServiceError as exc:
|
||||
status, payload = HTTPStatus.BAD_REQUEST, exc.to_dict()
|
||||
except Exception as exc:
|
||||
status, payload = HTTPStatus.INTERNAL_SERVER_ERROR, {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "unhandled_error",
|
||||
"message": str(exc),
|
||||
"details": {},
|
||||
},
|
||||
}
|
||||
self._send_json(status, payload)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
|
||||
body = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status.value)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
return CanonRequestHandler
|
||||
|
||||
|
||||
def _route(
|
||||
path: str,
|
||||
query: dict[str, list[str]],
|
||||
root: Path | None,
|
||||
) -> tuple[HTTPStatus, dict[str, Any]]:
|
||||
if path == "/health":
|
||||
return HTTPStatus.OK, {"ok": True, "service": "info-tech-canon"}
|
||||
if path == "/inspect":
|
||||
return HTTPStatus.OK, inspect_canon(root)
|
||||
if path == "/artifacts":
|
||||
return HTTPStatus.OK, list_artifacts(root, kind=_first(query, "kind"))
|
||||
if path == "/models":
|
||||
return HTTPStatus.OK, list_models(root)
|
||||
if path == "/standards":
|
||||
return HTTPStatus.OK, list_standards(root)
|
||||
if path == "/validate":
|
||||
payload = validate_canon(root)
|
||||
return (HTTPStatus.OK if payload["ok"] else HTTPStatus.BAD_REQUEST), payload
|
||||
if path == "/graph":
|
||||
graph_format = _first(query, "format") or "json"
|
||||
return HTTPStatus.OK, artifact_graph(root, output_format=graph_format)
|
||||
if path.startswith("/profiles/") and path.endswith("/inspect"):
|
||||
profile = path.removeprefix("/profiles/").removesuffix("/inspect").strip("/")
|
||||
return HTTPStatus.OK, profile_inspect(profile, root)
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "not_found",
|
||||
"message": f"Unknown endpoint: {path}",
|
||||
"details": {"path": path},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], name: str) -> str | None:
|
||||
values = query.get(name) or []
|
||||
return values[0] if values else None
|
||||
65
src/info_tech_canon/bench.py
Normal file
65
src/info_tech_canon/bench.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
|
||||
BENCH_PACKAGE = "_info_tech_canon_infospace_bench"
|
||||
BENCH_SOURCE_ROOT = (
|
||||
Path(__file__).resolve().parents[3] / "infospace-bench" / "src" / "infospace_bench"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_package() -> ModuleType:
|
||||
existing = sys.modules.get(BENCH_PACKAGE)
|
||||
if existing is not None:
|
||||
return existing
|
||||
package = types.ModuleType(BENCH_PACKAGE)
|
||||
package.__path__ = [str(BENCH_SOURCE_ROOT)] # type: ignore[attr-defined]
|
||||
sys.modules[BENCH_PACKAGE] = package
|
||||
return package
|
||||
|
||||
|
||||
def _load_module(name: str) -> ModuleType:
|
||||
_ensure_package()
|
||||
module_name = f"{BENCH_PACKAGE}.{name}"
|
||||
existing = sys.modules.get(module_name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
path = BENCH_SOURCE_ROOT / f"{name}.py"
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"Missing infospace-bench module: {path}")
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Unable to load infospace-bench module: {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
errors = _load_module("errors")
|
||||
models = _load_module("models")
|
||||
lifecycle = _load_module("lifecycle")
|
||||
checks = _load_module("checks")
|
||||
inspection = _load_module("inspection")
|
||||
|
||||
Infospace = models.Infospace
|
||||
KnowledgeArtifact = models.KnowledgeArtifact
|
||||
load_infospace = lifecycle.load_infospace
|
||||
run_collection_checks = checks.run_collection_checks
|
||||
relationship_summary = inspection.relationship_summary
|
||||
export_mermaid = inspection.export_mermaid
|
||||
|
||||
__all__ = [
|
||||
"Infospace",
|
||||
"KnowledgeArtifact",
|
||||
"export_mermaid",
|
||||
"load_infospace",
|
||||
"relationship_summary",
|
||||
"run_collection_checks",
|
||||
]
|
||||
134
src/info_tech_canon/cli.py
Normal file
134
src/info_tech_canon/cli.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .api import serve
|
||||
from .service import (
|
||||
CanonServiceError,
|
||||
artifact_graph,
|
||||
inspect_canon,
|
||||
list_artifacts,
|
||||
list_models,
|
||||
list_standards,
|
||||
profile_inspect,
|
||||
validate_canon,
|
||||
)
|
||||
|
||||
|
||||
Command = Callable[[argparse.Namespace], dict[str, Any]]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="info-tech-canon")
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
default="",
|
||||
help="Infospace root. Defaults to ./infospace from the repository root.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
inspect = sub.add_parser("inspect", help="Inspect the canon infospace")
|
||||
inspect.set_defaults(handler=_inspect)
|
||||
|
||||
artifacts = sub.add_parser("artifacts", help="List canon artifacts")
|
||||
artifacts.add_argument("--kind", default="")
|
||||
artifacts.set_defaults(handler=_artifacts)
|
||||
|
||||
models = sub.add_parser("models", help="List canon model artifacts")
|
||||
models.set_defaults(handler=_models)
|
||||
|
||||
standards = sub.add_parser("standards", help="List canon standard artifacts")
|
||||
standards.set_defaults(handler=_standards)
|
||||
|
||||
validate = sub.add_parser("validate", help="Validate the canon infospace")
|
||||
validate.set_defaults(handler=_validate)
|
||||
|
||||
graph = sub.add_parser("graph", help="Export the canon artifact graph")
|
||||
graph.add_argument("--format", choices=["json", "mermaid"], default="json")
|
||||
graph.set_defaults(handler=_graph)
|
||||
|
||||
profile = sub.add_parser("profile", help="Inspect canon profiles")
|
||||
profile_sub = profile.add_subparsers(dest="profile_command", required=True)
|
||||
profile_inspect_cmd = profile_sub.add_parser("inspect", help="Inspect a profile")
|
||||
profile_inspect_cmd.add_argument("profile")
|
||||
profile_inspect_cmd.set_defaults(handler=_profile_inspect)
|
||||
|
||||
api = sub.add_parser("api", help="Run the read-only local API")
|
||||
api.add_argument("--host", default="127.0.0.1")
|
||||
api.add_argument("--port", type=int, default=8765)
|
||||
api.set_defaults(handler=_api)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
handler: Command = args.handler
|
||||
try:
|
||||
result = handler(args)
|
||||
except CanonServiceError as exc:
|
||||
_print_json(exc.to_dict())
|
||||
return 2
|
||||
except Exception as exc:
|
||||
_print_json(
|
||||
{
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "unhandled_error",
|
||||
"message": str(exc),
|
||||
"details": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
return 1
|
||||
if result:
|
||||
_print_json(result)
|
||||
return 0 if result.get("ok", False) else 1
|
||||
|
||||
|
||||
def _root(args: argparse.Namespace) -> Path | None:
|
||||
return Path(args.root) if args.root else None
|
||||
|
||||
|
||||
def _inspect(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return inspect_canon(_root(args))
|
||||
|
||||
|
||||
def _artifacts(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return list_artifacts(_root(args), kind=args.kind or None)
|
||||
|
||||
|
||||
def _models(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return list_models(_root(args))
|
||||
|
||||
|
||||
def _standards(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return list_standards(_root(args))
|
||||
|
||||
|
||||
def _validate(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return validate_canon(_root(args))
|
||||
|
||||
|
||||
def _graph(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return artifact_graph(_root(args), output_format=args.format)
|
||||
|
||||
|
||||
def _profile_inspect(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return profile_inspect(args.profile, _root(args))
|
||||
|
||||
|
||||
def _api(args: argparse.Namespace) -> dict[str, Any]:
|
||||
serve(host=args.host, port=args.port, root=_root(args))
|
||||
return {}
|
||||
|
||||
|
||||
def _print_json(data: dict[str, Any]) -> None:
|
||||
json.dump(data, sys.stdout, indent=2, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
262
src/info_tech_canon/service.py
Normal file
262
src/info_tech_canon/service.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .bench import (
|
||||
Infospace,
|
||||
KnowledgeArtifact,
|
||||
export_mermaid,
|
||||
load_infospace,
|
||||
relationship_summary,
|
||||
run_collection_checks,
|
||||
)
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_INFOSPACE_ROOT = REPO_ROOT / "infospace"
|
||||
|
||||
|
||||
class CanonServiceError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanonContext:
|
||||
repo_root: Path
|
||||
infospace_root: Path
|
||||
infospace: Infospace
|
||||
|
||||
|
||||
def load_context(root: Path | str | None = None) -> CanonContext:
|
||||
infospace_root = Path(root) if root else DEFAULT_INFOSPACE_ROOT
|
||||
try:
|
||||
infospace = load_infospace(infospace_root)
|
||||
except Exception as exc:
|
||||
raise CanonServiceError(
|
||||
"infospace_load_failed",
|
||||
f"Unable to load infospace at {infospace_root}",
|
||||
{"root": str(infospace_root), "reason": str(exc)},
|
||||
) from exc
|
||||
return CanonContext(
|
||||
repo_root=REPO_ROOT,
|
||||
infospace_root=infospace_root,
|
||||
infospace=infospace,
|
||||
)
|
||||
|
||||
|
||||
def inspect_canon(root: Path | str | None = None) -> dict[str, Any]:
|
||||
context = load_context(root)
|
||||
artifacts = context.infospace.artifacts
|
||||
kinds = Counter(artifact.kind for artifact in artifacts)
|
||||
return {
|
||||
"ok": True,
|
||||
"repo": {
|
||||
"slug": "info-tech-canon",
|
||||
"root": str(context.repo_root),
|
||||
},
|
||||
"infospace": {
|
||||
"slug": context.infospace.config.slug,
|
||||
"name": context.infospace.config.name,
|
||||
"root": str(context.infospace_root),
|
||||
"artifact_count": len(artifacts),
|
||||
"kinds": dict(sorted(kinds.items())),
|
||||
},
|
||||
"service": {
|
||||
"package": "info_tech_canon",
|
||||
"contract": "cli-json-api",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_artifacts(
|
||||
root: Path | str | None = None,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
context = load_context(root)
|
||||
artifacts = [
|
||||
_artifact_to_dict(artifact, context.infospace_root)
|
||||
for artifact in context.infospace.artifacts
|
||||
if kind is None or artifact.kind == kind
|
||||
]
|
||||
return {
|
||||
"ok": True,
|
||||
"count": len(artifacts),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
|
||||
def list_models(root: Path | str | None = None) -> dict[str, Any]:
|
||||
return list_artifacts(root, kind="model")
|
||||
|
||||
|
||||
def list_standards(root: Path | str | None = None) -> dict[str, Any]:
|
||||
return list_artifacts(root, kind="standard")
|
||||
|
||||
|
||||
def validate_canon(root: Path | str | None = None) -> dict[str, Any]:
|
||||
context = load_context(root)
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
artifact_ids = {artifact.id for artifact in context.infospace.artifacts}
|
||||
for artifact in context.infospace.artifacts:
|
||||
artifact_path = context.infospace_root / artifact.path
|
||||
if not artifact_path.is_file():
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_artifact_path",
|
||||
"artifact_id": artifact.id,
|
||||
"path": artifact.path,
|
||||
}
|
||||
)
|
||||
for relationship in artifact.relationships:
|
||||
target = relationship.get("target")
|
||||
if target not in artifact_ids:
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_relationship_target",
|
||||
"artifact_id": artifact.id,
|
||||
"target": target,
|
||||
}
|
||||
)
|
||||
|
||||
for discipline in context.infospace.config.disciplines:
|
||||
discipline_path = context.infospace_root / discipline.path
|
||||
if not discipline_path.is_file():
|
||||
errors.append(
|
||||
{
|
||||
"code": "missing_discipline_path",
|
||||
"discipline": discipline.name,
|
||||
"path": discipline.path,
|
||||
}
|
||||
)
|
||||
|
||||
checks = run_collection_checks(context.infospace.artifacts)
|
||||
threshold_errors = _evaluate_thresholds(
|
||||
checks.metrics,
|
||||
context.infospace.config.viability,
|
||||
)
|
||||
errors.extend(threshold_errors)
|
||||
|
||||
return {
|
||||
"ok": not errors,
|
||||
"errors": errors,
|
||||
"metrics": checks.metrics,
|
||||
"details": checks.details,
|
||||
}
|
||||
|
||||
|
||||
def artifact_graph(
|
||||
root: Path | str | None = None,
|
||||
*,
|
||||
output_format: str = "json",
|
||||
) -> dict[str, Any]:
|
||||
context = load_context(root)
|
||||
summary = relationship_summary(context.infospace.artifacts)
|
||||
if output_format == "mermaid":
|
||||
return {"ok": True, "format": "mermaid", "graph": export_mermaid(summary)}
|
||||
if output_format != "json":
|
||||
raise CanonServiceError(
|
||||
"unsupported_graph_format",
|
||||
f"Unsupported graph format: {output_format}",
|
||||
{"supported": ["json", "mermaid"]},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"format": "json",
|
||||
"graph": {
|
||||
"node_count": summary.node_count,
|
||||
"edge_count": summary.edge_count,
|
||||
"nodes": summary.nodes,
|
||||
"edges": [asdict(edge) for edge in summary.edges],
|
||||
"relationship_types": summary.relationship_types,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def profile_inspect(
|
||||
profile: str,
|
||||
root: Path | str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
context = load_context(root)
|
||||
profile_path = context.infospace_root / "profiles" / profile / "profile.yaml"
|
||||
if not profile_path.is_file():
|
||||
raise CanonServiceError(
|
||||
"missing_profile",
|
||||
f"Profile not found: {profile}",
|
||||
{"profile": profile, "path": str(profile_path)},
|
||||
)
|
||||
with profile_path.open("r", encoding="utf-8") as handle:
|
||||
data = yaml.safe_load(handle) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise CanonServiceError(
|
||||
"invalid_profile",
|
||||
f"Profile must be a YAML mapping: {profile}",
|
||||
{"profile": profile, "path": str(profile_path)},
|
||||
)
|
||||
return {"ok": True, "profile": data, "path": str(profile_path)}
|
||||
|
||||
|
||||
def _artifact_to_dict(
|
||||
artifact: KnowledgeArtifact,
|
||||
infospace_root: Path,
|
||||
) -> dict[str, Any]:
|
||||
data = artifact.to_dict()
|
||||
data["exists"] = (infospace_root / artifact.path).is_file()
|
||||
return data
|
||||
|
||||
|
||||
def _evaluate_thresholds(
|
||||
metrics: dict[str, float],
|
||||
thresholds: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
errors: list[dict[str, Any]] = []
|
||||
for metric, threshold in thresholds.items():
|
||||
value = metrics.get(metric)
|
||||
if value is None:
|
||||
continue
|
||||
min_value = getattr(threshold, "min", None)
|
||||
max_value = getattr(threshold, "max", None)
|
||||
if min_value is not None and value < min_value:
|
||||
errors.append(
|
||||
{
|
||||
"code": "metric_below_threshold",
|
||||
"metric": metric,
|
||||
"value": value,
|
||||
"min": min_value,
|
||||
}
|
||||
)
|
||||
if max_value is not None and value > max_value:
|
||||
errors.append(
|
||||
{
|
||||
"code": "metric_above_threshold",
|
||||
"metric": metric,
|
||||
"value": value,
|
||||
"max": max_value,
|
||||
}
|
||||
)
|
||||
return errors
|
||||
Reference in New Issue
Block a user