neon3-sdk 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: neon3-sdk
3
+ Version: 0.1.1
4
+ Summary: Public Python client SDK for the Neon3 control-plane protocol.
5
+ Author: Neon3
6
+ License: MIT OR Apache-2.0
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+
13
+ # neon3-sdk
14
+
15
+ Python SDK for the Neon3 `neon3.rpc` control plane.
16
+
17
+ ```bash
18
+ cd packages/python-sdk
19
+ pip install -e .
20
+ python -m neon3_sdk calculator
21
+ ```
22
+
23
+ Tests and deterministic scenario:
24
+
25
+ ```bash
26
+ python -m unittest discover -s tests -v
27
+ python -m neon3_sdk calculator --once
28
+ ```
29
+
30
+ ### Event subscriptions
31
+
32
+ The SDK also exposes the canonical `neon3.event` stream. A WGPU window owner
33
+ publishes `ui.file_drop.accepted` for OS file drops; same-machine tools can
34
+ subscribe without implementing a second transport:
35
+
36
+ ```python
37
+ from neon3_sdk import EventClient
38
+
39
+ events = EventClient.connect("127.0.0.1:39101").subscribe(
40
+ name="ui.file_drop.accepted"
41
+ )
42
+ with events:
43
+ for image in events.file_drops():
44
+ print(image.source_path)
45
+ ```
46
+
47
+ When `--neon-root` is omitted, the command uses the SDK-local `release`
48
+ directory. Set `NEON_ROOT` or pass `--neon-root <path>` only to override it.
49
+ Runtime clients can select a checkout profile with `RuntimeConfig(profile="debug")`
50
+ or `RuntimeConfig(profile="release")`; `auto` preserves the release-first default.
@@ -0,0 +1,38 @@
1
+ # neon3-sdk
2
+
3
+ Python SDK for the Neon3 `neon3.rpc` control plane.
4
+
5
+ ```bash
6
+ cd packages/python-sdk
7
+ pip install -e .
8
+ python -m neon3_sdk calculator
9
+ ```
10
+
11
+ Tests and deterministic scenario:
12
+
13
+ ```bash
14
+ python -m unittest discover -s tests -v
15
+ python -m neon3_sdk calculator --once
16
+ ```
17
+
18
+ ### Event subscriptions
19
+
20
+ The SDK also exposes the canonical `neon3.event` stream. A WGPU window owner
21
+ publishes `ui.file_drop.accepted` for OS file drops; same-machine tools can
22
+ subscribe without implementing a second transport:
23
+
24
+ ```python
25
+ from neon3_sdk import EventClient
26
+
27
+ events = EventClient.connect("127.0.0.1:39101").subscribe(
28
+ name="ui.file_drop.accepted"
29
+ )
30
+ with events:
31
+ for image in events.file_drops():
32
+ print(image.source_path)
33
+ ```
34
+
35
+ When `--neon-root` is omitted, the command uses the SDK-local `release`
36
+ directory. Set `NEON_ROOT` or pass `--neon-root <path>` only to override it.
37
+ Runtime clients can select a checkout profile with `RuntimeConfig(profile="debug")`
38
+ or `RuntimeConfig(profile="release")`; `auto` preserves the release-first default.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "neon3-sdk"
7
+ version = "0.1.1"
8
+ description = "Public Python client SDK for the Neon3 control-plane protocol."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT OR Apache-2.0" }
12
+ authors = [{ name = "Neon3" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ ]
18
+
19
+ [project.scripts]
20
+ neon3-sdk = "neon3_sdk.cli:main"
21
+ neon3-component-gallery-probe = "neon3_sdk.bin.component_gallery_probe:main"
22
+ neon3-api-contract-probe = "neon3_sdk.bin.api_contract_probe:main"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.setuptools.package-data]
28
+ neon3_sdk = ["fixtures/*.nui"]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,55 @@
1
+ """Public Python SDK for Neon3's transport-independent control-plane protocol."""
2
+
3
+ from .client import NeonClient
4
+ from .calculator import CalculatorDomain, CalculatorServer
5
+ from .errors import NeonError, ProtocolError, RemoteError, TransportError
6
+ from .models import AssetRef, ClientIdentity, EventEnvelope, RpcResponse, ServiceDescription, ServiceHealth, UiFileDropPayload
7
+ from .nui import ComponentGallery, GallerySubmission
8
+ from .input import InputClient, KeyEvent
9
+ from .render import Backend, BackendNegotiation, Camera3D, ColorSpace, ExternalSurface, RenderClient, SurfaceKind, SurfaceOpen, SurfaceSize, SurfaceTarget, WorldInformation, WorldPlacement
10
+ from .runtime import RuntimeConfig, RuntimeEndpoints, RuntimeMode, RuntimeSession, default_neon_root
11
+ from .ui import UiClient, UiProgram
12
+ from .event import EventClient, EventFilter, EventSubscription
13
+
14
+ __all__ = [
15
+ "AssetRef",
16
+ "ClientIdentity",
17
+ "CalculatorDomain",
18
+ "CalculatorServer",
19
+ "Camera3D",
20
+ "Backend",
21
+ "BackendNegotiation",
22
+ "ColorSpace",
23
+ "ComponentGallery",
24
+ "GallerySubmission",
25
+ "ExternalSurface",
26
+ "InputClient",
27
+ "KeyEvent",
28
+ "NeonClient",
29
+ "NeonError",
30
+ "ProtocolError",
31
+ "RemoteError",
32
+ "RenderClient",
33
+ "RpcResponse",
34
+ "ServiceDescription",
35
+ "ServiceHealth",
36
+ "SurfaceKind",
37
+ "SurfaceOpen",
38
+ "SurfaceSize",
39
+ "SurfaceTarget",
40
+ "TransportError",
41
+ "RuntimeConfig",
42
+ "RuntimeEndpoints",
43
+ "RuntimeMode",
44
+ "RuntimeSession",
45
+ "default_neon_root",
46
+ "UiClient",
47
+ "UiProgram",
48
+ "EventClient",
49
+ "EventFilter",
50
+ "EventSubscription",
51
+ "EventEnvelope",
52
+ "UiFileDropPayload",
53
+ "WorldPlacement",
54
+ "WorldInformation",
55
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Executable SDK diagnostics."""
@@ -0,0 +1,57 @@
1
+ """Executable JSONL probe for the public multi-mode SDK API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ from ..client import NeonClient
12
+ from ..errors import NeonError
13
+ from ..render import Camera3D, RenderClient, SurfaceKind, SurfaceOpen, SurfaceSize, SurfaceTarget, WorldInformation
14
+ from ..runtime import RuntimeConfig, RuntimeEndpoints, RuntimeMode, RuntimeSession, default_neon_root
15
+
16
+
17
+ def main() -> int:
18
+ parser = argparse.ArgumentParser()
19
+ parser.add_argument("--neon-root", type=Path, default=default_neon_root())
20
+ parser.add_argument("--external-surface", action="store_true")
21
+ args = parser.parse_args()
22
+ run_id = str(uuid.uuid4())
23
+ mode = RuntimeMode.EXTERNAL_SURFACE if args.external_surface else RuntimeMode.HEADLESS
24
+ config = RuntimeConfig(neon_root=str(args.neon_root), mode=mode, endpoints=RuntimeEndpoints())
25
+ try:
26
+ with RuntimeSession(config):
27
+ rpc = NeonClient.connect(config.endpoints.wgpu, origin="neon3-sdk-api-probe", kind="external_host" if args.external_surface else "cli")
28
+ description = rpc.describe("wgpu-runtime")
29
+ emit(run_id, "describe", "passed", service=description.service, capabilities=list(description.capabilities), epoch=description.epoch)
30
+ renderer = RenderClient(rpc)
31
+ if args.external_surface:
32
+ surface = renderer.open_surface(SurfaceOpen("api-probe-session", "api-probe-surface", SurfaceKind.SCREEN_UI, SurfaceSize(320, 180), targets=(SurfaceTarget("api-probe-color"),)))
33
+ emit(run_id, "surface.open", "passed", descriptor=surface.descriptor)
34
+ emit(run_id, "surface.acquire", "passed", handles=surface.acquire_current_process())
35
+ emit(run_id, "surface.frame", "passed", frame=surface.frame())
36
+ world = WorldInformation("api-probe-world", 1)
37
+ world_result = renderer.configure_world(world)
38
+ emit(run_id, "world.configure", "passed", input=world.to_wire(), result=world_result)
39
+ camera = Camera3D("api-probe-camera", "api-probe-world", (0.0, 1.0, 3.0), (0.0, 0.0, 0.0, 1.0), 1.0, 0.1, 100.0, description.epoch, 1)
40
+ camera_result = renderer.submit_camera(camera)
41
+ emit(run_id, "camera.submit", "passed", input=camera.to_wire(), result=camera_result)
42
+ diagnostics = renderer.diagnostics()
43
+ graph = renderer.graph_snapshot()
44
+ emit(run_id, "render.inspect", "passed", diagnostics=diagnostics, graph=graph)
45
+ emit(run_id, "result", "passed", modes=[mode.value for mode in RuntimeMode], external_surface_contract="negotiated_only")
46
+ return 0
47
+ except (NeonError, OSError, RuntimeError, TimeoutError, ValueError) as error:
48
+ emit(run_id, "result", "failed", error_type=type(error).__name__, error=str(error))
49
+ return 1
50
+
51
+
52
+ def emit(run_id: str, stage: str, status: str, **data: object) -> None:
53
+ print(json.dumps({"run_id": run_id, "stage": stage, "status": status, **data}, ensure_ascii=True), flush=True)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ sys.exit(main())
@@ -0,0 +1,191 @@
1
+ """Run the canonical component gallery through actual local Neon3 services.
2
+
3
+ Each JSONL record includes the stable request ID or process identity needed to
4
+ correlate the Python client, UI runtime, and renderer journals.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from neon3_sdk.client import NeonClient
19
+ from neon3_sdk.errors import NeonError
20
+ from neon3_sdk.models import AssetRef
21
+ from neon3_sdk.nui import ComponentGallery
22
+ from neon3_sdk.runtime import default_neon_root
23
+
24
+
25
+ def main() -> int:
26
+ args = parse_args()
27
+ run_id = str(uuid.uuid4())
28
+ processes: list[subprocess.Popen[str]] = []
29
+ outcome = "failed"
30
+ try:
31
+ neon_root = args.neon_root.resolve()
32
+ runtime_dir = next(
33
+ (
34
+ neon_root / "target" / profile
35
+ for profile in ("release", "debug")
36
+ if all((neon_root / "target" / profile / name).is_file() for name in ("neon-eventd.exe", "neon-wgpu-runtime.exe", "neon-ui-runtime.exe"))
37
+ ),
38
+ neon_root / "target" / "release",
39
+ )
40
+ executables = {
41
+ "eventd": runtime_dir / "neon-eventd.exe",
42
+ "wgpu-runtime": runtime_dir / "neon-wgpu-runtime.exe",
43
+ "ui-runtime": runtime_dir / "neon-ui-runtime.exe",
44
+ "nui-flow-demo": runtime_dir / "nui_flow_demo.exe",
45
+ }
46
+ missing = [str(path) for path in executables.values() if not path.is_file()]
47
+ if missing:
48
+ emit(run_id, "setup", "failed", missing_binaries=missing)
49
+ return 2
50
+ endpoints = {
51
+ "eventd": args.eventd_endpoint,
52
+ "ui-runtime": args.ui_endpoint,
53
+ "wgpu-runtime": args.wgpu_endpoint,
54
+ }
55
+ processes = start_services(executables, endpoints, neon_root, run_id)
56
+ ui = wait_for_health(args.ui_endpoint, "ui-runtime", args.timeout_seconds, run_id)
57
+ wgpu = wait_for_health(args.wgpu_endpoint, "wgpu-runtime", args.timeout_seconds, run_id)
58
+ wait_for_health(args.eventd_endpoint, "eventd", args.timeout_seconds, run_id)
59
+ description = ui.describe("ui-runtime")
60
+ emit(
61
+ run_id,
62
+ "ui.describe",
63
+ "passed",
64
+ endpoint=args.ui_endpoint,
65
+ service=description.service,
66
+ epoch=description.epoch,
67
+ capabilities=list(description.capabilities),
68
+ )
69
+ gallery = ComponentGallery(neon_root, executables["nui-flow-demo"])
70
+ asset = AssetRef("sdk-probe-project", 1, 1, "image")
71
+ submission = gallery.submit(args.ui_endpoint, asset, timeout_seconds=args.timeout_seconds)
72
+ emit(
73
+ run_id,
74
+ "gallery.submit",
75
+ "passed",
76
+ endpoint=args.ui_endpoint,
77
+ source=str(gallery.source_path),
78
+ executable=str(submission.executable),
79
+ input_asset=asset.to_wire(),
80
+ return_code=submission.return_code,
81
+ )
82
+ diagnostics = wgpu.diagnostics()
83
+ graph = wgpu.call("wgpu-runtime", "wgpu.render.graph.snapshot", request_id=f"sdk-gallery-graph-{run_id}")
84
+ expected_fragment = "nui-flow-case-component-gallery"
85
+ fragment = wgpu.call(
86
+ "wgpu-runtime",
87
+ "wgpu.ui.fragment.snapshot",
88
+ {"fragment_id": expected_fragment},
89
+ request_id=f"sdk-gallery-fragment-{run_id}",
90
+ )
91
+ fragment_count = diagnostics.get("fragment_count") if isinstance(diagnostics, dict) else None
92
+ graph_data = graph.result if isinstance(graph.result, dict) else {}
93
+ fragment_data = fragment.result if isinstance(fragment.result, dict) else {}
94
+ submitted_id = fragment_data.get("fragment", {}).get("fragment_id")
95
+ passed = fragment_count is not None and fragment_count >= 1 and submitted_id == expected_fragment
96
+ emit(
97
+ run_id,
98
+ "renderer.verify",
99
+ "passed" if passed else "failed",
100
+ endpoint=args.wgpu_endpoint,
101
+ request_id=fragment.request_id,
102
+ producer={"ui_runtime_epoch": description.epoch, "nui_source": str(gallery.source_path)},
103
+ consumer={"wgpu_runtime_epoch": wgpu.health("wgpu-runtime").epoch, "fragment_count": fragment_count},
104
+ expected_fragment_id=expected_fragment,
105
+ submitted_fragment_id=submitted_id,
106
+ fragment_revision=fragment_data.get("fragment_revision"),
107
+ fragment_sequence=fragment_data.get("sequence"),
108
+ graph_revision=graph_data.get("graph_revision"),
109
+ )
110
+ if not passed:
111
+ return 1
112
+ outcome = "passed"
113
+ return 0
114
+ except (NeonError, OSError, RuntimeError, subprocess.TimeoutExpired) as error:
115
+ emit(run_id, "probe", "failed", error_type=type(error).__name__, error=str(error))
116
+ return 1
117
+ finally:
118
+ stop_services(processes, run_id)
119
+ emit(run_id, "result", outcome)
120
+
121
+
122
+ def parse_args() -> argparse.Namespace:
123
+ parser = argparse.ArgumentParser(description="Submit and verify Neon3's complete ImGui component gallery.")
124
+ parser.add_argument("--neon-root", type=Path, default=default_neon_root())
125
+ parser.add_argument("--eventd-endpoint", default="127.0.0.1:39101")
126
+ parser.add_argument("--ui-endpoint", default="127.0.0.1:39102")
127
+ parser.add_argument("--wgpu-endpoint", default="127.0.0.1:39103")
128
+ parser.add_argument("--timeout-seconds", type=float, default=15.0)
129
+ return parser.parse_args()
130
+
131
+
132
+ def start_services(executables: dict[str, Path], endpoints: dict[str, str], cwd: Path, run_id: str) -> list[subprocess.Popen[str]]:
133
+ commands = [
134
+ ("eventd", [str(executables["eventd"]), "--server", endpoints["eventd"], "1"]),
135
+ ("wgpu-runtime", [str(executables["wgpu-runtime"]), "--headless-server", endpoints["wgpu-runtime"]]),
136
+ (
137
+ "ui-runtime",
138
+ [
139
+ str(executables["ui-runtime"]),
140
+ "--forward-server",
141
+ endpoints["ui-runtime"],
142
+ endpoints["wgpu-runtime"],
143
+ "127.0.0.1:39104",
144
+ "--eventd",
145
+ endpoints["eventd"],
146
+ ],
147
+ ),
148
+ ]
149
+ processes = []
150
+ for name, command in commands:
151
+ process = subprocess.Popen(command, cwd=cwd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
152
+ processes.append(process)
153
+ emit(run_id, "service.start", "started", service=name, pid=process.pid, endpoint=endpoints.get(name))
154
+ return processes
155
+
156
+
157
+ def wait_for_health(endpoint: str, target: str, timeout_seconds: float, run_id: str) -> NeonClient:
158
+ deadline = time.monotonic() + timeout_seconds
159
+ last_error = "not attempted"
160
+ while time.monotonic() < deadline:
161
+ client = NeonClient.connect(endpoint, origin="neon3-component-gallery-probe", timeout_seconds=0.5)
162
+ try:
163
+ health = client.health(target)
164
+ if health.status == "healthy":
165
+ emit(run_id, "service.health", "passed", service=target, endpoint=endpoint, epoch=health.epoch)
166
+ return client
167
+ last_error = f"unexpected health status: {health.status}"
168
+ except NeonError as error:
169
+ last_error = str(error)
170
+ time.sleep(0.1)
171
+ raise RuntimeError(f"health timeout for {target} at {endpoint}: {last_error}")
172
+
173
+
174
+ def stop_services(processes: list[subprocess.Popen[str]], run_id: str) -> None:
175
+ for process in reversed(processes):
176
+ if process.poll() is None:
177
+ process.terminate()
178
+ try:
179
+ process.wait(timeout=3)
180
+ except subprocess.TimeoutExpired:
181
+ process.kill()
182
+ process.wait(timeout=3)
183
+ emit(run_id, "service.stop", "stopped", pid=process.pid, return_code=process.returncode)
184
+
185
+
186
+ def emit(run_id: str, stage: str, status: str, **data: Any) -> None:
187
+ print(json.dumps({"run_id": run_id, "stage": stage, "status": status, **data}, ensure_ascii=True), flush=True)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
@@ -0,0 +1,228 @@
1
+ """Python-owned calculator domain connected to the Neon3 UI runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import struct
8
+ import threading
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .client import NeonClient
15
+ from .errors import ProtocolError, RemoteError, TransportError
16
+
17
+ CALCULATOR_FLOW = (Path(__file__).with_name("fixtures") / "calculator.nui").read_text(encoding="utf-8")
18
+
19
+
20
+ @dataclass
21
+ class CalculatorState:
22
+ display: float = 0.0
23
+ accumulator: float = 0.0
24
+ pending: float = 0.0
25
+ operation: str = "add"
26
+ awaiting_operand: bool = True
27
+ revision: int = 0
28
+
29
+
30
+ class CalculatorDomain:
31
+ """Owns calculator rules and returns only typed UI input publications."""
32
+
33
+ def __init__(self) -> None:
34
+ self.state = CalculatorState()
35
+ self._lock = threading.Lock()
36
+ self._seen: dict[str, dict[str, Any]] = {}
37
+
38
+ def apply_event(self, event: dict[str, Any], program_revision: dict[str, Any], input_revision: int) -> dict[str, Any]:
39
+ event_id = event.get("event_id", "")
40
+ with self._lock:
41
+ if event_id in self._seen:
42
+ return self._seen[event_id]
43
+ intent = event.get("intent", "")
44
+ if intent.startswith("calculator.number."):
45
+ digit = {
46
+ "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4,
47
+ "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9,
48
+ }[intent.rsplit(".", 1)[1]]
49
+ self.state.display = digit if self.state.awaiting_operand else self.state.display * 10 + digit
50
+ self.state.awaiting_operand = False
51
+ elif intent == "calculator.clear":
52
+ self.state = CalculatorState()
53
+ elif intent.startswith("calculator.operator."):
54
+ if not self.state.awaiting_operand:
55
+ self.state.accumulator = (
56
+ self._calculate(self.state.accumulator, self.state.display, self.state.operation)
57
+ if self.state.pending
58
+ else self.state.display
59
+ )
60
+ self.state.pending = self.state.accumulator
61
+ self.state.operation = intent.rsplit(".", 1)[1]
62
+ self.state.awaiting_operand = True
63
+ elif intent == "calculator.equals":
64
+ if not self.state.awaiting_operand and self.state.pending:
65
+ self.state.display = self._calculate(self.state.accumulator, self.state.display, self.state.operation)
66
+ self.state.accumulator = self.state.display
67
+ self.state.pending = self.state.display
68
+ self.state.awaiting_operand = True
69
+ else:
70
+ raise ValueError(f"unsupported calculator intent: {intent}")
71
+ self.state.revision += 1
72
+ publication = self._publication(program_revision, input_revision, event_id)
73
+ self._seen[event_id] = publication
74
+ return publication
75
+
76
+ @staticmethod
77
+ def _calculate(left: float, right: float, operation: str) -> float:
78
+ if operation == "add":
79
+ return left + right
80
+ if operation == "subtract":
81
+ return left - right
82
+ if operation == "multiply":
83
+ return left * right
84
+ if operation == "divide":
85
+ if right == 0:
86
+ raise ValueError("division by zero")
87
+ return left / right
88
+ return right
89
+
90
+ def _publication(self, program_revision: dict[str, Any], input_revision: int, request_id: str) -> dict[str, Any]:
91
+ next_input_revision = input_revision + 1
92
+ return {
93
+ "scalar_frame": {
94
+ "program_revision": program_revision,
95
+ "expected_input_revision": input_revision,
96
+ "request_id": request_id,
97
+ "idempotency_key": f"calculator-input:{self.state.revision}",
98
+ "changes": [
99
+ {"key": "display", "value": {"kind": "f32", "value": self.state.display}},
100
+ {"key": "accumulator", "value": {"kind": "f32", "value": self.state.accumulator}},
101
+ {"key": "pending", "value": {"kind": "f32", "value": self.state.pending}},
102
+ {"key": "operation", "value": {"kind": "enum", "value": self.state.operation}},
103
+ ],
104
+ },
105
+ "grid_inputs": [],
106
+ "presentation_update": None,
107
+ "calculator": {"revision": self.state.revision, "input_revision": next_input_revision, "state": self.state.__dict__.copy()},
108
+ }
109
+
110
+
111
+ class CalculatorServer:
112
+ """Length-prefixed JSON Neon RPC server for the Python calculator domain."""
113
+
114
+ def __init__(self, endpoint: str, domain: CalculatorDomain | None = None) -> None:
115
+ host, port = endpoint.rsplit(":", 1)
116
+ self.endpoint = (host, int(port))
117
+ self.domain = domain or CalculatorDomain()
118
+ self._listener: socket.socket | None = None
119
+ self._stop = threading.Event()
120
+ self.ready = threading.Event()
121
+ self.start_error: Exception | None = None
122
+
123
+ def serve(self) -> None:
124
+ try:
125
+ listener = socket.socket()
126
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
127
+ listener.bind(self.endpoint)
128
+ listener.listen(16)
129
+ listener.settimeout(0.2)
130
+ self._listener = listener
131
+ self.ready.set()
132
+ while not self._stop.is_set():
133
+ try:
134
+ stream, _ = listener.accept()
135
+ except socket.timeout:
136
+ continue
137
+ threading.Thread(target=self._handle, args=(stream,), daemon=True).start()
138
+ except OSError as error:
139
+ self.start_error = error
140
+ self.ready.set()
141
+ finally:
142
+ if self._listener is not None:
143
+ self._listener.close()
144
+
145
+ def stop(self) -> None:
146
+ self._stop.set()
147
+ if self._listener is not None:
148
+ self._listener.close()
149
+
150
+ def _handle(self, stream: socket.socket) -> None:
151
+ with stream:
152
+ try:
153
+ request = json.loads(_recv_frame(stream).decode("utf-8"))
154
+ response = self._dispatch(request)
155
+ _send_frame(stream, json.dumps(response, separators=(",", ":")).encode("utf-8"))
156
+ except Exception as error:
157
+ request_id = request.get("request_id", "unknown") if isinstance(request, dict) else "unknown"
158
+ response = _response(request_id, "failed", error={"code": "calculator_error", "message": str(error)})
159
+ try:
160
+ _send_frame(stream, json.dumps(response, separators=(",", ":")).encode("utf-8"))
161
+ except OSError:
162
+ pass
163
+
164
+ def _dispatch(self, request: dict[str, Any]) -> dict[str, Any]:
165
+ request_id = request["request_id"]
166
+ method = request["method"]
167
+ if method == "service.health":
168
+ return _response(request_id, "accepted", result={"service": "calculator-python", "status": "healthy", "epoch": 1})
169
+ if method == "service.describe":
170
+ return _response(request_id, "accepted", result={"service": "calculator-python", "protocol_version": {"major": 1, "minor": 0}, "endpoint": f"{self.endpoint[0]}:{self.endpoint[1]}", "epoch": 1, "capabilities": ["calculator.evaluate.v1", "ui.host.publication.v1"]})
171
+ if method == "debug.snapshot.get":
172
+ return _response(request_id, "accepted", result={"service": "calculator-python", "epoch": 1, "revision": self.domain.state.revision, "state": self.domain.state.__dict__.copy()})
173
+ if method != "ui.host.inbound":
174
+ return _response(request_id, "rejected", error={"code": "unsupported_method", "message": "method is not supported"})
175
+ inbound = request["params"]
176
+ event = inbound["event"]
177
+ program_revision = event["program_revision"]
178
+ input_revision = event["input_revision"]
179
+ try:
180
+ publication = self.domain.apply_event(event, program_revision, input_revision)
181
+ except ValueError as error:
182
+ return _response(request_id, "rejected", error={"code": "calculator_rejected", "message": str(error)})
183
+ return _response(request_id, "accepted", revision=publication["calculator"]["input_revision"], result={k: v for k, v in publication.items() if k != "calculator"}, snapshot=publication["calculator"])
184
+
185
+
186
+ def submit_calculator(client: NeonClient, source: str = CALCULATOR_FLOW) -> dict[str, Any]:
187
+ return client.call("ui-runtime", "ui.flow.submit", {"source": source}, idempotency_key="calculator-flow-v1").result
188
+
189
+
190
+ def send_calculator_event(client: NeonClient, program_revision: dict[str, Any], input_revision: int, intent: str, source_node_key: str) -> Any:
191
+ event_id = str(uuid.uuid4())
192
+ event = {
193
+ "event_id": event_id,
194
+ "kind": "activate",
195
+ "intent": intent,
196
+ "source_node_key": source_node_key,
197
+ "payload": {},
198
+ "program_revision": program_revision,
199
+ "input_revision": input_revision,
200
+ "request_id": event_id,
201
+ "idempotency_key": f"calculator-event:{event_id}",
202
+ "interaction": {"interaction_id": event_id, "sequence": 1, "renderer_epoch": 1},
203
+ }
204
+ return client.call("ui-runtime", "ui.host.inbound", {"kind": "semantic_intent", "event": event}, idempotency_key=f"calculator-host:{event_id}")
205
+
206
+
207
+ def _response(request_id: str, status: str, *, result: Any = None, snapshot: Any = None, revision: int | None = None, error: dict[str, Any] | None = None) -> dict[str, Any]:
208
+ return {"request_id": request_id, "status": status, "revision": revision, "result": result, "snapshot": snapshot, "error": error}
209
+
210
+
211
+ def _recv_frame(stream: socket.socket) -> bytes:
212
+ header = _recv_exact(stream, 4)
213
+ size = struct.unpack(">I", header)[0]
214
+ return _recv_exact(stream, size)
215
+
216
+
217
+ def _recv_exact(stream: socket.socket, size: int) -> bytes:
218
+ data = bytearray()
219
+ while len(data) < size:
220
+ chunk = stream.recv(size - len(data))
221
+ if not chunk:
222
+ raise TransportError("connection_closed")
223
+ data.extend(chunk)
224
+ return bytes(data)
225
+
226
+
227
+ def _send_frame(stream: socket.socket, payload: bytes) -> None:
228
+ stream.sendall(struct.pack(">I", len(payload)) + payload)