gridfleet-testkit 0.4.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 (34) hide show
  1. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/CHANGELOG.md +31 -0
  2. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/PKG-INFO +71 -11
  3. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/README.md +69 -9
  4. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/__init__.py +19 -9
  5. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/allocation.py +11 -5
  6. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/appium.py +21 -12
  7. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/client.py +200 -46
  8. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/pytest_plugin.py +37 -4
  9. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/pyproject.toml +6 -3
  10. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/tests/test_allocation.py +51 -10
  11. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/tests/test_appium.py +16 -8
  12. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/tests/test_client.py +421 -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.4.0 → gridfleet_testkit-0.5.0}/tests/test_package_metadata.py +26 -0
  16. {gridfleet_testkit-0.4.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.4.0/uv.lock +0 -609
  20. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/.gitignore +0 -0
  21. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/__init__.py +0 -0
  22. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/_example_helpers.py +0 -0
  23. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/assets/hello-world.zip +0 -0
  24. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/test_android_mobile_screenshot.py +0 -0
  25. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/test_android_tv_screenshot.py +0 -0
  26. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/test_firetv_screenshot.py +0 -0
  27. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/test_ios_simulator_screenshot.py +0 -0
  28. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/test_roku_screenshot.py +0 -0
  29. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/test_roku_sideload_screenshot.py +0 -0
  30. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/examples/test_tvos_screenshot.py +0 -0
  31. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/py.typed +0 -0
  32. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/gridfleet_testkit/sessions.py +0 -0
  33. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/tests/test_driver_agnostic_guard.py +0 -0
  34. {gridfleet_testkit-0.4.0 → gridfleet_testkit-0.5.0}/tests/test_sessions.py +0 -0
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
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
+
5
36
  ## [0.4.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.3.0...gridfleet-testkit-v0.4.0) (2026-05-06)
6
37
 
7
38
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfleet-testkit
3
- Version: 0.4.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,11 +304,45 @@ 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.
282
342
 
283
343
  ### Reduced HTTP round-trips on claim
284
344
 
285
- `gridfleet-testkit` 0.4.0 lets the manager inline the device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
345
+ The manager can inline device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
286
346
 
287
347
  ```python
288
348
  client = GridFleetClient()
@@ -291,7 +351,7 @@ allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
291
351
  # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
292
352
  ```
293
353
 
294
- **Masking change**: inline `config` is always masked sensitive values are replaced with `"********"`. Use `client.get_device_config(connection_target, reveal=True)` if you need raw secrets after the driver session is up. `allocated.config_is_masked` is `True` whenever the inline path was taken.
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.
295
355
 
296
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.
297
357
 
@@ -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,11 +270,45 @@ 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.
248
308
 
249
309
  ### Reduced HTTP round-trips on claim
250
310
 
251
- `gridfleet-testkit` 0.4.0 lets the manager inline the device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
311
+ The manager can inline device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
252
312
 
253
313
  ```python
254
314
  client = GridFleetClient()
@@ -257,7 +317,7 @@ allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
257
317
  # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
258
318
  ```
259
319
 
260
- **Masking change**: inline `config` is always masked sensitive values are replaced with `"********"`. Use `client.get_device_config(connection_target, reveal=True)` if you need raw secrets after the driver session is up. `allocated.config_is_masked` is `True` whenever the inline path was taken.
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.
261
321
 
262
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.
263
323
 
@@ -1,11 +1,9 @@
1
1
  """Supported Python integration helpers for GridFleet.
2
2
 
3
- **0.4.0 masking change**: when callers opt in to inline `config` via
4
- `claim_device(include=("config",))`, the inline `config` payload is **always
5
- masked** (sensitive values are replaced with `"********"`). Code that needs raw
6
- secrets must continue to call `client.get_device_config(connection_target,
7
- reveal=True)` explicitly. `AllocatedDevice.config_is_masked` is `True` whenever
8
- the inline path was taken.
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)`.
9
7
 
10
8
  Environment variables read by the client:
11
9
 
@@ -31,15 +29,17 @@ from .appium import (
31
29
  create_appium_driver,
32
30
  get_connection_target_from_driver,
33
31
  get_device_config_for_driver,
32
+ get_device_test_data_for_driver,
34
33
  )
35
34
  from .client import (
36
- GRID_URL,
37
- GRIDFLEET_API_URL,
35
+ CooldownResult,
38
36
  GridFleetClient,
39
37
  HeartbeatThread,
40
38
  NoClaimableDevicesError,
41
39
  ReserveCapabilitiesUnsupportedError,
42
40
  UnknownIncludeError,
41
+ _default_api_url,
42
+ _default_grid_url,
43
43
  register_run_cleanup,
44
44
  )
45
45
  from .sessions import build_error_session_payload
@@ -47,12 +47,13 @@ from .sessions import build_error_session_payload
47
47
  try:
48
48
  __version__ = version("gridfleet-testkit")
49
49
  except PackageNotFoundError:
50
- __version__ = "0.4.0"
50
+ __version__ = "0.5.0"
51
51
 
52
52
  __all__ = [
53
53
  "GRIDFLEET_API_URL",
54
54
  "GRID_URL",
55
55
  "AllocatedDevice",
56
+ "CooldownResult",
56
57
  "GridFleetClient",
57
58
  "HeartbeatThread",
58
59
  "NoClaimableDevicesError",
@@ -65,7 +66,16 @@ __all__ = [
65
66
  "create_appium_driver",
66
67
  "get_connection_target_from_driver",
67
68
  "get_device_config_for_driver",
69
+ "get_device_test_data_for_driver",
68
70
  "hydrate_allocated_device",
69
71
  "hydrate_allocated_device_from_driver",
70
72
  "register_run_cleanup",
71
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}")
@@ -39,8 +39,8 @@ class AllocatedDevice:
39
39
  claimed_at: str
40
40
  config: dict[str, Any] | None
41
41
  live_capabilities: dict[str, Any] | None
42
+ test_data: dict[str, Any] | None = None
42
43
  unavailable_includes: tuple[UnavailableInclude, ...] = ()
43
- config_is_masked: bool = False
44
44
 
45
45
  @property
46
46
  def is_real_device(self) -> bool:
@@ -121,6 +121,7 @@ def hydrate_allocated_device(
121
121
  client: GridFleetClient,
122
122
  fetch_config: bool = True,
123
123
  fetch_capabilities: bool = False,
124
+ fetch_test_data: bool = False,
124
125
  ) -> AllocatedDevice:
125
126
  """Combine a claim response with optional static config and live capabilities.
126
127
 
@@ -142,13 +143,10 @@ def hydrate_allocated_device(
142
143
  inline_config = payload.get("config")
143
144
  if isinstance(inline_config, dict):
144
145
  config: dict[str, Any] | None = inline_config
145
- config_is_masked = True
146
146
  elif fetch_config and connection_target and "config" not in unavailable_set:
147
147
  config = client.get_device_config(connection_target)
148
- config_is_masked = False
149
148
  else:
150
149
  config = None
151
- config_is_masked = False
152
150
  inline_capabilities = payload.get("live_capabilities")
153
151
  if isinstance(inline_capabilities, dict):
154
152
  live_capabilities: dict[str, Any] | None = inline_capabilities
@@ -157,6 +155,14 @@ def hydrate_allocated_device(
157
155
  else:
158
156
  live_capabilities = None
159
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
165
+
160
166
  return AllocatedDevice(
161
167
  run_id=run_id,
162
168
  device_id=device_id,
@@ -175,8 +181,8 @@ def hydrate_allocated_device(
175
181
  claimed_by=_string_value(payload, "claimed_by"),
176
182
  claimed_at=_string_value(payload, "claimed_at"),
177
183
  config=config,
178
- config_is_masked=config_is_masked,
179
184
  live_capabilities=live_capabilities,
185
+ test_data=test_data,
180
186
  unavailable_includes=unavailable_includes,
181
187
  )
182
188
 
@@ -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)