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,190 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import json
5
+ import subprocess
6
+ import time
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+ from pydantic import ValidationError
12
+
13
+ from androidctl.daemon.client import (
14
+ DaemonApiError,
15
+ DaemonClient,
16
+ DaemonProtocolError,
17
+ IncompatibleDaemonError,
18
+ try_get_healthy_daemon,
19
+ )
20
+ from androidctl.daemon.launcher import resolve_launch_spec
21
+ from androidctl.daemon.owner import derive_owner_id
22
+ from androidctl_contracts.paths import daemon_state_root
23
+ from androidctl_contracts.user_state import ActiveDaemonRecord
24
+
25
+
26
+ def resolve_daemon_client(
27
+ *,
28
+ workspace_root: Path,
29
+ cwd: Path,
30
+ env: Mapping[str, str],
31
+ ) -> DaemonClient:
32
+ resolved_workspace_root = workspace_root.resolve()
33
+ owner_id = derive_owner_id(env=env)
34
+ existing = _healthy_client_from_record(
35
+ _read_active_daemon_record(resolved_workspace_root),
36
+ workspace_root=resolved_workspace_root,
37
+ owner_id=owner_id,
38
+ )
39
+ if existing is not None:
40
+ return existing
41
+
42
+ _launch_daemon_process(
43
+ cwd=cwd,
44
+ env=env,
45
+ workspace_root=resolved_workspace_root,
46
+ owner_id=owner_id,
47
+ )
48
+ launched = _wait_for_healthy_client(
49
+ workspace_root=resolved_workspace_root,
50
+ owner_id=owner_id,
51
+ timeout_seconds=5.0,
52
+ )
53
+ if launched is not None:
54
+ return launched
55
+ raise RuntimeError("failed to discover or launch a healthy androidctld daemon")
56
+
57
+
58
+ def discover_existing_daemon_client(
59
+ *,
60
+ workspace_root: Path,
61
+ env: Mapping[str, str],
62
+ ) -> DaemonClient | None:
63
+ resolved_workspace_root = workspace_root.resolve()
64
+ owner_id = derive_owner_id(env=env)
65
+ return _healthy_client_from_record(
66
+ _read_active_daemon_record(resolved_workspace_root),
67
+ workspace_root=resolved_workspace_root,
68
+ owner_id=owner_id,
69
+ )
70
+
71
+
72
+ def _healthy_client_from_record(
73
+ record: ActiveDaemonRecord | None,
74
+ *,
75
+ workspace_root: Path,
76
+ owner_id: str,
77
+ ) -> DaemonClient | None:
78
+ if record is None:
79
+ return None
80
+ if record.workspace_root != workspace_root.as_posix():
81
+ return None
82
+ if not _is_loopback_host(record.host):
83
+ raise RuntimeError(f"active daemon host must be loopback: {record.host!r}")
84
+ client = DaemonClient.from_active_record(record, owner_id=owner_id)
85
+ if record.owner_id != owner_id:
86
+ try:
87
+ client.health(record)
88
+ except IncompatibleDaemonError as error:
89
+ raise DaemonApiError(
90
+ code="WORKSPACE_BUSY",
91
+ message="workspace daemon is owned by a different shell or agent",
92
+ details={"ownerId": record.owner_id},
93
+ ) from error
94
+ except DaemonApiError as error:
95
+ if error.code == "WORKSPACE_BUSY":
96
+ raise error
97
+ return None
98
+ except (DaemonProtocolError, httpx.RequestError, httpx.HTTPStatusError):
99
+ return None
100
+ raise DaemonApiError(
101
+ code="WORKSPACE_BUSY",
102
+ message="workspace daemon is owned by a different shell or agent",
103
+ details={"ownerId": record.owner_id},
104
+ )
105
+ try:
106
+ health = try_get_healthy_daemon(client, record)
107
+ except IncompatibleDaemonError:
108
+ raise
109
+ except DaemonProtocolError:
110
+ return None
111
+ if health is None:
112
+ return None
113
+ return client
114
+
115
+
116
+ def _wait_for_healthy_client(
117
+ *,
118
+ workspace_root: Path,
119
+ owner_id: str,
120
+ timeout_seconds: float,
121
+ ) -> DaemonClient | None:
122
+ deadline = time.monotonic() + timeout_seconds
123
+ while time.monotonic() < deadline:
124
+ client = _healthy_client_from_record(
125
+ _read_active_daemon_record(workspace_root),
126
+ workspace_root=workspace_root,
127
+ owner_id=owner_id,
128
+ )
129
+ if client is not None:
130
+ return client
131
+ time.sleep(0.05)
132
+ return None
133
+
134
+
135
+ def _launch_daemon_process(
136
+ *,
137
+ cwd: Path,
138
+ env: Mapping[str, str],
139
+ workspace_root: Path,
140
+ owner_id: str,
141
+ ) -> None:
142
+ launch_spec = resolve_launch_spec(env=dict(env))
143
+ process_env = dict(env)
144
+ if launch_spec.env_overlay:
145
+ process_env.update(launch_spec.env_overlay)
146
+ log_dir = daemon_state_root(workspace_root) / "logs"
147
+ log_dir.mkdir(parents=True, exist_ok=True)
148
+ timestamp = int(time.time() * 1000)
149
+ log_path = log_dir / f"androidctld-{timestamp}.log"
150
+ log_file = log_path.open("a", buffering=1)
151
+ subprocess.Popen(
152
+ [
153
+ launch_spec.executable,
154
+ *launch_spec.argv,
155
+ "--workspace-root",
156
+ str(workspace_root),
157
+ "--owner-id",
158
+ owner_id,
159
+ ],
160
+ cwd=launch_spec.cwd or cwd,
161
+ env=process_env,
162
+ stdin=subprocess.DEVNULL,
163
+ stdout=log_file,
164
+ stderr=log_file,
165
+ start_new_session=True,
166
+ )
167
+ log_file.close()
168
+
169
+
170
+ def _read_active_daemon_record(workspace_root: Path) -> ActiveDaemonRecord | None:
171
+ active_path = daemon_state_root(workspace_root.resolve()) / "active.json"
172
+ if not active_path.exists():
173
+ return None
174
+ try:
175
+ payload = json.loads(active_path.read_text(encoding="utf-8"))
176
+ return ActiveDaemonRecord.model_validate(payload)
177
+ except (ValueError, json.JSONDecodeError, ValidationError):
178
+ return None
179
+
180
+
181
+ def _is_loopback_host(host: str) -> bool:
182
+ normalized = host.strip().lower()
183
+ if normalized == "localhost":
184
+ return True
185
+ if normalized.startswith("[") and normalized.endswith("]"):
186
+ normalized = normalized[1:-1]
187
+ try:
188
+ return ipaddress.ip_address(normalized).is_loopback
189
+ except ValueError:
190
+ return False
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class LaunchSpec:
12
+ executable: str
13
+ argv: tuple[str, ...] = ()
14
+ env_overlay: dict[str, str] | None = None
15
+ cwd: Path | None = None
16
+
17
+
18
+ def resolve_launch_spec(
19
+ *,
20
+ env: Mapping[str, str] | None = None,
21
+ ) -> LaunchSpec:
22
+ merged_env = os.environ if env is None else env
23
+ env_bin = merged_env.get("ANDROIDCTLD_BIN")
24
+ if env_bin:
25
+ return LaunchSpec(executable=env_bin)
26
+ return LaunchSpec(executable=sys.executable, argv=("-m", "androidctld"))
@@ -0,0 +1,349 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import os
5
+ import sys
6
+ from collections.abc import Mapping
7
+ from ctypes import wintypes
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ OWNER_ENV = "ANDROIDCTL_OWNER_ID"
13
+ DEFAULT_OWNER_HINT = "Set ANDROIDCTL_OWNER_ID explicitly."
14
+ _MAX_OWNER_PROCESS_HOPS = 64
15
+ _SHELL_PROCESS_NAMES = frozenset(
16
+ {"bash", "zsh", "fish", "sh", "ksh", "dash", "tcsh", "csh"}
17
+ )
18
+ _WINDOWS_SHELL_PROCESS_NAMES = frozenset(
19
+ {"bash.exe", "cmd.exe", "powershell.exe", "pwsh.exe", "sh.exe"}
20
+ )
21
+ _TH32CS_SNAPPROCESS = 0x00000002
22
+ _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
23
+ _ERROR_NO_MORE_FILES = 18
24
+ _WINDOWS_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _WindowsProcessInfo:
29
+ parent_pid: int
30
+ process_name: str
31
+
32
+
33
+ class _WindowsFileTime(ctypes.Structure):
34
+ _fields_ = [
35
+ ("dwLowDateTime", wintypes.DWORD),
36
+ ("dwHighDateTime", wintypes.DWORD),
37
+ ]
38
+
39
+
40
+ class _WindowsProcessEntry32(ctypes.Structure):
41
+ _fields_ = [
42
+ ("dwSize", wintypes.DWORD),
43
+ ("cntUsage", wintypes.DWORD),
44
+ ("th32ProcessID", wintypes.DWORD),
45
+ ("th32DefaultHeapID", ctypes.c_void_p),
46
+ ("th32ModuleID", wintypes.DWORD),
47
+ ("cntThreads", wintypes.DWORD),
48
+ ("th32ParentProcessID", wintypes.DWORD),
49
+ ("pcPriClassBase", wintypes.LONG),
50
+ ("dwFlags", wintypes.DWORD),
51
+ ("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
52
+ ]
53
+
54
+
55
+ def derive_owner_id(*, env: Mapping[str, str]) -> str:
56
+ configured = env.get(OWNER_ENV)
57
+ if configured is not None:
58
+ candidate = configured.strip()
59
+ if candidate:
60
+ return candidate
61
+ if sys.platform == "win32":
62
+ owner_id = _derive_windows_owner_id()
63
+ if owner_id is None:
64
+ raise ValueError(
65
+ "Unable to derive a safe owner identity automatically. "
66
+ f"{DEFAULT_OWNER_HINT}"
67
+ )
68
+ return owner_id
69
+ shell_pid = _find_interactive_shell_ancestor_pid(env)
70
+ if shell_pid is None:
71
+ raise ValueError(
72
+ "Unable to derive a safe owner identity automatically. "
73
+ f"{DEFAULT_OWNER_HINT}"
74
+ )
75
+ lifetime = _read_process_lifetime_discriminator(shell_pid)
76
+ if lifetime is None:
77
+ raise ValueError(
78
+ "Unable to derive a safe owner identity automatically. "
79
+ f"{DEFAULT_OWNER_HINT}"
80
+ )
81
+ return f"shell:{shell_pid}:{lifetime}"
82
+
83
+
84
+ def _find_interactive_shell_ancestor_pid(env: Mapping[str, str]) -> int | None:
85
+ del env
86
+ current_pid = os.getpid()
87
+ ancestor_pid = _read_parent_pid(current_pid)
88
+ if ancestor_pid is None:
89
+ return None
90
+ seen: set[int] = {current_pid}
91
+ hops = 0
92
+ while ancestor_pid > 1 and hops < _MAX_OWNER_PROCESS_HOPS:
93
+ if ancestor_pid in seen:
94
+ return None
95
+ seen.add(ancestor_pid)
96
+ process_name = _read_process_name(ancestor_pid)
97
+ if process_name is not None and process_name.casefold() in _SHELL_PROCESS_NAMES:
98
+ interactivity = _read_shell_interactivity(ancestor_pid)
99
+ if interactivity is None:
100
+ return None
101
+ if interactivity:
102
+ return ancestor_pid
103
+ next_pid = _read_parent_pid(ancestor_pid)
104
+ if next_pid is None:
105
+ return None
106
+ ancestor_pid = next_pid
107
+ hops += 1
108
+ return None
109
+
110
+
111
+ def _derive_windows_owner_id() -> str | None:
112
+ shell_pid = _find_windows_shell_ancestor_pid()
113
+ if shell_pid is None:
114
+ return None
115
+ lifetime = _read_windows_process_creation_filetime(shell_pid)
116
+ if lifetime is None:
117
+ return None
118
+ return f"shell:win32:{shell_pid}:{lifetime}"
119
+
120
+
121
+ def _find_windows_shell_ancestor_pid() -> int | None:
122
+ process_table = _read_windows_process_table()
123
+ if process_table is None:
124
+ return None
125
+ current_pid = os.getpid()
126
+ current = process_table.get(current_pid)
127
+ if current is None:
128
+ return None
129
+ ancestor_pid = current.parent_pid
130
+ seen: set[int] = {current_pid}
131
+ hops = 0
132
+ while ancestor_pid > 0 and hops < _MAX_OWNER_PROCESS_HOPS:
133
+ if ancestor_pid in seen:
134
+ return None
135
+ seen.add(ancestor_pid)
136
+ ancestor = process_table.get(ancestor_pid)
137
+ if ancestor is None:
138
+ return None
139
+ process_name = _normalize_windows_process_name(ancestor.process_name)
140
+ if process_name in _WINDOWS_SHELL_PROCESS_NAMES:
141
+ return ancestor_pid
142
+ ancestor_pid = ancestor.parent_pid
143
+ hops += 1
144
+ return None
145
+
146
+
147
+ def _normalize_windows_process_name(process_name: str) -> str:
148
+ normalized = process_name.strip().replace("/", "\\")
149
+ return normalized.rsplit("\\", maxsplit=1)[-1].casefold()
150
+
151
+
152
+ def _read_windows_process_table() -> dict[int, _WindowsProcessInfo] | None:
153
+ kernel32 = _load_windows_kernel32()
154
+ if kernel32 is None:
155
+ return None
156
+ _configure_windows_process_snapshot_functions(kernel32)
157
+ snapshot = kernel32.CreateToolhelp32Snapshot(_TH32CS_SNAPPROCESS, 0)
158
+ snapshot_value = _windows_handle_value(snapshot)
159
+ if snapshot_value is None or snapshot_value == _WINDOWS_INVALID_HANDLE_VALUE:
160
+ return None
161
+ try:
162
+ entry = _WindowsProcessEntry32()
163
+ entry.dwSize = ctypes.sizeof(_WindowsProcessEntry32)
164
+ if not kernel32.Process32FirstW(snapshot, ctypes.byref(entry)):
165
+ return None
166
+ process_table: dict[int, _WindowsProcessInfo] = {}
167
+ while True:
168
+ pid = int(entry.th32ProcessID)
169
+ if pid > 0:
170
+ process_table[pid] = _WindowsProcessInfo(
171
+ parent_pid=int(entry.th32ParentProcessID),
172
+ process_name=str(entry.szExeFile),
173
+ )
174
+ _set_windows_last_error(0)
175
+ if not kernel32.Process32NextW(snapshot, ctypes.byref(entry)):
176
+ if _get_windows_last_error() != _ERROR_NO_MORE_FILES:
177
+ return None
178
+ break
179
+ return process_table
180
+ finally:
181
+ kernel32.CloseHandle(snapshot)
182
+
183
+
184
+ def _read_windows_process_creation_filetime(pid: int) -> str | None:
185
+ kernel32 = _load_windows_kernel32()
186
+ if kernel32 is None:
187
+ return None
188
+ _configure_windows_process_time_functions(kernel32)
189
+ process = kernel32.OpenProcess(
190
+ _PROCESS_QUERY_LIMITED_INFORMATION,
191
+ False,
192
+ pid,
193
+ )
194
+ process_value = _windows_handle_value(process)
195
+ if process_value is None or process_value == 0:
196
+ return None
197
+ try:
198
+ creation_time = _WindowsFileTime()
199
+ exit_time = _WindowsFileTime()
200
+ kernel_time = _WindowsFileTime()
201
+ user_time = _WindowsFileTime()
202
+ ok = kernel32.GetProcessTimes(
203
+ process,
204
+ ctypes.byref(creation_time),
205
+ ctypes.byref(exit_time),
206
+ ctypes.byref(kernel_time),
207
+ ctypes.byref(user_time),
208
+ )
209
+ if not ok:
210
+ return None
211
+ filetime = (int(creation_time.dwHighDateTime) << 32) | int(
212
+ creation_time.dwLowDateTime
213
+ )
214
+ if filetime <= 0:
215
+ return None
216
+ return str(filetime)
217
+ finally:
218
+ kernel32.CloseHandle(process)
219
+
220
+
221
+ def _load_windows_kernel32() -> Any | None:
222
+ windll = getattr(ctypes, "WinDLL", None)
223
+ if windll is None:
224
+ return None
225
+ try:
226
+ return windll("kernel32", use_last_error=True)
227
+ except OSError:
228
+ return None
229
+
230
+
231
+ def _set_windows_last_error(error_code: int) -> None:
232
+ setter = getattr(ctypes, "set_last_error", None)
233
+ if setter is not None:
234
+ setter(error_code)
235
+
236
+
237
+ def _get_windows_last_error() -> int:
238
+ getter = getattr(ctypes, "get_last_error", None)
239
+ if getter is None:
240
+ return 0
241
+ return int(getter())
242
+
243
+
244
+ def _configure_windows_process_snapshot_functions(kernel32: Any) -> None:
245
+ kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
246
+ kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
247
+ kernel32.Process32FirstW.argtypes = [
248
+ wintypes.HANDLE,
249
+ ctypes.POINTER(_WindowsProcessEntry32),
250
+ ]
251
+ kernel32.Process32FirstW.restype = wintypes.BOOL
252
+ kernel32.Process32NextW.argtypes = [
253
+ wintypes.HANDLE,
254
+ ctypes.POINTER(_WindowsProcessEntry32),
255
+ ]
256
+ kernel32.Process32NextW.restype = wintypes.BOOL
257
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
258
+ kernel32.CloseHandle.restype = wintypes.BOOL
259
+
260
+
261
+ def _configure_windows_process_time_functions(kernel32: Any) -> None:
262
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
263
+ kernel32.OpenProcess.restype = wintypes.HANDLE
264
+ kernel32.GetProcessTimes.argtypes = [
265
+ wintypes.HANDLE,
266
+ ctypes.POINTER(_WindowsFileTime),
267
+ ctypes.POINTER(_WindowsFileTime),
268
+ ctypes.POINTER(_WindowsFileTime),
269
+ ctypes.POINTER(_WindowsFileTime),
270
+ ]
271
+ kernel32.GetProcessTimes.restype = wintypes.BOOL
272
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
273
+ kernel32.CloseHandle.restype = wintypes.BOOL
274
+
275
+
276
+ def _windows_handle_value(handle: object) -> int | None:
277
+ if handle is None:
278
+ return None
279
+ if isinstance(handle, int):
280
+ return handle
281
+ value = getattr(handle, "value", None)
282
+ if isinstance(value, int):
283
+ return value
284
+ return None
285
+
286
+
287
+ def _read_shell_interactivity(pid: int) -> bool | None:
288
+ tty_nr = _read_process_tty_nr(pid)
289
+ if tty_nr is None:
290
+ return None
291
+ return tty_nr != "0"
292
+
293
+
294
+ def _read_process_name(pid: int) -> str | None:
295
+ try:
296
+ return Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip()
297
+ except (OSError, RuntimeError):
298
+ return None
299
+
300
+
301
+ def _read_parent_pid(pid: int) -> int | None:
302
+ parts = _read_process_stat_fields(pid)
303
+ if parts is None:
304
+ return None
305
+ try:
306
+ return int(parts[3])
307
+ except ValueError:
308
+ return None
309
+
310
+
311
+ def _read_process_lifetime_discriminator(pid: int) -> str | None:
312
+ parts = _read_process_stat_fields(pid)
313
+ if parts is None or len(parts) < 22:
314
+ return None
315
+ start_ticks = parts[21].strip()
316
+ if not start_ticks:
317
+ return None
318
+ return start_ticks
319
+
320
+
321
+ def _read_process_tty_nr(pid: int) -> str | None:
322
+ parts = _read_process_stat_fields(pid)
323
+ if parts is None or len(parts) < 7:
324
+ return None
325
+ tty_nr = parts[6].strip()
326
+ if not tty_nr:
327
+ return None
328
+ return tty_nr
329
+
330
+
331
+ def _read_process_stat_fields(pid: int) -> list[str] | None:
332
+ try:
333
+ raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
334
+ except OSError:
335
+ return None
336
+ stat = raw.strip()
337
+ first_space = stat.find(" ")
338
+ comm_end = stat.rfind(")")
339
+ if first_space <= 0 or comm_end <= first_space:
340
+ return None
341
+ pid_part = stat[:first_space]
342
+ comm_part = stat[first_space + 1 : comm_end + 1]
343
+ remainder = stat[comm_end + 1 :].strip()
344
+ if not comm_part.startswith("("):
345
+ return None
346
+ parts = [pid_part, comm_part, *remainder.split()]
347
+ if len(parts) < 4:
348
+ return None
349
+ return parts
@@ -0,0 +1 @@
1
+ """Public CLI error mapping models and helpers."""