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.
Files changed (50) hide show
  1. tempestroid/__init__.py +198 -0
  2. tempestroid/bridge/__init__.py +35 -0
  3. tempestroid/bridge/device.py +148 -0
  4. tempestroid/bridge/handlers.py +103 -0
  5. tempestroid/bridge/jni.py +143 -0
  6. tempestroid/bridge/protocol.py +136 -0
  7. tempestroid/bridge/serializer.py +115 -0
  8. tempestroid/cli/__init__.py +27 -0
  9. tempestroid/cli/app_loader.py +114 -0
  10. tempestroid/cli/main.py +262 -0
  11. tempestroid/cli/packaging.py +212 -0
  12. tempestroid/cli/scaffold.py +178 -0
  13. tempestroid/cli/watcher.py +77 -0
  14. tempestroid/core/__init__.py +40 -0
  15. tempestroid/core/introspection.py +115 -0
  16. tempestroid/core/ir.py +131 -0
  17. tempestroid/core/reconciler.py +191 -0
  18. tempestroid/core/state.py +152 -0
  19. tempestroid/devserver/__init__.py +19 -0
  20. tempestroid/devserver/client.py +135 -0
  21. tempestroid/devserver/qr.py +34 -0
  22. tempestroid/devserver/server.py +149 -0
  23. tempestroid/native/__init__.py +11 -0
  24. tempestroid/native/dispatch.py +47 -0
  25. tempestroid/native/notifications.py +22 -0
  26. tempestroid/py.typed +0 -0
  27. tempestroid/renderers/__init__.py +9 -0
  28. tempestroid/renderers/compose/__init__.py +11 -0
  29. tempestroid/renderers/compose/style_translator.py +224 -0
  30. tempestroid/renderers/qt/__init__.py +20 -0
  31. tempestroid/renderers/qt/app_runner.py +61 -0
  32. tempestroid/renderers/qt/dev_loop.py +135 -0
  33. tempestroid/renderers/qt/renderer.py +1000 -0
  34. tempestroid/renderers/qt/simulator.py +85 -0
  35. tempestroid/renderers/qt/style_translator.py +224 -0
  36. tempestroid/style.py +466 -0
  37. tempestroid/widgets/__init__.py +82 -0
  38. tempestroid/widgets/base.py +170 -0
  39. tempestroid/widgets/button.py +25 -0
  40. tempestroid/widgets/events.py +151 -0
  41. tempestroid/widgets/indicators.py +39 -0
  42. tempestroid/widgets/inputs.py +193 -0
  43. tempestroid/widgets/layout.py +99 -0
  44. tempestroid/widgets/media.py +50 -0
  45. tempestroid/widgets/text.py +17 -0
  46. tempestroid-0.1.0.dist-info/METADATA +368 -0
  47. tempestroid-0.1.0.dist-info/RECORD +50 -0
  48. tempestroid-0.1.0.dist-info/WHEEL +4 -0
  49. tempestroid-0.1.0.dist-info/entry_points.txt +2 -0
  50. tempestroid-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)