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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- 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",)
|