117 lines
4.3 KiB
Python
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()
|