androidctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. androidctl/__init__.py +5 -0
  2. androidctl/__main__.py +4 -0
  3. androidctl/_version.py +1 -0
  4. androidctl/app.py +73 -0
  5. androidctl/cli_options.py +27 -0
  6. androidctl/command_payloads.py +264 -0
  7. androidctl/command_views.py +157 -0
  8. androidctl/commands/__init__.py +1 -0
  9. androidctl/commands/actions.py +236 -0
  10. androidctl/commands/adb_wireless.py +157 -0
  11. androidctl/commands/close.py +30 -0
  12. androidctl/commands/connect.py +69 -0
  13. androidctl/commands/execute.py +179 -0
  14. androidctl/commands/list_apps.py +26 -0
  15. androidctl/commands/observe.py +26 -0
  16. androidctl/commands/open.py +41 -0
  17. androidctl/commands/plumbing.py +58 -0
  18. androidctl/commands/run_pipeline.py +307 -0
  19. androidctl/commands/screenshot.py +29 -0
  20. androidctl/commands/setup.py +301 -0
  21. androidctl/commands/wait.py +60 -0
  22. androidctl/daemon/__init__.py +1 -0
  23. androidctl/daemon/client.py +348 -0
  24. androidctl/daemon/discovery.py +190 -0
  25. androidctl/daemon/launcher.py +26 -0
  26. androidctl/daemon/owner.py +349 -0
  27. androidctl/errors/__init__.py +1 -0
  28. androidctl/errors/mapping.py +149 -0
  29. androidctl/errors/models.py +16 -0
  30. androidctl/exit_codes.py +8 -0
  31. androidctl/output.py +147 -0
  32. androidctl/parsing/__init__.py +1 -0
  33. androidctl/parsing/duration.py +17 -0
  34. androidctl/parsing/open_target.py +51 -0
  35. androidctl/parsing/refs.py +12 -0
  36. androidctl/parsing/screen_id.py +10 -0
  37. androidctl/parsing/wait.py +70 -0
  38. androidctl/renderers/__init__.py +110 -0
  39. androidctl/renderers/_paths.py +109 -0
  40. androidctl/renderers/xml.py +234 -0
  41. androidctl/renderers/xml_projection.py +732 -0
  42. androidctl/resources/__init__.py +1 -0
  43. androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
  44. androidctl/setup/__init__.py +1 -0
  45. androidctl/setup/accessibility.py +159 -0
  46. androidctl/setup/adb.py +586 -0
  47. androidctl/setup/apk_resource.py +29 -0
  48. androidctl/setup/pairing.py +70 -0
  49. androidctl/setup/verify.py +175 -0
  50. androidctl/workspace/__init__.py +3 -0
  51. androidctl/workspace/resolve.py +27 -0
  52. androidctl-0.1.0.dist-info/METADATA +217 -0
  53. androidctl-0.1.0.dist-info/RECORD +187 -0
  54. androidctl-0.1.0.dist-info/WHEEL +5 -0
  55. androidctl-0.1.0.dist-info/entry_points.txt +3 -0
  56. androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
  57. androidctl-0.1.0.dist-info/top_level.txt +3 -0
  58. androidctl_contracts/__init__.py +55 -0
  59. androidctl_contracts/_version.py +1 -0
  60. androidctl_contracts/_wire_helpers.py +31 -0
  61. androidctl_contracts/base.py +142 -0
  62. androidctl_contracts/command_catalog.py +414 -0
  63. androidctl_contracts/command_results.py +630 -0
  64. androidctl_contracts/daemon_api.py +335 -0
  65. androidctl_contracts/errors.py +44 -0
  66. androidctl_contracts/paths.py +5 -0
  67. androidctl_contracts/public_screen.py +579 -0
  68. androidctl_contracts/user_state.py +23 -0
  69. androidctl_contracts/vocabulary.py +82 -0
  70. androidctld/__init__.py +5 -0
  71. androidctld/__main__.py +63 -0
  72. androidctld/_version.py +1 -0
  73. androidctld/actions/__init__.py +1 -0
  74. androidctld/actions/action_target.py +142 -0
  75. androidctld/actions/capabilities.py +539 -0
  76. androidctld/actions/executor.py +894 -0
  77. androidctld/actions/focus_confirmation.py +177 -0
  78. androidctld/actions/focused_input_admissibility.py +120 -0
  79. androidctld/actions/fresh_current.py +176 -0
  80. androidctld/actions/postconditions.py +473 -0
  81. androidctld/actions/repair.py +101 -0
  82. androidctld/actions/request_builder.py +204 -0
  83. androidctld/actions/settle.py +146 -0
  84. androidctld/actions/submit_confirmation.py +211 -0
  85. androidctld/actions/submit_routing.py +311 -0
  86. androidctld/actions/type_confirmation.py +257 -0
  87. androidctld/app_targets.py +71 -0
  88. androidctld/artifacts/__init__.py +1 -0
  89. androidctld/artifacts/models.py +26 -0
  90. androidctld/artifacts/screen_lookup.py +241 -0
  91. androidctld/artifacts/screen_payloads.py +109 -0
  92. androidctld/artifacts/writer.py +286 -0
  93. androidctld/auth/__init__.py +1 -0
  94. androidctld/auth/active_registry.py +266 -0
  95. androidctld/auth/secret_files.py +52 -0
  96. androidctld/auth/token_store.py +59 -0
  97. androidctld/commands/__init__.py +1 -0
  98. androidctld/commands/assembly.py +231 -0
  99. androidctld/commands/command_models.py +254 -0
  100. androidctld/commands/dispatch.py +99 -0
  101. androidctld/commands/executor.py +31 -0
  102. androidctld/commands/from_boundary.py +175 -0
  103. androidctld/commands/handlers/__init__.py +15 -0
  104. androidctld/commands/handlers/action.py +439 -0
  105. androidctld/commands/handlers/connect.py +94 -0
  106. androidctld/commands/handlers/list_apps.py +215 -0
  107. androidctld/commands/handlers/observe.py +121 -0
  108. androidctld/commands/handlers/screenshot.py +105 -0
  109. androidctld/commands/handlers/wait.py +286 -0
  110. androidctld/commands/models.py +65 -0
  111. androidctld/commands/open_targets.py +56 -0
  112. androidctld/commands/orchestration.py +353 -0
  113. androidctld/commands/registry.py +116 -0
  114. androidctld/commands/result_builders.py +40 -0
  115. androidctld/commands/result_models.py +555 -0
  116. androidctld/commands/results.py +108 -0
  117. androidctld/commands/semantic_command_names.py +17 -0
  118. androidctld/commands/semantic_error_mapping.py +93 -0
  119. androidctld/commands/semantic_truth.py +135 -0
  120. androidctld/commands/service.py +67 -0
  121. androidctld/config.py +75 -0
  122. androidctld/daemon/__init__.py +1 -0
  123. androidctld/daemon/active_slot.py +326 -0
  124. androidctld/daemon/envelope.py +30 -0
  125. androidctld/daemon/http_host.py +123 -0
  126. androidctld/daemon/ingress.py +112 -0
  127. androidctld/daemon/ownership_probe.py +204 -0
  128. androidctld/daemon/server.py +286 -0
  129. androidctld/daemon/service.py +99 -0
  130. androidctld/device/__init__.py +1 -0
  131. androidctld/device/action_models.py +154 -0
  132. androidctld/device/action_serialization.py +121 -0
  133. androidctld/device/adapters.py +220 -0
  134. androidctld/device/bootstrap.py +153 -0
  135. androidctld/device/connectors.py +231 -0
  136. androidctld/device/errors.py +100 -0
  137. androidctld/device/interfaces.py +58 -0
  138. androidctld/device/parsing.py +320 -0
  139. androidctld/device/rpc.py +483 -0
  140. androidctld/device/schema.py +114 -0
  141. androidctld/device/types.py +161 -0
  142. androidctld/errors/__init__.py +94 -0
  143. androidctld/logging/__init__.py +22 -0
  144. androidctld/observation.py +98 -0
  145. androidctld/protocol.py +53 -0
  146. androidctld/refs/__init__.py +1 -0
  147. androidctld/refs/models.py +54 -0
  148. androidctld/refs/repair.py +284 -0
  149. androidctld/refs/service.py +422 -0
  150. androidctld/rendering/__init__.py +1 -0
  151. androidctld/rendering/screen_xml.py +256 -0
  152. androidctld/runtime/__init__.py +21 -0
  153. androidctld/runtime/kernel.py +548 -0
  154. androidctld/runtime/lifecycle.py +19 -0
  155. androidctld/runtime/models.py +48 -0
  156. androidctld/runtime/screen_state.py +117 -0
  157. androidctld/runtime/state_repo.py +70 -0
  158. androidctld/runtime/store.py +76 -0
  159. androidctld/runtime_policy.py +127 -0
  160. androidctld/schema/__init__.py +5 -0
  161. androidctld/schema/base.py +132 -0
  162. androidctld/schema/core.py +35 -0
  163. androidctld/schema/daemon_api.py +108 -0
  164. androidctld/schema/persistence.py +161 -0
  165. androidctld/schema/persistence_io.py +41 -0
  166. androidctld/schema/validation_errors.py +309 -0
  167. androidctld/semantics/__init__.py +1 -0
  168. androidctld/semantics/compiler.py +610 -0
  169. androidctld/semantics/continuity.py +107 -0
  170. androidctld/semantics/labels.py +252 -0
  171. androidctld/semantics/models.py +25 -0
  172. androidctld/semantics/policy.py +23 -0
  173. androidctld/semantics/public_models.py +123 -0
  174. androidctld/semantics/registries.py +13 -0
  175. androidctld/semantics/submit_refs.py +417 -0
  176. androidctld/semantics/surface.py +254 -0
  177. androidctld/semantics/targets.py +167 -0
  178. androidctld/snapshots/__init__.py +1 -0
  179. androidctld/snapshots/models.py +219 -0
  180. androidctld/snapshots/refresh.py +273 -0
  181. androidctld/snapshots/schema.py +74 -0
  182. androidctld/snapshots/service.py +138 -0
  183. androidctld/text_equivalence.py +67 -0
  184. androidctld/waits/__init__.py +1 -0
  185. androidctld/waits/evaluators.py +216 -0
  186. androidctld/waits/loop.py +305 -0
  187. androidctld/waits/matcher.py +41 -0
@@ -0,0 +1,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."""