gridfleet-testkit 0.3.0__tar.gz → 0.5.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 (36) hide show
  1. gridfleet_testkit-0.5.0/CHANGELOG.md +82 -0
  2. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/PKG-INFO +88 -9
  3. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/README.md +86 -7
  4. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/__init__.py +31 -4
  5. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/allocation.py +61 -3
  6. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/appium.py +21 -12
  7. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/client.py +274 -49
  8. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/pytest_plugin.py +37 -4
  9. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/pyproject.toml +6 -3
  10. gridfleet_testkit-0.5.0/tests/test_allocation.py +371 -0
  11. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/tests/test_appium.py +16 -8
  12. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/tests/test_client.py +705 -31
  13. gridfleet_testkit-0.5.0/tests/test_client_test_data.py +64 -0
  14. gridfleet_testkit-0.5.0/tests/test_docs_contract.py +66 -0
  15. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/tests/test_package_metadata.py +26 -0
  16. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/tests/test_pytest_plugin.py +68 -11
  17. gridfleet_testkit-0.5.0/tests/test_pytest_plugin_test_data.py +20 -0
  18. gridfleet_testkit-0.5.0/uv.lock +648 -0
  19. gridfleet_testkit-0.3.0/CHANGELOG.md +0 -44
  20. gridfleet_testkit-0.3.0/tests/test_allocation.py +0 -175
  21. gridfleet_testkit-0.3.0/uv.lock +0 -609
  22. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/.gitignore +0 -0
  23. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/__init__.py +0 -0
  24. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/_example_helpers.py +0 -0
  25. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/assets/hello-world.zip +0 -0
  26. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/test_android_mobile_screenshot.py +0 -0
  27. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/test_android_tv_screenshot.py +0 -0
  28. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/test_firetv_screenshot.py +0 -0
  29. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/test_ios_simulator_screenshot.py +0 -0
  30. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/test_roku_screenshot.py +0 -0
  31. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/test_roku_sideload_screenshot.py +0 -0
  32. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/examples/test_tvos_screenshot.py +0 -0
  33. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/py.typed +0 -0
  34. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/sessions.py +0 -0
  35. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/tests/test_driver_agnostic_guard.py +0 -0
  36. {gridfleet_testkit-0.3.0 → gridfleet_testkit-0.5.0}/tests/test_sessions.py +0 -0
@@ -0,0 +1,82 @@
1
+ # Changelog — GridFleet Testkit
2
+
3
+ All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
+
5
+ ## [0.5.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.4.0...gridfleet-testkit-v0.5.0) (2026-05-08)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * remove device_config secret masking ([#104](https://github.com/quidow/gridfleet/issues/104))
11
+
12
+ ### Features
13
+
14
+ * **backend:** escalate device to maintenance after N cooldowns in same run ([#121](https://github.com/quidow/gridfleet/issues/121)) ([7fe01f7](https://github.com/quidow/gridfleet/commit/7fe01f768ff70cd3ddb7f26aec1ab7210b49987f))
15
+ * **main:** split device test_data from device_config + modal portal ([b5d0fa0](https://github.com/quidow/gridfleet/commit/b5d0fa09a862af742b3a2462667a86b1d3a867b6))
16
+ * **testkit:** add allocated_device test_data and hydration ([e814e3d](https://github.com/quidow/gridfleet/commit/e814e3d0040b87a547ffc892ee2064305724d576))
17
+ * **testkit:** add device_test_data pytest fixture ([51eeefc](https://github.com/quidow/gridfleet/commit/51eeefc522989e827610f2b1103d2ded0cadda89))
18
+ * **testkit:** add gridfleetclient test_data methods ([b7f1124](https://github.com/quidow/gridfleet/commit/b7f1124e49618a7193ffc61f9db066f847d38522))
19
+ * **testkit:** align public surface, fix run cleanup, lazy env reads ([#128](https://github.com/quidow/gridfleet/issues/128)) ([ee85958](https://github.com/quidow/gridfleet/commit/ee859581f84f77f43c2d0bb627eeeaef1e2a99db))
20
+
21
+
22
+ ### Dependencies
23
+
24
+ * **deps:** bump appium-python-client in /testkit ([dc12591](https://github.com/quidow/gridfleet/commit/dc12591f5ebfba2361341132df546f6325750a61))
25
+
26
+
27
+ ### Documentation
28
+
29
+ * **docs:** document discriminated-union release-with-cooldown response ([7fe01f7](https://github.com/quidow/gridfleet/commit/7fe01f768ff70cd3ddb7f26aec1ab7210b49987f))
30
+
31
+
32
+ ### Code Refactoring
33
+
34
+ * remove device_config secret masking ([#104](https://github.com/quidow/gridfleet/issues/104)) ([7329a31](https://github.com/quidow/gridfleet/commit/7329a3107814f653b81b2753e519e271ec0dd8bd))
35
+
36
+ ## [0.4.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.3.0...gridfleet-testkit-v0.4.0) (2026-05-06)
37
+
38
+
39
+ ### Features
40
+
41
+ * **testkit:** wire ?include=config,capabilities through claim/reserve and hydrate inline ([#95](https://github.com/quidow/gridfleet/issues/95)) ([20ed20d](https://github.com/quidow/gridfleet/commit/20ed20d9ee362890923146e771ad8805b45e5bfa))
42
+
43
+ ## [0.3.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.1...gridfleet-testkit-v0.3.0) (2026-05-05)
44
+
45
+
46
+ ### ⚠ BREAKING CHANGES
47
+
48
+ * **testkit:** promote public api helpers ([#92](https://github.com/quidow/gridfleet/issues/92))
49
+
50
+ ### Features
51
+
52
+ * **testkit:** add xdist recipe primitives ([#93](https://github.com/quidow/gridfleet/issues/93)) ([58fd3c3](https://github.com/quidow/gridfleet/commit/58fd3c3402ba7e735aae55e27abbe65a05c8ffe8))
53
+ * **testkit:** promote public api helpers ([#92](https://github.com/quidow/gridfleet/issues/92)) ([80d4483](https://github.com/quidow/gridfleet/commit/80d44832903f532de3da238d020b5dc27eb8b30e))
54
+
55
+
56
+ ### Bug Fixes
57
+
58
+ * **agent:** trigger release for port conflict cleanup ([6a561ca](https://github.com/quidow/gridfleet/commit/6a561ca480c62b9abb2d5141fa98fc4e1a7696b6))
59
+
60
+ ## [0.2.1](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.0...gridfleet-testkit-v0.2.1) (2026-05-03)
61
+
62
+
63
+ ### Bug Fixes
64
+
65
+ * **testkit:** bound supported python metadata ([c5fff86](https://github.com/quidow/gridfleet/commit/c5fff86cbb2a4897ac571c7c5b989f0361e49743))
66
+
67
+ ## [0.2.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.1.0...gridfleet-testkit-v0.2.0) (2026-05-03)
68
+
69
+
70
+ ### Features
71
+
72
+ * **testkit:** add run-scoped device cooldowns ([#54](https://github.com/quidow/gridfleet/issues/54)) ([6163dc9](https://github.com/quidow/gridfleet/commit/6163dc959334e933b43c20a99ad4edcbdae6c98b))
73
+
74
+
75
+ ### Bug Fixes
76
+
77
+ * idempotent device release after lifecycle cleanup ([#12](https://github.com/quidow/gridfleet/issues/12)) ([7a98a5d](https://github.com/quidow/gridfleet/commit/7a98a5d18330150aab0a852f6b894d1d53de257c))
78
+
79
+ ## 0.1.0 — Initial Public Preview
80
+
81
+ - Initial public preview of the GridFleet testkit.
82
+ - Python pytest/Appium helper package with device reservation, capability injection, and session lifecycle fixtures.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfleet-testkit
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -27,7 +27,7 @@ Requires-Dist: pytest<10,>=9.0.3
27
27
  Provides-Extra: appium
28
28
  Requires-Dist: appium-python-client<6,>=4.5; extra == 'appium'
29
29
  Provides-Extra: dev
30
- Requires-Dist: mypy<2,>=1.20.2; extra == 'dev'
30
+ Requires-Dist: mypy<3,>=1.20.2; extra == 'dev'
31
31
  Requires-Dist: pytest<10,>=9.0.3; extra == 'dev'
32
32
  Requires-Dist: ruff<1,>=0.15.12; extra == 'dev'
33
33
  Description-Content-Type: text/markdown
@@ -40,14 +40,28 @@ 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 public helpers:
43
+ - Supported pytest fixtures: `appium_driver`, `gridfleet_client`, `device_config`, `device_test_data`, `gridfleet_worker_id`
44
+ - Supported public Appium helpers:
44
45
  - `build_appium_options`
45
46
  - `create_appium_driver`
46
47
  - `get_connection_target_from_driver`
47
48
  - `get_device_config_for_driver`
49
+ - `get_device_test_data_for_driver`
50
+ - Supported public client helpers:
48
51
  - `GridFleetClient`
49
52
  - `HeartbeatThread`
50
53
  - `register_run_cleanup`
54
+ - Supported public allocation/session helpers:
55
+ - `AllocatedDevice`
56
+ - `UnavailableInclude`
57
+ - `CooldownResult`
58
+ - `build_error_session_payload`
59
+ - `hydrate_allocated_device`
60
+ - `hydrate_allocated_device_from_driver`
61
+ - Supported public exceptions:
62
+ - `NoClaimableDevicesError`
63
+ - `UnknownIncludeError`
64
+ - `ReserveCapabilitiesUnsupportedError`
51
65
  - Manual hardware examples under `testkit/examples/`
52
66
 
53
67
  ## What It Does Not Own
@@ -134,8 +148,12 @@ If you need raw Appium control instead, omit `pack_id` and `platform_id`, then p
134
148
  - Injects `gridfleet:testName` with the pytest test name
135
149
  - Reports final session status back to `GRIDFLEET_API_URL`
136
150
  - Exposes `device_config` for post-session config lookup using the runtime connection target
151
+ - Exposes `device_test_data` for post-session operator-attached test data using the runtime connection target
152
+ - Exposes `gridfleet_worker_id` which returns the pytest-xdist worker id, or `"controller"` for non-worker processes
137
153
  - Relies on manager-owned runtime isolation for Appium driver sub-ports and XCUITest build paths
138
154
 
155
+ If Appium driver creation fails before a Grid session exists, the pytest fixture registers a device-less terminal error session with an `error-<uuid>` session id, attempted capabilities, requested pack/platform metadata when available, and exception details, then re-raises the original exception. These rows make setup failures visible in the GridFleet Sessions view.
156
+
139
157
  ## Direct Appium Usage
140
158
 
141
159
  If you need to create a driver outside pytest, use the public Appium helpers:
@@ -162,15 +180,20 @@ finally:
162
180
 
163
181
  | Helper | Purpose |
164
182
  | --- | --- |
165
- | `GridFleetClient.list_devices(filters)` | List devices using backend filters such as `status`, `pack_id`, `platform_id`, `host_id`, `connection_target`, and `tags.*` |
183
+ | `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, ...) |
166
184
  | `GridFleetClient.get_device(device_id)` | Fetch one full device detail row by backend device id |
167
- | `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
185
+ | `GridFleetClient.get_device_config(connection_target)` | Look up a device by runtime connection target and fetch its config |
186
+ | `GridFleetClient.get_device_capabilities(device_id)` | Fetch current Appium capability metadata for a device |
187
+ | `GridFleetClient.get_device_test_data(device_id)` | Fetch operator-attached free-form test_data for a device |
188
+ | `GridFleetClient.replace_device_test_data(device_id, body)` | Replace test_data with the supplied object |
189
+ | `GridFleetClient.merge_device_test_data(device_id, body)` | Deep-merge into device test_data |
190
+ | `GridFleetClient.resolve_device_id_by_connection_target(connection_target)` | Resolve the backend device id for a runtime connection target |
168
191
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
169
192
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
170
193
  | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
171
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 |
172
195
  | `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 |
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 |
174
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 |
175
198
  | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
176
199
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
@@ -185,7 +208,8 @@ finally:
185
208
  | `build_error_session_payload(fields)` | Build a `/api/sessions` payload for driver-creation failures without importing pytest |
186
209
  | `hydrate_allocated_device(claim_response, run_id, client)` | Combine a claim response with optional device config and live capabilities |
187
210
  | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
188
- | `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` and signal cleanup that completes or cancels a run |
211
+ | `get_device_test_data_for_driver(driver, gridfleet_client=None)` | Fetch test_data for a live Appium driver |
212
+ | `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 |
189
213
 
190
214
  ### Worker Identity
191
215
 
@@ -216,7 +240,9 @@ run = client.reserve_devices(
216
240
  run_id = run["id"]
217
241
  worker_count = len(run["devices"])
218
242
  heartbeat_thread = client.start_heartbeat(run_id, interval=30)
219
- register_run_cleanup(client, run_id, heartbeat_thread)
243
+ cleanup = register_run_cleanup(client, run_id, heartbeat_thread)
244
+ # cleanup() runs at process exit; call client.complete_run(run_id) on success
245
+ # or client.cancel_run(run_id) on failure to set the run state explicitly.
220
246
 
221
247
  # If one reserved device fails setup:
222
248
  client.report_preparation_failure(
@@ -278,7 +304,60 @@ assert allocated.device_id == claim["device_id"]
278
304
  assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
279
305
  ```
280
306
 
281
- The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`.
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.
308
+
309
+ ### Run Cleanup Policy
310
+
311
+ `register_run_cleanup(...)` registers an atexit cleanup callable and returns it. By default it stops the heartbeat thread but does not complete or cancel the run, because process exit alone does not prove test success. Prefer explicit `client.complete_run(run_id)` after successful orchestration and `client.cancel_run(run_id)` for known failures. Pass `on_exit="complete"` or `on_exit="cancel"` only when that policy is correct for your script. Signal handlers are opt-in with `install_signal_handlers=True`; signal cleanup defaults to cancellation.
312
+
313
+ ### Device Test Data
314
+
315
+ The `device_test_data` fixture returns the operator-attached free-form test_data for the device assigned to the current test:
316
+
317
+ ```python
318
+ def test_uses_operator_data(appium_driver, device_test_data):
319
+ assert "account" in device_test_data
320
+ ```
321
+
322
+ Outside of pytest, use the client directly:
323
+
324
+ ```python
325
+ test_data = client.get_device_test_data(device_id)
326
+ ```
327
+
328
+ Or use the driver helper:
329
+
330
+ ```python
331
+ from gridfleet_testkit import get_device_test_data_for_driver
332
+
333
+ test_data = get_device_test_data_for_driver(driver)
334
+ ```
335
+
336
+ ### Errors and Result Types
337
+
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
+ - `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
+ - `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.
342
+
343
+ ### Reduced HTTP round-trips on claim
344
+
345
+ The manager can inline device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
346
+
347
+ ```python
348
+ 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
352
+ ```
353
+
354
+ `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
+
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.
357
+
358
+ `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
+
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.
282
361
 
283
362
  ## Examples
284
363
 
@@ -6,14 +6,28 @@
6
6
 
7
7
  - Stable import root: `gridfleet_testkit`
8
8
  - Supported pytest plugin: `gridfleet_testkit.pytest_plugin`
9
- - Supported public helpers:
9
+ - Supported pytest fixtures: `appium_driver`, `gridfleet_client`, `device_config`, `device_test_data`, `gridfleet_worker_id`
10
+ - Supported public Appium helpers:
10
11
  - `build_appium_options`
11
12
  - `create_appium_driver`
12
13
  - `get_connection_target_from_driver`
13
14
  - `get_device_config_for_driver`
15
+ - `get_device_test_data_for_driver`
16
+ - Supported public client helpers:
14
17
  - `GridFleetClient`
15
18
  - `HeartbeatThread`
16
19
  - `register_run_cleanup`
20
+ - Supported public allocation/session helpers:
21
+ - `AllocatedDevice`
22
+ - `UnavailableInclude`
23
+ - `CooldownResult`
24
+ - `build_error_session_payload`
25
+ - `hydrate_allocated_device`
26
+ - `hydrate_allocated_device_from_driver`
27
+ - Supported public exceptions:
28
+ - `NoClaimableDevicesError`
29
+ - `UnknownIncludeError`
30
+ - `ReserveCapabilitiesUnsupportedError`
17
31
  - Manual hardware examples under `testkit/examples/`
18
32
 
19
33
  ## What It Does Not Own
@@ -100,8 +114,12 @@ If you need raw Appium control instead, omit `pack_id` and `platform_id`, then p
100
114
  - Injects `gridfleet:testName` with the pytest test name
101
115
  - Reports final session status back to `GRIDFLEET_API_URL`
102
116
  - Exposes `device_config` for post-session config lookup using the runtime connection target
117
+ - Exposes `device_test_data` for post-session operator-attached test data using the runtime connection target
118
+ - Exposes `gridfleet_worker_id` which returns the pytest-xdist worker id, or `"controller"` for non-worker processes
103
119
  - Relies on manager-owned runtime isolation for Appium driver sub-ports and XCUITest build paths
104
120
 
121
+ If Appium driver creation fails before a Grid session exists, the pytest fixture registers a device-less terminal error session with an `error-<uuid>` session id, attempted capabilities, requested pack/platform metadata when available, and exception details, then re-raises the original exception. These rows make setup failures visible in the GridFleet Sessions view.
122
+
105
123
  ## Direct Appium Usage
106
124
 
107
125
  If you need to create a driver outside pytest, use the public Appium helpers:
@@ -128,15 +146,20 @@ finally:
128
146
 
129
147
  | Helper | Purpose |
130
148
  | --- | --- |
131
- | `GridFleetClient.list_devices(filters)` | List devices using backend filters such as `status`, `pack_id`, `platform_id`, `host_id`, `connection_target`, and `tags.*` |
149
+ | `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, ...) |
132
150
  | `GridFleetClient.get_device(device_id)` | Fetch one full device detail row by backend device id |
133
- | `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
151
+ | `GridFleetClient.get_device_config(connection_target)` | Look up a device by runtime connection target and fetch its config |
152
+ | `GridFleetClient.get_device_capabilities(device_id)` | Fetch current Appium capability metadata for a device |
153
+ | `GridFleetClient.get_device_test_data(device_id)` | Fetch operator-attached free-form test_data for a device |
154
+ | `GridFleetClient.replace_device_test_data(device_id, body)` | Replace test_data with the supplied object |
155
+ | `GridFleetClient.merge_device_test_data(device_id, body)` | Deep-merge into device test_data |
156
+ | `GridFleetClient.resolve_device_id_by_connection_target(connection_target)` | Resolve the backend device id for a runtime connection target |
134
157
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
135
158
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
136
159
  | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
137
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 |
138
161
  | `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 |
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 |
140
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 |
141
164
  | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
142
165
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
@@ -151,7 +174,8 @@ finally:
151
174
  | `build_error_session_payload(fields)` | Build a `/api/sessions` payload for driver-creation failures without importing pytest |
152
175
  | `hydrate_allocated_device(claim_response, run_id, client)` | Combine a claim response with optional device config and live capabilities |
153
176
  | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
154
- | `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` and signal cleanup that completes or cancels a run |
177
+ | `get_device_test_data_for_driver(driver, gridfleet_client=None)` | Fetch test_data for a live Appium driver |
178
+ | `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 |
155
179
 
156
180
  ### Worker Identity
157
181
 
@@ -182,7 +206,9 @@ run = client.reserve_devices(
182
206
  run_id = run["id"]
183
207
  worker_count = len(run["devices"])
184
208
  heartbeat_thread = client.start_heartbeat(run_id, interval=30)
185
- register_run_cleanup(client, run_id, heartbeat_thread)
209
+ cleanup = register_run_cleanup(client, run_id, heartbeat_thread)
210
+ # cleanup() runs at process exit; call client.complete_run(run_id) on success
211
+ # or client.cancel_run(run_id) on failure to set the run state explicitly.
186
212
 
187
213
  # If one reserved device fails setup:
188
214
  client.report_preparation_failure(
@@ -244,7 +270,60 @@ assert allocated.device_id == claim["device_id"]
244
270
  assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
245
271
  ```
246
272
 
247
- The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`.
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.
274
+
275
+ ### Run Cleanup Policy
276
+
277
+ `register_run_cleanup(...)` registers an atexit cleanup callable and returns it. By default it stops the heartbeat thread but does not complete or cancel the run, because process exit alone does not prove test success. Prefer explicit `client.complete_run(run_id)` after successful orchestration and `client.cancel_run(run_id)` for known failures. Pass `on_exit="complete"` or `on_exit="cancel"` only when that policy is correct for your script. Signal handlers are opt-in with `install_signal_handlers=True`; signal cleanup defaults to cancellation.
278
+
279
+ ### Device Test Data
280
+
281
+ The `device_test_data` fixture returns the operator-attached free-form test_data for the device assigned to the current test:
282
+
283
+ ```python
284
+ def test_uses_operator_data(appium_driver, device_test_data):
285
+ assert "account" in device_test_data
286
+ ```
287
+
288
+ Outside of pytest, use the client directly:
289
+
290
+ ```python
291
+ test_data = client.get_device_test_data(device_id)
292
+ ```
293
+
294
+ Or use the driver helper:
295
+
296
+ ```python
297
+ from gridfleet_testkit import get_device_test_data_for_driver
298
+
299
+ test_data = get_device_test_data_for_driver(driver)
300
+ ```
301
+
302
+ ### Errors and Result Types
303
+
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
+ - `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
+ - `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.
308
+
309
+ ### Reduced HTTP round-trips on claim
310
+
311
+ The manager can inline device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
312
+
313
+ ```python
314
+ 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
318
+ ```
319
+
320
+ `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
+
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.
323
+
324
+ `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
+
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.
248
327
 
249
328
  ## Examples
250
329
 
@@ -1,5 +1,10 @@
1
1
  """Supported Python integration helpers for GridFleet.
2
2
 
3
+ `device_config` values returned by the manager are verbatim; the testkit no
4
+ longer distinguishes between masked and revealed payloads. Code that wants
5
+ the live Appium-side config can use either inline `config` from
6
+ `claim_device(include=("config",))` or `client.get_device_config(connection_target)`.
7
+
3
8
  Environment variables read by the client:
4
9
 
5
10
  - GRID_URL: Selenium Grid URL used by Appium helper defaults.
@@ -13,19 +18,28 @@ this package because run-state sharing is consumer policy.
13
18
 
14
19
  from importlib.metadata import PackageNotFoundError, version
15
20
 
16
- from .allocation import AllocatedDevice, hydrate_allocated_device, hydrate_allocated_device_from_driver
21
+ from .allocation import (
22
+ AllocatedDevice,
23
+ UnavailableInclude,
24
+ hydrate_allocated_device,
25
+ hydrate_allocated_device_from_driver,
26
+ )
17
27
  from .appium import (
18
28
  build_appium_options,
19
29
  create_appium_driver,
20
30
  get_connection_target_from_driver,
21
31
  get_device_config_for_driver,
32
+ get_device_test_data_for_driver,
22
33
  )
23
34
  from .client import (
24
- GRID_URL,
25
- GRIDFLEET_API_URL,
35
+ CooldownResult,
26
36
  GridFleetClient,
27
37
  HeartbeatThread,
28
38
  NoClaimableDevicesError,
39
+ ReserveCapabilitiesUnsupportedError,
40
+ UnknownIncludeError,
41
+ _default_api_url,
42
+ _default_grid_url,
29
43
  register_run_cleanup,
30
44
  )
31
45
  from .sessions import build_error_session_payload
@@ -33,22 +47,35 @@ from .sessions import build_error_session_payload
33
47
  try:
34
48
  __version__ = version("gridfleet-testkit")
35
49
  except PackageNotFoundError:
36
- __version__ = "0.3.0"
50
+ __version__ = "0.5.0"
37
51
 
38
52
  __all__ = [
39
53
  "GRIDFLEET_API_URL",
40
54
  "GRID_URL",
41
55
  "AllocatedDevice",
56
+ "CooldownResult",
42
57
  "GridFleetClient",
43
58
  "HeartbeatThread",
44
59
  "NoClaimableDevicesError",
60
+ "ReserveCapabilitiesUnsupportedError",
61
+ "UnavailableInclude",
62
+ "UnknownIncludeError",
45
63
  "__version__",
46
64
  "build_appium_options",
47
65
  "build_error_session_payload",
48
66
  "create_appium_driver",
49
67
  "get_connection_target_from_driver",
50
68
  "get_device_config_for_driver",
69
+ "get_device_test_data_for_driver",
51
70
  "hydrate_allocated_device",
52
71
  "hydrate_allocated_device_from_driver",
53
72
  "register_run_cleanup",
54
73
  ]
74
+
75
+
76
+ def __getattr__(name: str) -> str:
77
+ if name == "GRID_URL":
78
+ return _default_grid_url()
79
+ if name == "GRIDFLEET_API_URL":
80
+ return _default_api_url()
81
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -9,6 +9,14 @@ if TYPE_CHECKING:
9
9
  from .client import GridFleetClient
10
10
 
11
11
 
12
+ @dataclass(frozen=True)
13
+ class UnavailableInclude:
14
+ """One include key the backend could not satisfy on this allocation."""
15
+
16
+ include: str
17
+ reason: str
18
+
19
+
12
20
  @dataclass(frozen=True)
13
21
  class AllocatedDevice:
14
22
  """Combined view of a claimed device, ready for driver creation."""
@@ -31,6 +39,8 @@ class AllocatedDevice:
31
39
  claimed_at: str
32
40
  config: dict[str, Any] | None
33
41
  live_capabilities: dict[str, Any] | None
42
+ test_data: dict[str, Any] | None = None
43
+ unavailable_includes: tuple[UnavailableInclude, ...] = ()
34
44
 
35
45
  @property
36
46
  def is_real_device(self) -> bool:
@@ -89,6 +99,21 @@ def _merge_device_detail(payload: dict[str, Any], detail: dict[str, Any]) -> dic
89
99
  return merged
90
100
 
91
101
 
102
+ def _parse_unavailable_includes(payload: dict[str, Any]) -> tuple[UnavailableInclude, ...]:
103
+ raw = payload.get("unavailable_includes")
104
+ if not isinstance(raw, list):
105
+ return ()
106
+ parsed: list[UnavailableInclude] = []
107
+ for entry in raw:
108
+ if not isinstance(entry, dict):
109
+ continue
110
+ include = entry.get("include")
111
+ reason = entry.get("reason")
112
+ if isinstance(include, str) and include and isinstance(reason, str) and reason:
113
+ parsed.append(UnavailableInclude(include=include, reason=reason))
114
+ return tuple(parsed)
115
+
116
+
92
117
  def hydrate_allocated_device(
93
118
  claim_response: dict[str, Any],
94
119
  *,
@@ -96,16 +121,47 @@ def hydrate_allocated_device(
96
121
  client: GridFleetClient,
97
122
  fetch_config: bool = True,
98
123
  fetch_capabilities: bool = False,
124
+ fetch_test_data: bool = False,
99
125
  ) -> AllocatedDevice:
100
- """Combine a claim response with optional static config and live capabilities."""
126
+ """Combine a claim response with optional static config and live capabilities.
127
+
128
+ Accepts a ``ClaimResponse`` payload from ``GridFleetClient.claim_device`` only.
129
+ Reserve responses (``RunCreateResponse.devices`` entries before any worker
130
+ has claimed) lack ``claimed_by`` / ``claimed_at`` and will raise
131
+ ``ValueError``. Iterate ``reserve_response['devices']`` and call
132
+ ``claim_device`` per worker before hydrating.
133
+ """
101
134
  payload = dict(claim_response)
102
135
  device_id = _string_value(payload, "device_id")
103
136
  if _needs_device_detail(payload):
104
137
  payload = _merge_device_detail(payload, client.get_device(device_id))
105
138
 
139
+ unavailable_includes = _parse_unavailable_includes(payload)
140
+ unavailable_set = {entry.include for entry in unavailable_includes}
141
+
106
142
  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
143
+ inline_config = payload.get("config")
144
+ if isinstance(inline_config, dict):
145
+ config: dict[str, Any] | None = inline_config
146
+ elif fetch_config and connection_target and "config" not in unavailable_set:
147
+ config = client.get_device_config(connection_target)
148
+ else:
149
+ config = None
150
+ inline_capabilities = payload.get("live_capabilities")
151
+ if isinstance(inline_capabilities, dict):
152
+ live_capabilities: dict[str, Any] | None = inline_capabilities
153
+ elif fetch_capabilities and "capabilities" not in unavailable_set:
154
+ live_capabilities = client.get_device_capabilities(device_id)
155
+ else:
156
+ live_capabilities = None
157
+
158
+ inline_test_data = payload.get("test_data")
159
+ if isinstance(inline_test_data, dict):
160
+ test_data: dict[str, Any] | None = inline_test_data
161
+ elif fetch_test_data and "test_data" not in unavailable_set:
162
+ test_data = client.get_device_test_data(device_id)
163
+ else:
164
+ test_data = None
109
165
 
110
166
  return AllocatedDevice(
111
167
  run_id=run_id,
@@ -126,6 +182,8 @@ def hydrate_allocated_device(
126
182
  claimed_at=_string_value(payload, "claimed_at"),
127
183
  config=config,
128
184
  live_capabilities=live_capabilities,
185
+ test_data=test_data,
186
+ unavailable_includes=unavailable_includes,
129
187
  )
130
188
 
131
189
 
@@ -8,15 +8,11 @@ from typing import TYPE_CHECKING, Any
8
8
  if TYPE_CHECKING:
9
9
  from collections.abc import Mapping
10
10
 
11
- from .client import GridFleetClient
12
-
13
- from .client import GRID_URL
11
+ from .client import GridFleetClient, _default_grid_url
14
12
 
15
13
 
16
14
  def _catalog_payload(catalog_client: Any | None) -> dict[str, Any]:
17
15
  if catalog_client is None:
18
- from .client import GridFleetClient
19
-
20
16
  catalog_client = GridFleetClient()
21
17
  if hasattr(catalog_client, "get_driver_pack_catalog"):
22
18
  payload = catalog_client.get_driver_pack_catalog()
@@ -97,7 +93,9 @@ def build_appium_options(
97
93
  catalog_client: Any | None = None,
98
94
  ) -> Any:
99
95
  """Build Appium options from driver-pack catalog platform metadata."""
100
- from appium.options.common import AppiumOptions
96
+ # appium is an optional dep (extra "appium"); imported lazily so consumers
97
+ # without the extra can still use the rest of testkit.
98
+ from appium.options.common import AppiumOptions # noqa: PLC0415
101
99
 
102
100
  params = dict(capabilities or {})
103
101
  explicit_platform_name = params.get("platformName")
@@ -129,11 +127,13 @@ def create_appium_driver(
129
127
  platform_id: str | None = None,
130
128
  capabilities: Mapping[str, Any] | None = None,
131
129
  test_name: str | None = None,
132
- grid_url: str = GRID_URL,
130
+ grid_url: str | None = None,
133
131
  catalog_client: Any | None = None,
134
132
  ) -> Any:
135
133
  """Create an Appium remote driver through Selenium Grid."""
136
- from appium import webdriver
134
+ # appium is an optional dep (extra "appium"); imported lazily so consumers
135
+ # without the extra can still use the rest of testkit.
136
+ from appium import webdriver # noqa: PLC0415
137
137
 
138
138
  options = build_appium_options(
139
139
  pack_id=pack_id,
@@ -142,7 +142,7 @@ def create_appium_driver(
142
142
  test_name=test_name,
143
143
  catalog_client=catalog_client,
144
144
  )
145
- return webdriver.Remote(grid_url, options=options)
145
+ return webdriver.Remote(grid_url or _default_grid_url(), options=options)
146
146
 
147
147
 
148
148
  def get_connection_target_from_driver(driver: Any) -> str:
@@ -158,10 +158,19 @@ def get_device_config_for_driver(
158
158
  driver: Any,
159
159
  *,
160
160
  gridfleet_client: GridFleetClient | None = None,
161
- reveal: bool = True,
162
161
  ) -> dict[str, Any]:
163
162
  """Fetch device config for a live Appium driver using its runtime connection target."""
164
- from .client import GridFleetClient
163
+ client = gridfleet_client or GridFleetClient()
164
+ return client.get_device_config(get_connection_target_from_driver(driver))
165
+
165
166
 
167
+ def get_device_test_data_for_driver(
168
+ driver: Any,
169
+ *,
170
+ gridfleet_client: GridFleetClient | None = None,
171
+ ) -> dict[str, Any]:
172
+ """Fetch operator-attached test_data for a live Appium driver session."""
166
173
  client = gridfleet_client or GridFleetClient()
167
- return client.get_device_config(get_connection_target_from_driver(driver), reveal=reveal)
174
+ connection_target = get_connection_target_from_driver(driver)
175
+ device_id = client.resolve_device_id_by_connection_target(connection_target)
176
+ return client.get_device_test_data(device_id)