"""Minimal graph library and RoboCrunch API client. Label nodes use an empty class name. Token nodes use the class name "token". """ from __future__ import annotations import argparse from dataclasses import dataclass, field import json from pathlib import Path import socket import sys from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlsplit from urllib.request import Request, urlopen DEFAULT_API = "https://robocrunch.net/api/run" DEFAULT_RULE_BASE = "https://symsuper.com/grammars/english-tau-c/" @dataclass(frozen=True, order=True) class NodeAddress: """Identify a node by its class name and ID. An empty class name identifies a Label node. Colons are allowed in IDs, but not in class names. """ class_name: str id: str def __post_init__(self) -> None: """Validate the address without rejecting an empty class name.""" for name, value in (("class_name", self.class_name), ("id", self.id)): if not isinstance(value, str): raise TypeError(f"{name} must be a string.") if value != value.strip() or "\n" in value or "\r" in value: raise ValueError(f"{name} must fit on one line without surrounding whitespace.") if not self.id: raise ValueError("A node ID cannot be empty.") if ":" in self.class_name: raise ValueError("A class name cannot contain ':'.") if self.class_name.startswith(("//", "##")): raise ValueError("This class name conflicts with graph line markers.") def to_text(self) -> str: """Return an explicit address, such as token:1 or :begin.""" return f"{self.class_name}:{self.id}" @dataclass class Graph: """Store node identities and directed edges. Each address identifies one node within this graph. Duplicate edges are ignored. Branching, cycles, and isolated nodes are supported. """ nodes: dict[NodeAddress, set[NodeAddress]] = field(default_factory=dict) def add_node(self, class_name: str, id: str) -> NodeAddress: """Create a node if absent and return its address.""" address = NodeAddress(class_name=class_name, id=id) self.nodes.setdefault(address, set()) return address def add_edge(self, source: NodeAddress, target: NodeAddress) -> None: """Add an edge, creating its endpoints if necessary.""" self.nodes.setdefault(source, set()).add(target) self.nodes.setdefault(target, set()) @property def node_count(self) -> int: """Return the number of nodes, including Label nodes.""" return len(self.nodes) @property def edge_count(self) -> int: """Return the number of distinct directed edges.""" return sum(len(targets) for targets in self.nodes.values()) @classmethod def read_text(cls, text: str) -> Graph: """Read a graph from SimpleGraphText. [[]] declares the empty Label class. [[token]] declares the Token class. ## starts a node; subsequent lines identify its outgoing targets. Forward references, branching, cycles, and isolated nodes are preserved. Blank lines and comments beginning with // are ignored. """ graph = cls() current_class = None current_node = None lines = text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n").split("\n") for number, raw in enumerate(lines, start=1): line = raw.strip() if not line or line.startswith("//"): continue try: if line.startswith("[[") and line.endswith("]]"): current_class = line[2:-2].strip() current_node = None elif line.startswith("##"): if current_class is None: raise ValueError("Node appears before any class declaration.") current_node = graph.add_node(class_name=current_class, id=line[2:].strip()) else: if current_node is None or current_class is None: raise ValueError("Edge appears before a current node.") if ":" in line: class_name, id = (part.strip() for part in line.split(sep=":", maxsplit=1)) else: class_name, id = current_class, line target = graph.add_node(class_name=class_name, id=id) graph.add_edge(source=current_node, target=target) except (ValueError, TypeError) as error: raise ValueError(f"Line {number}: {error}") from error return graph def write_text(self) -> str: """Serialize every node and edge as SimpleGraphText. Node declarations use deterministic class and ID ordering. Token sequence is defined by edges, independently of declaration order. Explicit target addresses preserve edges between different classes. """ def ordinal(value: str) -> bytes: """Produce the UTF-16 ordinal ordering used by .NET.""" return value.encode(encoding="utf-16-be") def address_order(address: NodeAddress) -> tuple[bytes, bytes]: """Order addresses by class name, then by ID.""" return ordinal(value=address.class_name), ordinal(value=address.id) lines = [] classes = sorted({address.class_name for address in self.nodes}, key=ordinal) for class_name in classes: lines.append(f"[[{class_name}]]") addresses = (address for address in self.nodes if address.class_name == class_name) for address in sorted(addresses, key=address_order): lines.append(f"## {address.id}") for target in sorted(self.nodes[address], key=address_order): target_text = target.to_text() if target_text.startswith("[[") and target_text.endswith("]]"): raise ValueError("This edge address would be parsed as a class declaration.") lines.append(target_text) lines.append("") lines.append("") return "\n".join(lines) + ("\n" if lines else "") @classmethod def read_file(cls, path: str | Path) -> Graph: """Read a UTF-8 graph file, accepting a UTF-8 byte-order mark.""" return cls.read_text(text=Path(path).read_text(encoding="utf-8-sig")) def write_file(self, path: str | Path) -> None: """Save the graph as UTF-8 with LF line endings.""" Path(path).write_text(data=self.write_text(), encoding="utf-8", newline="\n") @classmethod def from_labels(cls, labels: list[str]) -> Graph: """Build a Token path with one Label edge per Token. Labels are used exactly as supplied. This method does not insert begin or end automatically. """ graph = cls() previous = None for number, label in enumerate(labels, start=1): token = graph.add_node(class_name="token", id=str(number)) label_node = graph.add_node(class_name="", id=label) graph.add_edge(source=token, target=label_node) if previous is not None: graph.add_edge(source=previous, target=token) previous = token return graph @classmethod def from_text(cls, text: str) -> Graph: """Convert text to an English character graph. Insert begin/end boundaries. Preserve each character occurrence as a distinct Token. Repeated character labels share the same Label node. """ labels = ["begin"] for position, character in enumerate(text, start=1): labels.append(character_label(character=character, position=position)) labels.append("end") return cls.from_labels(labels=labels) _SIGNS = { ".": "Period", ",": "Comma", "?": "Question", "!": "Exclamation", ":": "Colon", ";": "Semicolon", "'": "Apostrophe", '"': "Quotation", "-": "Hyphen", "(": "Left-Parenthesis", ")": "Right-Parenthesis", "[": "Left-Bracket", "]": "Right-Bracket", "/": "Slash", } def character_label(character: str, position: int = 1) -> str: """Map one character to the labels used by the C# English adapter. Unsupported characters produce an error identifying their position. Whitespace follows .NET Char.IsWhiteSpace. """ if len(character) != 1: raise ValueError("Expected exactly one character.") if "A" <= character <= "Z": return f"Upper-{character}" if "a" <= character <= "z": return f"Lower-{character.upper()}" if "0" <= character <= "9": return f"Digit-{character}" whitespace = "\t\n\v\f\r \u0085\u00a0\u1680\u2028\u2029\u202f\u205f\u3000" if character in whitespace or "\u2000" <= character <= "\u200a": return "char-space" if character in _SIGNS: return f"Sign-{_SIGNS[character]}" raise ValueError(f"Unsupported character {character!r} at position {position} (U+{ord(character):04X}).") class RoboCrunchError(RuntimeError): """Report an HTTP, network, or response-format failure.""" def __init__(self, message: str, status_code: int | None = None, response_text: str = ""): super().__init__(message) self.status_code = status_code self.response_text = response_text @dataclass class RunResult: """Hold the parsed output graph, its original text, and response metadata.""" output_graph: Graph output_graph_text: str metadata: dict[str, Any] def write_file(self, path: str | Path = "OutputGraph.txt") -> None: """Save the returned graph text without reserializing it.""" Path(path).write_text(data=self.output_graph_text, encoding="utf-8", newline="\n") class RoboCrunchClient: """Call a configurable RoboCrunch API endpoint.""" def __init__(self, api_url: str = DEFAULT_API, timeout: float = 120.0): """Configure the complete endpoint URL and HTTP timeout in seconds.""" parsed = urlsplit(url=api_url) if parsed.scheme not in ("http", "https") or not parsed.hostname: raise ValueError("The API URL must be an absolute HTTP or HTTPS URL.") if timeout <= 0: raise ValueError("Timeout must be greater than zero.") self.api_url = api_url self.timeout = timeout def run(self, rule_base_url: str, graph: Graph | str | None = None, *, graph_url: str | None = None) -> RunResult: """Submit an inline graph or a public graph URL and read the result. Supply exactly one of graph and graph_url. An inline graph can be a Graph object or SimpleGraphText string. """ if (graph is None) == (graph_url is None): raise ValueError("Supply exactly one of graph and graph_url.") if not isinstance(rule_base_url, str) or not rule_base_url.strip(): raise ValueError("rule_base_url is required.") payload: dict[str, str] = {"ruleBaseUrl": rule_base_url} if graph_url is not None: if not isinstance(graph_url, str) or not graph_url.strip(): raise ValueError("graph_url cannot be empty.") payload["inputGraphUrl"] = graph_url else: if not isinstance(graph, (Graph, str)): raise TypeError("graph must be a Graph or SimpleGraphText string.") payload["inputGraph"] = graph.write_text() if isinstance(graph, Graph) else graph request_body = json.dumps(obj=payload, ensure_ascii=False).encode(encoding="utf-8") headers = {"Content-Type": "application/json; charset=utf-8", "Accept": "application/json"} request = Request(url=self.api_url, data=request_body, headers=headers, method="POST") try: with urlopen(url=request, timeout=self.timeout) as response: response_text = response.read().decode(encoding="utf-8-sig") except HTTPError as error: response_text = error.read().decode(encoding="utf-8", errors="replace") message = f"RoboCrunch returned HTTP {error.code}." try: problem = json.loads(s=response_text) if isinstance(problem, dict): details = [str(problem[key]) for key in ("title", "detail") if problem.get(key)] if isinstance(problem.get("errors"), dict): for values in problem["errors"].values(): messages = values if isinstance(values, list) else [values] details.extend(str(value) for value in messages) if details: message += " " + " ".join(details) except ValueError: pass raise RoboCrunchError(message=message, status_code=error.code, response_text=response_text) from error except (URLError, TimeoutError, socket.timeout, OSError) as error: raise RoboCrunchError(message=f"Could not call RoboCrunch: {error}") from error except UnicodeDecodeError as error: raise RoboCrunchError(message="RoboCrunch returned a response that is not UTF-8.") from error try: metadata = json.loads(s=response_text) if not isinstance(metadata, dict) or not isinstance(metadata.get("outputGraph"), str): raise ValueError("Expected a JSON object with an outputGraph string.") output_graph_text = metadata["outputGraph"] output_graph = Graph.read_text(text=output_graph_text) except (ValueError, TypeError) as error: raise RoboCrunchError(message=f"Invalid RoboCrunch response: {error}", response_text=response_text) from error return RunResult(output_graph=output_graph, output_graph_text=output_graph_text, metadata=metadata) def main(argv: list[str] | None = None) -> int: """Run the API client or build an input graph without calling the API.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--api", default=DEFAULT_API, help="Complete API endpoint URL.") parser.add_argument("--rulebase", default=DEFAULT_RULE_BASE, help="RuleBase directory URL.") source = parser.add_mutually_exclusive_group(required=True) source.add_argument("--text", help="English text converted to a character graph.") source.add_argument("--text-file", type=Path, help="UTF-8 text file converted to a character graph.") source.add_argument("--graph-file", type=Path, help="Existing UTF-8 SimpleGraphText file.") source.add_argument("--graph-url", help="Public graph URL; downloaded by the server.") parser.add_argument("--save-input", type=Path, help="Also save the inline input graph.") parser.add_argument("--build-only", action="store_true", help="Save an input graph without calling the API.") parser.add_argument("--output", type=Path, default=Path("OutputGraph.txt"), help="Destination graph file.") parser.add_argument("--timeout", type=float, default=120.0, help="HTTP timeout in seconds.") args = parser.parse_args(args=argv) try: if args.text is not None: graph = Graph.from_text(text=args.text) elif args.text_file is not None: text = args.text_file.read_text(encoding="utf-8-sig") graph = Graph.from_text(text=text) elif args.graph_file is not None: graph = Graph.read_file(path=args.graph_file) else: graph = None if graph is None and (args.save_input is not None or args.build_only): raise ValueError("--save-input and --build-only require text or a local graph file.") if args.save_input is not None: graph.write_file(path=args.save_input) if args.build_only: graph.write_file(path=args.output) print(f"Input graph saved to {args.output}: {graph.node_count} nodes, {graph.edge_count} edges.") else: client = RoboCrunchClient(api_url=args.api, timeout=args.timeout) result = client.run(rule_base_url=args.rulebase, graph=graph, graph_url=args.graph_url) result.write_file(path=args.output) print(f"Output graph saved to {args.output}: {result.output_graph.node_count} nodes, {result.output_graph.edge_count} edges.") return 0 except (RoboCrunchError, ValueError, TypeError, OSError) as error: print(f"Error: {error}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())