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,149 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+
6
+ import click
7
+ import httpx
8
+
9
+ from androidctl.daemon.client import (
10
+ DaemonApiError,
11
+ DaemonProtocolError,
12
+ IncompatibleDaemonError,
13
+ )
14
+ from androidctl.errors.models import PublicError
15
+ from androidctl.exit_codes import ExitCode
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class _MappedCode:
20
+ code: str
21
+ exit_code: ExitCode
22
+ hint: str | None = None
23
+ message: str | None = None
24
+
25
+
26
+ _DAEMON_CODE_TABLE: Mapping[str, _MappedCode] = {
27
+ # This table only applies to daemon error envelopes, not semantic result codes.
28
+ "DAEMON_BAD_REQUEST": _MappedCode("DAEMON_UNAVAILABLE", ExitCode.ENVIRONMENT),
29
+ "WORKSPACE_BUSY": _MappedCode(
30
+ "WORKSPACE_BUSY",
31
+ ExitCode.ERROR,
32
+ "close the conflicting workspace daemon or use a different workspace",
33
+ ),
34
+ "RUNTIME_BUSY": _MappedCode(
35
+ "RUNTIME_BUSY",
36
+ ExitCode.ERROR,
37
+ "wait for the active progress command to finish, then retry",
38
+ ),
39
+ "RUNTIME_NOT_CONNECTED": _MappedCode(
40
+ "DEVICE_NOT_CONNECTED",
41
+ ExitCode.ENVIRONMENT,
42
+ "re-run `androidctl connect`",
43
+ ),
44
+ "SCREEN_NOT_READY": _MappedCode(
45
+ "SCREEN_UNAVAILABLE",
46
+ ExitCode.ERROR,
47
+ "run `androidctl observe` to refresh the current screen",
48
+ ),
49
+ "REF_RESOLUTION_FAILED": _MappedCode(
50
+ "REF_NOT_FOUND",
51
+ ExitCode.ERROR,
52
+ "run `androidctl observe` and choose a ref from the latest screen",
53
+ ),
54
+ "WORKSPACE_UNAVAILABLE": _MappedCode("WORKSPACE_UNAVAILABLE", ExitCode.ENVIRONMENT),
55
+ "ARTIFACT_ROOT_UNWRITABLE": _MappedCode(
56
+ "WORKSPACE_STATE_UNWRITABLE", ExitCode.ENVIRONMENT
57
+ ),
58
+ "DEVICE_DISCONNECTED": _MappedCode(
59
+ "DEVICE_NOT_CONNECTED",
60
+ ExitCode.ENVIRONMENT,
61
+ ),
62
+ "DEVICE_AGENT_UNAVAILABLE": _MappedCode(
63
+ "DEVICE_AGENT_UNAVAILABLE", ExitCode.ENVIRONMENT
64
+ ),
65
+ "DEVICE_AGENT_VERSION_MISMATCH": _MappedCode(
66
+ "DEVICE_AGENT_VERSION_MISMATCH",
67
+ ExitCode.ENVIRONMENT,
68
+ "install matching androidctld and Android agent/APK versions",
69
+ ),
70
+ "DEVICE_AGENT_UNAUTHORIZED": _MappedCode(
71
+ "DAEMON_UNAVAILABLE",
72
+ ExitCode.ENVIRONMENT,
73
+ "re-run androidctl connect to refresh daemon-to-device authorization",
74
+ "device agent rejected daemon authorization",
75
+ ),
76
+ "ACCESSIBILITY_NOT_READY": _MappedCode("ACCESSIBILITY_NOT_READY", ExitCode.ERROR),
77
+ "DAEMON_UNAUTHORIZED": _MappedCode("DAEMON_UNAVAILABLE", ExitCode.ENVIRONMENT),
78
+ "INTERNAL_COMMAND_FAILURE": _MappedCode(
79
+ "DAEMON_UNAVAILABLE",
80
+ ExitCode.ENVIRONMENT,
81
+ "retry the command; if it keeps failing, inspect daemon logs",
82
+ "androidctld failed while handling the request",
83
+ ),
84
+ "DEVICE_RPC_TRANSPORT_RESET": _MappedCode(
85
+ "DAEMON_UNAVAILABLE",
86
+ ExitCode.ENVIRONMENT,
87
+ "retry the command after the daemon is available",
88
+ "device RPC transport was reset",
89
+ ),
90
+ }
91
+
92
+
93
+ def map_exception(error: Exception) -> PublicError:
94
+ if isinstance(error, click.UsageError):
95
+ return PublicError(
96
+ code="USAGE_ERROR",
97
+ message=error.format_message(),
98
+ hint=None,
99
+ exit_code=ExitCode.USAGE,
100
+ )
101
+ if isinstance(error, DaemonApiError):
102
+ return _map_daemon_api_error(error)
103
+ if isinstance(error, IncompatibleDaemonError):
104
+ return PublicError(
105
+ code="DAEMON_UNAVAILABLE",
106
+ message=str(error),
107
+ hint="install matching androidctl and androidctld versions",
108
+ exit_code=ExitCode.ENVIRONMENT,
109
+ )
110
+ if isinstance(error, (httpx.RequestError, httpx.HTTPStatusError)):
111
+ return PublicError(
112
+ code="DAEMON_UNAVAILABLE",
113
+ message="unable to reach androidctld daemon",
114
+ hint="retry the command after the daemon is available",
115
+ exit_code=ExitCode.ENVIRONMENT,
116
+ )
117
+ if isinstance(
118
+ error,
119
+ (DaemonProtocolError, click.ClickException, RuntimeError, OSError),
120
+ ):
121
+ return PublicError(
122
+ code="DAEMON_UNAVAILABLE",
123
+ message=str(error),
124
+ hint="retry the command after the daemon is available",
125
+ exit_code=ExitCode.ENVIRONMENT,
126
+ )
127
+ return PublicError(
128
+ code="DAEMON_UNAVAILABLE",
129
+ message=str(error),
130
+ hint="retry the command after the daemon is available",
131
+ exit_code=ExitCode.ENVIRONMENT,
132
+ )
133
+
134
+
135
+ def _map_daemon_api_error(error: DaemonApiError) -> PublicError:
136
+ mapped = _DAEMON_CODE_TABLE.get(error.code)
137
+ if mapped is None:
138
+ return PublicError(
139
+ code="DAEMON_UNAVAILABLE",
140
+ message=error.message,
141
+ hint="retry the command after the daemon is available",
142
+ exit_code=ExitCode.ENVIRONMENT,
143
+ )
144
+ return PublicError(
145
+ code=mapped.code,
146
+ message=mapped.message or error.message,
147
+ hint=mapped.hint,
148
+ exit_code=mapped.exit_code,
149
+ )
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ from androidctl.exit_codes import ExitCode
7
+
8
+ ErrorTier = Literal["usage", "preDispatch", "outer"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class PublicError:
13
+ code: str
14
+ message: str
15
+ hint: str | None
16
+ exit_code: ExitCode
@@ -0,0 +1,8 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class ExitCode(IntEnum):
5
+ OK = 0
6
+ ERROR = 1
7
+ USAGE = 2
8
+ ENVIRONMENT = 3
androidctl/output.py ADDED
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+ from xml.sax.saxutils import escape
5
+
6
+ import click
7
+
8
+ CLI_RENDER_FAILED = "CLI_RENDER_FAILED"
9
+ CLI_OUTPUT_FAILED = "CLI_OUTPUT_FAILED"
10
+
11
+ CLI_RENDER_FAILED_MESSAGE = "androidctl failed while rendering command output"
12
+ CLI_OUTPUT_FAILED_MESSAGE = "androidctl failed while writing command output"
13
+
14
+ _RETAINED_FALLBACK_ENVELOPES = {
15
+ "connect": "bootstrap",
16
+ "screenshot": "artifact",
17
+ "close": "lifecycle",
18
+ }
19
+
20
+ _SEMANTIC_FALLBACK_CATEGORIES = {
21
+ "observe": "observe",
22
+ "open": "open",
23
+ "tap": "transition",
24
+ "long-tap": "transition",
25
+ "focus": "transition",
26
+ "type": "transition",
27
+ "submit": "transition",
28
+ "scroll": "transition",
29
+ "back": "transition",
30
+ "home": "transition",
31
+ "recents": "transition",
32
+ "notifications": "transition",
33
+ "wait": "wait",
34
+ }
35
+
36
+ _LIST_APPS_FALLBACK_COMMAND = "list-apps"
37
+
38
+
39
+ class CliOutputError(Exception):
40
+ def __init__(self, stream_name: str) -> None:
41
+ super().__init__(stream_name)
42
+ self.stream_name = stream_name
43
+
44
+
45
+ def xml_text_to_utf8_bytes(xml_text: str) -> bytes:
46
+ try:
47
+ return xml_text.encode("utf-8") + b"\n"
48
+ except Exception as error:
49
+ raise CliOutputError("encoding") from error
50
+
51
+
52
+ def write_stdout_xml(xml_text: str) -> None:
53
+ write_stdout_bytes(xml_text_to_utf8_bytes(xml_text))
54
+
55
+
56
+ def write_stderr_xml(xml_text: str) -> None:
57
+ write_stderr_bytes(xml_text_to_utf8_bytes(xml_text))
58
+
59
+
60
+ def write_stdout_bytes(data: bytes) -> None:
61
+ _write_binary_stream("stdout", data)
62
+
63
+
64
+ def write_stderr_bytes(data: bytes) -> None:
65
+ _write_binary_stream("stderr", data)
66
+
67
+
68
+ def static_cli_failure_xml_bytes(*, command: str | None, code: str) -> bytes:
69
+ message = (
70
+ CLI_RENDER_FAILED_MESSAGE
71
+ if code == CLI_RENDER_FAILED
72
+ else CLI_OUTPUT_FAILED_MESSAGE
73
+ )
74
+ public_command = _fallback_public_command(command)
75
+ xml_text = _static_cli_failure_xml(
76
+ command=public_command,
77
+ code=code,
78
+ message=message,
79
+ )
80
+ return xml_text.encode("ascii") + b"\n"
81
+
82
+
83
+ def _write_binary_stream(
84
+ stream_name: Literal["stdout", "stderr"],
85
+ data: bytes,
86
+ ) -> None:
87
+ try:
88
+ stream = click.get_binary_stream(stream_name)
89
+ written = stream.write(data)
90
+ if isinstance(written, int) and written < len(data):
91
+ raise OSError("short write")
92
+ stream.flush()
93
+ except Exception as error:
94
+ raise CliOutputError(stream_name) from error
95
+
96
+
97
+ def _static_cli_failure_xml(*, command: str, code: str, message: str) -> str:
98
+ if command == _LIST_APPS_FALLBACK_COMMAND:
99
+ return (
100
+ f'<errorResult ok="false" code="{_xml_attr(code)}" '
101
+ 'exitCode="3" tier="outer" '
102
+ f'command="{_xml_attr(command)}">'
103
+ f"<message>{_xml_text(message)}</message>"
104
+ "</errorResult>"
105
+ )
106
+
107
+ retained_envelope = _RETAINED_FALLBACK_ENVELOPES.get(command)
108
+ if retained_envelope is not None:
109
+ return (
110
+ f'<retainedResult ok="false" command="{_xml_attr(command)}" '
111
+ f'envelope="{_xml_attr(retained_envelope)}" code="{_xml_attr(code)}">'
112
+ f"<message>{_xml_text(message)}</message>"
113
+ "<artifacts />"
114
+ "</retainedResult>"
115
+ )
116
+
117
+ category = _SEMANTIC_FALLBACK_CATEGORIES.get(command, "transition")
118
+ return (
119
+ f'<result ok="false" command="{_xml_attr(command)}" '
120
+ f'category="{_xml_attr(category)}" payloadMode="none" '
121
+ f'code="{_xml_attr(code)}">'
122
+ f"<message>{_xml_text(message)}</message>"
123
+ '<truth executionOutcome="unknown" continuityStatus="none" '
124
+ 'observationQuality="none" />'
125
+ "<uncertainty />"
126
+ "<warnings />"
127
+ "<artifacts />"
128
+ "</result>"
129
+ )
130
+
131
+
132
+ def _fallback_public_command(command: str | None) -> str:
133
+ if command == _LIST_APPS_FALLBACK_COMMAND:
134
+ return command
135
+ if command in _RETAINED_FALLBACK_ENVELOPES:
136
+ return command
137
+ if command in _SEMANTIC_FALLBACK_CATEGORIES:
138
+ return command
139
+ return "observe"
140
+
141
+
142
+ def _xml_attr(value: str) -> str:
143
+ return escape(value, {'"': "&quot;"})
144
+
145
+
146
+ def _xml_text(value: str) -> str:
147
+ return escape(value)
@@ -0,0 +1 @@
1
+ """Command argument parsing helpers."""
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _DURATION_RE = re.compile(r"^(?P<value>\d+)(?P<unit>ms|s)$")
6
+
7
+
8
+ def parse_duration_ms(raw: str) -> int:
9
+ normalized = raw.strip().lower()
10
+ match = _DURATION_RE.fullmatch(normalized)
11
+ if match is None:
12
+ raise ValueError("duration must use an integer followed by ms or s")
13
+ value = int(match.group("value"))
14
+ unit = match.group("unit")
15
+ if unit == "ms":
16
+ return value
17
+ return value * 1000
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import SplitResult, urlsplit
4
+
5
+ from androidctl_contracts.daemon_api import (
6
+ OpenAppTargetPayload,
7
+ OpenTargetPayload,
8
+ OpenUrlTargetPayload,
9
+ )
10
+
11
+
12
+ def parse_open_target(raw_target: str) -> OpenTargetPayload:
13
+ target = raw_target.strip()
14
+ if not target:
15
+ raise ValueError("open target cannot be empty")
16
+ if target.startswith("app:"):
17
+ package_name = target[len("app:") :].strip()
18
+ if not package_name:
19
+ raise ValueError("app target must include a package name")
20
+ return OpenAppTargetPayload(kind="app", value=package_name)
21
+ if target.startswith("url:"):
22
+ url = target[len("url:") :].strip()
23
+ if not url:
24
+ raise ValueError("url target must not be empty")
25
+ return OpenUrlTargetPayload(kind="url", value=url)
26
+ if _is_http_url(target):
27
+ return OpenUrlTargetPayload(kind="url", value=target)
28
+ raise ValueError(
29
+ "target must be app:<package>, url:<target>, or absolute http(s)://..."
30
+ )
31
+
32
+
33
+ def _is_http_url(value: str) -> bool:
34
+ if any(char.isspace() for char in value):
35
+ return False
36
+ parsed = urlsplit(value)
37
+ if parsed.scheme not in {"http", "https"}:
38
+ return False
39
+ return _is_valid_http_url(parsed)
40
+
41
+
42
+ def _is_valid_http_url(parsed: SplitResult) -> bool:
43
+ if not parsed.netloc:
44
+ return False
45
+ if not parsed.hostname or any(char.isspace() for char in parsed.hostname):
46
+ return False
47
+ try:
48
+ _ = parsed.port
49
+ except ValueError:
50
+ return False
51
+ return True
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ REF_PATTERN = re.compile(r"^n[1-9][0-9]*$")
6
+
7
+
8
+ def parse_ref(raw_ref: str) -> str:
9
+ value = raw_ref.strip()
10
+ if not REF_PATTERN.fullmatch(value):
11
+ raise ValueError("ref must match n<number>, for example n3")
12
+ return value
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def parse_screen_id_override(value: str | None) -> str | None:
5
+ if value is None:
6
+ return None
7
+ normalized = value.strip()
8
+ if not normalized:
9
+ raise ValueError("--screen-id must be non-empty")
10
+ return normalized
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from androidctl.command_payloads import (
4
+ CliWaitPredicatePayload,
5
+ LateBoundScreenRelativePredicate,
6
+ )
7
+ from androidctl.parsing.refs import parse_ref
8
+ from androidctl.parsing.screen_id import parse_screen_id_override
9
+ from androidctl_contracts.daemon_api import (
10
+ AppPredicatePayload,
11
+ GonePredicatePayload,
12
+ IdlePredicatePayload,
13
+ ScreenChangePredicatePayload,
14
+ TextPresentPredicatePayload,
15
+ )
16
+
17
+ _WAIT_PREDICATES = {"app", "gone", "idle", "screen-change", "text-present"}
18
+
19
+
20
+ def parse_wait_predicate(
21
+ until: str,
22
+ *,
23
+ ref: str | None = None,
24
+ text: str | None = None,
25
+ package_name: str | None = None,
26
+ source_screen_id: str | None = None,
27
+ ) -> CliWaitPredicatePayload:
28
+ normalized_until = until.strip().lower()
29
+ if normalized_until not in _WAIT_PREDICATES:
30
+ allowed = ", ".join(sorted(_WAIT_PREDICATES))
31
+ raise ValueError(f"wait predicate must be one of: {allowed}")
32
+
33
+ if normalized_until == "screen-change":
34
+ normalized_source_screen_id = parse_screen_id_override(source_screen_id)
35
+ if normalized_source_screen_id is None:
36
+ return LateBoundScreenRelativePredicate(kind="screen-change")
37
+ return ScreenChangePredicatePayload(
38
+ kind="screen-change",
39
+ source_screen_id=normalized_source_screen_id,
40
+ )
41
+
42
+ if normalized_until == "gone":
43
+ if ref is None or not ref.strip():
44
+ raise ValueError("gone wait requires --ref")
45
+ normalized_source_screen_id = parse_screen_id_override(source_screen_id)
46
+ if normalized_source_screen_id is None:
47
+ return LateBoundScreenRelativePredicate(
48
+ kind="gone",
49
+ ref=parse_ref(ref),
50
+ )
51
+ return GonePredicatePayload(
52
+ kind="gone",
53
+ ref=parse_ref(ref),
54
+ source_screen_id=normalized_source_screen_id,
55
+ )
56
+
57
+ if normalized_until == "text-present":
58
+ if text is None or not text.strip():
59
+ raise ValueError("text-present wait requires --text")
60
+ return TextPresentPredicatePayload(kind="text-present", text=text)
61
+
62
+ if normalized_until == "app":
63
+ if package_name is None or not package_name.strip():
64
+ raise ValueError("app wait requires --app")
65
+ return AppPredicatePayload(
66
+ kind="app",
67
+ package_name=package_name.strip(),
68
+ )
69
+
70
+ return IdlePredicatePayload(kind="idle")
@@ -0,0 +1,110 @@
1
+ """Public renderer entrypoints and semantic projection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import TypeAlias
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from androidctl.renderers._paths import normalize_public_path
11
+ from androidctl_contracts.command_catalog import entry_for_result_command
12
+ from androidctl_contracts.command_results import (
13
+ CommandResultCore,
14
+ ListAppsResult,
15
+ RetainedResultEnvelope,
16
+ dump_canonical_command_result,
17
+ )
18
+ from androidctl_contracts.vocabulary import PublicResultFamily
19
+
20
+ _PATH_KEYS = {"screenshotPng", "screenXml"}
21
+ RenderPayload: TypeAlias = BaseModel | Mapping[str, object]
22
+ ProjectionValue: TypeAlias = (
23
+ str
24
+ | int
25
+ | float
26
+ | bool
27
+ | None
28
+ | list["ProjectionValue"]
29
+ | dict[str, "ProjectionValue"]
30
+ )
31
+ ProjectionDict: TypeAlias = dict[str, ProjectionValue]
32
+
33
+
34
+ def projection_dict(
35
+ payload: RenderPayload,
36
+ ) -> ProjectionDict:
37
+ validated = _validated_payload(payload)
38
+ if isinstance(validated, CommandResultCore):
39
+ dumped = dump_canonical_command_result(validated)
40
+ elif isinstance(validated, (RetainedResultEnvelope, ListAppsResult)):
41
+ dumped = validated.model_dump(by_alias=True, mode="json", exclude_none=True)
42
+ else:
43
+ dumped = validated.model_dump(mode="json", exclude_none=True)
44
+ return _normalize_mapping(dumped)
45
+
46
+
47
+ def _validated_payload(payload: RenderPayload) -> BaseModel:
48
+ string_keyed = _string_keyed_payload(payload)
49
+ command = string_keyed.get("command")
50
+ if isinstance(command, str):
51
+ entry = entry_for_result_command(command)
52
+ if entry is not None and entry.result_family is PublicResultFamily.RETAINED:
53
+ return RetainedResultEnvelope.model_validate(string_keyed)
54
+ if entry is not None and entry.result_family is PublicResultFamily.LIST_APPS:
55
+ return ListAppsResult.model_validate(string_keyed)
56
+ return CommandResultCore.model_validate(string_keyed)
57
+
58
+
59
+ def _string_keyed_payload(payload: RenderPayload) -> dict[str, object]:
60
+ if isinstance(payload, BaseModel):
61
+ dumped = payload.model_dump(mode="json", by_alias=True)
62
+ if not isinstance(dumped, Mapping):
63
+ raise TypeError("renderer payload model must dump to a mapping")
64
+ return {key: item for key, item in dumped.items() if isinstance(key, str)}
65
+ if isinstance(payload, Mapping):
66
+ return {key: item for key, item in payload.items() if isinstance(key, str)}
67
+ raise TypeError("renderer payload must be a pydantic model or mapping")
68
+
69
+
70
+ def _normalize_mapping(value: Mapping[str, object]) -> ProjectionDict:
71
+ normalized: ProjectionDict = {}
72
+ for key, item in value.items():
73
+ if key in _PATH_KEYS and isinstance(item, str):
74
+ normalized[key] = _normalize_path(item)
75
+ continue
76
+ normalized[key] = _normalize_value(item)
77
+ return normalized
78
+
79
+
80
+ def _normalize_sequence(value: Sequence[object]) -> list[ProjectionValue]:
81
+ return [_normalize_value(item) for item in value]
82
+
83
+
84
+ def _normalize_value(value: object) -> ProjectionValue:
85
+ if isinstance(value, Mapping):
86
+ string_keyed = {
87
+ key: item for key, item in value.items() if isinstance(key, str)
88
+ }
89
+ return _normalize_mapping(string_keyed)
90
+ if isinstance(value, Sequence) and not isinstance(value, str):
91
+ return _normalize_sequence(value)
92
+ if value is None or isinstance(value, (str, int, float, bool)):
93
+ return value
94
+ raise TypeError(f"unsupported renderer projection value: {type(value).__name__}")
95
+
96
+
97
+ def _normalize_path(path: str) -> str:
98
+ normalized = path.replace("\\", "/")
99
+ marker = "/.androidctl/"
100
+ marker_index = normalized.rfind(marker)
101
+ workspace_root = normalized[:marker_index] if marker_index >= 0 else None
102
+ artifact_root = (
103
+ f"{workspace_root}/.androidctl" if workspace_root is not None else None
104
+ )
105
+ public_path = normalize_public_path(
106
+ normalized,
107
+ workspace_root=workspace_root,
108
+ artifact_root=artifact_root,
109
+ )
110
+ return public_path or normalized