gridfleet-testkit 0.7.0__tar.gz → 0.9.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 (35) hide show
  1. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/CHANGELOG.md +33 -0
  2. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/PKG-INFO +91 -49
  3. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/README.md +90 -48
  4. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/gridfleet_testkit/__init__.py +7 -1
  5. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/gridfleet_testkit/allocation.py +7 -0
  6. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/gridfleet_testkit/client.py +43 -1
  7. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/pyproject.toml +1 -1
  8. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_allocation.py +11 -0
  9. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_client.py +21 -0
  10. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_pytest_plugin.py +27 -0
  11. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/uv.lock +128 -128
  12. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/.gitignore +0 -0
  13. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/__init__.py +0 -0
  14. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/_example_helpers.py +0 -0
  15. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/assets/hello-world.zip +0 -0
  16. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/test_android_mobile_screenshot.py +0 -0
  17. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/test_android_tv_screenshot.py +0 -0
  18. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/test_firetv_screenshot.py +0 -0
  19. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/test_ios_simulator_screenshot.py +0 -0
  20. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/test_roku_screenshot.py +0 -0
  21. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/test_roku_sideload_screenshot.py +0 -0
  22. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/examples/test_tvos_screenshot.py +0 -0
  23. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/gridfleet_testkit/appium.py +0 -0
  24. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/gridfleet_testkit/py.typed +0 -0
  25. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/gridfleet_testkit/pytest_plugin.py +0 -0
  26. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/gridfleet_testkit/sessions.py +0 -0
  27. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_appium.py +0 -0
  28. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_client_test_data.py +0 -0
  29. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_docs_contract.py +0 -0
  30. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_driver_agnostic_guard.py +0 -0
  31. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_package_metadata.py +0 -0
  32. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_pytest_plugin_grid_run_id_injection.py +0 -0
  33. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_pytest_plugin_test_data.py +0 -0
  34. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_sessions.py +0 -0
  35. {gridfleet_testkit-0.7.0 → gridfleet_testkit-0.9.0}/tests/test_sessions_resolve_device_handle.py +0 -0
@@ -2,6 +2,39 @@
2
2
 
3
3
  All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
4
 
5
+ ## [0.9.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.8.0...gridfleet-testkit-v0.9.0) (2026-05-12)
6
+
7
+
8
+ ### Features
9
+
10
+ * **testkit:** add run detail client helper ([c80a8cc](https://github.com/quidow/gridfleet/commit/c80a8cc95093c2f46a7e714c96ff0b33018af5ba))
11
+ * **testkit:** expose allocation device tags ([0bf5e2e](https://github.com/quidow/gridfleet/commit/0bf5e2e04b806fadd0afcd3c95073828d0c2414e))
12
+ * **testkit:** support tag-based device targeting ([db0d0e3](https://github.com/quidow/gridfleet/commit/db0d0e3d3d1231828bb22a707d3bdcab6c0ec717))
13
+
14
+
15
+ ### Documentation
16
+
17
+ * **testkit:** document tag-based device targeting ([096841b](https://github.com/quidow/gridfleet/commit/096841b737dec71524d0edfa4c538d9cc69e7c2c))
18
+
19
+ ## [0.8.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.7.0...gridfleet-testkit-v0.8.0) (2026-05-12)
20
+
21
+
22
+ ### Features
23
+
24
+ * **backend,testkit:** recreate run device cooldown api ([fccfbc7](https://github.com/quidow/gridfleet/commit/fccfbc7bcf694f8c59cbaa394bb075d20e1b34f0))
25
+ * **testkit:** add cooldown_device client helper and result types ([584f411](https://github.com/quidow/gridfleet/commit/584f411f3b72f815e6f4055667c0eaccb1926bc5))
26
+
27
+
28
+ ### Dependencies
29
+
30
+ * **deps:** bump mypy in /agent ([#195](https://github.com/quidow/gridfleet/issues/195)) ([1317e59](https://github.com/quidow/gridfleet/commit/1317e59bbd4ae6969ed3c717c24b43dbfefec722))
31
+
32
+
33
+ ### Documentation
34
+
35
+ * **testkit:** correct cooldown_device ttl error in readme ([f768b6b](https://github.com/quidow/gridfleet/commit/f768b6b11b87ad3c4aa369ee2bcdebfeed5c1f86))
36
+ * **testkit:** document cooldown_device api and result types ([0e2418b](https://github.com/quidow/gridfleet/commit/0e2418b0ea3b952d6151a08e3f75116de7edcdd8))
37
+
5
38
  ## [0.7.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.6.0...gridfleet-testkit-v0.7.0) (2026-05-11)
6
39
 
7
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfleet-testkit
3
- Version: 0.7.0
3
+ Version: 0.9.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
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  - Stable import root: `gridfleet_testkit`
42
42
  - Supported pytest plugin: `gridfleet_testkit.pytest_plugin`
43
- - Supported pytest fixtures: `appium_driver`, `gridfleet_client`, `device_config`, `device_test_data`, `gridfleet_worker_id`
43
+ - Supported pytest fixtures: `appium_driver`, `gridfleet_client`, `device_config`, `device_test_data`, `device_handle`, `gridfleet_worker_id`
44
44
  - Supported public Appium helpers:
45
45
  - `build_appium_options`
46
46
  - `create_appium_driver`
@@ -54,12 +54,15 @@ Description-Content-Type: text/markdown
54
54
  - Supported public allocation/session helpers:
55
55
  - `AllocatedDevice`
56
56
  - `UnavailableInclude`
57
- - `CooldownResult`
58
57
  - `build_error_session_payload`
59
58
  - `hydrate_allocated_device`
60
59
  - `hydrate_allocated_device_from_driver`
60
+ - `resolve_device_handle_from_driver`
61
+ - Supported public result types:
62
+ - `CooldownResult`
63
+ - `CooldownSetResult`
64
+ - `CooldownEscalatedResult`
61
65
  - Supported public exceptions:
62
- - `NoClaimableDevicesError`
63
66
  - `UnknownIncludeError`
64
67
  - `ReserveCapabilitiesUnsupportedError`
65
68
  - Manual hardware examples under `testkit/examples/`
@@ -111,6 +114,7 @@ The package supports Python 3.10 and newer.
111
114
  | `GRIDFLEET_TESTKIT_PASSWORD` | unset | Machine-auth password sent as HTTP Basic auth on every API call. Required when the manager runs with `GRIDFLEET_AUTH_ENABLED=true`. Use the same value as the manager's `GRIDFLEET_MACHINE_AUTH_PASSWORD`. |
112
115
  | `GRIDFLEET_TESTKIT_PACK_ID` | unset | Optional default driver pack id for Appium option building |
113
116
  | `GRIDFLEET_TESTKIT_PLATFORM_ID` | unset | Optional default platform id for Appium option building |
117
+ | `GRIDFLEET_RUN_ID` | `free` | Optional run id injected into Appium capabilities as `gridfleet:run_id`. The pytest plugin sets this automatically when sessions are created inside a reserved run. |
114
118
 
115
119
  The package assumes a running GridFleet API, a reachable Selenium Grid hub, and platform-specific Appium driver setup on the registered hosts. When auth is disabled on the manager, leave `GRIDFLEET_TESTKIT_USERNAME` / `GRIDFLEET_TESTKIT_PASSWORD` unset and the testkit will send no `Authorization` header.
116
120
 
@@ -146,9 +150,11 @@ If you need raw Appium control instead, omit `pack_id` and `platform_id`, then p
146
150
 
147
151
  - Creates an Appium session through `GRID_URL`
148
152
  - Injects `gridfleet:testName` with the pytest test name
153
+ - Injects `gridfleet:run_id` when a `GRIDFLEET_RUN_ID` environment variable is present (for example, inside a reserved run)
149
154
  - Reports final session status back to `GRIDFLEET_API_URL`
150
155
  - Exposes `device_config` for post-session config lookup using the runtime connection target
151
156
  - Exposes `device_test_data` for post-session operator-attached test data using the runtime connection target
157
+ - Exposes `device_handle` for fetching the canonical manager device row using the runtime connection target
152
158
  - Exposes `gridfleet_worker_id` which returns the pytest-xdist worker id, or `"controller"` for non-worker processes
153
159
  - Relies on manager-owned runtime isolation for Appium driver sub-ports and XCUITest build paths
154
160
 
@@ -183,40 +189,74 @@ finally:
183
189
  | `GridFleetClient.list_devices(*, pack_id=None, status=None, host_id=None, ...)` | List devices using backend keyword filters (pack_id, platform_id, status, host_id, connection_target, tags, ...) |
184
190
  | `GridFleetClient.get_device(device_id)` | Fetch one full device detail row by backend device id |
185
191
  | `GridFleetClient.get_device_config(connection_target)` | Look up a device by runtime connection target and fetch its config |
192
+ | `GridFleetClient.get_device_by_connection_target(connection_target)` | Fetch one device detail row by runtime connection target |
186
193
  | `GridFleetClient.get_device_capabilities(device_id)` | Fetch current Appium capability metadata for a device |
187
194
  | `GridFleetClient.get_device_test_data(device_id)` | Fetch operator-attached free-form test_data for a device |
195
+ | `GridFleetClient.get_run(run_id)` | Fetch one run detail row by backend run id |
188
196
  | `GridFleetClient.replace_device_test_data(device_id, body)` | Replace test_data with the supplied object |
189
197
  | `GridFleetClient.merge_device_test_data(device_id, body)` | Deep-merge into device test_data |
190
198
  | `GridFleetClient.resolve_device_id_by_connection_target(connection_target)` | Resolve the backend device id for a runtime connection target |
191
199
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
192
200
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
193
- | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
194
- | `GridFleetClient.claim_device_with_retry(run_id, worker_id=..., max_wait_sec=300)` | Claim one reserved device, sleeping according to server `Retry-After` responses |
195
- | `GridFleetClient.release_device(run_id, device_id=..., worker_id=...)` | Release a worker claim without cooldown |
196
- | `GridFleetClient.release_device_safe(run_id, device_id=..., worker_id=...)` | Return `True` when release is accepted, `False` when the run is gone or the claim is already unclaimed, and raise on wrong-worker conflicts or unsafe errors |
197
- | `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 |
198
- | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
201
+ | `GridFleetClient.signal_ready(run_id)` | Signal that a run is ready |
199
202
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
200
203
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
201
204
  | `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
202
205
  | `GridFleetClient.register_session(fields)` | Register a Grid/Appium session with optional requested capability metadata |
203
206
  | `GridFleetClient.register_session_from_driver(driver, fields)` | Extract session id and capabilities from an Appium driver and register the session |
207
+ | `GridFleetClient.notify_session_finished(session_id)` | Tell the manager the WebDriver session has ended |
204
208
  | `GridFleetClient.update_session_status(session_id, status)` | Report final session status |
205
209
  | `GridFleetClient.complete_run(run_id)` | Complete a run |
206
210
  | `GridFleetClient.cancel_run(run_id)` | Cancel a run |
211
+ | `GridFleetClient.cooldown_device(run_id, device_id, reason=..., ttl_seconds=...)` | Exclude a reserved device from the run with a cooldown TTL |
207
212
  | `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
208
213
  | `build_error_session_payload(fields)` | Build a `/api/sessions` payload for driver-creation failures without importing pytest |
209
- | `hydrate_allocated_device(claim_response, run_id, client)` | Combine a claim response with optional device config and live capabilities |
214
+ | `hydrate_allocated_device(device_handle, run_id, client)` | Combine a device handle with optional device config and live capabilities |
210
215
  | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
216
+ | `resolve_device_handle_from_driver(driver, client)` | Resolve the assigned manager device row from a running Appium session |
211
217
  | `get_device_test_data_for_driver(driver, gridfleet_client=None)` | Fetch test_data for a live Appium driver |
212
218
  | `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` cleanup callable and return it; stops the heartbeat thread on exit but does not complete or cancel the run by default |
213
219
 
220
+ ### Targeting Devices by Tag
221
+
222
+ GridFleet injects device tags into Grid node stereotypes as `appium:gridfleet:tag:<key>` capabilities, so Selenium Grid can route sessions to devices matching specific tags.
223
+
224
+ ```python
225
+ @pytest.mark.parametrize(
226
+ "appium_driver",
227
+ [
228
+ {
229
+ "pack_id": "appium-uiautomator2",
230
+ "platform_id": "android_mobile",
231
+ "appium:gridfleet:tag:screen_type": "4k",
232
+ }
233
+ ],
234
+ indirect=True,
235
+ )
236
+ def test_4k_display(appium_driver):
237
+ ...
238
+ ```
239
+
240
+ The same capability works for free sessions:
241
+
242
+ ```python
243
+ driver = create_appium_driver(
244
+ pack_id="appium-uiautomator2",
245
+ platform_id="android_mobile",
246
+ capabilities={"appium:gridfleet:tag:screen_type": "4k"},
247
+ )
248
+ ```
249
+
250
+ When an operator edits device tags, GridFleet marks the device for re-verification. The next verification restarts the Appium node and re-registers it with the updated Grid stereotype.
251
+
214
252
  ### Worker Identity
215
253
 
216
- `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.
254
+ `worker_id` is an arbitrary string used for reservation telemetry and run 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.
217
255
 
218
256
  ### Reservation Flow
219
257
 
258
+ GridFleet runs are grid-routed: once devices are reserved, the manager tags matching Grid nodes with the run id, and Selenium Grid routes new Appium sessions to those nodes automatically via the `gridfleet:run_id` capability. There are no per-worker claim or release calls.
259
+
220
260
  ```python
221
261
  from gridfleet_testkit import GridFleetClient, register_run_cleanup
222
262
 
@@ -258,53 +298,51 @@ client.signal_active(run_id)
258
298
 
259
299
  Use `count` for exact reservations. Use `allocation: "all_available"` when CI should reserve every currently eligible matching device and size its worker pool from `len(run["devices"])`.
260
300
 
261
- ### Worker Claim With Cooldown
301
+ 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.
302
+
303
+ ### Cooling Down an Unstable Device
304
+
305
+ If a reserved device becomes unstable during a test, you can put it on cooldown so it is excluded from the run for a TTL. If the same device is cooled down too many times in the same run, it is escalated to maintenance automatically.
262
306
 
263
307
  ```python
264
308
  from gridfleet_testkit import GridFleetClient
265
309
 
266
- client = GridFleetClient("http://manager-ip:8000/api")
310
+ client = GridFleetClient()
267
311
 
268
- claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
269
- device_id = claim["device_id"]
312
+ result = client.cooldown_device(
313
+ run_id="run-123",
314
+ device_id="device-456",
315
+ reason="Connection dropped mid-test",
316
+ ttl_seconds=120,
317
+ )
270
318
 
271
- try:
272
- # Create the Appium session and run test setup for this worker.
273
- ...
274
- except RuntimeError as exc:
275
- client.release_device_with_cooldown(
276
- run_id,
277
- device_id=device_id,
278
- worker_id="gw0",
279
- reason=str(exc),
280
- ttl_seconds=60,
281
- )
282
- raise
283
- else:
284
- client.release_device(run_id, device_id=device_id, worker_id="gw0")
319
+ if result["status"] == "cooldown_set":
320
+ print(f"Device on cooldown until {result['excluded_until']}")
321
+ elif result["status"] == "maintenance_escalated":
322
+ print(f"Escalated after {result['cooldown_count']} cooldowns")
285
323
  ```
286
324
 
287
- 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.
288
-
289
- 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.
325
+ The manager enforces a maximum TTL via the `general.device_cooldown_max_sec` setting. The default is 3600 seconds. An `httpx.HTTPStatusError` with status 422 is raised if `ttl_seconds` exceeds the maximum.
290
326
 
291
327
  ### Allocated Device Hydration
292
328
 
293
- 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.
329
+ Use `hydrate_allocated_device(...)` immediately after reserving a run when a custom plugin needs a stable object instead of raw device-handle JSON.
294
330
 
295
331
  ```python
296
- from gridfleet_testkit import GridFleetClient, hydrate_allocated_device
332
+ from gridfleet_testkit import GridFleetClient, hydrate_allocated_device, resolve_device_handle_from_driver
297
333
 
298
- client = GridFleetClient("http://manager-ip:8000/api")
334
+ client = GridFleetClient()
299
335
  run_id = "run-123"
300
- claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
301
- allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
302
336
 
303
- assert allocated.device_id == claim["device_id"]
337
+ # After creating an Appium session, resolve the assigned device handle
338
+ device_handle = resolve_device_handle_from_driver(driver, client=client)
339
+ allocated = hydrate_allocated_device(device_handle, run_id=run_id, client=client)
340
+
341
+ assert allocated.device_id == device_handle["device_id"]
304
342
  assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
305
343
  ```
306
344
 
307
- The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`. Pass `fetch_test_data=True` to also populate `allocated.test_data`. The `test_data` field is also available directly from the claim response when the manager inlines it.
345
+ The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`. Pass `fetch_test_data=True` to also populate `allocated.test_data`. The `test_data` field is also available directly from the reserve response when the manager inlines it.
308
346
 
309
347
  ### Run Cleanup Policy
310
348
 
@@ -335,29 +373,33 @@ test_data = get_device_test_data_for_driver(driver)
335
373
 
336
374
  ### Errors and Result Types
337
375
 
338
- - `NoClaimableDevicesError(RuntimeError)`: raised when the manager reports no run devices are claimable yet. Exposes `run_id`, `retry_after_sec`, and `next_available_at`. The `RuntimeError` base is part of the contract — consumers can rely on it.
339
376
  - `UnknownIncludeError(ValueError)`: raised when the backend rejects one or more `?include=` keys. Exposes `values` with the rejected key names. The `ValueError` base is part of the contract.
340
377
  - `ReserveCapabilitiesUnsupportedError(ValueError)`: raised when a reserve-time `include` request contains `"capabilities"`, which is not supported at reserve time. The `ValueError` base is part of the contract.
341
- - `CooldownResult`: union response type from `release_device_with_cooldown`, with `status` equal to `"cooldown_set"` or `"maintenance_escalated"`. `CooldownSetResult` and `CooldownEscalatedResult` are the concrete TypedDict variants.
378
+ - `CooldownResult`: union response type from `cooldown_device`, with `status` equal to `"cooldown_set"` or `"maintenance_escalated"`. `CooldownSetResult` and `CooldownEscalatedResult` are the concrete TypedDict variants.
342
379
 
343
- ### Reduced HTTP round-trips on claim
380
+ ### Reduced HTTP Round-trips on Reserve
344
381
 
345
- The manager can inline device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
382
+ The manager can inline device config and live capabilities into the `reserve_devices` response, eliminating per-worker follow-up GETs.
346
383
 
347
384
  ```python
348
385
  client = GridFleetClient()
349
- claim = client.claim_device(run_id, worker_id="w0", include=("config", "capabilities"))
350
- allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
351
- # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
386
+ run = client.reserve_devices(
387
+ name="my-test-run",
388
+ requirements=[...],
389
+ include=("config", "capabilities"),
390
+ )
391
+ for device_handle in run["devices"]:
392
+ allocated = hydrate_allocated_device(device_handle, run_id=run["id"], client=client)
393
+ # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
352
394
  ```
353
395
 
354
396
  `device_config` and inline `config` payloads are returned verbatim from the manager. The testkit does not perform client-side secret masking or reveal toggles. Protect device config with manager authentication, operator access control, and your lab's secret-handling policy.
355
397
 
356
- `reserve_devices` accepts `include=("config",)` only — `include=("capabilities",)` raises `ReserveCapabilitiesUnsupportedError` client-side because reserve-time capabilities are not yet device-bound. Pass `include=` on the per-worker `claim_device` call instead.
398
+ `reserve_devices` accepts `include=("config",)` only — `include=("capabilities",)` raises `ReserveCapabilitiesUnsupportedError` client-side because reserve-time capabilities are not yet device-bound. Pass `include=` on the per-worker `hydrate_allocated_device` call instead if you need capabilities after sessions are running.
357
399
 
358
400
  `include=` must be a sequence of strings (tuple or list) — order is preserved in the emitted query parameter. Passing a bare string like `include="config"` raises `TypeError` to avoid silently splitting the value into characters.
359
401
 
360
- `hydrate_allocated_device` accepts claim responses only. For multi-device reservations, iterate `reserve_response["devices"]`, call `claim_device` per worker, and hydrate each claim response.
402
+ `hydrate_allocated_device` accepts device-handle payloads such as `reserve_response["devices"]` entries or rows returned by `get_device_by_connection_target`.
361
403
 
362
404
  ## Examples
363
405
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  - Stable import root: `gridfleet_testkit`
8
8
  - Supported pytest plugin: `gridfleet_testkit.pytest_plugin`
9
- - Supported pytest fixtures: `appium_driver`, `gridfleet_client`, `device_config`, `device_test_data`, `gridfleet_worker_id`
9
+ - Supported pytest fixtures: `appium_driver`, `gridfleet_client`, `device_config`, `device_test_data`, `device_handle`, `gridfleet_worker_id`
10
10
  - Supported public Appium helpers:
11
11
  - `build_appium_options`
12
12
  - `create_appium_driver`
@@ -20,12 +20,15 @@
20
20
  - Supported public allocation/session helpers:
21
21
  - `AllocatedDevice`
22
22
  - `UnavailableInclude`
23
- - `CooldownResult`
24
23
  - `build_error_session_payload`
25
24
  - `hydrate_allocated_device`
26
25
  - `hydrate_allocated_device_from_driver`
26
+ - `resolve_device_handle_from_driver`
27
+ - Supported public result types:
28
+ - `CooldownResult`
29
+ - `CooldownSetResult`
30
+ - `CooldownEscalatedResult`
27
31
  - Supported public exceptions:
28
- - `NoClaimableDevicesError`
29
32
  - `UnknownIncludeError`
30
33
  - `ReserveCapabilitiesUnsupportedError`
31
34
  - Manual hardware examples under `testkit/examples/`
@@ -77,6 +80,7 @@ The package supports Python 3.10 and newer.
77
80
  | `GRIDFLEET_TESTKIT_PASSWORD` | unset | Machine-auth password sent as HTTP Basic auth on every API call. Required when the manager runs with `GRIDFLEET_AUTH_ENABLED=true`. Use the same value as the manager's `GRIDFLEET_MACHINE_AUTH_PASSWORD`. |
78
81
  | `GRIDFLEET_TESTKIT_PACK_ID` | unset | Optional default driver pack id for Appium option building |
79
82
  | `GRIDFLEET_TESTKIT_PLATFORM_ID` | unset | Optional default platform id for Appium option building |
83
+ | `GRIDFLEET_RUN_ID` | `free` | Optional run id injected into Appium capabilities as `gridfleet:run_id`. The pytest plugin sets this automatically when sessions are created inside a reserved run. |
80
84
 
81
85
  The package assumes a running GridFleet API, a reachable Selenium Grid hub, and platform-specific Appium driver setup on the registered hosts. When auth is disabled on the manager, leave `GRIDFLEET_TESTKIT_USERNAME` / `GRIDFLEET_TESTKIT_PASSWORD` unset and the testkit will send no `Authorization` header.
82
86
 
@@ -112,9 +116,11 @@ If you need raw Appium control instead, omit `pack_id` and `platform_id`, then p
112
116
 
113
117
  - Creates an Appium session through `GRID_URL`
114
118
  - Injects `gridfleet:testName` with the pytest test name
119
+ - Injects `gridfleet:run_id` when a `GRIDFLEET_RUN_ID` environment variable is present (for example, inside a reserved run)
115
120
  - Reports final session status back to `GRIDFLEET_API_URL`
116
121
  - Exposes `device_config` for post-session config lookup using the runtime connection target
117
122
  - Exposes `device_test_data` for post-session operator-attached test data using the runtime connection target
123
+ - Exposes `device_handle` for fetching the canonical manager device row using the runtime connection target
118
124
  - Exposes `gridfleet_worker_id` which returns the pytest-xdist worker id, or `"controller"` for non-worker processes
119
125
  - Relies on manager-owned runtime isolation for Appium driver sub-ports and XCUITest build paths
120
126
 
@@ -149,40 +155,74 @@ finally:
149
155
  | `GridFleetClient.list_devices(*, pack_id=None, status=None, host_id=None, ...)` | List devices using backend keyword filters (pack_id, platform_id, status, host_id, connection_target, tags, ...) |
150
156
  | `GridFleetClient.get_device(device_id)` | Fetch one full device detail row by backend device id |
151
157
  | `GridFleetClient.get_device_config(connection_target)` | Look up a device by runtime connection target and fetch its config |
158
+ | `GridFleetClient.get_device_by_connection_target(connection_target)` | Fetch one device detail row by runtime connection target |
152
159
  | `GridFleetClient.get_device_capabilities(device_id)` | Fetch current Appium capability metadata for a device |
153
160
  | `GridFleetClient.get_device_test_data(device_id)` | Fetch operator-attached free-form test_data for a device |
161
+ | `GridFleetClient.get_run(run_id)` | Fetch one run detail row by backend run id |
154
162
  | `GridFleetClient.replace_device_test_data(device_id, body)` | Replace test_data with the supplied object |
155
163
  | `GridFleetClient.merge_device_test_data(device_id, body)` | Deep-merge into device test_data |
156
164
  | `GridFleetClient.resolve_device_id_by_connection_target(connection_target)` | Resolve the backend device id for a runtime connection target |
157
165
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
158
166
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
159
- | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
160
- | `GridFleetClient.claim_device_with_retry(run_id, worker_id=..., max_wait_sec=300)` | Claim one reserved device, sleeping according to server `Retry-After` responses |
161
- | `GridFleetClient.release_device(run_id, device_id=..., worker_id=...)` | Release a worker claim without cooldown |
162
- | `GridFleetClient.release_device_safe(run_id, device_id=..., worker_id=...)` | Return `True` when release is accepted, `False` when the run is gone or the claim is already unclaimed, and raise on wrong-worker conflicts or unsafe errors |
163
- | `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 |
164
- | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
167
+ | `GridFleetClient.signal_ready(run_id)` | Signal that a run is ready |
165
168
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
166
169
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
167
170
  | `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
168
171
  | `GridFleetClient.register_session(fields)` | Register a Grid/Appium session with optional requested capability metadata |
169
172
  | `GridFleetClient.register_session_from_driver(driver, fields)` | Extract session id and capabilities from an Appium driver and register the session |
173
+ | `GridFleetClient.notify_session_finished(session_id)` | Tell the manager the WebDriver session has ended |
170
174
  | `GridFleetClient.update_session_status(session_id, status)` | Report final session status |
171
175
  | `GridFleetClient.complete_run(run_id)` | Complete a run |
172
176
  | `GridFleetClient.cancel_run(run_id)` | Cancel a run |
177
+ | `GridFleetClient.cooldown_device(run_id, device_id, reason=..., ttl_seconds=...)` | Exclude a reserved device from the run with a cooldown TTL |
173
178
  | `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
174
179
  | `build_error_session_payload(fields)` | Build a `/api/sessions` payload for driver-creation failures without importing pytest |
175
- | `hydrate_allocated_device(claim_response, run_id, client)` | Combine a claim response with optional device config and live capabilities |
180
+ | `hydrate_allocated_device(device_handle, run_id, client)` | Combine a device handle with optional device config and live capabilities |
176
181
  | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
182
+ | `resolve_device_handle_from_driver(driver, client)` | Resolve the assigned manager device row from a running Appium session |
177
183
  | `get_device_test_data_for_driver(driver, gridfleet_client=None)` | Fetch test_data for a live Appium driver |
178
184
  | `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` cleanup callable and return it; stops the heartbeat thread on exit but does not complete or cancel the run by default |
179
185
 
186
+ ### Targeting Devices by Tag
187
+
188
+ GridFleet injects device tags into Grid node stereotypes as `appium:gridfleet:tag:<key>` capabilities, so Selenium Grid can route sessions to devices matching specific tags.
189
+
190
+ ```python
191
+ @pytest.mark.parametrize(
192
+ "appium_driver",
193
+ [
194
+ {
195
+ "pack_id": "appium-uiautomator2",
196
+ "platform_id": "android_mobile",
197
+ "appium:gridfleet:tag:screen_type": "4k",
198
+ }
199
+ ],
200
+ indirect=True,
201
+ )
202
+ def test_4k_display(appium_driver):
203
+ ...
204
+ ```
205
+
206
+ The same capability works for free sessions:
207
+
208
+ ```python
209
+ driver = create_appium_driver(
210
+ pack_id="appium-uiautomator2",
211
+ platform_id="android_mobile",
212
+ capabilities={"appium:gridfleet:tag:screen_type": "4k"},
213
+ )
214
+ ```
215
+
216
+ When an operator edits device tags, GridFleet marks the device for re-verification. The next verification restarts the Appium node and re-registers it with the updated Grid stereotype.
217
+
180
218
  ### Worker Identity
181
219
 
182
- `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.
220
+ `worker_id` is an arbitrary string used for reservation telemetry and run 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.
183
221
 
184
222
  ### Reservation Flow
185
223
 
224
+ GridFleet runs are grid-routed: once devices are reserved, the manager tags matching Grid nodes with the run id, and Selenium Grid routes new Appium sessions to those nodes automatically via the `gridfleet:run_id` capability. There are no per-worker claim or release calls.
225
+
186
226
  ```python
187
227
  from gridfleet_testkit import GridFleetClient, register_run_cleanup
188
228
 
@@ -224,53 +264,51 @@ client.signal_active(run_id)
224
264
 
225
265
  Use `count` for exact reservations. Use `allocation: "all_available"` when CI should reserve every currently eligible matching device and size its worker pool from `len(run["devices"])`.
226
266
 
227
- ### Worker Claim With Cooldown
267
+ 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.
268
+
269
+ ### Cooling Down an Unstable Device
270
+
271
+ If a reserved device becomes unstable during a test, you can put it on cooldown so it is excluded from the run for a TTL. If the same device is cooled down too many times in the same run, it is escalated to maintenance automatically.
228
272
 
229
273
  ```python
230
274
  from gridfleet_testkit import GridFleetClient
231
275
 
232
- client = GridFleetClient("http://manager-ip:8000/api")
276
+ client = GridFleetClient()
233
277
 
234
- claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
235
- device_id = claim["device_id"]
278
+ result = client.cooldown_device(
279
+ run_id="run-123",
280
+ device_id="device-456",
281
+ reason="Connection dropped mid-test",
282
+ ttl_seconds=120,
283
+ )
236
284
 
237
- try:
238
- # Create the Appium session and run test setup for this worker.
239
- ...
240
- except RuntimeError as exc:
241
- client.release_device_with_cooldown(
242
- run_id,
243
- device_id=device_id,
244
- worker_id="gw0",
245
- reason=str(exc),
246
- ttl_seconds=60,
247
- )
248
- raise
249
- else:
250
- client.release_device(run_id, device_id=device_id, worker_id="gw0")
285
+ if result["status"] == "cooldown_set":
286
+ print(f"Device on cooldown until {result['excluded_until']}")
287
+ elif result["status"] == "maintenance_escalated":
288
+ print(f"Escalated after {result['cooldown_count']} cooldowns")
251
289
  ```
252
290
 
253
- 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.
254
-
255
- 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.
291
+ The manager enforces a maximum TTL via the `general.device_cooldown_max_sec` setting. The default is 3600 seconds. An `httpx.HTTPStatusError` with status 422 is raised if `ttl_seconds` exceeds the maximum.
256
292
 
257
293
  ### Allocated Device Hydration
258
294
 
259
- 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.
295
+ Use `hydrate_allocated_device(...)` immediately after reserving a run when a custom plugin needs a stable object instead of raw device-handle JSON.
260
296
 
261
297
  ```python
262
- from gridfleet_testkit import GridFleetClient, hydrate_allocated_device
298
+ from gridfleet_testkit import GridFleetClient, hydrate_allocated_device, resolve_device_handle_from_driver
263
299
 
264
- client = GridFleetClient("http://manager-ip:8000/api")
300
+ client = GridFleetClient()
265
301
  run_id = "run-123"
266
- claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
267
- allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
268
302
 
269
- assert allocated.device_id == claim["device_id"]
303
+ # After creating an Appium session, resolve the assigned device handle
304
+ device_handle = resolve_device_handle_from_driver(driver, client=client)
305
+ allocated = hydrate_allocated_device(device_handle, run_id=run_id, client=client)
306
+
307
+ assert allocated.device_id == device_handle["device_id"]
270
308
  assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
271
309
  ```
272
310
 
273
- The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`. Pass `fetch_test_data=True` to also populate `allocated.test_data`. The `test_data` field is also available directly from the claim response when the manager inlines it.
311
+ The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`. Pass `fetch_test_data=True` to also populate `allocated.test_data`. The `test_data` field is also available directly from the reserve response when the manager inlines it.
274
312
 
275
313
  ### Run Cleanup Policy
276
314
 
@@ -301,29 +339,33 @@ test_data = get_device_test_data_for_driver(driver)
301
339
 
302
340
  ### Errors and Result Types
303
341
 
304
- - `NoClaimableDevicesError(RuntimeError)`: raised when the manager reports no run devices are claimable yet. Exposes `run_id`, `retry_after_sec`, and `next_available_at`. The `RuntimeError` base is part of the contract — consumers can rely on it.
305
342
  - `UnknownIncludeError(ValueError)`: raised when the backend rejects one or more `?include=` keys. Exposes `values` with the rejected key names. The `ValueError` base is part of the contract.
306
343
  - `ReserveCapabilitiesUnsupportedError(ValueError)`: raised when a reserve-time `include` request contains `"capabilities"`, which is not supported at reserve time. The `ValueError` base is part of the contract.
307
- - `CooldownResult`: union response type from `release_device_with_cooldown`, with `status` equal to `"cooldown_set"` or `"maintenance_escalated"`. `CooldownSetResult` and `CooldownEscalatedResult` are the concrete TypedDict variants.
344
+ - `CooldownResult`: union response type from `cooldown_device`, with `status` equal to `"cooldown_set"` or `"maintenance_escalated"`. `CooldownSetResult` and `CooldownEscalatedResult` are the concrete TypedDict variants.
308
345
 
309
- ### Reduced HTTP round-trips on claim
346
+ ### Reduced HTTP Round-trips on Reserve
310
347
 
311
- The manager can inline device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
348
+ The manager can inline device config and live capabilities into the `reserve_devices` response, eliminating per-worker follow-up GETs.
312
349
 
313
350
  ```python
314
351
  client = GridFleetClient()
315
- claim = client.claim_device(run_id, worker_id="w0", include=("config", "capabilities"))
316
- allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
317
- # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
352
+ run = client.reserve_devices(
353
+ name="my-test-run",
354
+ requirements=[...],
355
+ include=("config", "capabilities"),
356
+ )
357
+ for device_handle in run["devices"]:
358
+ allocated = hydrate_allocated_device(device_handle, run_id=run["id"], client=client)
359
+ # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
318
360
  ```
319
361
 
320
362
  `device_config` and inline `config` payloads are returned verbatim from the manager. The testkit does not perform client-side secret masking or reveal toggles. Protect device config with manager authentication, operator access control, and your lab's secret-handling policy.
321
363
 
322
- `reserve_devices` accepts `include=("config",)` only — `include=("capabilities",)` raises `ReserveCapabilitiesUnsupportedError` client-side because reserve-time capabilities are not yet device-bound. Pass `include=` on the per-worker `claim_device` call instead.
364
+ `reserve_devices` accepts `include=("config",)` only — `include=("capabilities",)` raises `ReserveCapabilitiesUnsupportedError` client-side because reserve-time capabilities are not yet device-bound. Pass `include=` on the per-worker `hydrate_allocated_device` call instead if you need capabilities after sessions are running.
323
365
 
324
366
  `include=` must be a sequence of strings (tuple or list) — order is preserved in the emitted query parameter. Passing a bare string like `include="config"` raises `TypeError` to avoid silently splitting the value into characters.
325
367
 
326
- `hydrate_allocated_device` accepts claim responses only. For multi-device reservations, iterate `reserve_response["devices"]`, call `claim_device` per worker, and hydrate each claim response.
368
+ `hydrate_allocated_device` accepts device-handle payloads such as `reserve_response["devices"]` entries or rows returned by `get_device_by_connection_target`.
327
369
 
328
370
  ## Examples
329
371
 
@@ -31,6 +31,9 @@ from .appium import (
31
31
  get_device_test_data_for_driver,
32
32
  )
33
33
  from .client import (
34
+ CooldownEscalatedResult,
35
+ CooldownResult,
36
+ CooldownSetResult,
34
37
  GridFleetClient,
35
38
  HeartbeatThread,
36
39
  ReserveCapabilitiesUnsupportedError,
@@ -44,12 +47,15 @@ from .sessions import build_error_session_payload, resolve_device_handle_from_dr
44
47
  try:
45
48
  __version__ = version("gridfleet-testkit")
46
49
  except PackageNotFoundError:
47
- __version__ = "0.7.0"
50
+ __version__ = "0.9.0"
48
51
 
49
52
  __all__ = [
50
53
  "GRIDFLEET_API_URL",
51
54
  "GRID_URL",
52
55
  "AllocatedDevice",
56
+ "CooldownEscalatedResult",
57
+ "CooldownResult",
58
+ "CooldownSetResult",
53
59
  "GridFleetClient",
54
60
  "HeartbeatThread",
55
61
  "ReserveCapabilitiesUnsupportedError",
@@ -39,6 +39,7 @@ class AllocatedDevice:
39
39
  live_capabilities: dict[str, Any] | None
40
40
  test_data: dict[str, Any] | None = None
41
41
  unavailable_includes: tuple[UnavailableInclude, ...] = ()
42
+ tags: dict[str, str] | None = None
42
43
 
43
44
  @property
44
45
  def is_real_device(self) -> bool:
@@ -153,6 +154,11 @@ def hydrate_allocated_device(
153
154
  test_data = client.get_device_test_data(device_id)
154
155
  else:
155
156
  test_data = None
157
+ inline_tags = payload.get("tags")
158
+ if isinstance(inline_tags, dict):
159
+ tags: dict[str, str] | None = inline_tags
160
+ else:
161
+ tags = None
156
162
 
157
163
  return AllocatedDevice(
158
164
  run_id=run_id,
@@ -173,6 +179,7 @@ def hydrate_allocated_device(
173
179
  live_capabilities=live_capabilities,
174
180
  test_data=test_data,
175
181
  unavailable_includes=unavailable_includes,
182
+ tags=tags,
176
183
  )
177
184
 
178
185