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,301 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import AbstractContextManager, nullcontext
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import NoReturn
7
+
8
+ import typer
9
+
10
+ from androidctl import __version__
11
+ from androidctl.cli_options import command_cli_options
12
+ from androidctl.exit_codes import ExitCode
13
+ from androidctl.setup import accessibility as setup_accessibility
14
+ from androidctl.setup import adb as setup_adb
15
+ from androidctl.setup import pairing as setup_pairing
16
+ from androidctl.setup import verify as setup_verify
17
+ from androidctl.setup.apk_resource import (
18
+ packaged_agent_apk_name,
19
+ packaged_agent_apk_path,
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SetupError(RuntimeError):
25
+ code: str
26
+ layer: str
27
+ message: str
28
+ exit_code: ExitCode = ExitCode.ERROR
29
+
30
+ def __str__(self) -> str:
31
+ return self.message
32
+
33
+
34
+ def register(app: typer.Typer) -> None:
35
+ @app.command(
36
+ "setup",
37
+ help="Onboarding helper for preparing an authorized ADB device.",
38
+ )
39
+ def setup(
40
+ ctx: typer.Context,
41
+ adb: bool = typer.Option(False, "--adb", help="Use authorized ADB transport."),
42
+ serial: str | None = typer.Option(None, "--serial", help="ADB serial."),
43
+ apk: Path | None = typer.Option(
44
+ None,
45
+ "--apk",
46
+ help="Override Android Device Agent APK path.",
47
+ ),
48
+ dry_run: bool = typer.Option(
49
+ False,
50
+ "--dry-run",
51
+ help="Print the setup plan without running ADB or mutating a device.",
52
+ ),
53
+ skip_install: bool = typer.Option(
54
+ False,
55
+ "--skip-install",
56
+ help="Skip the APK install step.",
57
+ ),
58
+ manual_accessibility: bool = typer.Option(
59
+ False,
60
+ "--manual-accessibility",
61
+ help="Skip ADB Accessibility writes and guide manual enablement.",
62
+ ),
63
+ workspace_root: Path | None = typer.Option(None, "--workspace-root"),
64
+ ) -> None:
65
+ options = command_cli_options(ctx, workspace_root=workspace_root)
66
+ try:
67
+ run_setup(
68
+ adb=adb,
69
+ serial=serial,
70
+ apk=apk,
71
+ dry_run=dry_run,
72
+ skip_install=skip_install,
73
+ manual_accessibility=manual_accessibility,
74
+ workspace_root=options.workspace_root,
75
+ )
76
+ except SetupError as error:
77
+ _emit_failure(error)
78
+
79
+
80
+ def run_setup(
81
+ *,
82
+ adb: bool,
83
+ serial: str | None,
84
+ apk: Path | None,
85
+ dry_run: bool,
86
+ skip_install: bool,
87
+ manual_accessibility: bool,
88
+ workspace_root: Path | None,
89
+ ) -> None:
90
+ if not adb:
91
+ raise SetupError(
92
+ code="SETUP_REQUIRES_ADB",
93
+ layer="usage",
94
+ message="setup currently requires --adb",
95
+ exit_code=ExitCode.USAGE,
96
+ )
97
+
98
+ _emit_progress("androidctl setup: authorized ADB onboarding")
99
+ if dry_run:
100
+ _emit_dry_run_plan(
101
+ serial=serial,
102
+ apk=apk,
103
+ skip_install=skip_install,
104
+ manual_accessibility=manual_accessibility,
105
+ workspace_root=workspace_root,
106
+ )
107
+ return
108
+
109
+ try:
110
+ devices = setup_adb.list_adb_devices()
111
+ selected_device = setup_adb.select_eligible_device(devices, serial=serial)
112
+ except setup_adb.SetupAdbError as error:
113
+ raise SetupError(
114
+ code=error.code,
115
+ layer=error.layer,
116
+ message=error.message,
117
+ exit_code=ExitCode.ENVIRONMENT,
118
+ ) from error
119
+
120
+ if serial is None:
121
+ _emit_progress("ADB: selected the only authorized device")
122
+ else:
123
+ _emit_progress("ADB: selected requested authorized device")
124
+
125
+ if skip_install:
126
+ _emit_progress("install: skipped by --skip-install")
127
+ else:
128
+ _emit_progress(_apk_plan_line(apk, dry_run=False))
129
+ _install_agent_apk(apk=apk, serial=selected_device.serial)
130
+
131
+ token = _start_setup_activity_with_token(serial=selected_device.serial)
132
+ _enable_accessibility(
133
+ serial=selected_device.serial,
134
+ manual_accessibility=manual_accessibility,
135
+ )
136
+ _verify_setup_readiness(
137
+ serial=selected_device.serial,
138
+ token=token,
139
+ workspace_root=workspace_root,
140
+ )
141
+ _emit_progress("status: setup complete")
142
+
143
+
144
+ def _emit_dry_run_plan(
145
+ *,
146
+ serial: str | None,
147
+ apk: Path | None,
148
+ skip_install: bool,
149
+ manual_accessibility: bool,
150
+ workspace_root: Path | None,
151
+ ) -> None:
152
+ _emit_progress("mode: dry-run; no ADB command or device mutation will run")
153
+ if workspace_root is not None:
154
+ _emit_progress("workspace: command override provided")
155
+ if serial is None:
156
+ _emit_progress("ADB: would select the only authorized device")
157
+ else:
158
+ _emit_progress("ADB: would select the requested authorized device")
159
+ if skip_install:
160
+ _emit_progress("install: skipped by --skip-install")
161
+ else:
162
+ _emit_progress(_apk_plan_line(apk, dry_run=True))
163
+ _emit_progress("launch: would open AndroidCtl and start the foreground server")
164
+ _emit_progress("token: would provision a host-generated device token")
165
+ if manual_accessibility:
166
+ _emit_progress("accessibility: would enter manual enablement fallback")
167
+ else:
168
+ _emit_progress("accessibility: would try ADB enable, then fallback if needed")
169
+ _emit_progress("verify: would connect daemon runtime and run readiness checks")
170
+ _emit_progress("status: dry-run complete")
171
+
172
+
173
+ def _apk_plan_line(
174
+ apk: Path | None,
175
+ *,
176
+ dry_run: bool,
177
+ ) -> str:
178
+ prefix = "would use" if dry_run else "using"
179
+ if apk is not None:
180
+ return f"install: {prefix} override APK path"
181
+ apk_name = packaged_agent_apk_name(__version__)
182
+ return f"install: {prefix} packaged APK {apk_name}"
183
+
184
+
185
+ def _install_agent_apk(
186
+ *,
187
+ apk: Path | None,
188
+ serial: str,
189
+ ) -> None:
190
+ try:
191
+ with _apk_path_context(apk) as apk_path:
192
+ setup_adb.install_apk(apk_path, serial=serial)
193
+ except FileNotFoundError as error:
194
+ raise SetupError(
195
+ code="APK_NOT_FOUND",
196
+ layer="install",
197
+ message=str(error),
198
+ ) from error
199
+ except setup_adb.SetupAdbError as error:
200
+ raise SetupError(
201
+ code=error.code,
202
+ layer="install",
203
+ message=error.message,
204
+ ) from error
205
+ _emit_progress("install: APK installed")
206
+
207
+
208
+ def _apk_path_context(apk: Path | None) -> AbstractContextManager[Path]:
209
+ if apk is not None:
210
+ return nullcontext(apk)
211
+ return packaged_agent_apk_path(__version__)
212
+
213
+
214
+ def _start_setup_activity_with_token(*, serial: str) -> str:
215
+ try:
216
+ setup_adb.force_stop_app(serial=serial)
217
+ token = setup_pairing.generate_host_token()
218
+ setup_adb.start_setup_activity(
219
+ serial=serial,
220
+ string_extras={setup_pairing.SETUP_DEVICE_TOKEN_EXTRA: token},
221
+ )
222
+ except setup_pairing.SetupPairingError as error:
223
+ raise SetupError(
224
+ code=error.code,
225
+ layer=error.layer,
226
+ message=error.message,
227
+ ) from error
228
+ except setup_adb.SetupAdbError as error:
229
+ raise SetupError(
230
+ code=error.code,
231
+ layer="launch",
232
+ message=error.message,
233
+ ) from error
234
+ _emit_progress("launch: existing app process stopped")
235
+ _emit_progress("launch: setup activity started")
236
+ _emit_progress("token: provisioned host-generated device token")
237
+ return token
238
+
239
+
240
+ def _enable_accessibility(
241
+ *,
242
+ serial: str,
243
+ manual_accessibility: bool,
244
+ ) -> None:
245
+ if manual_accessibility:
246
+ _emit_accessibility_fallback("manual enablement requested")
247
+ return
248
+ try:
249
+ result = setup_accessibility.enable_agent_accessibility(serial=serial)
250
+ except setup_accessibility.SetupAccessibilityError as error:
251
+ _emit_progress(
252
+ f"accessibility: ADB enable not confirmed ({error.code}); "
253
+ "using manual fallback"
254
+ )
255
+ _emit_accessibility_fallback(error.message)
256
+ return
257
+ if result.changed_service_list:
258
+ _emit_progress("accessibility: AndroidCtl service added via ADB settings")
259
+ else:
260
+ _emit_progress("accessibility: AndroidCtl service already in ADB settings")
261
+ _emit_progress("accessibility: ADB settings write confirmed")
262
+
263
+
264
+ def _emit_accessibility_fallback(reason: str) -> None:
265
+ _emit_progress(f"accessibility: manual fallback required: {reason}")
266
+ _emit_progress(
267
+ f"accessibility: {setup_accessibility.MANUAL_ACCESSIBILITY_FALLBACK}"
268
+ )
269
+
270
+
271
+ def _verify_setup_readiness(
272
+ *,
273
+ serial: str,
274
+ token: str,
275
+ workspace_root: Path | None,
276
+ ) -> None:
277
+ try:
278
+ setup_verify.verify_setup_readiness(
279
+ serial=serial,
280
+ token=token,
281
+ workspace_root=workspace_root,
282
+ )
283
+ except setup_verify.SetupVerificationError as error:
284
+ raise SetupError(
285
+ code=error.code,
286
+ layer=error.layer,
287
+ message=error.message,
288
+ ) from error
289
+ _emit_progress("verify: daemon connect/readiness check succeeded")
290
+
291
+
292
+ def _emit_progress(message: str) -> None:
293
+ typer.echo(message, err=True)
294
+
295
+
296
+ def _emit_failure(error: SetupError) -> NoReturn:
297
+ typer.echo(
298
+ f"androidctl setup failed [{error.layer}/{error.code}]: {error.message}",
299
+ err=True,
300
+ )
301
+ raise typer.Exit(code=int(error.exit_code))
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from androidctl.cli_options import CliOptions
8
+ from androidctl.command_payloads import build_wait_command
9
+ from androidctl.commands import run_pipeline
10
+ from androidctl.commands.execute import emit_usage_error
11
+ from androidctl.commands.plumbing import build_and_run_command
12
+ from androidctl.parsing.duration import parse_duration_ms
13
+ from androidctl.parsing.wait import parse_wait_predicate
14
+
15
+
16
+ def register(app: typer.Typer) -> None:
17
+ @app.command("wait")
18
+ def wait_command(
19
+ ctx: typer.Context,
20
+ until: str = typer.Option(..., "--until", help="Predicate kind to wait for."),
21
+ ref: str | None = typer.Option(None, "--ref", help="Ref for gone waits."),
22
+ text: str | None = typer.Option(
23
+ None, "--text", help="Text to match for text-present waits."
24
+ ),
25
+ app_target: str | None = typer.Option(
26
+ None, "--app", help="Package name to match for app waits."
27
+ ),
28
+ screen_id: str | None = typer.Option(
29
+ None, "--screen-id", help="Override the source screen id for this wait."
30
+ ),
31
+ timeout: str = typer.Option("2000ms", "--timeout"),
32
+ workspace_root: Path | None = typer.Option(None, "--workspace-root"),
33
+ ) -> None:
34
+ def build_request(options: CliOptions) -> run_pipeline.CliCommandRequest:
35
+ try:
36
+ predicate = parse_wait_predicate(
37
+ until,
38
+ ref=ref,
39
+ text=text,
40
+ package_name=app_target,
41
+ source_screen_id=screen_id,
42
+ )
43
+ timeout_ms = parse_duration_ms(timeout)
44
+ except ValueError as error:
45
+ emit_usage_error(str(error))
46
+
47
+ return run_pipeline.CliCommandRequest(
48
+ public_command="wait",
49
+ command=build_wait_command(
50
+ predicate=predicate,
51
+ timeout_ms=timeout_ms,
52
+ ),
53
+ workspace_root=options.workspace_root,
54
+ )
55
+
56
+ build_and_run_command(
57
+ ctx=ctx,
58
+ workspace_root=workspace_root,
59
+ build_request=build_request,
60
+ )
@@ -0,0 +1 @@
1
+ """Daemon discovery/startup/client helpers."""
@@ -0,0 +1,348 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, TypeVar
5
+
6
+ import httpx
7
+ from pydantic import BaseModel, ValidationError
8
+
9
+ from androidctl import __version__ as ANDROIDCTL_VERSION
10
+ from androidctl_contracts.command_catalog import (
11
+ entry_for_daemon_kind,
12
+ runtime_close_entry,
13
+ )
14
+ from androidctl_contracts.command_results import (
15
+ CommandResultCore,
16
+ ListAppsResult,
17
+ RetainedResultEnvelope,
18
+ )
19
+ from androidctl_contracts.daemon_api import (
20
+ OWNER_HEADER_NAME,
21
+ TOKEN_HEADER_NAME,
22
+ CommandRunRequest,
23
+ DaemonErrorEnvelope,
24
+ HealthResult,
25
+ RuntimeGetResult,
26
+ RuntimePayload,
27
+ WaitCommandPayload,
28
+ )
29
+ from androidctl_contracts.user_state import ActiveDaemonRecord
30
+ from androidctl_contracts.vocabulary import PublicResultFamily
31
+
32
+ ModelT = TypeVar("ModelT", bound=BaseModel)
33
+ CommandResultPayload = CommandResultCore | RetainedResultEnvelope | ListAppsResult
34
+
35
+ _DEFAULT_DAEMON_TIMEOUT_SECONDS = 5.0
36
+ _LONG_REQUEST_READ_TIMEOUT_GRACE_SECONDS = 2.0
37
+
38
+
39
+ class DaemonProtocolError(RuntimeError):
40
+ pass
41
+
42
+
43
+ class IncompatibleDaemonError(DaemonProtocolError):
44
+ pass
45
+
46
+
47
+ class IncompatibleDaemonVersionError(IncompatibleDaemonError):
48
+ def __init__(self, *, expected_version: str, actual_version: str) -> None:
49
+ super().__init__(
50
+ "androidctl/androidctld release version mismatch: "
51
+ f"cli={expected_version} daemon={actual_version}"
52
+ )
53
+ self.expected_version = expected_version
54
+ self.actual_version = actual_version
55
+
56
+
57
+ class DaemonApiError(RuntimeError):
58
+ def __init__(
59
+ self,
60
+ *,
61
+ code: str = "DAEMON_API_ERROR",
62
+ message: str = "daemon request failed",
63
+ details: dict[str, Any] | None = None,
64
+ ) -> None:
65
+ super().__init__(message)
66
+ self.code = code
67
+ self.message = message
68
+ self.details = details or {}
69
+
70
+
71
+ class DaemonClient:
72
+ def __init__(
73
+ self,
74
+ http_client: httpx.Client,
75
+ *,
76
+ owner_id: str | None = None,
77
+ token: str | None = None,
78
+ ) -> None:
79
+ self._http = http_client
80
+ self._owner_id = owner_id
81
+ self._token = token
82
+
83
+ @classmethod
84
+ def from_active_record(
85
+ cls,
86
+ record: ActiveDaemonRecord,
87
+ *,
88
+ owner_id: str,
89
+ http_client: httpx.Client | None = None,
90
+ ) -> DaemonClient:
91
+ base_url = f"http://{record.host}:{record.port}"
92
+ return cls(
93
+ http_client or httpx.Client(base_url=base_url, trust_env=False),
94
+ owner_id=owner_id,
95
+ token=record.token,
96
+ )
97
+
98
+ def health(self, record: ActiveDaemonRecord | None = None) -> HealthResult:
99
+ result = self._post_json(
100
+ "/health",
101
+ payload={},
102
+ token=self._resolve_token(record),
103
+ )
104
+ try:
105
+ health = HealthResult.model_validate(result)
106
+ except ValidationError as error:
107
+ if _is_legacy_health_schema_failure(error):
108
+ raise IncompatibleDaemonError(
109
+ "androidctl/androidctld health payload is incompatible; "
110
+ "install matching androidctl and androidctld versions"
111
+ ) from error
112
+ raise DaemonProtocolError("invalid health response schema") from error
113
+
114
+ if health.service != "androidctld":
115
+ raise DaemonProtocolError(
116
+ f"incompatible daemon service: {health.service!r}"
117
+ )
118
+ if record is not None:
119
+ if health.workspace_root != record.workspace_root:
120
+ raise DaemonProtocolError("health response workspace root mismatch")
121
+ if health.owner_id != record.owner_id:
122
+ raise DaemonProtocolError("health response owner id mismatch")
123
+ if health.version != ANDROIDCTL_VERSION:
124
+ raise IncompatibleDaemonVersionError(
125
+ expected_version=ANDROIDCTL_VERSION,
126
+ actual_version=health.version,
127
+ )
128
+ return health
129
+
130
+ def get_runtime(
131
+ self,
132
+ record: ActiveDaemonRecord | None = None,
133
+ ) -> RuntimePayload:
134
+ result = self._post_json(
135
+ "/runtime/get",
136
+ payload={},
137
+ token=self._resolve_token(record),
138
+ )
139
+ return _validate_model(
140
+ RuntimeGetResult,
141
+ result,
142
+ message="invalid runtime/get response schema",
143
+ ).runtime
144
+
145
+ def close_runtime(
146
+ self,
147
+ record: ActiveDaemonRecord | None = None,
148
+ ) -> RetainedResultEnvelope:
149
+ result = self._post_json(
150
+ "/runtime/close",
151
+ payload={},
152
+ token=self._resolve_token(record),
153
+ )
154
+ entry = runtime_close_entry()
155
+ close_result = _validate_model(
156
+ RetainedResultEnvelope,
157
+ result,
158
+ message="invalid runtime/close response schema",
159
+ )
160
+ _assert_result_command(close_result, entry.result_command)
161
+ return close_result
162
+
163
+ def run_command(
164
+ self,
165
+ *,
166
+ request: CommandRunRequest,
167
+ record: ActiveDaemonRecord | None = None,
168
+ ) -> CommandResultPayload:
169
+ result = self._post_json(
170
+ "/commands/run",
171
+ payload=request.model_dump(exclude_none=True, exclude_defaults=True),
172
+ token=self._resolve_token(record),
173
+ timeout=_command_request_timeout(request),
174
+ )
175
+ entry = entry_for_daemon_kind(request.command.kind)
176
+ if entry is None:
177
+ raise DaemonProtocolError(
178
+ f"unknown command catalog entry for kind={request.command.kind!r}"
179
+ )
180
+ if entry.result_family is PublicResultFamily.SEMANTIC:
181
+ command_result: CommandResultPayload = _validate_model(
182
+ CommandResultCore,
183
+ result,
184
+ message="invalid commands/run response schema",
185
+ )
186
+ elif entry.result_family is PublicResultFamily.RETAINED:
187
+ command_result = _validate_model(
188
+ RetainedResultEnvelope,
189
+ result,
190
+ message="invalid commands/run response schema",
191
+ )
192
+ elif entry.result_family is PublicResultFamily.LIST_APPS:
193
+ command_result = _validate_model(
194
+ ListAppsResult,
195
+ result,
196
+ message="invalid commands/run response schema",
197
+ )
198
+ else:
199
+ raise DaemonProtocolError(
200
+ f"unsupported result family {entry.result_family!r}"
201
+ )
202
+ _assert_result_command(command_result, entry.result_command)
203
+ return command_result
204
+
205
+ def _resolve_token(self, record: ActiveDaemonRecord | None) -> str:
206
+ if record is not None:
207
+ return record.token
208
+ if self._token:
209
+ return self._token
210
+ raise DaemonProtocolError("daemon token is not configured")
211
+
212
+ def _post_json(
213
+ self,
214
+ path: str,
215
+ *,
216
+ payload: dict[str, Any],
217
+ token: str,
218
+ timeout: httpx.Timeout | None = None,
219
+ ) -> dict[str, Any]:
220
+ headers = {TOKEN_HEADER_NAME: token}
221
+ if self._owner_id is not None:
222
+ headers[OWNER_HEADER_NAME] = self._owner_id
223
+ response = self._http.post(
224
+ path,
225
+ headers=headers,
226
+ json=payload,
227
+ timeout=timeout or _default_request_timeout(),
228
+ )
229
+ envelope: dict[str, Any] | None = None
230
+ envelope_error: DaemonProtocolError | None = None
231
+ try:
232
+ envelope = self._parse_json_envelope(response)
233
+ except DaemonProtocolError as error:
234
+ envelope_error = error
235
+
236
+ if envelope is None:
237
+ response.raise_for_status()
238
+ if envelope_error is None:
239
+ raise DaemonProtocolError("invalid daemon response envelope")
240
+ raise envelope_error
241
+
242
+ ok = envelope.get("ok")
243
+ if ok is False:
244
+ typed_error = _validate_model(
245
+ DaemonErrorEnvelope,
246
+ envelope,
247
+ message="invalid daemon error envelope",
248
+ ).error
249
+ raise DaemonApiError(
250
+ code=typed_error.code.value,
251
+ message=typed_error.message,
252
+ details=dict(typed_error.details),
253
+ )
254
+ if response.is_error:
255
+ response.raise_for_status()
256
+ if ok is not True:
257
+ raise DaemonProtocolError("daemon success envelope missing ok=true")
258
+
259
+ result = envelope.get("result")
260
+ if not isinstance(result, dict):
261
+ raise DaemonProtocolError("daemon response missing 'result' payload")
262
+ return result
263
+
264
+ def _parse_json_envelope(self, response: httpx.Response) -> dict[str, Any]:
265
+ try:
266
+ payload = response.json()
267
+ except json.JSONDecodeError as error:
268
+ raise DaemonProtocolError("daemon response is not valid JSON") from error
269
+ if not isinstance(payload, dict):
270
+ raise DaemonProtocolError("daemon response is not a JSON object")
271
+ return payload
272
+
273
+
274
+ def try_get_healthy_daemon(
275
+ client: DaemonClient,
276
+ record: ActiveDaemonRecord,
277
+ ) -> HealthResult | None:
278
+ try:
279
+ return client.health(record)
280
+ except (DaemonApiError, httpx.RequestError, httpx.HTTPStatusError):
281
+ return None
282
+
283
+
284
+ def _command_request_timeout(request: CommandRunRequest) -> httpx.Timeout:
285
+ return _timeout_with_read_budget(_command_read_budget_ms(request))
286
+
287
+
288
+ def _command_read_budget_ms(request: CommandRunRequest) -> int | None:
289
+ if not isinstance(request.command, WaitCommandPayload):
290
+ return None
291
+ return _optional_non_negative_int(request.command.timeout_ms)
292
+
293
+
294
+ def _timeout_with_read_budget(read_budget_ms: int | None) -> httpx.Timeout:
295
+ read_timeout = _DEFAULT_DAEMON_TIMEOUT_SECONDS
296
+ if read_budget_ms is not None:
297
+ read_timeout = max(
298
+ _DEFAULT_DAEMON_TIMEOUT_SECONDS,
299
+ (read_budget_ms / 1000.0) + _LONG_REQUEST_READ_TIMEOUT_GRACE_SECONDS,
300
+ )
301
+ return httpx.Timeout(
302
+ connect=_DEFAULT_DAEMON_TIMEOUT_SECONDS,
303
+ read=read_timeout,
304
+ write=_DEFAULT_DAEMON_TIMEOUT_SECONDS,
305
+ pool=_DEFAULT_DAEMON_TIMEOUT_SECONDS,
306
+ )
307
+
308
+
309
+ def _default_request_timeout() -> httpx.Timeout:
310
+ return _timeout_with_read_budget(None)
311
+
312
+
313
+ def _optional_non_negative_int(value: object) -> int | None:
314
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
315
+ return None
316
+ return value
317
+
318
+
319
+ def _validate_model(
320
+ model_type: type[ModelT],
321
+ payload: object,
322
+ *,
323
+ message: str,
324
+ ) -> ModelT:
325
+ try:
326
+ return model_type.model_validate(payload)
327
+ except ValidationError as error:
328
+ raise DaemonProtocolError(message) from error
329
+
330
+
331
+ def _assert_result_command(
332
+ result: CommandResultPayload,
333
+ expected_command: str,
334
+ ) -> None:
335
+ if result.command != expected_command:
336
+ raise DaemonProtocolError(
337
+ "daemon result command mismatch: "
338
+ f"expected {expected_command!r}, got {result.command!r}"
339
+ )
340
+
341
+
342
+ def _is_legacy_health_schema_failure(error: ValidationError) -> bool:
343
+ errors = error.errors()
344
+ if len(errors) != 1:
345
+ return False
346
+ first_error = errors[0]
347
+ location = first_error.get("loc")
348
+ return first_error.get("type") == "extra_forbidden" and location == ("apiVersion",)