orchestrata_ultimate.refactor.codemod.client
Reference › orchestrata_ultimate › refactor › codemod › client
The JSON-RPC 2.0 client for the codemod daemon. scripts.codemod is its only importer; it talks to the server over the Unix socket that the daemon manages, and decodes commit results into the CommitReport type from report.
CodemodClient.connect(socket_path) opens the socket and raises ConnectionError if no daemon answers; the class is a context manager that closes the connection. Each public method wraps one RPC call through _call, which serializes requests under a lock and reads the matching response; an error reply raises CodemodRpcError with the code and message. The graph queries are query_dependencies, query_dependents, query_blast_radius, query_cycles, query_topological_order, query_centrality, query_impact_score, query_coupling_score and query_abstraction_level, and resolve_nodes turns node ids into names. The write path is run_pass(pass_name, params, run_id, observe), which opens or extends a pending run, then commit(run_id, dry_run, baseline, observe), which returns a CommitReport. status and reload report on and refresh the daemon.
At a glance
| Source | src/orchestrata_ultimate/refactor/codemod/client.py |
| Lines of code | 359 |
| Classes | 2 |
| Functions | 0 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.refactor.codemod.report |
| Imported by | scripts.codemod |
| Tests that import it | tests/refactor/codemod/test_client_coverage.py, tests/refactor/codemod/test_concurrency.py, tests/refactor/codemod/test_ipc_protocol.py, tests/scripts/test_codemod_cli_daemon.py |
Class diagram
Dependency diagram
API
Classes
CodemodRpcError
Bases: RuntimeError · Line 25
The daemon answered a request with a JSON-RPC error.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(code: int, message: str) -> None |
CodemodClient
Decorators: traces(spec='110', story='US-110-013', reqs=['REQ-110-139', 'REQ-110-140']) · Line 35
JSON-RPC 2.0 client for the codemod daemon.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__() -> None |
Initialize CodemodClient with no active connection. |
connect |
connect(socket_path: Path) -> CodemodClient |
Connect to the codemod daemon Unix socket. |
close |
close() -> None |
Close the connection to the daemon. |
__enter__ |
__enter__() -> CodemodClient |
Context manager entry. |
__exit__ |
__exit__(**args: object) -> None |
Context manager exit — close connection. |
query_dependencies |
query_dependencies(node_id: str) -> list[int] |
Query direct dependencies of a node. |
query_dependents |
query_dependents(node_id: str) -> list[int] |
Query direct dependents of a node. |
query_blast_radius |
query_blast_radius(node_id: str, hops: int = 3) -> dict[str, Any] |
Query blast radius of a node (transitive dependents). |
query_cycles |
query_cycles() -> list[list[int]] |
Import cycles: each a list of CIG node ids. |
query_topological_order |
query_topological_order() -> list[int] |
Node ids in dependency order (dependencies first). |
query_centrality |
query_centrality(top_k: int = 20, exact: bool = False) -> list[tuple[int, float]] |
Betweenness centrality: the top_k (node id, score) pairs. |
query_impact_score |
query_impact_score(node_id: str) -> float |
PageRank-based impact score of a node. |
query_coupling_score |
query_coupling_score(node_id: str) -> dict[str, Any] |
Coupling of a node: efferent, afferent, instability. |
query_abstraction_level |
query_abstraction_level(node_id: str) -> dict[str, Any] |
Abstraction of a node: abstract_count, concrete_count, ratio. |
resolve_nodes |
resolve_nodes(node_ids: list[int]) -> list[dict[str, Any]] |
Names of CIG node ids: {"id", "qualified_name", "path"} records. |
run_pass |
run_pass(pass_name: str, params: dict, run_id: str | None = None, observe: bool = False) -> dict[str, Any] |
Apply a transform pass inside a pending run held by the daemon. |
commit |
commit(run_id: str, *, dry_run: bool = True, baseline: str = 'delta', observe: bool = False) -> CommitReport |
Review and commit a pending run; the run ends either way. |
status |
status() -> dict |
Query daemon status. |
reload |
reload() -> dict |
Force workspace reload (bypass inotify). |
_call |
_call(method: str, params: dict) -> Any |
Send a JSON-RPC 2.0 request and return the result. |
_recv_response |
_recv_response(expected_id: int) -> dict |
Receive and parse a JSON-RPC response from the socket. |
Source
"""SPEC-110 codemod engine — JSON-RPC 2.0 client for the codemod daemon.
Connects to the CodemodServer via Unix socket and exposes JSON-RPC methods
as Python functions. Supports context manager protocol.
"""
from __future__ import annotations
import json
import logging
import socket
import threading
from pathlib import Path
from typing import Any
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.refactor.codemod.report import CommitReport
logger = logging.getLogger(__name__)
JSONRPC_VERSION = "2.0"
RECV_BUFFER = 65536
class CodemodRpcError(RuntimeError):
"""The daemon answered a request with a JSON-RPC error."""
def __init__(self, code: int, message: str) -> None:
super().__init__(f"JSON-RPC error {code}: {message}")
self.code = code
self.rpc_message = message
@traces(spec="110", story="US-110-013", reqs=["REQ-110-139", "REQ-110-140"])
class CodemodClient:
"""JSON-RPC 2.0 client for the codemod daemon.
Connects to the Unix socket created by CodemodServer and exposes
workspace operations as Python methods. Supports context manager
protocol for automatic connection cleanup.
"""
def __init__(self) -> None:
"""Initialize CodemodClient with no active connection."""
self._socket: socket.socket | None = None
self._request_id = 0
self._lock = threading.Lock()
self._buffer = b""
self._socket_path: Path | None = None
@traces(spec="110", story="US-110-013", reqs=["REQ-110-139"])
def connect(self, socket_path: Path) -> CodemodClient:
"""Connect to the codemod daemon Unix socket.
Args:
socket_path: Path to the Unix domain socket.
Returns:
Self for chaining.
Raises:
ConnectionError: If the daemon is not running.
"""
self._socket_path = socket_path
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
self._socket.connect(str(socket_path))
except (ConnectionRefusedError, FileNotFoundError, OSError) as e:
self._socket = None
raise ConnectionError(
f"Cannot connect to codemod daemon at {socket_path}: {e}"
) from e
self._buffer = b""
logger.info("Connected to codemod daemon at %s", socket_path)
return self
def close(self) -> None:
"""Close the connection to the daemon."""
if self._socket is not None:
try:
self._socket.close()
except OSError as e:
logger.warning("Error closing client socket: %s", e)
self._socket = None
def __enter__(self) -> CodemodClient:
"""Context manager entry."""
return self
def __exit__(self, *args: object) -> None:
"""Context manager exit — close connection."""
self.close()
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_dependencies(self, node_id: str) -> list[int]:
"""Query direct dependencies of a node.
Args:
node_id: Qualified name (CIG key) of the node.
Returns:
List of dependency node IDs. These are CIG integer ids, not keys:
the server returns ``cig.dependencies(cig.resolve(node_id))``, and
``CodeIntelligenceGraph.dependencies`` is ``(int) -> list[int]``.
The old ``list[str]`` annotation was wrong in both directions --
the argument is a key, the result is ids.
"""
result: list[int] = self._call("query_dependencies", {"node_id": node_id})
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_dependents(self, node_id: str) -> list[int]:
"""Query direct dependents of a node.
Args:
node_id: Qualified name (CIG key) of the node.
Returns:
List of dependent node IDs -- CIG integer ids, for the same reason
as :meth:`query_dependencies`.
"""
result: list[int] = self._call("query_dependents", {"node_id": node_id})
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_blast_radius(self, node_id: str, hops: int = 3) -> dict[str, Any]:
"""Query blast radius of a node (transitive dependents).
Args:
node_id: Qualified name or key of the node.
hops: Maximum traversal depth.
Returns:
BlastRadius dict with affected_nodes, affected_files, impact_score.
"""
# dict, not list[str]: the server hands back
# {"affected_nodes": [...], "affected_files": [...], "impact_score": float}
# (server.py's query_blast_radius branch). The declared list[str] was
# wrong -- a caller iterating the result would have walked dict KEYS.
# Unlike the sibling query_dependencies/query_dependents, which really
# do return lists, this one is a record. Nothing called it, and its only
# test asserted the raises-on-missing-parameter path, so the mismatch
# was never exercised.
result: dict[str, Any] = self._call(
"query_blast_radius", {"node_id": node_id, "hops": hops}
)
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_cycles(self) -> list[list[int]]:
"""Import cycles: each a list of CIG node ids."""
result: list[list[int]] = self._call("query_cycles", {})
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_topological_order(self) -> list[int]:
"""Node ids in dependency order (dependencies first)."""
result: list[int] = self._call("query_topological_order", {})
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_centrality(
self, top_k: int = 20, exact: bool = False
) -> list[tuple[int, float]]:
"""Betweenness centrality: the ``top_k`` ``(node id, score)`` pairs."""
raw = self._call("query_centrality", {"top_k": top_k, "exact": exact})
return [(int(node_id), float(score)) for node_id, score in raw]
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_impact_score(self, node_id: str) -> float:
"""PageRank-based impact score of a node."""
result: float = self._call("query_impact_score", {"node_id": node_id})
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_coupling_score(self, node_id: str) -> dict[str, Any]:
"""Coupling of a node: ``efferent``, ``afferent``, ``instability``."""
result: dict[str, Any] = self._call(
"query_coupling_score", {"node_id": node_id}
)
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def query_abstraction_level(self, node_id: str) -> dict[str, Any]:
"""Abstraction of a node: ``abstract_count``, ``concrete_count``, ``ratio``."""
result: dict[str, Any] = self._call(
"query_abstraction_level", {"node_id": node_id}
)
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def resolve_nodes(self, node_ids: list[int]) -> list[dict[str, Any]]:
"""Names of CIG node ids: ``{"id", "qualified_name", "path"}`` records.
Ids the graph does not hold are omitted from the reply.
"""
result: list[dict[str, Any]] = self._call(
"resolve_nodes", {"node_ids": list(node_ids)}
)
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def run_pass(
self,
pass_name: str,
params: dict,
run_id: str | None = None,
observe: bool = False,
) -> dict[str, Any]:
"""Apply a transform pass inside a pending run held by the daemon.
The daemon keeps the mutated trees; ``commit`` names the run, so no
ChangeSet crosses the socket. The first call opens the run (and the
daemon's write lock); pass the returned ``run_id`` to stack more
passes onto it.
Args:
pass_name: Name of the TransformPass class.
params: Constructor parameters for the pass.
run_id: The run to extend; ``None`` opens a new one.
observe: Attach the REQ-110-026 observers to this pass.
Returns:
``{"run_id", "file_count", "paths", "pass_name"}``; the counts
cover the whole run so far.
"""
request: dict[str, Any] = {
"pass_name": pass_name,
"params": params,
"observe": observe,
}
if run_id is not None:
request["run_id"] = run_id
result: dict[str, Any] = self._call("run_pass", request)
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-138"])
def commit(
self,
run_id: str,
*,
dry_run: bool = True,
baseline: str = "delta",
observe: bool = False,
) -> CommitReport:
"""Review and commit a pending run; the run ends either way.
Args:
run_id: The run ``run_pass`` returned.
dry_run: If True, produce the report without writing; the daemon
restores the trees the run mutated.
baseline: The review engine's baseline (``delta`` or ``absolute``).
observe: Attach the REQ-110-026 observers to this commit.
Returns:
CommitReport with success, files_written, findings, duration.
"""
result: dict[str, Any] = self._call(
"commit",
{
"changes": {"run_id": run_id},
"dry_run": dry_run,
"baseline": baseline,
"observe": observe,
},
)
return CommitReport.from_dict(result["report"])
def status(self) -> dict:
"""Query daemon status.
Returns:
Dict with memory_rss, client_count, file_count.
"""
result: dict = self._call("status", {})
return result
def reload(self) -> dict:
"""Force workspace reload (bypass inotify).
Returns:
Dict with reload status.
"""
result: dict = self._call("reload", {})
return result
@traces(spec="110", story="US-110-013", reqs=["REQ-110-139"])
def _call(self, method: str, params: dict) -> Any:
"""Send a JSON-RPC 2.0 request and return the result.
Args:
method: JSON-RPC method name.
params: Method parameters.
Returns:
The 'result' field from the JSON-RPC response.
Raises:
ConnectionError: If not connected.
RuntimeError: If the server returns an error.
"""
if self._socket is None:
raise ConnectionError("Not connected to codemod daemon")
with self._lock:
self._request_id += 1
request_id = self._request_id
request = {
"jsonrpc": JSONRPC_VERSION,
"method": method,
"params": params,
"id": request_id,
}
payload = json.dumps(request) + "\n"
try:
self._socket.sendall(payload.encode("utf-8"))
except OSError as e:
self._socket = None
raise ConnectionError(f"Failed to send request: {e}") from e
response = self._recv_response(request_id)
if "error" in response:
err = response["error"]
raise CodemodRpcError(err.get("code", -1), err.get("message", "Unknown"))
return response.get("result")
@traces(spec="110", story="US-110-013", reqs=["REQ-110-139"])
def _recv_response(self, expected_id: int) -> dict:
"""Receive and parse a JSON-RPC response from the socket."""
while True:
if b"\n" in self._buffer:
line, self._buffer = self._buffer.split(b"\n", 1)
if line.strip():
try:
response: dict = json.loads(line.decode("utf-8"))
if response.get("id") == expected_id:
return response
except (json.JSONDecodeError, UnicodeDecodeError) as e:
logger.warning("Malformed response: %s", e)
continue
if self._socket is None:
raise ConnectionError("Socket closed while waiting for response")
try:
data = self._socket.recv(RECV_BUFFER)
except OSError as e:
self._socket = None
raise ConnectionError(f"Failed to receive response: {e}") from e
if not data:
self._socket = None
raise ConnectionError("Daemon closed connection")
self._buffer += data