Files
cadSet/SimpleCADAPI/tools/studio_api_contract.py
T

117 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Validate submitted Studio source against the imported SimpleCADAPI runtime.
This is a server-side guard only. Agent-facing API documentation remains the
original SimpleCADAPI skill and its API Markdown pages.
"""
from __future__ import annotations
import argparse
import ast
import importlib
import json
from pathlib import Path
from typing import Any
import simplecadapi as scad
STDLIB_MODULES = ("gear", "bearing", "chain", "fastener")
# Make the package's public ``scad.std.<module>`` paths available before AST
# resolution. Model source may use any of these documented standard factories.
for _module_name in STDLIB_MODULES:
importlib.import_module(f"simplecadapi.std.{_module_name}")
def resolve_simplecad_path(parts: list[str]) -> Any:
value: Any = scad
for part in parts:
value = getattr(value, part)
return value
class SourceValidator(ast.NodeVisitor):
def __init__(self) -> None:
self.package_aliases: set[str] = set()
self.imported_names: dict[str, tuple[str, list[str]]] = {}
self.errors: list[dict[str, object]] = []
def error(self, node: ast.AST, message: str) -> None:
self.errors.append({
"line": getattr(node, "lineno", None),
"column": getattr(node, "col_offset", None),
"message": message,
})
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
if alias.name == "simplecadapi":
self.package_aliases.add(alias.asname or "simplecadapi")
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
module = node.module or ""
if module == "simplecadapi" or module.startswith("simplecadapi."):
module_parts = module.split(".")[1:]
for alias in node.names:
if alias.name == "*":
self.error(node, "Wildcard imports from SimpleCADAPI are not allowed in submitted source.")
continue
try:
resolve_simplecad_path(module_parts + [alias.name])
except AttributeError:
self.error(alias, f"SimpleCADAPI import does not exist: {module}.{alias.name}")
self.imported_names[alias.asname or alias.name] = (module, module_parts + [alias.name])
self.generic_visit(node)
def attribute_path(self, node: ast.Attribute) -> tuple[str, list[str]] | None:
parts: list[str] = [node.attr]
current: ast.AST = node.value
while isinstance(current, ast.Attribute):
parts.append(current.attr)
current = current.value
if isinstance(current, ast.Name) and current.id in self.package_aliases:
return current.id, list(reversed(parts))
return None
def visit_Attribute(self, node: ast.Attribute) -> None:
resolved = self.attribute_path(node)
if resolved:
_, parts = resolved
try:
resolve_simplecad_path(parts)
except AttributeError:
self.error(node, f"SimpleCADAPI attribute does not exist: scad.{'.'.join(parts)}")
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
if isinstance(node.func, ast.Name) and node.func.id in {"getattr", "setattr", "hasattr", "dir", "inspect"}:
self.error(node, "Dynamic or introspective SDK access is not allowed in submitted SimpleCADAPI source.")
self.generic_visit(node)
def validate_source(source_path: Path) -> dict[str, object]:
try:
source = source_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(source_path))
except (OSError, SyntaxError) as exc:
return {"valid": False, "errors": [{"message": f"Cannot parse Python source: {exc}"}]}
validator = SourceValidator()
validator.visit(tree)
if not validator.package_aliases:
validator.errors.append({"message": "Submitted SimpleCADAPI source must import simplecadapi as scad."})
return {"valid": not validator.errors, "errors": validator.errors}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
args = parser.parse_args()
print(json.dumps(validate_source(args.source), ensure_ascii=True))
if __name__ == "__main__":
main()