Skip to content

Commit 8233eb5

Browse files
committed
wip
1 parent 2b50713 commit 8233eb5

38 files changed

Lines changed: 5008 additions & 0 deletions

‎tools/control/__init__.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Programmatic control of a running SpringBoard editor.
2+
3+
with connect(write_dir) as sb:
4+
lighting = sb.editor("lightingEditor")
5+
sun = sb.commands["SetSunParametersCommand"]
6+
...
7+
8+
`handles` is the surface a script works through, `transport` the socket and
9+
discovery underneath it. Every name is resolved against the live editor's
10+
schema when it is looked up, so a script declares its handles at the top and a
11+
typo costs a connect rather than a run.
12+
13+
See docs/design/programmatic-control.md.
14+
"""
15+
16+
from .client import Control, connect, connect_session
17+
from .errors import ControlError, UnknownNameError
18+
from .handles import Camera, Command, Commands, Dialog, Editor, FieldSpec
19+
20+
__all__ = [
21+
"Camera",
22+
"Command",
23+
"Commands",
24+
"Control",
25+
"ControlError",
26+
"Dialog",
27+
"Editor",
28+
"FieldSpec",
29+
"UnknownNameError",
30+
"connect",
31+
"connect_session",
32+
]

‎tools/control/client.py‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""A connected session: the handles, over a connection."""
2+
3+
from collections.abc import Generator, Mapping
4+
from contextlib import contextmanager
5+
from pathlib import Path
6+
from typing import Any
7+
8+
from .errors import UNKNOWN_NAME_CODE, ConnectionClosedError, UnknownNameError
9+
from .handles import Camera, Commands, Dialog, Editor, index_dialogs, index_editors
10+
from .transport import CONNECT_TIMEOUT_S, Connection, open_connection, open_replacement_connection
11+
12+
13+
class Control:
14+
"""A connected editor session.
15+
16+
`describe` is fetched once, at connect: the live editor is the only
17+
authority on what exists, since the set of editors depends on build flags
18+
and on whatever registers itself later.
19+
"""
20+
21+
def __init__(self, connection: Connection, write_dir: Path) -> None:
22+
self._connection = connection
23+
self._write_dir = write_dir
24+
schema = connection.call("describe")
25+
# Handles keep the Control facade, rather than the raw socket, so a
26+
# project reload can replace the socket without invalidating handles
27+
# that a caller declared before the reload.
28+
self._editors = index_editors(self, schema)
29+
self._dialogs = index_dialogs(self, schema)
30+
self.commands = Commands(self, schema["commands"])
31+
32+
@property
33+
def instance_id(self) -> str:
34+
return self._connection.instance_id
35+
36+
# ── Declaring handles ──
37+
38+
def editor(self, name: str) -> Editor:
39+
"""The editor registered under `name`, opened.
40+
41+
Raises immediately if there is no such editor, which is why a script
42+
declares the ones it needs before doing anything else.
43+
"""
44+
if name not in self._editors:
45+
raise UnknownNameError(
46+
UNKNOWN_NAME_CODE,
47+
f"no editor {name!r}. Editors: {', '.join(sorted(self._editors))}",
48+
)
49+
return self._editors[name].open()
50+
51+
def dialog(self, name: str) -> Dialog:
52+
"""Return the typed domain-input dialog registered under ``name``."""
53+
if name not in self._dialogs:
54+
raise UnknownNameError(
55+
UNKNOWN_NAME_CODE,
56+
f"no dialog {name!r}. Dialogs: {', '.join(sorted(self._dialogs))}",
57+
)
58+
return self._dialogs[name]
59+
60+
@property
61+
def editors(self) -> Mapping[str, Editor]:
62+
return self._editors
63+
64+
@property
65+
def dialogs(self) -> Mapping[str, Dialog]:
66+
return self._dialogs
67+
68+
@property
69+
def camera(self) -> Camera:
70+
return Camera(self)
71+
72+
# ── Calls ──
73+
74+
def echo(self, message: str, *, level: int = 0) -> None:
75+
"""Add a line to the developer console."""
76+
self.call("console.echo", message=message, level=level)
77+
78+
def capture(self, path: Path | str) -> Path:
79+
"""Capture the window. Returns once the image is on disk, and contains
80+
every call made before it on this connection."""
81+
target = Path(path).resolve()
82+
target.parent.mkdir(parents=True, exist_ok=True)
83+
self.call("capture", path=str(target))
84+
return target
85+
86+
def reload_native_modules(self) -> None:
87+
"""Reload native modules and reconnect to their replacement channel."""
88+
previous_instance_id = self.instance_id
89+
self._trigger_reload("runtime.reload_native_modules")
90+
self._reconnect_after_native_reload(previous_instance_id)
91+
92+
def reset_session(self) -> int:
93+
"""Undo native history, reload modules, and return the undo count."""
94+
previous_instance_id = self.instance_id
95+
result = self._trigger_reload("runtime.reset_session")
96+
self._reconnect_after_native_reload(previous_instance_id)
97+
return int(result.get("undone", 0))
98+
99+
def _trigger_reload(self, method: str) -> dict[str, Any]:
100+
try:
101+
return self._connection.call(method)
102+
except ConnectionClosedError:
103+
# The command can tear down this socket before its acknowledgement
104+
# reaches the client. The replacement channel is handled by the
105+
# caller; never retry the lifecycle request itself.
106+
return {}
107+
108+
def _reconnect_after_native_reload(
109+
self, previous_instance_id: str, *, timeout_s: float = CONNECT_TIMEOUT_S
110+
) -> None:
111+
self.close()
112+
self._connection = open_replacement_connection(self._write_dir, previous_instance_id, timeout_s)
113+
schema = self._connection.call("describe")
114+
self._editors = index_editors(self, schema)
115+
self._dialogs = index_dialogs(self, schema)
116+
self.commands = Commands(self, schema["commands"])
117+
118+
def wait_for_update(self) -> None:
119+
"""Wait for native input already sent to be consumed."""
120+
self.call("runtime.barrier")
121+
122+
def call(self, method: str, **params: object) -> dict[str, Any]:
123+
"""One raw call, for a method with no handle in front of it yet."""
124+
try:
125+
return self._connection.call(method, **params)
126+
except ConnectionClosedError:
127+
# A project reload replaces the native module and closes every old
128+
# socket before publishing the replacement server. Calls made after
129+
# the UI has observed the reload should transparently use that new
130+
# channel; callers should not have to know about the module lifetime.
131+
previous_instance_id = self.instance_id
132+
self._reconnect_after_native_reload(previous_instance_id, timeout_s=15.0)
133+
return self._connection.call(method, **params)
134+
135+
def close(self) -> None:
136+
self._connection.close()
137+
138+
def __enter__(self) -> "Control":
139+
return self
140+
141+
def __exit__(self, *_: object) -> None:
142+
self.close()
143+
144+
145+
@contextmanager
146+
def connect(write_dir: Path | str, timeout_s: float = CONNECT_TIMEOUT_S) -> Generator[Control]:
147+
"""Attach to the editor session whose write dir this is."""
148+
control = connect_session(Path(write_dir), timeout_s)
149+
try:
150+
yield control
151+
finally:
152+
control.close()
153+
154+
155+
def connect_session(write_dir: Path, timeout_s: float = CONNECT_TIMEOUT_S) -> Control:
156+
"""Attach without owning the lifetime, for a caller that closes it itself."""
157+
return Control(open_connection(write_dir, timeout_s), write_dir)

‎tools/control/errors.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""What a refused call raises. Shared by the transport and the handles."""
2+
3+
UNKNOWN_NAME_CODE = -32001
4+
INVALID_PARAMS_CODE = -32602
5+
6+
7+
class ControlError(RuntimeError):
8+
"""The editor refused a call."""
9+
10+
def __init__(self, code: int, message: str) -> None:
11+
super().__init__(message)
12+
self.code = code
13+
14+
15+
class ConnectionClosedError(ControlError):
16+
"""The native module ended this socket while its server was replaced."""
17+
18+
19+
class UnknownNameError(ControlError):
20+
"""An editor, field, command or option that does not exist.
21+
22+
Raised where the name is looked up, not where it is used, so a script that
23+
declares its handles at the top fails before it touches any state.
24+
"""

0 commit comments

Comments
 (0)