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,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"]
|