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.
- neon3_sdk-0.1.1/PKG-INFO +50 -0
- neon3_sdk-0.1.1/README.md +38 -0
- neon3_sdk-0.1.1/pyproject.toml +31 -0
- neon3_sdk-0.1.1/setup.cfg +4 -0
- neon3_sdk-0.1.1/src/neon3_sdk/__init__.py +55 -0
- neon3_sdk-0.1.1/src/neon3_sdk/__main__.py +3 -0
- neon3_sdk-0.1.1/src/neon3_sdk/bin/__init__.py +1 -0
- neon3_sdk-0.1.1/src/neon3_sdk/bin/api_contract_probe.py +57 -0
- neon3_sdk-0.1.1/src/neon3_sdk/bin/component_gallery_probe.py +191 -0
- neon3_sdk-0.1.1/src/neon3_sdk/calculator.py +228 -0
- neon3_sdk-0.1.1/src/neon3_sdk/cli.py +277 -0
- neon3_sdk-0.1.1/src/neon3_sdk/client.py +144 -0
- neon3_sdk-0.1.1/src/neon3_sdk/errors.py +29 -0
- neon3_sdk-0.1.1/src/neon3_sdk/event.py +177 -0
- neon3_sdk-0.1.1/src/neon3_sdk/fixtures/calculator.nui +32 -0
- neon3_sdk-0.1.1/src/neon3_sdk/input.py +42 -0
- neon3_sdk-0.1.1/src/neon3_sdk/models.py +136 -0
- neon3_sdk-0.1.1/src/neon3_sdk/nui.py +52 -0
- neon3_sdk-0.1.1/src/neon3_sdk/render.py +232 -0
- neon3_sdk-0.1.1/src/neon3_sdk/runtime.py +125 -0
- neon3_sdk-0.1.1/src/neon3_sdk/ui.py +50 -0
- neon3_sdk-0.1.1/src/neon3_sdk.egg-info/PKG-INFO +50 -0
- neon3_sdk-0.1.1/src/neon3_sdk.egg-info/SOURCES.txt +25 -0
- neon3_sdk-0.1.1/src/neon3_sdk.egg-info/dependency_links.txt +1 -0
- neon3_sdk-0.1.1/src/neon3_sdk.egg-info/entry_points.txt +4 -0
- neon3_sdk-0.1.1/src/neon3_sdk.egg-info/top_level.txt +1 -0
- neon3_sdk-0.1.1/tests/test_client.py +175 -0
neon3_sdk-0.1.1/PKG-INFO
ADDED
|
@@ -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,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 @@
|
|
|
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)
|