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,161 @@
|
|
|
1
|
+
"""Persistence boundary models for androidctld local state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
ConfigDict,
|
|
10
|
+
Field,
|
|
11
|
+
ValidationError,
|
|
12
|
+
field_validator,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from androidctld.protocol import RuntimeStatus
|
|
16
|
+
from androidctld.schema.base import ApiModel, to_camel
|
|
17
|
+
from androidctld.schema.core import (
|
|
18
|
+
SchemaDecodeError,
|
|
19
|
+
expect_field,
|
|
20
|
+
expect_int,
|
|
21
|
+
expect_object,
|
|
22
|
+
)
|
|
23
|
+
from androidctld.schema.validation_errors import validation_error_to_schema_decode_error
|
|
24
|
+
|
|
25
|
+
RUNTIME_STATE_SCHEMA_VERSION = 1
|
|
26
|
+
RUNTIME_STATE_FILE_NAME = "runtime.json"
|
|
27
|
+
|
|
28
|
+
EnumT = TypeVar("EnumT", bound=Enum)
|
|
29
|
+
PersistenceModelT = TypeVar("PersistenceModelT", bound="PersistenceModel")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def expect_schema_version(
|
|
33
|
+
payload: object,
|
|
34
|
+
field_name: str,
|
|
35
|
+
version: int,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
parsed = expect_object(payload, field_name)
|
|
38
|
+
actual = expect_int(
|
|
39
|
+
expect_field(parsed, "schemaVersion", f"{field_name}.schemaVersion"),
|
|
40
|
+
f"{field_name}.schemaVersion",
|
|
41
|
+
minimum=1,
|
|
42
|
+
)
|
|
43
|
+
if actual != version:
|
|
44
|
+
raise SchemaDecodeError(
|
|
45
|
+
f"{field_name}.schemaVersion",
|
|
46
|
+
f"must be {version}",
|
|
47
|
+
)
|
|
48
|
+
return parsed
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _strip_string(value: object) -> object:
|
|
52
|
+
if isinstance(value, str):
|
|
53
|
+
return value.strip()
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _coerce_enum(value: object, enum_type: type[EnumT]) -> object:
|
|
58
|
+
normalized = _strip_string(value)
|
|
59
|
+
if isinstance(normalized, enum_type):
|
|
60
|
+
return normalized
|
|
61
|
+
if isinstance(normalized, str):
|
|
62
|
+
try:
|
|
63
|
+
return enum_type(normalized)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return normalized
|
|
66
|
+
return normalized
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PersistenceModel(ApiModel):
|
|
70
|
+
model_config = ConfigDict(
|
|
71
|
+
strict=True,
|
|
72
|
+
extra="ignore",
|
|
73
|
+
alias_generator=to_camel,
|
|
74
|
+
validate_by_alias=True,
|
|
75
|
+
validate_by_name=True,
|
|
76
|
+
use_enum_values=False,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_persistence_payload(
|
|
81
|
+
model_type: type[PersistenceModelT],
|
|
82
|
+
payload: object,
|
|
83
|
+
*,
|
|
84
|
+
field_name: str,
|
|
85
|
+
schema_version: int | None,
|
|
86
|
+
) -> PersistenceModelT:
|
|
87
|
+
if schema_version is None:
|
|
88
|
+
parsed = expect_object(payload, field_name)
|
|
89
|
+
else:
|
|
90
|
+
parsed = expect_schema_version(payload, field_name, schema_version)
|
|
91
|
+
parsed.pop("schemaVersion", None)
|
|
92
|
+
try:
|
|
93
|
+
return model_type.model_validate(parsed)
|
|
94
|
+
except ValidationError as error:
|
|
95
|
+
raise validation_error_to_schema_decode_error(
|
|
96
|
+
error,
|
|
97
|
+
field_name=field_name,
|
|
98
|
+
) from error
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_persistence_model(
|
|
102
|
+
model_type: type[PersistenceModelT],
|
|
103
|
+
/,
|
|
104
|
+
**data: Any,
|
|
105
|
+
) -> PersistenceModelT:
|
|
106
|
+
return model_type.model_validate(data, by_name=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ActiveDaemonFile(PersistenceModel):
|
|
110
|
+
model_config = ConfigDict(extra="forbid")
|
|
111
|
+
|
|
112
|
+
pid: int = Field(ge=0)
|
|
113
|
+
host: str = Field(min_length=1)
|
|
114
|
+
port: int = Field(ge=1)
|
|
115
|
+
token: str = Field(min_length=1)
|
|
116
|
+
started_at: str = Field(min_length=1)
|
|
117
|
+
workspace_root: str = Field(min_length=1)
|
|
118
|
+
owner_id: str = Field(min_length=1)
|
|
119
|
+
|
|
120
|
+
@field_validator(
|
|
121
|
+
"host",
|
|
122
|
+
"token",
|
|
123
|
+
"started_at",
|
|
124
|
+
"workspace_root",
|
|
125
|
+
"owner_id",
|
|
126
|
+
mode="before",
|
|
127
|
+
)
|
|
128
|
+
@classmethod
|
|
129
|
+
def _strip_required_strings(cls, value: object) -> object:
|
|
130
|
+
return _strip_string(value)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class RuntimeStateFile(PersistenceModel):
|
|
134
|
+
model_config = ConfigDict(extra="forbid")
|
|
135
|
+
|
|
136
|
+
status: RuntimeStatus
|
|
137
|
+
screen_sequence: int = Field(default=0, ge=0)
|
|
138
|
+
updated_at: str = Field(min_length=1)
|
|
139
|
+
|
|
140
|
+
@field_validator("status", mode="before")
|
|
141
|
+
@classmethod
|
|
142
|
+
def _strip_status(cls, value: object) -> object:
|
|
143
|
+
return _coerce_enum(value, RuntimeStatus)
|
|
144
|
+
|
|
145
|
+
@field_validator("status")
|
|
146
|
+
@classmethod
|
|
147
|
+
def _reject_restart_unsafe_ready_status(
|
|
148
|
+
cls,
|
|
149
|
+
value: RuntimeStatus,
|
|
150
|
+
) -> RuntimeStatus:
|
|
151
|
+
if value is RuntimeStatus.READY:
|
|
152
|
+
raise ValueError("persisted runtime status cannot be ready")
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
@field_validator(
|
|
156
|
+
"updated_at",
|
|
157
|
+
mode="before",
|
|
158
|
+
)
|
|
159
|
+
@classmethod
|
|
160
|
+
def _strip_required_strings(cls, value: object) -> object:
|
|
161
|
+
return _strip_string(value)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""File I/O helpers for JSON persistence boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def dump_formatted_json(handle: IO[str], payload: object) -> None:
|
|
13
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
14
|
+
handle.write("\n")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def atomic_write_json(path: Path, payload: object) -> None:
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
temp_path = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
|
|
20
|
+
try:
|
|
21
|
+
with temp_path.open("w", encoding="utf-8") as handle:
|
|
22
|
+
dump_formatted_json(handle, payload)
|
|
23
|
+
os.replace(temp_path, path)
|
|
24
|
+
finally:
|
|
25
|
+
try:
|
|
26
|
+
temp_path.unlink()
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
pass
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_json_object(path: Path) -> dict[str, Any]:
|
|
34
|
+
try:
|
|
35
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
36
|
+
payload = json.load(handle)
|
|
37
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
38
|
+
raise ValueError(str(error)) from error
|
|
39
|
+
if not isinstance(payload, dict):
|
|
40
|
+
raise ValueError("root JSON value must be an object")
|
|
41
|
+
return dict(payload)
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Validation error adapters for boundary models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from androidctld.device.errors import DeviceBootstrapError, device_rpc_failed
|
|
12
|
+
from androidctld.errors import DaemonError, bad_request
|
|
13
|
+
from androidctld.schema.core import SchemaDecodeError
|
|
14
|
+
|
|
15
|
+
_COMMAND_UNION_TAGS = {
|
|
16
|
+
"connect",
|
|
17
|
+
"observe",
|
|
18
|
+
"listApps",
|
|
19
|
+
"open",
|
|
20
|
+
"tap",
|
|
21
|
+
"longTap",
|
|
22
|
+
"focus",
|
|
23
|
+
"type",
|
|
24
|
+
"submit",
|
|
25
|
+
"scroll",
|
|
26
|
+
"back",
|
|
27
|
+
"home",
|
|
28
|
+
"recents",
|
|
29
|
+
"notifications",
|
|
30
|
+
"wait",
|
|
31
|
+
"screenshot",
|
|
32
|
+
}
|
|
33
|
+
_OPEN_TARGET_UNION_TAGS = {"app", "url"}
|
|
34
|
+
_WAIT_PREDICATE_UNION_TAGS = {
|
|
35
|
+
"screen-change",
|
|
36
|
+
"text-present",
|
|
37
|
+
"gone",
|
|
38
|
+
"app",
|
|
39
|
+
"idle",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validation_error_field_path(location: Sequence[Any]) -> str:
|
|
44
|
+
parts: list[str] = []
|
|
45
|
+
for item in location:
|
|
46
|
+
if isinstance(item, int):
|
|
47
|
+
if not parts:
|
|
48
|
+
parts.append(f"[{item}]")
|
|
49
|
+
else:
|
|
50
|
+
parts[-1] = f"{parts[-1]}[{item}]"
|
|
51
|
+
continue
|
|
52
|
+
parts.append(str(item))
|
|
53
|
+
if not parts:
|
|
54
|
+
return "root"
|
|
55
|
+
return ".".join(parts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validation_error_leaf(location: Sequence[Any]) -> str:
|
|
59
|
+
if not location:
|
|
60
|
+
return "root"
|
|
61
|
+
return str(location[-1])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validation_error_container(location: Sequence[Any]) -> tuple[Any, ...]:
|
|
65
|
+
if location:
|
|
66
|
+
return tuple(location[:-1])
|
|
67
|
+
return ()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _prefix_field_name(field_name: str | None, field: str) -> str:
|
|
71
|
+
if field_name is None:
|
|
72
|
+
return field
|
|
73
|
+
if field == "root":
|
|
74
|
+
return field_name
|
|
75
|
+
if field.startswith("["):
|
|
76
|
+
return f"{field_name}{field}"
|
|
77
|
+
return f"{field_name}.{field}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_discriminated_union_location(
|
|
81
|
+
location: Sequence[Any],
|
|
82
|
+
*,
|
|
83
|
+
error_type: str | None = None,
|
|
84
|
+
ctx: dict[str, Any] | None = None,
|
|
85
|
+
) -> Sequence[Any]:
|
|
86
|
+
parts = list(location)
|
|
87
|
+
if error_type == "union_tag_invalid" and ctx is not None:
|
|
88
|
+
discriminator = ctx.get("discriminator")
|
|
89
|
+
if isinstance(discriminator, str):
|
|
90
|
+
parts.append(discriminator.strip("'"))
|
|
91
|
+
|
|
92
|
+
normalized: list[Any] = []
|
|
93
|
+
index = 0
|
|
94
|
+
while index < len(parts):
|
|
95
|
+
item = parts[index]
|
|
96
|
+
normalized.append(item)
|
|
97
|
+
if (
|
|
98
|
+
item == "command"
|
|
99
|
+
and index + 1 < len(parts)
|
|
100
|
+
and parts[index + 1] in _COMMAND_UNION_TAGS
|
|
101
|
+
):
|
|
102
|
+
index += 2
|
|
103
|
+
continue
|
|
104
|
+
if (
|
|
105
|
+
item == "target"
|
|
106
|
+
and index + 1 < len(parts)
|
|
107
|
+
and parts[index + 1] in _OPEN_TARGET_UNION_TAGS
|
|
108
|
+
):
|
|
109
|
+
index += 2
|
|
110
|
+
continue
|
|
111
|
+
if (
|
|
112
|
+
item == "predicate"
|
|
113
|
+
and index + 1 < len(parts)
|
|
114
|
+
and parts[index + 1] in _WAIT_PREDICATE_UNION_TAGS
|
|
115
|
+
):
|
|
116
|
+
index += 2
|
|
117
|
+
continue
|
|
118
|
+
index += 1
|
|
119
|
+
return tuple(normalized)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validation_error_problem(error_type: str, ctx: dict[str, Any] | None) -> str:
|
|
123
|
+
if error_type in {"bool_type", "bool_parsing"}:
|
|
124
|
+
return "must be a boolean"
|
|
125
|
+
if error_type in {"int_type", "int_parsing"}:
|
|
126
|
+
return "must be an integer"
|
|
127
|
+
if error_type == "is_instance_of" and ctx is not None:
|
|
128
|
+
class_name = ctx.get("class")
|
|
129
|
+
if class_name == "CommandKind":
|
|
130
|
+
return "must be a supported command kind"
|
|
131
|
+
if class_name == "RuntimeStatus":
|
|
132
|
+
return "must be a supported runtime status"
|
|
133
|
+
if error_type == "list_type":
|
|
134
|
+
return "must be a list"
|
|
135
|
+
if error_type in {"dict_type", "model_type"}:
|
|
136
|
+
return "must be a JSON object"
|
|
137
|
+
if error_type in {"string_type", "string_sub_type", "string_unicode"}:
|
|
138
|
+
return "must be a string"
|
|
139
|
+
if error_type == "value_error" and ctx is not None:
|
|
140
|
+
error = ctx.get("error")
|
|
141
|
+
if isinstance(error, ValueError):
|
|
142
|
+
return str(error)
|
|
143
|
+
if error_type == "literal_error" and ctx is not None:
|
|
144
|
+
expected = ctx.get("expected")
|
|
145
|
+
if expected == "'done', 'partial' or 'timeout'":
|
|
146
|
+
return "must be one of done|partial|timeout"
|
|
147
|
+
if error_type == "union_tag_invalid" and ctx is not None:
|
|
148
|
+
expected_tags = ctx.get("expected_tags")
|
|
149
|
+
if expected_tags == "'app', 'url'":
|
|
150
|
+
return "must be app or url"
|
|
151
|
+
if error_type == "missing":
|
|
152
|
+
return "is required"
|
|
153
|
+
if error_type == "extra_forbidden":
|
|
154
|
+
return "has unsupported fields"
|
|
155
|
+
if error_type == "greater_than_equal" and ctx is not None:
|
|
156
|
+
minimum = ctx.get("ge")
|
|
157
|
+
if isinstance(minimum, int) and not isinstance(minimum, bool):
|
|
158
|
+
return f"must be an integer >= {minimum}"
|
|
159
|
+
if error_type == "greater_than" and ctx is not None:
|
|
160
|
+
minimum = ctx.get("gt")
|
|
161
|
+
if isinstance(minimum, int) and not isinstance(minimum, bool):
|
|
162
|
+
return f"must be an integer > {minimum}"
|
|
163
|
+
if error_type == "string_too_short" and ctx is not None:
|
|
164
|
+
min_length = ctx.get("min_length")
|
|
165
|
+
if isinstance(min_length, int) and not isinstance(min_length, bool):
|
|
166
|
+
if min_length == 1:
|
|
167
|
+
return "must be a non-empty string"
|
|
168
|
+
return f"must be a string with at least {min_length} characters"
|
|
169
|
+
return "is invalid"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validation_error_field(
|
|
173
|
+
location: Sequence[Any],
|
|
174
|
+
*,
|
|
175
|
+
error_type: str | None = None,
|
|
176
|
+
ctx: dict[str, Any] | None = None,
|
|
177
|
+
field_name: str | None = None,
|
|
178
|
+
) -> str:
|
|
179
|
+
location = _normalize_discriminated_union_location(
|
|
180
|
+
location,
|
|
181
|
+
error_type=error_type,
|
|
182
|
+
ctx=ctx,
|
|
183
|
+
)
|
|
184
|
+
return _prefix_field_name(field_name, validation_error_field_path(location))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _validation_error_extra_fields(
|
|
188
|
+
errors: Sequence[Any],
|
|
189
|
+
*,
|
|
190
|
+
field_name: str | None = None,
|
|
191
|
+
) -> tuple[str, list[str]] | None:
|
|
192
|
+
grouped: dict[tuple[Any, ...], list[str]] = defaultdict(list)
|
|
193
|
+
order: list[tuple[Any, ...]] = []
|
|
194
|
+
for raw_item in errors:
|
|
195
|
+
item = cast(dict[str, Any], raw_item)
|
|
196
|
+
if str(item["type"]) != "extra_forbidden":
|
|
197
|
+
continue
|
|
198
|
+
location = _normalize_discriminated_union_location(
|
|
199
|
+
cast(Sequence[Any], item["loc"])
|
|
200
|
+
)
|
|
201
|
+
container = _validation_error_container(location)
|
|
202
|
+
if container not in grouped:
|
|
203
|
+
order.append(container)
|
|
204
|
+
grouped[container].append(_validation_error_leaf(location))
|
|
205
|
+
if not grouped:
|
|
206
|
+
return None
|
|
207
|
+
chosen = min(order, key=len) # Same-depth ties intentionally keep first seen.
|
|
208
|
+
unknown_fields = sorted(grouped[chosen])
|
|
209
|
+
return (
|
|
210
|
+
_prefix_field_name(field_name, validation_error_field_path(chosen)),
|
|
211
|
+
unknown_fields,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validation_error_to_schema_decode_error(
|
|
216
|
+
error: ValidationError,
|
|
217
|
+
*,
|
|
218
|
+
field_name: str | None = None,
|
|
219
|
+
) -> SchemaDecodeError:
|
|
220
|
+
errors = error.errors()
|
|
221
|
+
extra_fields = _validation_error_extra_fields(errors, field_name=field_name)
|
|
222
|
+
if extra_fields is not None:
|
|
223
|
+
field, _unknown_fields = extra_fields
|
|
224
|
+
return SchemaDecodeError(field, "has unsupported fields")
|
|
225
|
+
first_error = cast(dict[str, Any], errors[0])
|
|
226
|
+
location = cast(Sequence[Any], first_error["loc"])
|
|
227
|
+
field = _validation_error_field(
|
|
228
|
+
location,
|
|
229
|
+
error_type=str(first_error["type"]),
|
|
230
|
+
ctx=first_error.get("ctx"),
|
|
231
|
+
field_name=field_name,
|
|
232
|
+
)
|
|
233
|
+
problem = _validation_error_problem(
|
|
234
|
+
str(first_error["type"]),
|
|
235
|
+
first_error.get("ctx"),
|
|
236
|
+
)
|
|
237
|
+
return SchemaDecodeError(field, problem)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validation_error_to_bad_request(
|
|
241
|
+
error: ValidationError,
|
|
242
|
+
*,
|
|
243
|
+
field_name: str | None = None,
|
|
244
|
+
) -> DaemonError:
|
|
245
|
+
errors = error.errors()
|
|
246
|
+
extra_fields = _validation_error_extra_fields(errors, field_name=field_name)
|
|
247
|
+
if extra_fields is not None:
|
|
248
|
+
field, unknown_fields = extra_fields
|
|
249
|
+
return bad_request(
|
|
250
|
+
f"{field} has unsupported fields",
|
|
251
|
+
{
|
|
252
|
+
"field": field,
|
|
253
|
+
"unknownFields": unknown_fields,
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
first_error = cast(dict[str, Any], errors[0])
|
|
257
|
+
location = cast(Sequence[Any], first_error["loc"])
|
|
258
|
+
field = _validation_error_field(
|
|
259
|
+
location,
|
|
260
|
+
error_type=str(first_error["type"]),
|
|
261
|
+
ctx=first_error.get("ctx"),
|
|
262
|
+
field_name=field_name,
|
|
263
|
+
)
|
|
264
|
+
problem = _validation_error_problem(
|
|
265
|
+
str(first_error["type"]),
|
|
266
|
+
first_error.get("ctx"),
|
|
267
|
+
)
|
|
268
|
+
return bad_request(
|
|
269
|
+
f"{field} {problem}",
|
|
270
|
+
{"field": field},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def validation_error_to_device_bootstrap_error(
|
|
275
|
+
error: ValidationError,
|
|
276
|
+
*,
|
|
277
|
+
field_name: str | None = None,
|
|
278
|
+
retryable: bool = False,
|
|
279
|
+
) -> DeviceBootstrapError:
|
|
280
|
+
errors = error.errors()
|
|
281
|
+
extra_fields = _validation_error_extra_fields(errors, field_name=field_name)
|
|
282
|
+
if extra_fields is not None:
|
|
283
|
+
field, unknown_fields = extra_fields
|
|
284
|
+
return device_rpc_failed(
|
|
285
|
+
f"device RPC {field} has unsupported fields",
|
|
286
|
+
{
|
|
287
|
+
"field": field,
|
|
288
|
+
"reason": "invalid_payload",
|
|
289
|
+
"unknownFields": unknown_fields,
|
|
290
|
+
},
|
|
291
|
+
retryable=retryable,
|
|
292
|
+
)
|
|
293
|
+
first_error = cast(dict[str, Any], errors[0])
|
|
294
|
+
location = cast(Sequence[Any], first_error["loc"])
|
|
295
|
+
field = _validation_error_field(
|
|
296
|
+
location,
|
|
297
|
+
error_type=str(first_error["type"]),
|
|
298
|
+
ctx=first_error.get("ctx"),
|
|
299
|
+
field_name=field_name,
|
|
300
|
+
)
|
|
301
|
+
problem = _validation_error_problem(
|
|
302
|
+
str(first_error["type"]),
|
|
303
|
+
first_error.get("ctx"),
|
|
304
|
+
)
|
|
305
|
+
return device_rpc_failed(
|
|
306
|
+
f"device RPC {field} {problem}",
|
|
307
|
+
{"field": field, "reason": "invalid_payload"},
|
|
308
|
+
retryable=retryable,
|
|
309
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Semantic screen compilation."""
|