gridfleet-testkit 0.2.1__tar.gz → 0.3.0__tar.gz

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 (32) hide show
  1. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/.gitignore +1 -0
  2. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/CHANGELOG.md +17 -0
  3. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/PKG-INFO +34 -1
  4. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/README.md +33 -0
  5. gridfleet_testkit-0.3.0/gridfleet_testkit/__init__.py +54 -0
  6. gridfleet_testkit-0.3.0/gridfleet_testkit/allocation.py +144 -0
  7. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/gridfleet_testkit/client.py +204 -2
  8. gridfleet_testkit-0.3.0/gridfleet_testkit/pytest_plugin.py +127 -0
  9. gridfleet_testkit-0.3.0/gridfleet_testkit/sessions.py +76 -0
  10. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/pyproject.toml +4 -2
  11. gridfleet_testkit-0.3.0/tests/test_allocation.py +175 -0
  12. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/tests/test_client.py +380 -0
  13. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/tests/test_package_metadata.py +13 -0
  14. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/tests/test_pytest_plugin.py +74 -97
  15. gridfleet_testkit-0.3.0/tests/test_sessions.py +87 -0
  16. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/uv.lock +1 -1
  17. gridfleet_testkit-0.2.1/gridfleet_testkit/__init__.py +0 -37
  18. gridfleet_testkit-0.2.1/gridfleet_testkit/pytest_plugin.py +0 -247
  19. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/__init__.py +0 -0
  20. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/_example_helpers.py +0 -0
  21. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/assets/hello-world.zip +0 -0
  22. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/test_android_mobile_screenshot.py +0 -0
  23. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/test_android_tv_screenshot.py +0 -0
  24. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/test_firetv_screenshot.py +0 -0
  25. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/test_ios_simulator_screenshot.py +0 -0
  26. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/test_roku_screenshot.py +0 -0
  27. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/test_roku_sideload_screenshot.py +0 -0
  28. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/examples/test_tvos_screenshot.py +0 -0
  29. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/gridfleet_testkit/appium.py +0 -0
  30. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/gridfleet_testkit/py.typed +0 -0
  31. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/tests/test_appium.py +0 -0
  32. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.3.0}/tests/test_driver_agnostic_guard.py +0 -0
@@ -14,6 +14,7 @@ venv/
14
14
  htmlcov/
15
15
 
16
16
  # Frontend
17
+ node_modules/
17
18
  frontend/node_modules/
18
19
  frontend/dist/
19
20
 
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
4
 
5
+ ## [0.3.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.1...gridfleet-testkit-v0.3.0) (2026-05-05)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * **testkit:** promote public api helpers ([#92](https://github.com/quidow/gridfleet/issues/92))
11
+
12
+ ### Features
13
+
14
+ * **testkit:** add xdist recipe primitives ([#93](https://github.com/quidow/gridfleet/issues/93)) ([58fd3c3](https://github.com/quidow/gridfleet/commit/58fd3c3402ba7e735aae55e27abbe65a05c8ffe8))
15
+ * **testkit:** promote public api helpers ([#92](https://github.com/quidow/gridfleet/issues/92)) ([80d4483](https://github.com/quidow/gridfleet/commit/80d44832903f532de3da238d020b5dc27eb8b30e))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **agent:** trigger release for port conflict cleanup ([6a561ca](https://github.com/quidow/gridfleet/commit/6a561ca480c62b9abb2d5141fa98fc4e1a7696b6))
21
+
5
22
  ## [0.2.1](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.0...gridfleet-testkit-v0.2.1) (2026-05-03)
6
23
 
7
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfleet-testkit
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Supported pytest and run-orchestration helpers for GridFleet integrations
5
5
  Project-URL: Homepage, https://github.com/quidow/gridfleet
6
6
  Project-URL: Repository, https://github.com/quidow/gridfleet
@@ -162,22 +162,35 @@ finally:
162
162
 
163
163
  | Helper | Purpose |
164
164
  | --- | --- |
165
+ | `GridFleetClient.list_devices(filters)` | List devices using backend filters such as `status`, `pack_id`, `platform_id`, `host_id`, `connection_target`, and `tags.*` |
166
+ | `GridFleetClient.get_device(device_id)` | Fetch one full device detail row by backend device id |
165
167
  | `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
166
168
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
167
169
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
168
170
  | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
169
171
  | `GridFleetClient.claim_device_with_retry(run_id, worker_id=..., max_wait_sec=300)` | Claim one reserved device, sleeping according to server `Retry-After` responses |
170
172
  | `GridFleetClient.release_device(run_id, device_id=..., worker_id=...)` | Release a worker claim without cooldown |
173
+ | `GridFleetClient.release_device_safe(run_id, device_id=..., worker_id=...)` | Release a worker claim and tolerate 404/409 when cleanup races with run finalization or a prior release |
171
174
  | `GridFleetClient.release_device_with_cooldown(run_id, device_id=..., worker_id=..., reason=..., ttl_seconds=...)` | Release a worker claim and keep that run from reclaiming the device until cooldown expires |
172
175
  | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
173
176
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
174
177
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
175
178
  | `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
179
+ | `GridFleetClient.register_session(fields)` | Register a Grid/Appium session with optional requested capability metadata |
180
+ | `GridFleetClient.register_session_from_driver(driver, fields)` | Extract session id and capabilities from an Appium driver and register the session |
181
+ | `GridFleetClient.update_session_status(session_id, status)` | Report final session status |
176
182
  | `GridFleetClient.complete_run(run_id)` | Complete a run |
177
183
  | `GridFleetClient.cancel_run(run_id)` | Cancel a run |
178
184
  | `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
185
+ | `build_error_session_payload(fields)` | Build a `/api/sessions` payload for driver-creation failures without importing pytest |
186
+ | `hydrate_allocated_device(claim_response, run_id, client)` | Combine a claim response with optional device config and live capabilities |
187
+ | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
179
188
  | `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` and signal cleanup that completes or cancels a run |
180
189
 
190
+ ### Worker Identity
191
+
192
+ `worker_id` is an arbitrary string used for claim ownership, telemetry, and cooldown attribution. For pytest-xdist, pass `request.config.workerinput["workerid"]` from worker processes; values are normally `gw0`, `gw1`, and so on. For controller-only flows, use `"controller"` or a stable hostname. For custom schedulers, use a UUID or job-specific worker name.
193
+
181
194
  ### Reservation Flow
182
195
 
183
196
  ```python
@@ -247,6 +260,26 @@ else:
247
260
 
248
261
  Cooldowns are scoped to the active run. They prevent the same run from reclaiming the device until `ttl_seconds` expires, but completing or cancelling the run releases the physical device normally.
249
262
 
263
+ For pytest-xdist controller/worker orchestration, see [Testkit xdist recipe](../docs/guides/testkit-xdist-recipe.md). The recipe is copyable guidance, not a public testkit abstraction.
264
+
265
+ ### Allocated Device Hydration
266
+
267
+ Use `hydrate_allocated_device(claim_response, run_id=run_id, client=client)` immediately after a worker claim when a custom plugin needs a stable object instead of raw claim JSON.
268
+
269
+ ```python
270
+ from gridfleet_testkit import GridFleetClient, hydrate_allocated_device
271
+
272
+ client = GridFleetClient("http://manager-ip:8000/api")
273
+ run_id = "run-123"
274
+ claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
275
+ allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
276
+
277
+ assert allocated.device_id == claim["device_id"]
278
+ assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
279
+ ```
280
+
281
+ The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`.
282
+
250
283
  ## Examples
251
284
 
252
285
  Baseline screenshot examples:
@@ -128,22 +128,35 @@ finally:
128
128
 
129
129
  | Helper | Purpose |
130
130
  | --- | --- |
131
+ | `GridFleetClient.list_devices(filters)` | List devices using backend filters such as `status`, `pack_id`, `platform_id`, `host_id`, `connection_target`, and `tags.*` |
132
+ | `GridFleetClient.get_device(device_id)` | Fetch one full device detail row by backend device id |
131
133
  | `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
132
134
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
133
135
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
134
136
  | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
135
137
  | `GridFleetClient.claim_device_with_retry(run_id, worker_id=..., max_wait_sec=300)` | Claim one reserved device, sleeping according to server `Retry-After` responses |
136
138
  | `GridFleetClient.release_device(run_id, device_id=..., worker_id=...)` | Release a worker claim without cooldown |
139
+ | `GridFleetClient.release_device_safe(run_id, device_id=..., worker_id=...)` | Release a worker claim and tolerate 404/409 when cleanup races with run finalization or a prior release |
137
140
  | `GridFleetClient.release_device_with_cooldown(run_id, device_id=..., worker_id=..., reason=..., ttl_seconds=...)` | Release a worker claim and keep that run from reclaiming the device until cooldown expires |
138
141
  | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
139
142
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
140
143
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
141
144
  | `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
145
+ | `GridFleetClient.register_session(fields)` | Register a Grid/Appium session with optional requested capability metadata |
146
+ | `GridFleetClient.register_session_from_driver(driver, fields)` | Extract session id and capabilities from an Appium driver and register the session |
147
+ | `GridFleetClient.update_session_status(session_id, status)` | Report final session status |
142
148
  | `GridFleetClient.complete_run(run_id)` | Complete a run |
143
149
  | `GridFleetClient.cancel_run(run_id)` | Cancel a run |
144
150
  | `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
151
+ | `build_error_session_payload(fields)` | Build a `/api/sessions` payload for driver-creation failures without importing pytest |
152
+ | `hydrate_allocated_device(claim_response, run_id, client)` | Combine a claim response with optional device config and live capabilities |
153
+ | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
145
154
  | `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` and signal cleanup that completes or cancels a run |
146
155
 
156
+ ### Worker Identity
157
+
158
+ `worker_id` is an arbitrary string used for claim ownership, telemetry, and cooldown attribution. For pytest-xdist, pass `request.config.workerinput["workerid"]` from worker processes; values are normally `gw0`, `gw1`, and so on. For controller-only flows, use `"controller"` or a stable hostname. For custom schedulers, use a UUID or job-specific worker name.
159
+
147
160
  ### Reservation Flow
148
161
 
149
162
  ```python
@@ -213,6 +226,26 @@ else:
213
226
 
214
227
  Cooldowns are scoped to the active run. They prevent the same run from reclaiming the device until `ttl_seconds` expires, but completing or cancelling the run releases the physical device normally.
215
228
 
229
+ For pytest-xdist controller/worker orchestration, see [Testkit xdist recipe](../docs/guides/testkit-xdist-recipe.md). The recipe is copyable guidance, not a public testkit abstraction.
230
+
231
+ ### Allocated Device Hydration
232
+
233
+ Use `hydrate_allocated_device(claim_response, run_id=run_id, client=client)` immediately after a worker claim when a custom plugin needs a stable object instead of raw claim JSON.
234
+
235
+ ```python
236
+ from gridfleet_testkit import GridFleetClient, hydrate_allocated_device
237
+
238
+ client = GridFleetClient("http://manager-ip:8000/api")
239
+ run_id = "run-123"
240
+ claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
241
+ allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
242
+
243
+ assert allocated.device_id == claim["device_id"]
244
+ assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
245
+ ```
246
+
247
+ The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`.
248
+
216
249
  ## Examples
217
250
 
218
251
  Baseline screenshot examples:
@@ -0,0 +1,54 @@
1
+ """Supported Python integration helpers for GridFleet.
2
+
3
+ Environment variables read by the client:
4
+
5
+ - GRID_URL: Selenium Grid URL used by Appium helper defaults.
6
+ - GRIDFLEET_API_URL: GridFleet manager API base URL.
7
+ - GRIDFLEET_TESTKIT_USERNAME: optional Basic auth username.
8
+ - GRIDFLEET_TESTKIT_PASSWORD: optional Basic auth password.
9
+
10
+ Recipe-local run-state sharing variables are intentionally not exported from
11
+ this package because run-state sharing is consumer policy.
12
+ """
13
+
14
+ from importlib.metadata import PackageNotFoundError, version
15
+
16
+ from .allocation import AllocatedDevice, hydrate_allocated_device, hydrate_allocated_device_from_driver
17
+ from .appium import (
18
+ build_appium_options,
19
+ create_appium_driver,
20
+ get_connection_target_from_driver,
21
+ get_device_config_for_driver,
22
+ )
23
+ from .client import (
24
+ GRID_URL,
25
+ GRIDFLEET_API_URL,
26
+ GridFleetClient,
27
+ HeartbeatThread,
28
+ NoClaimableDevicesError,
29
+ register_run_cleanup,
30
+ )
31
+ from .sessions import build_error_session_payload
32
+
33
+ try:
34
+ __version__ = version("gridfleet-testkit")
35
+ except PackageNotFoundError:
36
+ __version__ = "0.3.0"
37
+
38
+ __all__ = [
39
+ "GRIDFLEET_API_URL",
40
+ "GRID_URL",
41
+ "AllocatedDevice",
42
+ "GridFleetClient",
43
+ "HeartbeatThread",
44
+ "NoClaimableDevicesError",
45
+ "__version__",
46
+ "build_appium_options",
47
+ "build_error_session_payload",
48
+ "create_appium_driver",
49
+ "get_connection_target_from_driver",
50
+ "get_device_config_for_driver",
51
+ "hydrate_allocated_device",
52
+ "hydrate_allocated_device_from_driver",
53
+ "register_run_cleanup",
54
+ ]
@@ -0,0 +1,144 @@
1
+ """Allocated-device hydration helpers for GridFleet testkit consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from .client import GridFleetClient
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AllocatedDevice:
14
+ """Combined view of a claimed device, ready for driver creation."""
15
+
16
+ run_id: str
17
+ device_id: str
18
+ identity_value: str
19
+ name: str
20
+ pack_id: str
21
+ platform_id: str
22
+ platform_label: str | None
23
+ os_version: str | None
24
+ connection_target: str | None
25
+ host_ip: str | None
26
+ device_type: str
27
+ connection_type: str
28
+ manufacturer: str | None
29
+ model: str | None
30
+ claimed_by: str
31
+ claimed_at: str
32
+ config: dict[str, Any] | None
33
+ live_capabilities: dict[str, Any] | None
34
+
35
+ @property
36
+ def is_real_device(self) -> bool:
37
+ return self.device_type == "real_device"
38
+
39
+ @property
40
+ def is_simulator(self) -> bool:
41
+ return self.device_type in {"simulator", "emulator"}
42
+
43
+ @property
44
+ def udid(self) -> str | None:
45
+ if self.connection_target:
46
+ return self.connection_target
47
+ value = (self.live_capabilities or {}).get("appium:udid")
48
+ return value if isinstance(value, str) and value else None
49
+
50
+ @property
51
+ def device_ip(self) -> str | None:
52
+ """Best-effort address, preferring host IP before live device/config IP fields."""
53
+ if self.host_ip:
54
+ return self.host_ip
55
+ live_value = (self.live_capabilities or {}).get("appium:deviceIP")
56
+ if isinstance(live_value, str) and live_value:
57
+ return live_value
58
+ config_value = (self.config or {}).get("ip")
59
+ return config_value if isinstance(config_value, str) and config_value else None
60
+
61
+ @property
62
+ def platform_name(self) -> str:
63
+ return self.platform_label or self.platform_id
64
+
65
+
66
+ def _string_value(payload: dict[str, Any], key: str, *, default: str | None = None) -> str:
67
+ value = payload.get(key, default)
68
+ if isinstance(value, str) and value:
69
+ return value
70
+ raise ValueError(f"Allocated device payload is missing {key}")
71
+
72
+
73
+ def _optional_string_value(payload: dict[str, Any], key: str) -> str | None:
74
+ value = payload.get(key)
75
+ return value if isinstance(value, str) and value else None
76
+
77
+
78
+ def _needs_device_detail(payload: dict[str, Any]) -> bool:
79
+ return any(payload.get(key) is None for key in ("name", "device_type", "connection_type", "manufacturer", "model"))
80
+
81
+
82
+ def _merge_device_detail(payload: dict[str, Any], detail: dict[str, Any]) -> dict[str, Any]:
83
+ merged = dict(payload)
84
+ for key in ("name", "device_type", "connection_type", "manufacturer", "model"):
85
+ if merged.get(key) is None and detail.get(key) is not None:
86
+ merged[key] = detail[key]
87
+ if merged.get("host_ip") is None and detail.get("ip_address") is not None:
88
+ merged["host_ip"] = detail["ip_address"]
89
+ return merged
90
+
91
+
92
+ def hydrate_allocated_device(
93
+ claim_response: dict[str, Any],
94
+ *,
95
+ run_id: str,
96
+ client: GridFleetClient,
97
+ fetch_config: bool = True,
98
+ fetch_capabilities: bool = False,
99
+ ) -> AllocatedDevice:
100
+ """Combine a claim response with optional static config and live capabilities."""
101
+ payload = dict(claim_response)
102
+ device_id = _string_value(payload, "device_id")
103
+ if _needs_device_detail(payload):
104
+ payload = _merge_device_detail(payload, client.get_device(device_id))
105
+
106
+ connection_target = _optional_string_value(payload, "connection_target")
107
+ config = client.get_device_config(connection_target) if fetch_config and connection_target else None
108
+ live_capabilities = client.get_device_capabilities(device_id) if fetch_capabilities else None
109
+
110
+ return AllocatedDevice(
111
+ run_id=run_id,
112
+ device_id=device_id,
113
+ identity_value=_string_value(payload, "identity_value"),
114
+ name=_string_value(payload, "name", default=device_id),
115
+ pack_id=_string_value(payload, "pack_id"),
116
+ platform_id=_string_value(payload, "platform_id"),
117
+ platform_label=_optional_string_value(payload, "platform_label"),
118
+ os_version=_optional_string_value(payload, "os_version"),
119
+ connection_target=connection_target,
120
+ host_ip=_optional_string_value(payload, "host_ip"),
121
+ device_type=_string_value(payload, "device_type"),
122
+ connection_type=_string_value(payload, "connection_type"),
123
+ manufacturer=_optional_string_value(payload, "manufacturer"),
124
+ model=_optional_string_value(payload, "model"),
125
+ claimed_by=_string_value(payload, "claimed_by"),
126
+ claimed_at=_string_value(payload, "claimed_at"),
127
+ config=config,
128
+ live_capabilities=live_capabilities,
129
+ )
130
+
131
+
132
+ def hydrate_allocated_device_from_driver(
133
+ allocated: AllocatedDevice,
134
+ driver: Any,
135
+ *,
136
+ client: GridFleetClient,
137
+ ) -> AllocatedDevice:
138
+ """Refresh live capabilities from a running Appium driver session."""
139
+ capabilities = getattr(driver, "capabilities", None)
140
+ if isinstance(capabilities, dict):
141
+ live_capabilities = dict(capabilities)
142
+ else:
143
+ live_capabilities = client.get_device_capabilities(allocated.device_id)
144
+ return replace(allocated, live_capabilities=live_capabilities)
@@ -38,14 +38,16 @@ class NoClaimableDevicesError(RuntimeError):
38
38
  message: str,
39
39
  *,
40
40
  retry_after_sec: int,
41
+ run_id: str = "",
41
42
  next_available_at: str | None = None,
42
43
  ) -> None:
44
+ self.run_id = run_id
43
45
  self.retry_after_sec = retry_after_sec
44
46
  self.next_available_at = next_available_at
45
47
  super().__init__(message)
46
48
 
47
49
 
48
- def _raise_for_status(resp: Any) -> None:
50
+ def _raise_for_status(resp: Any, *, run_id: str) -> None:
49
51
  if resp.status_code == 409:
50
52
  try:
51
53
  payload = resp.json()
@@ -61,12 +63,40 @@ def _raise_for_status(resp: Any) -> None:
61
63
  next_available_at = details.get("next_available_at")
62
64
  raise NoClaimableDevicesError(
63
65
  str(error.get("message") or "No unclaimed devices available in this run"),
66
+ run_id=run_id,
64
67
  retry_after_sec=retry_after,
65
68
  next_available_at=next_available_at if isinstance(next_available_at, str) else None,
66
69
  )
67
70
  resp.raise_for_status()
68
71
 
69
72
 
73
+ def _is_safe_release_conflict(resp: Any) -> bool:
74
+ try:
75
+ payload = resp.json()
76
+ except Exception:
77
+ return False
78
+ detail = payload.get("detail") if isinstance(payload, dict) else None
79
+ return isinstance(detail, str) and "is not claimed" in detail.lower()
80
+
81
+
82
+ def _query_params(values: dict[str, Any]) -> list[tuple[str, str | int | float | bool | None]]:
83
+ params: list[tuple[str, str | int | float | bool | None]] = []
84
+ for key, value in values.items():
85
+ if value is None:
86
+ continue
87
+ if isinstance(value, bool):
88
+ params.append((key, str(value).lower()))
89
+ else:
90
+ params.append((key, str(value)))
91
+ return params
92
+
93
+
94
+ def _raise_or_warn(operation: str, suppress_errors: bool, exc: Exception) -> None:
95
+ if not suppress_errors:
96
+ raise exc
97
+ logger.warning("Failed to %s with GridFleet: %s", operation, exc)
98
+
99
+
70
100
  class HeartbeatThread(threading.Thread):
71
101
  """Background thread that sends periodic heartbeat pings for an active test run."""
72
102
 
@@ -115,6 +145,66 @@ class GridFleetClient:
115
145
  self.base_url = base_url.rstrip("/")
116
146
  self._auth = auth if auth is not None else _default_auth()
117
147
 
148
+ def list_devices(
149
+ self,
150
+ *,
151
+ pack_id: str | None = None,
152
+ platform_id: str | None = None,
153
+ status: str | None = None,
154
+ host_id: str | None = None,
155
+ identity_value: str | None = None,
156
+ connection_target: str | None = None,
157
+ device_type: str | None = None,
158
+ connection_type: str | None = None,
159
+ os_version: str | None = None,
160
+ search: str | None = None,
161
+ hardware_health_status: str | None = None,
162
+ hardware_telemetry_state: str | None = None,
163
+ needs_attention: bool | None = None,
164
+ tags: dict[str, str] | None = None,
165
+ ) -> list[dict[str, Any]]:
166
+ """List devices with backend filter passthrough."""
167
+ params = _query_params(
168
+ {
169
+ "pack_id": pack_id,
170
+ "platform_id": platform_id,
171
+ "status": status,
172
+ "host_id": host_id,
173
+ "identity_value": identity_value,
174
+ "connection_target": connection_target,
175
+ "device_type": device_type,
176
+ "connection_type": connection_type,
177
+ "os_version": os_version,
178
+ "search": search,
179
+ "hardware_health_status": hardware_health_status,
180
+ "hardware_telemetry_state": hardware_telemetry_state,
181
+ "needs_attention": needs_attention,
182
+ }
183
+ )
184
+ if tags:
185
+ params.extend((f"tags.{key}", value) for key, value in tags.items())
186
+ resp = httpx.get(
187
+ f"{self.base_url}/devices",
188
+ params=params,
189
+ timeout=10,
190
+ auth=self._auth,
191
+ )
192
+ resp.raise_for_status()
193
+ payload = resp.json()
194
+ if isinstance(payload, dict) and isinstance(payload.get("items"), list):
195
+ return cast("list[dict[str, Any]]", payload["items"])
196
+ return cast("list[dict[str, Any]]", payload)
197
+
198
+ def get_device(self, device_id: str) -> dict[str, Any]:
199
+ """Fetch one device detail row by backend device id."""
200
+ resp = httpx.get(
201
+ f"{self.base_url}/devices/{device_id}",
202
+ timeout=10,
203
+ auth=self._auth,
204
+ )
205
+ resp.raise_for_status()
206
+ return cast("dict[str, Any]", resp.json())
207
+
118
208
  def get_device_config(self, connection_target: str, reveal: bool = True) -> dict[str, Any]:
119
209
  """Fetch device config by looking up the current runtime connection target."""
120
210
  resp = httpx.get(
@@ -211,7 +301,7 @@ class GridFleetClient:
211
301
  timeout=10,
212
302
  auth=self._auth,
213
303
  )
214
- _raise_for_status(resp)
304
+ _raise_for_status(resp, run_id=run_id)
215
305
  return cast("dict[str, Any]", resp.json())
216
306
 
217
307
  def release_device(self, run_id: str, *, device_id: str, worker_id: str) -> None:
@@ -223,6 +313,24 @@ class GridFleetClient:
223
313
  )
224
314
  resp.raise_for_status()
225
315
 
316
+ def release_device_safe(self, run_id: str, *, device_id: str, worker_id: str) -> bool:
317
+ """Release a claim while tolerating already-terminal run/device states.
318
+
319
+ Returns True when the manager accepts the release, False when the run is
320
+ gone or the claim is explicitly already unclaimed. Other HTTP errors,
321
+ including wrong-worker conflicts, still raise.
322
+ """
323
+ resp = httpx.post(
324
+ f"{self.base_url}/runs/{run_id}/release",
325
+ json={"device_id": device_id, "worker_id": worker_id},
326
+ timeout=10,
327
+ auth=self._auth,
328
+ )
329
+ if resp.status_code == 404 or (resp.status_code == 409 and _is_safe_release_conflict(resp)):
330
+ return False
331
+ resp.raise_for_status()
332
+ return True
333
+
226
334
  def release_device_with_cooldown(
227
335
  self,
228
336
  run_id: str,
@@ -274,6 +382,100 @@ class GridFleetClient:
274
382
  resp.raise_for_status()
275
383
  return cast("dict[str, Any]", resp.json())
276
384
 
385
+ def register_session(
386
+ self,
387
+ *,
388
+ session_id: str,
389
+ test_name: str | None = None,
390
+ device_id: str | None = None,
391
+ connection_target: str | None = None,
392
+ status: str = "running",
393
+ requested_pack_id: str | None = None,
394
+ requested_platform_id: str | None = None,
395
+ requested_device_type: str | None = None,
396
+ requested_connection_type: str | None = None,
397
+ requested_capabilities: dict[str, Any] | None = None,
398
+ error_type: str | None = None,
399
+ error_message: str | None = None,
400
+ run_id: str | None = None,
401
+ suppress_errors: bool = True,
402
+ ) -> dict[str, Any] | None:
403
+ """Register a Grid/Appium session with the manager."""
404
+ try:
405
+ resp = httpx.post(
406
+ f"{self.base_url}/sessions",
407
+ json={
408
+ "session_id": session_id,
409
+ "test_name": test_name,
410
+ "device_id": device_id,
411
+ "connection_target": connection_target,
412
+ "status": status,
413
+ "requested_pack_id": requested_pack_id,
414
+ "requested_platform_id": requested_platform_id,
415
+ "requested_device_type": requested_device_type,
416
+ "requested_connection_type": requested_connection_type,
417
+ "requested_capabilities": requested_capabilities,
418
+ "error_type": error_type,
419
+ "error_message": error_message,
420
+ "run_id": run_id,
421
+ },
422
+ timeout=5,
423
+ auth=self._auth,
424
+ )
425
+ resp.raise_for_status()
426
+ except (httpx.HTTPError, TypeError, ValueError) as exc:
427
+ _raise_or_warn("register session", suppress_errors, exc)
428
+ return None
429
+ return cast("dict[str, Any]", resp.json())
430
+
431
+ def update_session_status(
432
+ self,
433
+ session_id: str,
434
+ status: str,
435
+ *,
436
+ suppress_errors: bool = True,
437
+ ) -> dict[str, Any] | None:
438
+ """Update a registered session status."""
439
+ try:
440
+ resp = httpx.patch(
441
+ f"{self.base_url}/sessions/{session_id}/status",
442
+ json={"status": status},
443
+ timeout=5,
444
+ auth=self._auth,
445
+ )
446
+ resp.raise_for_status()
447
+ except (httpx.HTTPError, TypeError, ValueError) as exc:
448
+ _raise_or_warn("report session status", suppress_errors, exc)
449
+ return None
450
+ return cast("dict[str, Any]", resp.json())
451
+
452
+ def register_session_from_driver(
453
+ self,
454
+ driver: Any,
455
+ *,
456
+ test_name: str | None = None,
457
+ run_id: str | None = None,
458
+ suppress_errors: bool = True,
459
+ ) -> dict[str, Any] | None:
460
+ """Extract session metadata from an Appium driver and register it."""
461
+ capabilities = getattr(driver, "capabilities", {})
462
+ if not isinstance(capabilities, dict):
463
+ capabilities = {}
464
+ session_id = getattr(driver, "session_id", None)
465
+ if not isinstance(session_id, str) or not session_id:
466
+ raise RuntimeError("Created Appium driver did not expose a session ID")
467
+ device_id = capabilities.get("appium:gridfleet:deviceId") or capabilities.get("gridfleet:deviceId")
468
+ connection_target = capabilities.get("appium:udid") or capabilities.get("appium:deviceName")
469
+ return self.register_session(
470
+ session_id=session_id,
471
+ test_name=test_name,
472
+ device_id=device_id if isinstance(device_id, str) and device_id else None,
473
+ connection_target=connection_target if isinstance(connection_target, str) and connection_target else None,
474
+ requested_capabilities=capabilities,
475
+ run_id=run_id,
476
+ suppress_errors=suppress_errors,
477
+ )
478
+
277
479
  def complete_run(self, run_id: str) -> None:
278
480
  httpx.post(
279
481
  f"{self.base_url}/runs/{run_id}/complete",