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,555 @@
1
+ """Canonical command success result models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Annotated, Any, Literal, cast
8
+
9
+ from pydantic import (
10
+ ConfigDict,
11
+ Field,
12
+ StringConstraints,
13
+ field_validator,
14
+ model_serializer,
15
+ )
16
+
17
+ from androidctl_contracts.command_catalog import (
18
+ entry_for_result_command,
19
+ )
20
+ from androidctl_contracts.command_results import (
21
+ ActionTargetPayload,
22
+ CommandResultCore,
23
+ RetainedResultEnvelope,
24
+ TruthPayload,
25
+ )
26
+ from androidctl_contracts.command_results import (
27
+ ArtifactPayload as SemanticArtifactPayload,
28
+ )
29
+ from androidctl_contracts.vocabulary import SemanticResultCode
30
+ from androidctld.artifacts.models import ScreenArtifacts
31
+ from androidctld.schema import ApiModel
32
+ from androidctld.schema.base import dump_api_model
33
+ from androidctld.semantics.public_models import PublicScreen, dump_public_screen
34
+
35
+ TrimmedString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
36
+ NonNegativeInt = Annotated[int, Field(ge=0)]
37
+
38
+
39
+ def _strip_optional_string(value: object) -> object:
40
+ if value is None:
41
+ return None
42
+ if isinstance(value, str):
43
+ normalized = value.strip()
44
+ if not normalized:
45
+ return None
46
+ return normalized
47
+ return value
48
+
49
+
50
+ class CommandResultModel(ApiModel):
51
+ model_config = ConfigDict(
52
+ strict=True,
53
+ extra="forbid",
54
+ alias_generator=ApiModel.model_config["alias_generator"],
55
+ validate_by_alias=True,
56
+ validate_by_name=True,
57
+ use_enum_values=False,
58
+ frozen=True,
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class SemanticResultAssemblyInput:
64
+ """Internal host-owned inputs for public semantic result assembly."""
65
+
66
+ app_payload: CommandAppPayload | None = None
67
+ action_target: ActionTargetPayload | None = None
68
+ execution_outcome: Literal[
69
+ "dispatched",
70
+ "notAttempted",
71
+ "notApplicable",
72
+ "unknown",
73
+ ] = "dispatched"
74
+ warnings: tuple[str, ...] = field(default_factory=tuple)
75
+
76
+
77
+ class CommandAppPayload(CommandResultModel):
78
+ package_name: TrimmedString | None
79
+ activity_name: str | None = None
80
+ requested_package_name: TrimmedString | None = None
81
+ resolved_package_name: TrimmedString | None = None
82
+ match_type: Literal["exact", "alias"] | None = None
83
+
84
+ @field_validator(
85
+ "activity_name",
86
+ "requested_package_name",
87
+ "resolved_package_name",
88
+ mode="before",
89
+ )
90
+ @classmethod
91
+ def _normalize_activity_name(cls, value: object) -> object:
92
+ return _strip_optional_string(value)
93
+
94
+ @model_serializer(mode="wrap")
95
+ def _serialize_model(self, handler: Any) -> dict[str, Any]:
96
+ payload = cast(dict[str, Any], handler(self))
97
+ if self.requested_package_name is None:
98
+ payload.pop("requestedPackageName", None)
99
+ if self.resolved_package_name is None:
100
+ payload.pop("resolvedPackageName", None)
101
+ if self.match_type is None:
102
+ payload.pop("matchType", None)
103
+ return payload
104
+
105
+
106
+ class CommandScreenPayload(CommandResultModel):
107
+ screen_id: TrimmedString
108
+ sequence: NonNegativeInt
109
+ path_json: str | None = None
110
+
111
+ @field_validator("path_json", mode="before")
112
+ @classmethod
113
+ def _normalize_paths(cls, value: object) -> object:
114
+ return _strip_optional_string(value)
115
+
116
+
117
+ def semantic_artifact_payload(
118
+ artifacts: ScreenArtifacts | None,
119
+ ) -> SemanticArtifactPayload:
120
+ if artifacts is None:
121
+ return SemanticArtifactPayload()
122
+ return SemanticArtifactPayload(
123
+ screenshot_png=artifacts.screenshot_png,
124
+ screen_xml=artifacts.screen_xml,
125
+ )
126
+
127
+
128
+ _PAYLOAD_LIGHT_LOST_TRUTH_CODES = frozenset(
129
+ {
130
+ SemanticResultCode.DEVICE_UNAVAILABLE,
131
+ SemanticResultCode.POST_ACTION_OBSERVATION_LOST,
132
+ }
133
+ )
134
+
135
+
136
+ def semantic_screen_payload(
137
+ public_screen: PublicScreen | None,
138
+ *,
139
+ app_payload: CommandAppPayload | None = None,
140
+ ) -> dict[str, object] | None:
141
+ if public_screen is None:
142
+ return None
143
+ payload = dump_public_screen(public_screen)
144
+ if app_payload is None:
145
+ return payload
146
+
147
+ app = payload.get("app")
148
+ if isinstance(app, dict):
149
+ app.update(dump_api_model(app_payload))
150
+ return payload
151
+
152
+
153
+ def retained_artifact_payload(artifacts: ScreenArtifacts | None) -> dict[str, Any]:
154
+ if artifacts is None or artifacts.screenshot_png is None:
155
+ return {}
156
+ return {"screenshotPng": artifacts.screenshot_png}
157
+
158
+
159
+ def retained_result_envelope_kind(command: str) -> str:
160
+ entry = entry_for_result_command(command)
161
+ if entry is None:
162
+ raise ValueError(f"unknown retained result command: {command!r}")
163
+ if entry.retained_envelope_kind is None:
164
+ raise ValueError(f"semantic command is not retained: {command!r}")
165
+ return entry.retained_envelope_kind.value
166
+
167
+
168
+ def build_retained_success_result(
169
+ *,
170
+ command: str,
171
+ artifacts: ScreenArtifacts | dict[str, Any] | None = None,
172
+ details: dict[str, Any] | None = None,
173
+ ) -> RetainedResultEnvelope:
174
+ return RetainedResultEnvelope(
175
+ ok=True,
176
+ command=command,
177
+ envelope=retained_result_envelope_kind(command),
178
+ artifacts=_coerce_retained_artifacts(artifacts),
179
+ details={} if details is None else dict(details),
180
+ )
181
+
182
+
183
+ def build_retained_failure_result(
184
+ *,
185
+ command: str,
186
+ code: object,
187
+ message: str,
188
+ artifacts: ScreenArtifacts | dict[str, Any] | None = None,
189
+ details: dict[str, Any] | None = None,
190
+ ) -> RetainedResultEnvelope:
191
+ code_value = getattr(code, "value", code)
192
+ return RetainedResultEnvelope(
193
+ ok=False,
194
+ command=command,
195
+ envelope=retained_result_envelope_kind(command),
196
+ code=str(code_value),
197
+ message=message,
198
+ artifacts=_coerce_retained_artifacts(artifacts),
199
+ details={} if details is None else dict(details),
200
+ )
201
+
202
+
203
+ _RETAINED_FAILURE_PROJECTIONS: dict[tuple[str, str], tuple[str, str]] = {
204
+ ("screenshot", "ARTIFACT_ROOT_UNWRITABLE"): (
205
+ "WORKSPACE_STATE_UNWRITABLE",
206
+ "workspace",
207
+ ),
208
+ ("screenshot", "ARTIFACT_WRITE_FAILED"): (
209
+ "WORKSPACE_STATE_UNWRITABLE",
210
+ "workspace",
211
+ ),
212
+ ("connect", "DEVICE_AGENT_UNAUTHORIZED"): (
213
+ "DEVICE_AGENT_UNAUTHORIZED",
214
+ "device",
215
+ ),
216
+ ("connect", "DEVICE_AGENT_VERSION_MISMATCH"): (
217
+ "DEVICE_AGENT_VERSION_MISMATCH",
218
+ "device",
219
+ ),
220
+ ("screenshot", "DEVICE_AGENT_VERSION_MISMATCH"): (
221
+ "DEVICE_AGENT_VERSION_MISMATCH",
222
+ "device",
223
+ ),
224
+ }
225
+
226
+ _RETAINED_REASON_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
227
+ _RETAINED_RELEASE_VERSION_RE = re.compile(
228
+ r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$"
229
+ )
230
+ _DEVICE_SERIAL_REASON_RE = re.compile(r"^(?:emulator-\d+|[A-Z0-9]{6,})$")
231
+ _FINGERPRINT_REASON_RE = re.compile(r"^(?:[0-9a-f]{16,}|[0-9a-f]{2}:){7,}[0-9a-f]{2}$")
232
+ _SAFE_TOKEN_REASON_VALUES = frozenset({"wrong-token"})
233
+
234
+
235
+ def build_projected_retained_failure_result(
236
+ *,
237
+ command: str,
238
+ code: object,
239
+ message: str,
240
+ artifacts: ScreenArtifacts | dict[str, Any] | None = None,
241
+ details: dict[str, Any] | None = None,
242
+ source_kind: str | None = None,
243
+ operation: str | None = None,
244
+ ) -> RetainedResultEnvelope:
245
+ source_code = str(getattr(code, "value", code))
246
+ public_code, mapped_source_kind = _project_retained_failure_code(
247
+ command=command,
248
+ source_code=source_code,
249
+ )
250
+ projected_source_kind = source_kind or mapped_source_kind
251
+ return build_retained_failure_result(
252
+ command=command,
253
+ code=public_code,
254
+ message=_project_retained_failure_message(
255
+ command=command,
256
+ source_code=source_code,
257
+ message=message,
258
+ ),
259
+ artifacts=artifacts,
260
+ details=_project_retained_failure_details(
261
+ source_code=source_code,
262
+ public_code=public_code,
263
+ source_kind=projected_source_kind,
264
+ operation=operation,
265
+ details=details,
266
+ ),
267
+ )
268
+
269
+
270
+ def build_projected_retained_failure_result_for_error(
271
+ *,
272
+ command: str,
273
+ error: Any,
274
+ artifacts: ScreenArtifacts | dict[str, Any] | None = None,
275
+ source_kind: str | None = None,
276
+ operation: str | None = None,
277
+ ) -> RetainedResultEnvelope:
278
+ return build_projected_retained_failure_result(
279
+ command=command,
280
+ code=error.code,
281
+ message=error.message,
282
+ artifacts=artifacts,
283
+ details=error.details,
284
+ source_kind=source_kind,
285
+ operation=operation,
286
+ )
287
+
288
+
289
+ def _project_retained_failure_code(
290
+ *,
291
+ command: str,
292
+ source_code: str,
293
+ ) -> tuple[str, str | None]:
294
+ projected = _RETAINED_FAILURE_PROJECTIONS.get((command, source_code))
295
+ if projected is not None:
296
+ return projected
297
+ return source_code, None
298
+
299
+
300
+ def _project_retained_failure_message(
301
+ *,
302
+ command: str,
303
+ source_code: str,
304
+ message: str,
305
+ ) -> str:
306
+ del command, source_code
307
+ return message
308
+
309
+
310
+ def _project_retained_failure_details(
311
+ *,
312
+ source_code: str,
313
+ public_code: str,
314
+ source_kind: str | None,
315
+ operation: str | None,
316
+ details: dict[str, Any] | None,
317
+ ) -> dict[str, Any]:
318
+ projected = _sanitize_retained_failure_details(details)
319
+ normalized_operation = _stable_detail_scalar(operation)
320
+ if normalized_operation is not None:
321
+ projected["operation"] = normalized_operation
322
+ normalized_source_kind = _stable_detail_scalar(source_kind)
323
+ if public_code != source_code or normalized_source_kind is not None:
324
+ projected["sourceCode"] = source_code
325
+ if normalized_source_kind is not None:
326
+ projected["sourceKind"] = normalized_source_kind
327
+ return projected
328
+
329
+
330
+ def _sanitize_retained_failure_details(
331
+ details: dict[str, Any] | None,
332
+ ) -> dict[str, Any]:
333
+ if not details:
334
+ return {}
335
+ projected: dict[str, Any] = {}
336
+ reason = _stable_reason_detail(details.get("reason"))
337
+ if reason is not None:
338
+ projected["reason"] = reason
339
+ expected_release_version = _stable_release_version_detail(
340
+ details.get("expectedReleaseVersion")
341
+ )
342
+ if expected_release_version is not None:
343
+ projected["expectedReleaseVersion"] = expected_release_version
344
+ actual_release_version = _stable_release_version_detail(
345
+ details.get("actualReleaseVersion")
346
+ )
347
+ if actual_release_version is not None:
348
+ projected["actualReleaseVersion"] = actual_release_version
349
+ return projected
350
+
351
+
352
+ def _stable_reason_detail(value: object) -> str | None:
353
+ if not isinstance(value, str):
354
+ return None
355
+ normalized = value.strip()
356
+ if not normalized or normalized != value:
357
+ return None
358
+ lower = normalized.lower()
359
+ if not _RETAINED_REASON_RE.fullmatch(normalized):
360
+ return None
361
+ if _DEVICE_SERIAL_REASON_RE.fullmatch(normalized):
362
+ return None
363
+ if _FINGERPRINT_REASON_RE.fullmatch(lower):
364
+ return None
365
+ if "token" in lower and lower not in _SAFE_TOKEN_REASON_VALUES:
366
+ return None
367
+ if any(
368
+ marker in lower
369
+ for marker in (
370
+ "bearer",
371
+ "://",
372
+ "www.",
373
+ ".androidctl",
374
+ "artifact-root",
375
+ "artifact_path",
376
+ "artifact-path",
377
+ "raw-rid",
378
+ "raw_rid",
379
+ "rawrid",
380
+ "snapshot",
381
+ "fingerprint",
382
+ )
383
+ ):
384
+ return None
385
+ if lower.startswith(("rid-", "rid_", "snapshot-", "snapshot_")):
386
+ return None
387
+ return normalized
388
+
389
+
390
+ def _stable_detail_scalar(value: object) -> str | int | float | bool | None:
391
+ if isinstance(value, bool):
392
+ return value
393
+ if isinstance(value, str):
394
+ normalized = value.strip()
395
+ return normalized or None
396
+ if isinstance(value, (int, float)):
397
+ return value
398
+ return None
399
+
400
+
401
+ def _stable_release_version_detail(value: object) -> str | None:
402
+ if not isinstance(value, str):
403
+ return None
404
+ normalized = value.strip()
405
+ if not normalized or normalized != value:
406
+ return None
407
+ if len(normalized) > 32:
408
+ return None
409
+ if _RETAINED_RELEASE_VERSION_RE.fullmatch(normalized) is None:
410
+ return None
411
+ return normalized
412
+
413
+
414
+ def dump_retained_result_envelope(
415
+ payload: RetainedResultEnvelope | dict[str, Any],
416
+ ) -> dict[str, Any]:
417
+ result = (
418
+ payload
419
+ if isinstance(payload, RetainedResultEnvelope)
420
+ else RetainedResultEnvelope.model_validate(payload)
421
+ )
422
+ dumped = result.model_dump(by_alias=True, mode="json")
423
+ if dumped.get("code") is None:
424
+ dumped.pop("code", None)
425
+ if dumped.get("message") is None:
426
+ dumped.pop("message", None)
427
+ return dumped
428
+
429
+
430
+ def _coerce_retained_artifacts(
431
+ artifacts: ScreenArtifacts | dict[str, Any] | None,
432
+ ) -> dict[str, Any]:
433
+ if isinstance(artifacts, ScreenArtifacts):
434
+ return retained_artifact_payload(artifacts)
435
+ if artifacts is None:
436
+ return {}
437
+ return dict(artifacts)
438
+
439
+
440
+ def build_semantic_success_result(
441
+ *,
442
+ command: str,
443
+ category: str,
444
+ source_screen_id: str | None,
445
+ next_screen: PublicScreen | None,
446
+ next_screen_id: str | None = None,
447
+ screen_payload: dict[str, object] | None = None,
448
+ app_payload: CommandAppPayload | None = None,
449
+ action_target: ActionTargetPayload | dict[str, object] | None = None,
450
+ artifacts: ScreenArtifacts | None,
451
+ continuity_status: str,
452
+ execution_outcome: str,
453
+ observation_quality: str = "authoritative",
454
+ changed: bool | None = None,
455
+ warnings: list[str] | None = None,
456
+ ) -> CommandResultCore:
457
+ resolved_screen_payload = (
458
+ semantic_screen_payload(next_screen, app_payload=app_payload)
459
+ if screen_payload is None
460
+ else screen_payload
461
+ )
462
+ resolved_next_screen_id = (
463
+ (None if next_screen is None else next_screen.screen_id)
464
+ if next_screen_id is None
465
+ else next_screen_id
466
+ )
467
+ truth_payload_kwargs: dict[str, object] = {
468
+ "execution_outcome": execution_outcome,
469
+ "continuity_status": continuity_status,
470
+ "observation_quality": observation_quality,
471
+ }
472
+ if changed is not None:
473
+ truth_payload_kwargs["changed"] = changed
474
+ result_kwargs = {
475
+ "ok": True,
476
+ "command": command,
477
+ "category": category,
478
+ "payload_mode": "full",
479
+ "truth": TruthPayload(**truth_payload_kwargs),
480
+ "screen": resolved_screen_payload,
481
+ "warnings": [] if warnings is None else warnings,
482
+ "artifacts": semantic_artifact_payload(artifacts),
483
+ }
484
+ if source_screen_id is not None:
485
+ result_kwargs["source_screen_id"] = source_screen_id
486
+ if resolved_next_screen_id is not None:
487
+ result_kwargs["next_screen_id"] = resolved_next_screen_id
488
+ if action_target is not None:
489
+ result_kwargs["action_target"] = action_target
490
+ return CommandResultCore(**result_kwargs)
491
+
492
+
493
+ def build_semantic_failure_result(
494
+ *,
495
+ command: str,
496
+ category: str,
497
+ code: SemanticResultCode,
498
+ message: str,
499
+ execution_outcome: str = "notApplicable",
500
+ source_screen_id: str | None,
501
+ current_screen: PublicScreen | None,
502
+ artifacts: ScreenArtifacts | None,
503
+ continuity_status: str = "none",
504
+ observation_quality: str = "none",
505
+ changed: bool | None = None,
506
+ ) -> CommandResultCore:
507
+ effective_current_screen = (
508
+ None if code in _PAYLOAD_LIGHT_LOST_TRUTH_CODES else current_screen
509
+ )
510
+ semantic_artifacts = (
511
+ SemanticArtifactPayload()
512
+ if code in _PAYLOAD_LIGHT_LOST_TRUTH_CODES
513
+ else semantic_artifact_payload(artifacts)
514
+ )
515
+ if effective_current_screen is None:
516
+ result_kwargs: dict[str, object] = {
517
+ "ok": False,
518
+ "command": command,
519
+ "category": category,
520
+ "payload_mode": "none",
521
+ "code": code,
522
+ "message": message,
523
+ "truth": TruthPayload(
524
+ execution_outcome=execution_outcome,
525
+ continuity_status="none",
526
+ observation_quality="none",
527
+ ),
528
+ "artifacts": semantic_artifacts,
529
+ }
530
+ if source_screen_id is not None:
531
+ result_kwargs["source_screen_id"] = source_screen_id
532
+ return CommandResultCore(**result_kwargs)
533
+ truth_payload_kwargs: dict[str, object] = {
534
+ "execution_outcome": execution_outcome,
535
+ "continuity_status": continuity_status,
536
+ "observation_quality": observation_quality,
537
+ }
538
+ if changed is not None:
539
+ truth_payload_kwargs["changed"] = changed
540
+ result_kwargs = {
541
+ "ok": False,
542
+ "command": command,
543
+ "category": category,
544
+ "code": code,
545
+ "message": message,
546
+ "payload_mode": "full",
547
+ "next_screen_id": effective_current_screen.screen_id,
548
+ "truth": TruthPayload(**truth_payload_kwargs),
549
+ "screen": semantic_screen_payload(effective_current_screen),
550
+ "warnings": [],
551
+ "artifacts": semantic_artifacts,
552
+ }
553
+ if source_screen_id is not None:
554
+ result_kwargs["source_screen_id"] = source_screen_id
555
+ return CommandResultCore(**result_kwargs)
@@ -0,0 +1,108 @@
1
+ """Command result payload helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from androidctl_contracts.command_catalog import entry_for_result_command
9
+ from androidctl_contracts.command_results import (
10
+ CommandResultCore,
11
+ ListAppsResult,
12
+ RetainedResultEnvelope,
13
+ )
14
+ from androidctl_contracts.vocabulary import PublicResultFamily
15
+ from androidctld.artifacts.models import ScreenArtifacts
16
+ from androidctld.commands.models import CachedCommandError, CommandRecord, CommandStatus
17
+ from androidctld.commands.result_builders import screen_payload
18
+ from androidctld.errors import DaemonError
19
+ from androidctld.schema.base import dump_api_model
20
+ from androidctld.semantics.public_models import PublicScreen
21
+
22
+
23
+ def complete_record_with_result(
24
+ record: CommandRecord,
25
+ payload: dict[str, Any],
26
+ ) -> None:
27
+ expected_result_command = record.result_command
28
+ if expected_result_command is None or not expected_result_command.strip():
29
+ raise ValueError("record result_command must be populated")
30
+ catalog_entry = entry_for_result_command(expected_result_command)
31
+ if catalog_entry is None:
32
+ raise ValueError(f"unknown result command: {expected_result_command!r}")
33
+ result: CommandResultCore | RetainedResultEnvelope | ListAppsResult
34
+ if catalog_entry.result_family is PublicResultFamily.SEMANTIC:
35
+ result = CommandResultCore.model_validate(payload)
36
+ elif catalog_entry.result_family is PublicResultFamily.RETAINED:
37
+ result = RetainedResultEnvelope.model_validate(payload)
38
+ elif catalog_entry.result_family is PublicResultFamily.LIST_APPS:
39
+ result = ListAppsResult.model_validate(payload)
40
+ else:
41
+ raise ValueError(f"unsupported result family: {catalog_entry.result_family!r}")
42
+ if result.command != expected_result_command:
43
+ raise ValueError("result.command must match record result command")
44
+ record.status = CommandStatus.SUCCEEDED
45
+ record.completed_at = _now_isoformat()
46
+ record.result = result
47
+ record.error = None
48
+
49
+
50
+ def complete_record_with_error(
51
+ record: CommandRecord,
52
+ error: DaemonError,
53
+ ) -> None:
54
+ record.status = CommandStatus.FAILED
55
+ record.completed_at = _now_isoformat()
56
+ record.result = None
57
+ record.error = CachedCommandError.from_daemon_error(error)
58
+
59
+
60
+ def screen_summary(
61
+ public_screen: PublicScreen, artifacts: ScreenArtifacts
62
+ ) -> dict[str, Any]:
63
+ return dump_api_model(
64
+ screen_payload(
65
+ public_screen,
66
+ artifacts,
67
+ sequence=_screen_sequence_from_artifacts(artifacts),
68
+ )
69
+ )
70
+
71
+
72
+ def screen_changed(
73
+ previous_screen: PublicScreen | None,
74
+ public_screen: PublicScreen,
75
+ ) -> bool:
76
+ if previous_screen is None:
77
+ return True
78
+ previous_groups = [
79
+ group.model_dump(by_alias=True, mode="json") for group in previous_screen.groups
80
+ ]
81
+ current_groups = [
82
+ group.model_dump(by_alias=True, mode="json") for group in public_screen.groups
83
+ ]
84
+ return (
85
+ previous_screen.app.package_name != public_screen.app.package_name
86
+ or previous_screen.app.activity_name != public_screen.app.activity_name
87
+ or previous_screen.surface.keyboard_visible
88
+ != public_screen.surface.keyboard_visible
89
+ or previous_groups != current_groups
90
+ )
91
+
92
+
93
+ def _screen_sequence_from_artifacts(artifacts: ScreenArtifacts) -> int:
94
+ screen_json = artifacts.screen_json
95
+ if screen_json is None:
96
+ return 0
97
+ stem = screen_json.rsplit("/", maxsplit=1)[-1].removesuffix(".json")
98
+ sequence_text = stem.removeprefix("obs-")
99
+ return int(sequence_text) if sequence_text.isdigit() else 0
100
+
101
+
102
+ def _now_isoformat() -> str:
103
+ return (
104
+ datetime.now(timezone.utc)
105
+ .replace(microsecond=0)
106
+ .isoformat()
107
+ .replace("+00:00", "Z")
108
+ )
@@ -0,0 +1,17 @@
1
+ """Helpers for daemon/public semantic command naming boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from androidctl_contracts.command_catalog import entry_for_daemon_kind
6
+ from androidctld.protocol import CommandKind
7
+
8
+
9
+ def semantic_result_command_for_daemon_kind(kind: CommandKind | str) -> str:
10
+ normalized_kind = kind.value if isinstance(kind, CommandKind) else str(kind)
11
+ entry = entry_for_daemon_kind(normalized_kind)
12
+ if entry is None:
13
+ return normalized_kind
14
+ return entry.result_command
15
+
16
+
17
+ __all__ = ["semantic_result_command_for_daemon_kind"]