tempestroid 0.1.0__py3-none-any.whl
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.
- tempestroid/__init__.py +198 -0
- tempestroid/bridge/__init__.py +35 -0
- tempestroid/bridge/device.py +148 -0
- tempestroid/bridge/handlers.py +103 -0
- tempestroid/bridge/jni.py +143 -0
- tempestroid/bridge/protocol.py +136 -0
- tempestroid/bridge/serializer.py +115 -0
- tempestroid/cli/__init__.py +27 -0
- tempestroid/cli/app_loader.py +114 -0
- tempestroid/cli/main.py +262 -0
- tempestroid/cli/packaging.py +212 -0
- tempestroid/cli/scaffold.py +178 -0
- tempestroid/cli/watcher.py +77 -0
- tempestroid/core/__init__.py +40 -0
- tempestroid/core/introspection.py +115 -0
- tempestroid/core/ir.py +131 -0
- tempestroid/core/reconciler.py +191 -0
- tempestroid/core/state.py +152 -0
- tempestroid/devserver/__init__.py +19 -0
- tempestroid/devserver/client.py +135 -0
- tempestroid/devserver/qr.py +34 -0
- tempestroid/devserver/server.py +149 -0
- tempestroid/native/__init__.py +11 -0
- tempestroid/native/dispatch.py +47 -0
- tempestroid/native/notifications.py +22 -0
- tempestroid/py.typed +0 -0
- tempestroid/renderers/__init__.py +9 -0
- tempestroid/renderers/compose/__init__.py +11 -0
- tempestroid/renderers/compose/style_translator.py +224 -0
- tempestroid/renderers/qt/__init__.py +20 -0
- tempestroid/renderers/qt/app_runner.py +61 -0
- tempestroid/renderers/qt/dev_loop.py +135 -0
- tempestroid/renderers/qt/renderer.py +1000 -0
- tempestroid/renderers/qt/simulator.py +85 -0
- tempestroid/renderers/qt/style_translator.py +224 -0
- tempestroid/style.py +466 -0
- tempestroid/widgets/__init__.py +82 -0
- tempestroid/widgets/base.py +170 -0
- tempestroid/widgets/button.py +25 -0
- tempestroid/widgets/events.py +151 -0
- tempestroid/widgets/indicators.py +39 -0
- tempestroid/widgets/inputs.py +193 -0
- tempestroid/widgets/layout.py +99 -0
- tempestroid/widgets/media.py +50 -0
- tempestroid/widgets/text.py +17 -0
- tempestroid-0.1.0.dist-info/METADATA +368 -0
- tempestroid-0.1.0.dist-info/RECORD +50 -0
- tempestroid-0.1.0.dist-info/WHEEL +4 -0
- tempestroid-0.1.0.dist-info/entry_points.txt +2 -0
- tempestroid-0.1.0.dist-info/licenses/LICENSE +21 -0
tempestroid/__init__.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""tempestroid — build native Android apps in typed Python.
|
|
2
|
+
|
|
3
|
+
Public surface: the typed style model, declarative widget primitives, typed
|
|
4
|
+
events, the reconciler (IR + diff), the app/state loop, and introspection. The
|
|
5
|
+
Qt simulator runner lives under ``tempestroid.renderers.qt`` (needs the ``qt``
|
|
6
|
+
extra) and is imported on demand.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
from tempestroid.bridge import (
|
|
12
|
+
Bridge,
|
|
13
|
+
DeviceApp,
|
|
14
|
+
EventMessage,
|
|
15
|
+
JniBridge,
|
|
16
|
+
LoopbackBridge,
|
|
17
|
+
MountMessage,
|
|
18
|
+
PatchMessage,
|
|
19
|
+
run_device,
|
|
20
|
+
serialize_node,
|
|
21
|
+
serialize_patch,
|
|
22
|
+
)
|
|
23
|
+
from tempestroid.core import (
|
|
24
|
+
App,
|
|
25
|
+
Insert,
|
|
26
|
+
Node,
|
|
27
|
+
Patch,
|
|
28
|
+
Path,
|
|
29
|
+
Remove,
|
|
30
|
+
Reorder,
|
|
31
|
+
Replace,
|
|
32
|
+
Update,
|
|
33
|
+
build,
|
|
34
|
+
diff,
|
|
35
|
+
event_catalog,
|
|
36
|
+
introspect,
|
|
37
|
+
widget_catalog,
|
|
38
|
+
)
|
|
39
|
+
from tempestroid.devserver import (
|
|
40
|
+
DevServer,
|
|
41
|
+
render_qr,
|
|
42
|
+
run_dev_client,
|
|
43
|
+
serve_device,
|
|
44
|
+
)
|
|
45
|
+
from tempestroid.native import notify
|
|
46
|
+
from tempestroid.renderers.compose import to_compose
|
|
47
|
+
from tempestroid.style import (
|
|
48
|
+
AlignItems,
|
|
49
|
+
Border,
|
|
50
|
+
Color,
|
|
51
|
+
Corners,
|
|
52
|
+
Curve,
|
|
53
|
+
Edge,
|
|
54
|
+
FlexDirection,
|
|
55
|
+
FontStyle,
|
|
56
|
+
FontWeight,
|
|
57
|
+
Gradient,
|
|
58
|
+
GradientDirection,
|
|
59
|
+
GradientStop,
|
|
60
|
+
JustifyContent,
|
|
61
|
+
Shadow,
|
|
62
|
+
SideBorder,
|
|
63
|
+
Style,
|
|
64
|
+
TextAlign,
|
|
65
|
+
TextDecoration,
|
|
66
|
+
TextOverflow,
|
|
67
|
+
Transition,
|
|
68
|
+
)
|
|
69
|
+
from tempestroid.widgets import (
|
|
70
|
+
Button,
|
|
71
|
+
Checkbox,
|
|
72
|
+
Column,
|
|
73
|
+
Container,
|
|
74
|
+
DateChangeEvent,
|
|
75
|
+
DatePicker,
|
|
76
|
+
Event,
|
|
77
|
+
EventHandler,
|
|
78
|
+
EventValidationError,
|
|
79
|
+
FilePicker,
|
|
80
|
+
FileSelectEvent,
|
|
81
|
+
Icon,
|
|
82
|
+
Image,
|
|
83
|
+
ImageFit,
|
|
84
|
+
Input,
|
|
85
|
+
KeyboardType,
|
|
86
|
+
ProgressBar,
|
|
87
|
+
Row,
|
|
88
|
+
ScrollView,
|
|
89
|
+
SlideEvent,
|
|
90
|
+
Slider,
|
|
91
|
+
Spinner,
|
|
92
|
+
Switch,
|
|
93
|
+
TapEvent,
|
|
94
|
+
Text,
|
|
95
|
+
TextArea,
|
|
96
|
+
TextChangeEvent,
|
|
97
|
+
ToggleEvent,
|
|
98
|
+
Widget,
|
|
99
|
+
parse_event,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
__version__ = version("tempestroid")
|
|
104
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
105
|
+
__version__ = "0.0.0"
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"__version__",
|
|
109
|
+
# Style
|
|
110
|
+
"Style",
|
|
111
|
+
"Color",
|
|
112
|
+
"Edge",
|
|
113
|
+
"Border",
|
|
114
|
+
"SideBorder",
|
|
115
|
+
"Corners",
|
|
116
|
+
"Shadow",
|
|
117
|
+
"GradientStop",
|
|
118
|
+
"Gradient",
|
|
119
|
+
"Transition",
|
|
120
|
+
"FlexDirection",
|
|
121
|
+
"JustifyContent",
|
|
122
|
+
"AlignItems",
|
|
123
|
+
"TextAlign",
|
|
124
|
+
"FontWeight",
|
|
125
|
+
"FontStyle",
|
|
126
|
+
"TextDecoration",
|
|
127
|
+
"TextOverflow",
|
|
128
|
+
"GradientDirection",
|
|
129
|
+
"Curve",
|
|
130
|
+
# Widgets
|
|
131
|
+
"Widget",
|
|
132
|
+
"Text",
|
|
133
|
+
"Button",
|
|
134
|
+
"Column",
|
|
135
|
+
"Row",
|
|
136
|
+
"Container",
|
|
137
|
+
"ScrollView",
|
|
138
|
+
"Input",
|
|
139
|
+
"TextArea",
|
|
140
|
+
"Checkbox",
|
|
141
|
+
"Switch",
|
|
142
|
+
"Slider",
|
|
143
|
+
"KeyboardType",
|
|
144
|
+
"DatePicker",
|
|
145
|
+
"FilePicker",
|
|
146
|
+
"Image",
|
|
147
|
+
"ImageFit",
|
|
148
|
+
"Icon",
|
|
149
|
+
"ProgressBar",
|
|
150
|
+
"Spinner",
|
|
151
|
+
"EventHandler",
|
|
152
|
+
# Events (typed boundary contract)
|
|
153
|
+
"Event",
|
|
154
|
+
"TapEvent",
|
|
155
|
+
"TextChangeEvent",
|
|
156
|
+
"ToggleEvent",
|
|
157
|
+
"SlideEvent",
|
|
158
|
+
"DateChangeEvent",
|
|
159
|
+
"FileSelectEvent",
|
|
160
|
+
"EventValidationError",
|
|
161
|
+
"parse_event",
|
|
162
|
+
# Core (IR + reconciler)
|
|
163
|
+
"Path",
|
|
164
|
+
"Node",
|
|
165
|
+
"Replace",
|
|
166
|
+
"Update",
|
|
167
|
+
"Insert",
|
|
168
|
+
"Remove",
|
|
169
|
+
"Reorder",
|
|
170
|
+
"Patch",
|
|
171
|
+
"build",
|
|
172
|
+
"diff",
|
|
173
|
+
"App",
|
|
174
|
+
# Introspection
|
|
175
|
+
"introspect",
|
|
176
|
+
"widget_catalog",
|
|
177
|
+
"event_catalog",
|
|
178
|
+
# Compose renderer (Python side, phase B4)
|
|
179
|
+
"to_compose",
|
|
180
|
+
# Bridge (Python↔Kotlin boundary, phase B3)
|
|
181
|
+
"Bridge",
|
|
182
|
+
"LoopbackBridge",
|
|
183
|
+
"JniBridge",
|
|
184
|
+
"run_device",
|
|
185
|
+
"DeviceApp",
|
|
186
|
+
"MountMessage",
|
|
187
|
+
"PatchMessage",
|
|
188
|
+
"EventMessage",
|
|
189
|
+
"serialize_node",
|
|
190
|
+
"serialize_patch",
|
|
191
|
+
# Dev server (LAN code-push, phase B5)
|
|
192
|
+
"DevServer",
|
|
193
|
+
"run_dev_client",
|
|
194
|
+
"serve_device",
|
|
195
|
+
"render_qr",
|
|
196
|
+
# Native capabilities (phase B6)
|
|
197
|
+
"notify",
|
|
198
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Python↔Kotlin boundary: serialization, handler dispatch, and transport.
|
|
2
|
+
|
|
3
|
+
Carries the reconciler's IR/patches to the device renderer and validated events
|
|
4
|
+
back. The real device transport is a hand-rolled JNI shim (phase B3, Kotlin
|
|
5
|
+
side); this package is the transport-agnostic Python half, fully testable without
|
|
6
|
+
a device via :class:`LoopbackBridge`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from tempestroid.bridge.device import Bridge, DeviceApp, LoopbackBridge
|
|
10
|
+
from tempestroid.bridge.handlers import HandlerRegistry
|
|
11
|
+
from tempestroid.bridge.jni import JniBridge, run_device
|
|
12
|
+
from tempestroid.bridge.protocol import (
|
|
13
|
+
EventMessage,
|
|
14
|
+
MountMessage,
|
|
15
|
+
PatchMessage,
|
|
16
|
+
event_type_for,
|
|
17
|
+
handler_token,
|
|
18
|
+
)
|
|
19
|
+
from tempestroid.bridge.serializer import serialize_node, serialize_patch
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Bridge",
|
|
23
|
+
"LoopbackBridge",
|
|
24
|
+
"JniBridge",
|
|
25
|
+
"run_device",
|
|
26
|
+
"DeviceApp",
|
|
27
|
+
"HandlerRegistry",
|
|
28
|
+
"MountMessage",
|
|
29
|
+
"PatchMessage",
|
|
30
|
+
"EventMessage",
|
|
31
|
+
"handler_token",
|
|
32
|
+
"event_type_for",
|
|
33
|
+
"serialize_node",
|
|
34
|
+
"serialize_patch",
|
|
35
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Wire an ``App`` to a device over an abstract transport.
|
|
2
|
+
|
|
3
|
+
``DeviceApp`` is the device-side analogue of ``run_qt``: it owns an ``App`` plus a
|
|
4
|
+
:class:`HandlerRegistry`, serializes the initial tree and every patch batch onto
|
|
5
|
+
a :class:`Bridge`, and feeds incoming events back through the registry (which may
|
|
6
|
+
call ``app.set_state`` and trigger another patch batch).
|
|
7
|
+
|
|
8
|
+
The :class:`Bridge` is transport-agnostic. The real device transport is the JNI
|
|
9
|
+
shim (phase B3, Kotlin side); :class:`LoopbackBridge` is an in-memory transport
|
|
10
|
+
for tests and local wiring.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
import asyncio
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any, Generic, TypeVar
|
|
19
|
+
|
|
20
|
+
from tempestroid.bridge.handlers import HandlerRegistry
|
|
21
|
+
from tempestroid.bridge.protocol import EventMessage, MountMessage, PatchMessage
|
|
22
|
+
from tempestroid.bridge.serializer import serialize_node, serialize_patch
|
|
23
|
+
from tempestroid.core.ir import Patch
|
|
24
|
+
from tempestroid.core.state import App
|
|
25
|
+
from tempestroid.widgets import Widget
|
|
26
|
+
|
|
27
|
+
__all__ = ["Bridge", "LoopbackBridge", "DeviceApp"]
|
|
28
|
+
|
|
29
|
+
S = TypeVar("S")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Bridge(abc.ABC):
|
|
33
|
+
"""A transport that carries serialized messages to the device."""
|
|
34
|
+
|
|
35
|
+
@abc.abstractmethod
|
|
36
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
37
|
+
"""Send one serialized message to the device.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
message: A JSON-able message dict (``mount`` / ``patch``).
|
|
41
|
+
"""
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LoopbackBridge(Bridge):
|
|
46
|
+
"""In-memory bridge that records sent messages (for tests/local wiring)."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
"""Create a loopback bridge with an empty outbox."""
|
|
50
|
+
self.sent: list[dict[str, Any]] = []
|
|
51
|
+
|
|
52
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
53
|
+
"""Record a sent message.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
message: The message dict.
|
|
57
|
+
"""
|
|
58
|
+
self.sent.append(message)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DeviceApp(Generic[S]):
|
|
62
|
+
"""Runs an ``App`` against a device :class:`Bridge`.
|
|
63
|
+
|
|
64
|
+
Type Args:
|
|
65
|
+
S: The application state type.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
state: S,
|
|
71
|
+
view: Callable[[App[S]], Widget],
|
|
72
|
+
bridge: Bridge,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Initialize the device app.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
state: The initial application state.
|
|
78
|
+
view: Builds the widget tree from the app.
|
|
79
|
+
bridge: The transport to the device.
|
|
80
|
+
"""
|
|
81
|
+
self._bridge: Bridge = bridge
|
|
82
|
+
self._registry: HandlerRegistry = HandlerRegistry()
|
|
83
|
+
self._app: App[S] = App(state, view, apply_patches=self._on_patches)
|
|
84
|
+
# Strong refs to in-flight send tasks so the loop does not GC them.
|
|
85
|
+
self._pending: set[asyncio.Task[None]] = set()
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def app(self) -> App[S]:
|
|
89
|
+
"""The wrapped app.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The app.
|
|
93
|
+
"""
|
|
94
|
+
return self._app
|
|
95
|
+
|
|
96
|
+
async def start(self) -> None:
|
|
97
|
+
"""Build the initial tree, register handlers, and send the mount message."""
|
|
98
|
+
root = self._app.start()
|
|
99
|
+
self._registry.refresh(root)
|
|
100
|
+
await self._bridge.send(MountMessage(root=serialize_node(root)).model_dump())
|
|
101
|
+
|
|
102
|
+
def reload(self, view: Callable[[App[S]], Widget]) -> None:
|
|
103
|
+
"""Hot-reload the view, preserving state and patching the device.
|
|
104
|
+
|
|
105
|
+
Swaps the running app's view via :meth:`App.swap_view` (which diffs the
|
|
106
|
+
new tree against the live one and pushes the resulting patch batch over
|
|
107
|
+
the bridge through :meth:`_on_patches`), then refreshes the handler
|
|
108
|
+
registry so taps resolve against the reloaded closures even when the tree
|
|
109
|
+
was structurally unchanged.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
view: The reloaded view function.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
Exception: Whatever the new view/build raises — the swap is rolled
|
|
116
|
+
back. The caller (code-push client) falls back to a clean restart.
|
|
117
|
+
"""
|
|
118
|
+
self._app.swap_view(view)
|
|
119
|
+
self._registry.refresh(self._app.current_tree)
|
|
120
|
+
|
|
121
|
+
async def handle_event(self, message: dict[str, Any]) -> None:
|
|
122
|
+
"""Process an event coming back from the device.
|
|
123
|
+
|
|
124
|
+
Validates and dispatches via the registry; any resulting ``set_state``
|
|
125
|
+
schedules a coalesced rebuild whose patches are sent on the next tick.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
message: A serialized :class:`EventMessage` dict.
|
|
129
|
+
"""
|
|
130
|
+
event = EventMessage.model_validate(message)
|
|
131
|
+
await self._registry.dispatch(event.token, event.payload)
|
|
132
|
+
|
|
133
|
+
def _on_patches(self, patches: list[Patch]) -> None:
|
|
134
|
+
"""Refresh handlers from the new tree and send the patch batch.
|
|
135
|
+
|
|
136
|
+
Called by ``App`` after a rebuild (sync, on the loop). The send is
|
|
137
|
+
scheduled as a task since the bridge is async.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
patches: The patches produced by the reconciler.
|
|
141
|
+
"""
|
|
142
|
+
self._registry.refresh(self._app.current_tree)
|
|
143
|
+
message = PatchMessage(
|
|
144
|
+
patches=[serialize_patch(p) for p in patches]
|
|
145
|
+
).model_dump()
|
|
146
|
+
task = asyncio.get_running_loop().create_task(self._bridge.send(message))
|
|
147
|
+
self._pending.add(task)
|
|
148
|
+
task.add_done_callback(self._pending.discard)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Handler registry: map tokens to live callables and dispatch typed events.
|
|
2
|
+
|
|
3
|
+
The serializer sends handler **tokens** to the device. When the device sends an
|
|
4
|
+
event back, this registry resolves the token to the current Python callable,
|
|
5
|
+
**validates the payload** against the widget's declared event type (the A6
|
|
6
|
+
boundary contract), then invokes the handler. It is refreshed from the current
|
|
7
|
+
tree on every rebuild, so tokens always resolve to the latest callables.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from tempestroid.bridge.protocol import event_type_for, handler_token
|
|
17
|
+
from tempestroid.core.ir import Node
|
|
18
|
+
from tempestroid.widgets import Event, handler_accepts_event, parse_event
|
|
19
|
+
|
|
20
|
+
__all__ = ["HandlerRegistry"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _invoke(handler: Callable[..., Any], event: Event | None) -> Any: # noqa: ANN401 — handler return is arbitrary (a value or a coroutine to await)
|
|
24
|
+
"""Call a handler, passing the typed event only if it accepts one.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
handler: The resolved handler callable.
|
|
28
|
+
event: The validated event, or ``None`` when the widget emits no typed
|
|
29
|
+
event.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The handler's return value (possibly a coroutine to await).
|
|
33
|
+
"""
|
|
34
|
+
if event is not None and handler_accepts_event(handler):
|
|
35
|
+
return handler(event)
|
|
36
|
+
return handler()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HandlerRegistry:
|
|
40
|
+
"""Resolves handler tokens to callables and dispatches validated events."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
"""Create an empty registry."""
|
|
44
|
+
self._handlers: dict[str, tuple[Callable[[], Any], type[Event] | None]] = {}
|
|
45
|
+
|
|
46
|
+
def refresh(self, root: Node | None) -> None:
|
|
47
|
+
"""Rebuild the token→handler map by walking the current tree.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
root: The current root node, or ``None`` to clear.
|
|
51
|
+
"""
|
|
52
|
+
self._handlers.clear()
|
|
53
|
+
if root is not None:
|
|
54
|
+
self._walk(root, ())
|
|
55
|
+
|
|
56
|
+
def _walk(self, node: Node, path: tuple[int, ...]) -> None:
|
|
57
|
+
"""Register every handler prop on ``node`` and recurse.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
node: The node to inspect.
|
|
61
|
+
path: The node's path from the root.
|
|
62
|
+
"""
|
|
63
|
+
for name, value in node.props.items():
|
|
64
|
+
if callable(value):
|
|
65
|
+
token = handler_token(path, name)
|
|
66
|
+
self._handlers[token] = (value, event_type_for(node.type, name))
|
|
67
|
+
for index, child in enumerate(node.children):
|
|
68
|
+
self._walk(child, path + (index,))
|
|
69
|
+
|
|
70
|
+
async def dispatch(self, token: str, payload: dict[str, Any]) -> bool:
|
|
71
|
+
"""Validate a payload and invoke the handler for ``token``.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
token: The handler token from an :class:`EventMessage`.
|
|
75
|
+
payload: The raw event payload.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
``True`` if a handler was found and invoked, ``False`` otherwise.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
EventValidationError: If the payload fails validation for the
|
|
82
|
+
handler's declared event type.
|
|
83
|
+
"""
|
|
84
|
+
entry = self._handlers.get(token)
|
|
85
|
+
if entry is None:
|
|
86
|
+
return False
|
|
87
|
+
handler, event_type = entry
|
|
88
|
+
event: Event | None = None
|
|
89
|
+
if event_type is not None:
|
|
90
|
+
# Validate at the boundary before entering the handler (A6 contract).
|
|
91
|
+
event = parse_event(event_type, payload)
|
|
92
|
+
result = _invoke(handler, event)
|
|
93
|
+
if inspect.iscoroutine(result):
|
|
94
|
+
await result
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def tokens(self) -> list[str]:
|
|
98
|
+
"""Return the currently registered tokens (for inspection/tests).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The registered handler tokens.
|
|
102
|
+
"""
|
|
103
|
+
return list(self._handlers)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Device transport over the hand-rolled JNI bridge (phase B3).
|
|
2
|
+
|
|
3
|
+
This is the Python half of the on-device bridge. It plugs a :class:`JniBridge`
|
|
4
|
+
(which ships serialized ``mount``/``patch`` messages to Kotlin) and an incoming
|
|
5
|
+
event sink (which feeds device taps/text back into the :class:`DeviceApp`) into
|
|
6
|
+
one asyncio loop.
|
|
7
|
+
|
|
8
|
+
The native module ``_tempest_host`` is provided by ``libtempest_host.so`` only
|
|
9
|
+
inside the Android app (it registers itself via ``PyImport_AppendInittab`` before
|
|
10
|
+
the interpreter boots). It is therefore imported **lazily**, so this module
|
|
11
|
+
imports cleanly on the desktop (where the framework is developed and tested) and
|
|
12
|
+
only requires the native side when :func:`run_device` actually runs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any, Protocol, TypeVar, cast
|
|
21
|
+
|
|
22
|
+
from tempestroid.bridge.device import Bridge, DeviceApp
|
|
23
|
+
from tempestroid.core.state import App
|
|
24
|
+
from tempestroid.widgets import Widget
|
|
25
|
+
|
|
26
|
+
__all__ = ["JniBridge", "run_device", "run_device_file"]
|
|
27
|
+
|
|
28
|
+
S = TypeVar("S")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _NativeHost(Protocol):
|
|
32
|
+
"""The surface ``_tempest_host`` exposes to Python (see ``tempest_host.c``)."""
|
|
33
|
+
|
|
34
|
+
def send_to_host(self, message_json: str) -> None:
|
|
35
|
+
"""Hand a serialized message to Kotlin (Python → host)."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def set_event_sink(self, sink: Callable[[str, str], None]) -> None:
|
|
39
|
+
"""Register the callable the host invokes on a device event (host → Python)."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def native_host() -> _NativeHost:
|
|
44
|
+
"""Import and return the native ``_tempest_host`` module.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The native host module.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
RuntimeError: If imported off-device, where the module is absent.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
import _tempest_host # type: ignore[import-not-found]
|
|
54
|
+
except ImportError as exc: # pragma: no cover - desktop path
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
"_tempest_host is unavailable; run_device only works inside the "
|
|
57
|
+
"Android host (libtempest_host.so registers the module)."
|
|
58
|
+
) from exc
|
|
59
|
+
# The native module has no type stub; trust it implements the Protocol.
|
|
60
|
+
return cast(_NativeHost, _tempest_host)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class JniBridge(Bridge):
|
|
64
|
+
"""A :class:`Bridge` that ships messages to Kotlin via the native host.
|
|
65
|
+
|
|
66
|
+
Each message is JSON-encoded and handed to ``_tempest_host.send_to_host``,
|
|
67
|
+
which marshals it across JNI to ``PythonRuntime.onMessageFromPython`` on the
|
|
68
|
+
Kotlin side (the Compose renderer consumes it in phase B4).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
"""Bind the bridge to the native host module."""
|
|
73
|
+
self._host: _NativeHost = native_host()
|
|
74
|
+
|
|
75
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
76
|
+
"""Serialize and ship one message to the host.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
message: A JSON-able message dict (``mount`` / ``patch``).
|
|
80
|
+
"""
|
|
81
|
+
self._host.send_to_host(json.dumps(message))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_device(state: S, view: Callable[[App[S]], Widget]) -> None:
|
|
85
|
+
"""Boot a :class:`DeviceApp` on a fresh asyncio loop and run it forever.
|
|
86
|
+
|
|
87
|
+
This is the device-side analogue of ``run_qt``: it owns the loop, sends the
|
|
88
|
+
initial ``mount`` over a :class:`JniBridge`, registers the native event sink
|
|
89
|
+
(which marshals incoming device events back onto the loop), and blocks in
|
|
90
|
+
``run_forever``. Call it from the interpreter's main thread inside the host —
|
|
91
|
+
the Kotlin side already runs the interpreter off the UI thread.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
state: The initial application state.
|
|
95
|
+
view: Builds the widget tree from the app.
|
|
96
|
+
"""
|
|
97
|
+
loop = asyncio.new_event_loop()
|
|
98
|
+
asyncio.set_event_loop(loop)
|
|
99
|
+
device: DeviceApp[S] = DeviceApp(state, view, JniBridge())
|
|
100
|
+
|
|
101
|
+
def _on_event(token: str, payload_json: str) -> None:
|
|
102
|
+
"""Native callback: schedule an incoming event onto the loop.
|
|
103
|
+
|
|
104
|
+
Invoked by the host from the UI thread (with the GIL held), so it only
|
|
105
|
+
hands work to the loop via ``call_soon_threadsafe`` and returns fast.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
token: The handler token addressed by the event.
|
|
109
|
+
payload_json: The raw JSON payload (``""`` for none).
|
|
110
|
+
"""
|
|
111
|
+
payload: dict[str, Any] = json.loads(payload_json) if payload_json else {}
|
|
112
|
+
message: dict[str, Any] = {
|
|
113
|
+
"kind": "event",
|
|
114
|
+
"token": token,
|
|
115
|
+
"payload": payload,
|
|
116
|
+
}
|
|
117
|
+
loop.call_soon_threadsafe(
|
|
118
|
+
lambda: loop.create_task(device.handle_event(message))
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
native_host().set_event_sink(_on_event)
|
|
122
|
+
loop.create_task(device.start())
|
|
123
|
+
loop.run_forever()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_device_file(path: str) -> None:
|
|
127
|
+
"""Load an app file (``make_state`` + ``view``) and run it on the device.
|
|
128
|
+
|
|
129
|
+
The device entry point for an APK bundled by ``tempest build``: the user's
|
|
130
|
+
app source is packaged as an asset, extracted to ``path`` on first launch,
|
|
131
|
+
and run here. Mirrors :func:`run_device` but sources the state/view from a
|
|
132
|
+
file via the same loader the dev cockpit and code-push client use.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
path: Absolute path to the extracted app file on the device.
|
|
136
|
+
"""
|
|
137
|
+
from pathlib import Path
|
|
138
|
+
|
|
139
|
+
from tempestroid.cli.app_loader import spec_from_source
|
|
140
|
+
|
|
141
|
+
source = Path(path).read_text(encoding="utf-8")
|
|
142
|
+
spec = spec_from_source(source, filename=path)
|
|
143
|
+
run_device(spec.make_state(), spec.view)
|