androidctl 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 (187) hide show
  1. androidctl/__init__.py +5 -0
  2. androidctl/__main__.py +4 -0
  3. androidctl/_version.py +1 -0
  4. androidctl/app.py +73 -0
  5. androidctl/cli_options.py +27 -0
  6. androidctl/command_payloads.py +264 -0
  7. androidctl/command_views.py +157 -0
  8. androidctl/commands/__init__.py +1 -0
  9. androidctl/commands/actions.py +236 -0
  10. androidctl/commands/adb_wireless.py +157 -0
  11. androidctl/commands/close.py +30 -0
  12. androidctl/commands/connect.py +69 -0
  13. androidctl/commands/execute.py +179 -0
  14. androidctl/commands/list_apps.py +26 -0
  15. androidctl/commands/observe.py +26 -0
  16. androidctl/commands/open.py +41 -0
  17. androidctl/commands/plumbing.py +58 -0
  18. androidctl/commands/run_pipeline.py +307 -0
  19. androidctl/commands/screenshot.py +29 -0
  20. androidctl/commands/setup.py +301 -0
  21. androidctl/commands/wait.py +60 -0
  22. androidctl/daemon/__init__.py +1 -0
  23. androidctl/daemon/client.py +348 -0
  24. androidctl/daemon/discovery.py +190 -0
  25. androidctl/daemon/launcher.py +26 -0
  26. androidctl/daemon/owner.py +349 -0
  27. androidctl/errors/__init__.py +1 -0
  28. androidctl/errors/mapping.py +149 -0
  29. androidctl/errors/models.py +16 -0
  30. androidctl/exit_codes.py +8 -0
  31. androidctl/output.py +147 -0
  32. androidctl/parsing/__init__.py +1 -0
  33. androidctl/parsing/duration.py +17 -0
  34. androidctl/parsing/open_target.py +51 -0
  35. androidctl/parsing/refs.py +12 -0
  36. androidctl/parsing/screen_id.py +10 -0
  37. androidctl/parsing/wait.py +70 -0
  38. androidctl/renderers/__init__.py +110 -0
  39. androidctl/renderers/_paths.py +109 -0
  40. androidctl/renderers/xml.py +234 -0
  41. androidctl/renderers/xml_projection.py +732 -0
  42. androidctl/resources/__init__.py +1 -0
  43. androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
  44. androidctl/setup/__init__.py +1 -0
  45. androidctl/setup/accessibility.py +159 -0
  46. androidctl/setup/adb.py +586 -0
  47. androidctl/setup/apk_resource.py +29 -0
  48. androidctl/setup/pairing.py +70 -0
  49. androidctl/setup/verify.py +175 -0
  50. androidctl/workspace/__init__.py +3 -0
  51. androidctl/workspace/resolve.py +27 -0
  52. androidctl-0.1.0.dist-info/METADATA +217 -0
  53. androidctl-0.1.0.dist-info/RECORD +187 -0
  54. androidctl-0.1.0.dist-info/WHEEL +5 -0
  55. androidctl-0.1.0.dist-info/entry_points.txt +3 -0
  56. androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
  57. androidctl-0.1.0.dist-info/top_level.txt +3 -0
  58. androidctl_contracts/__init__.py +55 -0
  59. androidctl_contracts/_version.py +1 -0
  60. androidctl_contracts/_wire_helpers.py +31 -0
  61. androidctl_contracts/base.py +142 -0
  62. androidctl_contracts/command_catalog.py +414 -0
  63. androidctl_contracts/command_results.py +630 -0
  64. androidctl_contracts/daemon_api.py +335 -0
  65. androidctl_contracts/errors.py +44 -0
  66. androidctl_contracts/paths.py +5 -0
  67. androidctl_contracts/public_screen.py +579 -0
  68. androidctl_contracts/user_state.py +23 -0
  69. androidctl_contracts/vocabulary.py +82 -0
  70. androidctld/__init__.py +5 -0
  71. androidctld/__main__.py +63 -0
  72. androidctld/_version.py +1 -0
  73. androidctld/actions/__init__.py +1 -0
  74. androidctld/actions/action_target.py +142 -0
  75. androidctld/actions/capabilities.py +539 -0
  76. androidctld/actions/executor.py +894 -0
  77. androidctld/actions/focus_confirmation.py +177 -0
  78. androidctld/actions/focused_input_admissibility.py +120 -0
  79. androidctld/actions/fresh_current.py +176 -0
  80. androidctld/actions/postconditions.py +473 -0
  81. androidctld/actions/repair.py +101 -0
  82. androidctld/actions/request_builder.py +204 -0
  83. androidctld/actions/settle.py +146 -0
  84. androidctld/actions/submit_confirmation.py +211 -0
  85. androidctld/actions/submit_routing.py +311 -0
  86. androidctld/actions/type_confirmation.py +257 -0
  87. androidctld/app_targets.py +71 -0
  88. androidctld/artifacts/__init__.py +1 -0
  89. androidctld/artifacts/models.py +26 -0
  90. androidctld/artifacts/screen_lookup.py +241 -0
  91. androidctld/artifacts/screen_payloads.py +109 -0
  92. androidctld/artifacts/writer.py +286 -0
  93. androidctld/auth/__init__.py +1 -0
  94. androidctld/auth/active_registry.py +266 -0
  95. androidctld/auth/secret_files.py +52 -0
  96. androidctld/auth/token_store.py +59 -0
  97. androidctld/commands/__init__.py +1 -0
  98. androidctld/commands/assembly.py +231 -0
  99. androidctld/commands/command_models.py +254 -0
  100. androidctld/commands/dispatch.py +99 -0
  101. androidctld/commands/executor.py +31 -0
  102. androidctld/commands/from_boundary.py +175 -0
  103. androidctld/commands/handlers/__init__.py +15 -0
  104. androidctld/commands/handlers/action.py +439 -0
  105. androidctld/commands/handlers/connect.py +94 -0
  106. androidctld/commands/handlers/list_apps.py +215 -0
  107. androidctld/commands/handlers/observe.py +121 -0
  108. androidctld/commands/handlers/screenshot.py +105 -0
  109. androidctld/commands/handlers/wait.py +286 -0
  110. androidctld/commands/models.py +65 -0
  111. androidctld/commands/open_targets.py +56 -0
  112. androidctld/commands/orchestration.py +353 -0
  113. androidctld/commands/registry.py +116 -0
  114. androidctld/commands/result_builders.py +40 -0
  115. androidctld/commands/result_models.py +555 -0
  116. androidctld/commands/results.py +108 -0
  117. androidctld/commands/semantic_command_names.py +17 -0
  118. androidctld/commands/semantic_error_mapping.py +93 -0
  119. androidctld/commands/semantic_truth.py +135 -0
  120. androidctld/commands/service.py +67 -0
  121. androidctld/config.py +75 -0
  122. androidctld/daemon/__init__.py +1 -0
  123. androidctld/daemon/active_slot.py +326 -0
  124. androidctld/daemon/envelope.py +30 -0
  125. androidctld/daemon/http_host.py +123 -0
  126. androidctld/daemon/ingress.py +112 -0
  127. androidctld/daemon/ownership_probe.py +204 -0
  128. androidctld/daemon/server.py +286 -0
  129. androidctld/daemon/service.py +99 -0
  130. androidctld/device/__init__.py +1 -0
  131. androidctld/device/action_models.py +154 -0
  132. androidctld/device/action_serialization.py +121 -0
  133. androidctld/device/adapters.py +220 -0
  134. androidctld/device/bootstrap.py +153 -0
  135. androidctld/device/connectors.py +231 -0
  136. androidctld/device/errors.py +100 -0
  137. androidctld/device/interfaces.py +58 -0
  138. androidctld/device/parsing.py +320 -0
  139. androidctld/device/rpc.py +483 -0
  140. androidctld/device/schema.py +114 -0
  141. androidctld/device/types.py +161 -0
  142. androidctld/errors/__init__.py +94 -0
  143. androidctld/logging/__init__.py +22 -0
  144. androidctld/observation.py +98 -0
  145. androidctld/protocol.py +53 -0
  146. androidctld/refs/__init__.py +1 -0
  147. androidctld/refs/models.py +54 -0
  148. androidctld/refs/repair.py +284 -0
  149. androidctld/refs/service.py +422 -0
  150. androidctld/rendering/__init__.py +1 -0
  151. androidctld/rendering/screen_xml.py +256 -0
  152. androidctld/runtime/__init__.py +21 -0
  153. androidctld/runtime/kernel.py +548 -0
  154. androidctld/runtime/lifecycle.py +19 -0
  155. androidctld/runtime/models.py +48 -0
  156. androidctld/runtime/screen_state.py +117 -0
  157. androidctld/runtime/state_repo.py +70 -0
  158. androidctld/runtime/store.py +76 -0
  159. androidctld/runtime_policy.py +127 -0
  160. androidctld/schema/__init__.py +5 -0
  161. androidctld/schema/base.py +132 -0
  162. androidctld/schema/core.py +35 -0
  163. androidctld/schema/daemon_api.py +108 -0
  164. androidctld/schema/persistence.py +161 -0
  165. androidctld/schema/persistence_io.py +41 -0
  166. androidctld/schema/validation_errors.py +309 -0
  167. androidctld/semantics/__init__.py +1 -0
  168. androidctld/semantics/compiler.py +610 -0
  169. androidctld/semantics/continuity.py +107 -0
  170. androidctld/semantics/labels.py +252 -0
  171. androidctld/semantics/models.py +25 -0
  172. androidctld/semantics/policy.py +23 -0
  173. androidctld/semantics/public_models.py +123 -0
  174. androidctld/semantics/registries.py +13 -0
  175. androidctld/semantics/submit_refs.py +417 -0
  176. androidctld/semantics/surface.py +254 -0
  177. androidctld/semantics/targets.py +167 -0
  178. androidctld/snapshots/__init__.py +1 -0
  179. androidctld/snapshots/models.py +219 -0
  180. androidctld/snapshots/refresh.py +273 -0
  181. androidctld/snapshots/schema.py +74 -0
  182. androidctld/snapshots/service.py +138 -0
  183. androidctld/text_equivalence.py +67 -0
  184. androidctld/waits/__init__.py +1 -0
  185. androidctld/waits/evaluators.py +216 -0
  186. androidctld/waits/loop.py +305 -0
  187. androidctld/waits/matcher.py +41 -0
@@ -0,0 +1,353 @@
1
+ """Command run orchestration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable
7
+ from contextlib import AbstractContextManager, nullcontext
8
+ from contextvars import ContextVar
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from typing import Any, TypeAlias
12
+
13
+ from androidctl_contracts.command_catalog import (
14
+ CommandCatalogEntry,
15
+ entry_for_daemon_kind,
16
+ entry_for_result_command,
17
+ runtime_close_entry,
18
+ )
19
+ from androidctl_contracts.command_results import (
20
+ CommandResultCore,
21
+ ListAppsResult,
22
+ RetainedResultEnvelope,
23
+ dump_canonical_command_result,
24
+ )
25
+ from androidctl_contracts.vocabulary import PublicResultFamily
26
+ from androidctld.commands.command_models import InternalCommand
27
+ from androidctld.commands.models import CommandRecord, CommandStatus
28
+ from androidctld.commands.registry import CommandSpec, resolve_command_spec
29
+ from androidctld.commands.result_models import (
30
+ build_projected_retained_failure_result,
31
+ build_retained_success_result,
32
+ dump_retained_result_envelope,
33
+ )
34
+ from androidctld.commands.results import (
35
+ complete_record_with_error,
36
+ complete_record_with_result,
37
+ )
38
+ from androidctld.errors import DaemonError, DaemonErrorCode
39
+ from androidctld.logging import configure_logging
40
+ from androidctld.protocol import CommandKind
41
+ from androidctld.runtime.models import WorkspaceRuntime
42
+ from androidctld.runtime.store import RuntimeSerialCommandBusyError
43
+
44
+ SerialAdmission: TypeAlias = Callable[[str], AbstractContextManager[None]]
45
+ TimeFn: TypeAlias = Callable[[], float]
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class CommandRunContext:
50
+ runtime: WorkspaceRuntime
51
+ command: InternalCommand
52
+ spec: CommandSpec
53
+ catalog_entry: CommandCatalogEntry
54
+ expected_result_command: str
55
+ record: CommandRecord
56
+ started_at: str
57
+ started_monotonic: float
58
+
59
+
60
+ _CURRENT_CONTEXT: ContextVar[CommandRunContext | None] = ContextVar(
61
+ "androidctld_command_run_context",
62
+ default=None,
63
+ )
64
+
65
+
66
+ class CommandRunOrchestrator:
67
+ def __init__(
68
+ self,
69
+ *,
70
+ serial_admission: SerialAdmission | None = None,
71
+ time_fn: TimeFn | None = None,
72
+ logger: logging.Logger | None = None,
73
+ ) -> None:
74
+ self._serial_admission = serial_admission
75
+ self._time_fn = time_fn or (lambda: 0.0)
76
+ self._logger = logger or configure_logging()
77
+
78
+ def run(
79
+ self,
80
+ *,
81
+ runtime: WorkspaceRuntime,
82
+ command: InternalCommand,
83
+ execute: Callable[[], dict[str, Any]],
84
+ ) -> dict[str, Any]:
85
+ context = self._build_context(runtime=runtime, command=command)
86
+ self._log_command_started(context)
87
+ try:
88
+ with self._admit_serial_command(context):
89
+ token = _CURRENT_CONTEXT.set(context)
90
+ try:
91
+ result = execute()
92
+ finally:
93
+ _CURRENT_CONTEXT.reset(token)
94
+ finalized = self._finalize_result(
95
+ result,
96
+ expected_result_command=context.expected_result_command,
97
+ )
98
+ complete_record_with_result(context.record, finalized)
99
+ except RuntimeSerialCommandBusyError as error:
100
+ if context.catalog_entry.result_family is PublicResultFamily.RETAINED:
101
+ finalized = _retained_busy_result(
102
+ command=context.expected_result_command,
103
+ message=str(error),
104
+ )
105
+ complete_record_with_result(context.record, finalized)
106
+ self._log_command_finished(context)
107
+ return finalized
108
+ daemon_error = _serial_busy_daemon_error(error)
109
+ complete_record_with_error(context.record, daemon_error)
110
+ self._log_command_finished(context)
111
+ raise daemon_error from error
112
+ except DaemonError as error:
113
+ complete_record_with_error(context.record, error)
114
+ self._log_command_finished(context)
115
+ raise
116
+ except Exception as error:
117
+ complete_record_with_error(
118
+ context.record,
119
+ _internal_record_error(error),
120
+ )
121
+ self._log_command_finished(context)
122
+ raise
123
+ self._log_command_finished(context)
124
+ return finalized
125
+
126
+ def close_runtime(
127
+ self,
128
+ *,
129
+ runtime: WorkspaceRuntime,
130
+ close: Callable[[], None],
131
+ ) -> dict[str, Any]:
132
+ del runtime
133
+ entry = runtime_close_entry()
134
+ started_at = _now_isoformat()
135
+ record = CommandRecord(
136
+ command_id="semantic-boundary",
137
+ kind=CommandKind.CLOSE,
138
+ status=CommandStatus.RUNNING,
139
+ started_at=started_at,
140
+ result_command=entry.result_command,
141
+ )
142
+ try:
143
+ with self._admit_runtime_close(entry):
144
+ close()
145
+ finalized = self._finalize_result(
146
+ build_retained_success_result(command=entry.result_command),
147
+ expected_result_command=entry.result_command,
148
+ )
149
+ complete_record_with_result(record, finalized)
150
+ return finalized
151
+ except RuntimeSerialCommandBusyError as error:
152
+ finalized = _retained_busy_result(
153
+ command=entry.result_command,
154
+ message=str(error),
155
+ )
156
+ complete_record_with_result(record, finalized)
157
+ return finalized
158
+ except DaemonError as error:
159
+ complete_record_with_error(record, error)
160
+ raise
161
+ except Exception as error:
162
+ complete_record_with_error(record, _internal_record_error(error))
163
+ raise
164
+
165
+ def _build_context(
166
+ self,
167
+ *,
168
+ runtime: WorkspaceRuntime,
169
+ command: InternalCommand,
170
+ ) -> CommandRunContext:
171
+ spec = resolve_command_spec(command)
172
+ catalog_entry = entry_for_daemon_kind(spec.daemon_kind)
173
+ if catalog_entry is None:
174
+ raise ValueError(f"unknown daemon command kind: {spec.daemon_kind!r}")
175
+ started_at = _now_isoformat()
176
+ return CommandRunContext(
177
+ runtime=runtime,
178
+ command=command,
179
+ spec=spec,
180
+ catalog_entry=catalog_entry,
181
+ expected_result_command=catalog_entry.result_command,
182
+ record=CommandRecord(
183
+ command_id="semantic-boundary",
184
+ kind=_record_kind_for_context(command=command, spec=spec),
185
+ status=CommandStatus.RUNNING,
186
+ started_at=started_at,
187
+ result_command=catalog_entry.result_command,
188
+ ),
189
+ started_at=started_at,
190
+ started_monotonic=self._time_fn(),
191
+ )
192
+
193
+ def _admit_serial_command(
194
+ self,
195
+ context: CommandRunContext,
196
+ ) -> AbstractContextManager[None]:
197
+ if self._serial_admission is None:
198
+ return nullcontext()
199
+ return self._serial_admission(context.spec.daemon_kind)
200
+
201
+ def _admit_runtime_close(
202
+ self,
203
+ entry: CommandCatalogEntry,
204
+ ) -> AbstractContextManager[None]:
205
+ if self._serial_admission is None:
206
+ return nullcontext()
207
+ return self._serial_admission(entry.result_command)
208
+
209
+ def _finalize_result(
210
+ self,
211
+ payload: (
212
+ CommandResultCore | RetainedResultEnvelope | ListAppsResult | dict[str, Any]
213
+ ),
214
+ *,
215
+ expected_result_command: str,
216
+ ) -> dict[str, Any]:
217
+ catalog_entry = entry_for_result_command(expected_result_command)
218
+ if catalog_entry is None:
219
+ raise ValueError(f"unknown result command: {expected_result_command!r}")
220
+ result: CommandResultCore | RetainedResultEnvelope | ListAppsResult
221
+ if catalog_entry.result_family is PublicResultFamily.SEMANTIC:
222
+ result = (
223
+ payload
224
+ if isinstance(payload, CommandResultCore)
225
+ else CommandResultCore.model_validate(payload)
226
+ )
227
+ elif catalog_entry.result_family is PublicResultFamily.RETAINED:
228
+ result = (
229
+ payload
230
+ if isinstance(payload, RetainedResultEnvelope)
231
+ else RetainedResultEnvelope.model_validate(payload)
232
+ )
233
+ elif catalog_entry.result_family is PublicResultFamily.LIST_APPS:
234
+ result = (
235
+ payload
236
+ if isinstance(payload, ListAppsResult)
237
+ else ListAppsResult.model_validate(payload)
238
+ )
239
+ else:
240
+ raise ValueError(
241
+ f"unsupported result family: {catalog_entry.result_family!r}"
242
+ )
243
+ if result.command != expected_result_command:
244
+ raise ValueError(
245
+ "result.command must match command catalog result command: "
246
+ f"expected {expected_result_command!r}, got {result.command!r}"
247
+ )
248
+ if result.command != catalog_entry.result_command:
249
+ raise ValueError(f"unknown result command: {result.command!r}")
250
+ if isinstance(result, RetainedResultEnvelope):
251
+ return dump_retained_result_envelope(result)
252
+ if isinstance(result, ListAppsResult):
253
+ return result.model_dump(by_alias=True, mode="json")
254
+ return dump_canonical_command_result(result)
255
+
256
+ def _log_command_started(self, context: CommandRunContext) -> None:
257
+ self._logger.info(
258
+ "command started kind=%s result_command=%s",
259
+ context.record.kind.value,
260
+ context.expected_result_command,
261
+ )
262
+
263
+ def _log_command_finished(self, context: CommandRunContext) -> None:
264
+ elapsed_ms = max(
265
+ 0.0,
266
+ (self._time_fn() - context.started_monotonic) * 1000.0,
267
+ )
268
+ self._logger.info(
269
+ "command finished kind=%s result_command=%s status=%s elapsed_ms=%.3f",
270
+ context.record.kind.value,
271
+ context.expected_result_command,
272
+ context.record.status.value,
273
+ elapsed_ms,
274
+ )
275
+
276
+
277
+ def current_command_record(
278
+ *,
279
+ kind: CommandKind,
280
+ result_command: str,
281
+ ) -> CommandRecord:
282
+ context = _CURRENT_CONTEXT.get()
283
+ if (
284
+ context is not None
285
+ and context.record.kind == kind
286
+ and context.record.result_command == result_command
287
+ ):
288
+ return context.record
289
+ return CommandRecord(
290
+ command_id="semantic-boundary",
291
+ kind=kind,
292
+ status=CommandStatus.RUNNING,
293
+ started_at=_now_isoformat(),
294
+ result_command=result_command,
295
+ )
296
+
297
+
298
+ def _record_kind_for_context(
299
+ *,
300
+ command: InternalCommand,
301
+ spec: CommandSpec,
302
+ ) -> CommandKind:
303
+ if spec.family == "global_action":
304
+ return CommandKind.GLOBAL
305
+ return CommandKind(command.kind.value)
306
+
307
+
308
+ def _now_isoformat() -> str:
309
+ return (
310
+ datetime.now(timezone.utc)
311
+ .replace(microsecond=0)
312
+ .isoformat()
313
+ .replace("+00:00", "Z")
314
+ )
315
+
316
+
317
+ def _internal_record_error(error: BaseException) -> DaemonError:
318
+ message = str(error) or error.__class__.__name__
319
+ return DaemonError(
320
+ code=DaemonErrorCode.INTERNAL_COMMAND_FAILURE,
321
+ message=message,
322
+ retryable=False,
323
+ details={"exceptionType": error.__class__.__name__},
324
+ http_status=200,
325
+ )
326
+
327
+
328
+ def _serial_busy_daemon_error(error: BaseException) -> DaemonError:
329
+ return DaemonError(
330
+ code=DaemonErrorCode.RUNTIME_BUSY,
331
+ message=str(error),
332
+ retryable=True,
333
+ details={"reason": "overlapping_control_request"},
334
+ http_status=200,
335
+ )
336
+
337
+
338
+ def _retained_busy_result(*, command: str, message: str) -> dict[str, Any]:
339
+ return dump_retained_result_envelope(
340
+ build_projected_retained_failure_result(
341
+ command=command,
342
+ code=DaemonErrorCode.RUNTIME_BUSY,
343
+ message=message,
344
+ details={"reason": "overlapping_control_request"},
345
+ )
346
+ )
347
+
348
+
349
+ __all__ = [
350
+ "CommandRunContext",
351
+ "CommandRunOrchestrator",
352
+ "current_command_record",
353
+ ]
@@ -0,0 +1,116 @@
1
+ """Semantic command registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from androidctl_contracts.command_catalog import (
9
+ daemon_command_kinds_for_route,
10
+ )
11
+ from androidctld.commands.command_models import GlobalCommand
12
+ from androidctld.protocol import CommandKind
13
+
14
+ CommandFamily = Literal[
15
+ "connect",
16
+ "observe",
17
+ "list_apps",
18
+ "open",
19
+ "ref_action",
20
+ "type",
21
+ "scroll",
22
+ "global_action",
23
+ "wait",
24
+ "screenshot",
25
+ ]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CommandSpec:
30
+ daemon_kind: str
31
+ family: CommandFamily
32
+ dispatch_method_name: str
33
+
34
+ @property
35
+ def command_name(self) -> str:
36
+ return self.daemon_kind
37
+
38
+
39
+ _FAMILY_BY_DAEMON_KIND: dict[str, CommandFamily] = {
40
+ "connect": "connect",
41
+ "observe": "observe",
42
+ "listApps": "list_apps",
43
+ "open": "open",
44
+ "tap": "ref_action",
45
+ "longTap": "ref_action",
46
+ "focus": "ref_action",
47
+ "type": "type",
48
+ "submit": "ref_action",
49
+ "scroll": "scroll",
50
+ "back": "global_action",
51
+ "home": "global_action",
52
+ "recents": "global_action",
53
+ "notifications": "global_action",
54
+ "wait": "wait",
55
+ "screenshot": "screenshot",
56
+ }
57
+
58
+ _FAMILY_DISPATCH_METHOD_NAMES: dict[CommandFamily, str] = {
59
+ "connect": "execute_connect",
60
+ "observe": "execute_observe",
61
+ "list_apps": "execute_list_apps",
62
+ "open": "execute_open",
63
+ "ref_action": "execute_ref_action",
64
+ "type": "execute_ref_action",
65
+ "scroll": "execute_ref_action",
66
+ "global_action": "execute_global_action",
67
+ "wait": "execute_wait",
68
+ "screenshot": "execute_screenshot",
69
+ }
70
+
71
+ _commands_run_daemon_kinds = daemon_command_kinds_for_route("commands_run")
72
+ _family_keys = set(_FAMILY_BY_DAEMON_KIND)
73
+ if _family_keys != _commands_run_daemon_kinds:
74
+ missing = sorted(_commands_run_daemon_kinds - _family_keys)
75
+ extra = sorted(_family_keys - _commands_run_daemon_kinds)
76
+ raise RuntimeError(
77
+ "daemon command family mapping drifted from shared catalog: "
78
+ f"missing={missing}, extra={extra}"
79
+ )
80
+
81
+ COMMAND_SPECS: dict[str, CommandSpec] = {}
82
+ for daemon_kind in _FAMILY_BY_DAEMON_KIND:
83
+ family = _FAMILY_BY_DAEMON_KIND[daemon_kind]
84
+ COMMAND_SPECS[daemon_kind] = CommandSpec(
85
+ daemon_kind=daemon_kind,
86
+ family=family,
87
+ dispatch_method_name=_FAMILY_DISPATCH_METHOD_NAMES[family],
88
+ )
89
+
90
+
91
+ def get_command_spec(command_name: str) -> CommandSpec:
92
+ return COMMAND_SPECS[command_name]
93
+
94
+
95
+ def resolve_command_spec(command_or_name: object) -> CommandSpec:
96
+ if isinstance(command_or_name, str):
97
+ return get_command_spec(command_or_name)
98
+
99
+ if isinstance(command_or_name, GlobalCommand):
100
+ return get_command_spec(command_or_name.action)
101
+
102
+ command_name = getattr(command_or_name, "kind", None)
103
+ if isinstance(command_name, CommandKind):
104
+ command_name = command_name.value
105
+ if not isinstance(command_name, str):
106
+ raise KeyError(command_or_name)
107
+ return get_command_spec(command_name)
108
+
109
+
110
+ __all__ = [
111
+ "COMMAND_SPECS",
112
+ "CommandFamily",
113
+ "CommandSpec",
114
+ "get_command_spec",
115
+ "resolve_command_spec",
116
+ ]
@@ -0,0 +1,40 @@
1
+ """Builders for canonical command success results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from androidctld.app_targets import AppTargetMatch
6
+ from androidctld.artifacts.models import ScreenArtifacts
7
+ from androidctld.commands.result_models import (
8
+ CommandAppPayload,
9
+ CommandScreenPayload,
10
+ )
11
+ from androidctld.semantics.public_models import PublicScreen
12
+ from androidctld.snapshots.models import RawSnapshot
13
+
14
+
15
+ def app_payload(
16
+ snapshot: RawSnapshot,
17
+ *,
18
+ app_match: AppTargetMatch | None = None,
19
+ ) -> CommandAppPayload:
20
+ return CommandAppPayload(
21
+ package_name=snapshot.package_name,
22
+ activity_name=snapshot.activity_name,
23
+ requested_package_name=(
24
+ None if app_match is None else app_match.requested_package_name
25
+ ),
26
+ resolved_package_name=(
27
+ None if app_match is None else app_match.resolved_package_name
28
+ ),
29
+ match_type=None if app_match is None else app_match.match_type,
30
+ )
31
+
32
+
33
+ def screen_payload(
34
+ public_screen: PublicScreen, artifacts: ScreenArtifacts, *, sequence: int
35
+ ) -> CommandScreenPayload:
36
+ return CommandScreenPayload(
37
+ screen_id=public_screen.screen_id,
38
+ sequence=sequence,
39
+ path_json=artifacts.screen_json,
40
+ )