cad-editor/docs/automation/ocs.py
Hakan Seven 8bae517eec feat(automation): local TCP socket transport for --serve
OpenCADStudio --serve --port <N> listens on 127.0.0.1:<N> and serves the
same line-based JSON protocol over the socket (one client at a time; the
document session persists across reconnects), as an alternative to
stdin/stdout. ocs.py grows a port= option to connect either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:54:03 +03:00

153 lines
5.5 KiB
Python

"""Thin Python client for the Open CAD Studio headless automation server.
Launches `OpenCADStudio --serve` and talks to it over a line-based JSON protocol
(one request object per line on stdin, one response per line on stdout). There
is nothing to compile or maintain on the Python side — every method is one JSON
message; the real work is Open CAD Studio's own command system.
from ocs import Ocs
with Ocs(binary="OpenCADStudio") as ocs:
ocs.open("plan.dwg")
ocs.run("LAYER Walls")
print(ocs.entities()) # {"total": 42, "by_type": {...}}
ocs.save("plan_out.dwg")
Each call returns the parsed response dict and raises `OcsError` on `ok: false`.
"""
from __future__ import annotations
import json
import socket
import subprocess
from typing import Any, Optional
class OcsError(RuntimeError):
"""Raised when the server replies with `{"ok": false, ...}`."""
class Ocs:
"""Connect by spawning the server (default) or over a TCP socket.
- `Ocs()` spawns `OpenCADStudio --serve` and talks over stdin/stdout.
- `Ocs(port=4242)` connects to a server started with `--serve --port 4242`.
"""
def __init__(
self,
binary: str = "OpenCADStudio",
port: Optional[int] = None,
host: str = "127.0.0.1",
) -> None:
self.proc: Optional[subprocess.Popen] = None
self.sock: Optional[socket.socket] = None
if port is not None:
self.sock = socket.create_connection((host, port))
io = self.sock.makefile("rw")
self._r, self._w = io, io
else:
self.proc = subprocess.Popen(
[binary, "--serve"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
bufsize=1,
)
self._r, self._w = self.proc.stdout, self.proc.stdin
self._read() # the {"ready": true} greeting
# ── protocol ────────────────────────────────────────────────────────────
def _read(self) -> dict[str, Any]:
line = self._r.readline()
if not line:
raise OcsError("server closed the connection")
return json.loads(line)
def _send(self, **req: Any) -> dict[str, Any]:
self._w.write(json.dumps(req) + "\n")
self._w.flush()
resp = self._read()
if not resp.get("ok", False):
raise OcsError(resp.get("error", "unknown error"))
return resp
# ── operations ──────────────────────────────────────────────────────────
def new(self) -> dict[str, Any]:
"""Start an empty document."""
return self._send(op="new")
def open(self, path: str) -> dict[str, Any]:
"""Load a DWG/DXF drawing."""
return self._send(op="open", path=path)
def run(self, cmd: str) -> dict[str, Any]:
"""Run a command through Open CAD Studio's command system."""
return self._send(op="run", cmd=cmd)
def entities(self) -> dict[str, Any]:
"""Total entity count and a breakdown by type."""
return self._send(op="entities")
def query(
self,
type: Optional[str] = None,
layer: Optional[str] = None,
limit: Optional[int] = None,
) -> dict[str, Any]:
"""List entities (handle, type, layer, geometry), optionally filtered."""
return self._send(op="query", type=type, layer=layer, limit=limit)
def layers(self) -> dict[str, Any]:
"""List layers (name, color, on/off, frozen, locked) and the current one."""
return self._send(op="layers")
def header(self) -> dict[str, Any]:
"""Read drawing header variables (units, PDMODE/PDSIZE, LTSCALE, …)."""
return self._send(op="header")
def select(
self,
handles: Optional[list[str]] = None,
type: Optional[str] = None,
layer: Optional[str] = None,
clear: bool = False,
) -> dict[str, Any]:
"""Set the selection by handle, type, or layer (a following selection
command like ``run("ERASE")`` then acts on it). `clear=True` deselects."""
return self._send(
op="select", handles=handles, type=type, layer=layer, clear=clear
)
def undo(self) -> dict[str, Any]:
"""Undo the last change."""
return self._send(op="undo")
def redo(self) -> dict[str, Any]:
"""Redo the last undone change."""
return self._send(op="redo")
def save(self, path: Optional[str] = None) -> dict[str, Any]:
"""Write the document (defaults to the opened/last-saved path)."""
return self._send(op="save", path=path)
# ── lifecycle ───────────────────────────────────────────────────────────
def close(self) -> None:
try:
self._w.close()
except Exception:
pass
if self.proc is not None:
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
if self.sock is not None:
self.sock.close()
def __enter__(self) -> "Ocs":
return self
def __exit__(self, *_exc: object) -> None:
self.close()