gridfleet-testkit 0.6.0__tar.gz → 0.8.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.6.0 → gridfleet_testkit-0.8.0}/CHANGELOG.md +48 -0
  2. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/PKG-INFO +58 -49
  3. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/README.md +57 -48
  4. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/gridfleet_testkit/__init__.py +8 -6
  5. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/gridfleet_testkit/allocation.py +4 -15
  6. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/gridfleet_testkit/appium.py +1 -0
  7. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/gridfleet_testkit/client.py +43 -179
  8. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/gridfleet_testkit/pytest_plugin.py +11 -1
  9. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/gridfleet_testkit/sessions.py +16 -2
  10. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/pyproject.toml +1 -1
  11. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_allocation.py +20 -38
  12. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_appium.py +1 -0
  13. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_client.py +3 -501
  14. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_package_metadata.py +0 -2
  15. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_pytest_plugin.py +1 -0
  16. gridfleet_testkit-0.8.0/tests/test_pytest_plugin_grid_run_id_injection.py +56 -0
  17. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_sessions.py +1 -1
  18. gridfleet_testkit-0.8.0/tests/test_sessions_resolve_device_handle.py +25 -0
  19. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/uv.lock +131 -131
  20. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/.gitignore +0 -0
  21. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/__init__.py +0 -0
  22. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/_example_helpers.py +0 -0
  23. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/assets/hello-world.zip +0 -0
  24. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/test_android_mobile_screenshot.py +0 -0
  25. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/test_android_tv_screenshot.py +0 -0
  26. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/test_firetv_screenshot.py +0 -0
  27. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/test_ios_simulator_screenshot.py +0 -0
  28. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/test_roku_screenshot.py +0 -0
  29. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/test_roku_sideload_screenshot.py +0 -0
  30. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/examples/test_tvos_screenshot.py +0 -0
  31. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/gridfleet_testkit/py.typed +0 -0
  32. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_client_test_data.py +0 -0
  33. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_docs_contract.py +0 -0
  34. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_driver_agnostic_guard.py +0 -0
  35. {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.8.0}/tests/test_pytest_plugin_test_data.py +0 -0
@@ -2,6 +2,54 @@
2
2
 
3
3
  All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
4
 
5
+ ## [0.8.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.7.0...gridfleet-testkit-v0.8.0) (2026-05-12)
6
+
7
+
8
+ ### Features
9
+
10
+ * **backend,testkit:** recreate run device cooldown api ([fccfbc7](https://github.com/quidow/gridfleet/commit/fccfbc7bcf694f8c59cbaa394bb075d20e1b34f0))
11
+ * **testkit:** add cooldown_device client helper and result types ([584f411](https://github.com/quidow/gridfleet/commit/584f411f3b72f815e6f4055667c0eaccb1926bc5))
12
+
13
+
14
+ ### Dependencies
15
+
16
+ * **deps:** bump mypy in /agent ([#195](https://github.com/quidow/gridfleet/issues/195)) ([1317e59](https://github.com/quidow/gridfleet/commit/1317e59bbd4ae6969ed3c717c24b43dbfefec722))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **testkit:** correct cooldown_device ttl error in readme ([f768b6b](https://github.com/quidow/gridfleet/commit/f768b6b11b87ad3c4aa369ee2bcdebfeed5c1f86))
22
+ * **testkit:** document cooldown_device api and result types ([0e2418b](https://github.com/quidow/gridfleet/commit/0e2418b0ea3b952d6151a08e3f75116de7edcdd8))
23
+
24
+ ## [0.7.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.6.0...gridfleet-testkit-v0.7.0) (2026-05-11)
25
+
26
+
27
+ ### ⚠ BREAKING CHANGES
28
+
29
+ * **testkit:** AllocatedDevice hydration now accepts device handles, not claim payloads.
30
+ * **testkit:** GridFleetClient claim/release helpers and NoClaimableDevicesError are removed.
31
+
32
+ ### Features
33
+
34
+ * **testkit:** drop claim release client api ([8d1f295](https://github.com/quidow/gridfleet/commit/8d1f29504e6d640ce9d85c70594a515868045be8))
35
+ * **testkit:** inject grid run id capability ([b4ae38c](https://github.com/quidow/gridfleet/commit/b4ae38ce9969ae325bffdb9716ba7d6c52a699ac))
36
+ * **testkit:** resolve device handle by connection target ([22b4299](https://github.com/quidow/gridfleet/commit/22b4299d6bc4302503a2c2b6f17018ceebc03084))
37
+
38
+
39
+ ### Bug Fixes
40
+
41
+ * **agent:** release adapter-owned doctor refactor ([#165](https://github.com/quidow/gridfleet/issues/165)) ([f3ae257](https://github.com/quidow/gridfleet/commit/f3ae25787e2c8ef926312f11d2313c6513f8bfa9))
42
+
43
+
44
+ ### Dependencies
45
+
46
+ * **deps:** bump urllib3 from 2.6.3 to 2.7.0 in /testkit ([#186](https://github.com/quidow/gridfleet/issues/186)) ([dd7a1df](https://github.com/quidow/gridfleet/commit/dd7a1df8fb29ae76f618abab947db2027471b536))
47
+
48
+
49
+ ### Code Refactoring
50
+
51
+ * **testkit:** drop claim response allocation metadata ([f0eec3e](https://github.com/quidow/gridfleet/commit/f0eec3e9c9f804439241ddbbb56b196bc467effd))
52
+
5
53
  ## [0.6.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.5.0...gridfleet-testkit-v0.6.0) (2026-05-10)
6
54
 
7
55
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfleet-testkit
3
- Version: 0.6.0
3
+ Version: 0.8.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,6 +189,7 @@ 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 |
188
195
  | `GridFleetClient.replace_device_test_data(device_id, body)` | Replace test_data with the supplied object |
@@ -190,33 +197,33 @@ finally:
190
197
  | `GridFleetClient.resolve_device_id_by_connection_target(connection_target)` | Resolve the backend device id for a runtime connection target |
191
198
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
192
199
  | `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` |
200
+ | `GridFleetClient.signal_ready(run_id)` | Signal that a run is ready |
199
201
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
200
202
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
201
203
  | `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
202
204
  | `GridFleetClient.register_session(fields)` | Register a Grid/Appium session with optional requested capability metadata |
203
205
  | `GridFleetClient.register_session_from_driver(driver, fields)` | Extract session id and capabilities from an Appium driver and register the session |
206
+ | `GridFleetClient.notify_session_finished(session_id)` | Tell the manager the WebDriver session has ended |
204
207
  | `GridFleetClient.update_session_status(session_id, status)` | Report final session status |
205
208
  | `GridFleetClient.complete_run(run_id)` | Complete a run |
206
209
  | `GridFleetClient.cancel_run(run_id)` | Cancel a run |
210
+ | `GridFleetClient.cooldown_device(run_id, device_id, reason=..., ttl_seconds=...)` | Exclude a reserved device from the run with a cooldown TTL |
207
211
  | `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
208
212
  | `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 |
213
+ | `hydrate_allocated_device(device_handle, run_id, client)` | Combine a device handle with optional device config and live capabilities |
210
214
  | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
215
+ | `resolve_device_handle_from_driver(driver, client)` | Resolve the assigned manager device row from a running Appium session |
211
216
  | `get_device_test_data_for_driver(driver, gridfleet_client=None)` | Fetch test_data for a live Appium driver |
212
217
  | `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
218
 
214
219
  ### Worker Identity
215
220
 
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.
221
+ `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
222
 
218
223
  ### Reservation Flow
219
224
 
225
+ 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.
226
+
220
227
  ```python
221
228
  from gridfleet_testkit import GridFleetClient, register_run_cleanup
222
229
 
@@ -258,53 +265,51 @@ client.signal_active(run_id)
258
265
 
259
266
  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
267
 
261
- ### Worker Claim With Cooldown
268
+ 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.
269
+
270
+ ### Cooling Down an Unstable Device
271
+
272
+ 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
273
 
263
274
  ```python
264
275
  from gridfleet_testkit import GridFleetClient
265
276
 
266
- client = GridFleetClient("http://manager-ip:8000/api")
277
+ client = GridFleetClient()
267
278
 
268
- claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
269
- device_id = claim["device_id"]
279
+ result = client.cooldown_device(
280
+ run_id="run-123",
281
+ device_id="device-456",
282
+ reason="Connection dropped mid-test",
283
+ ttl_seconds=120,
284
+ )
270
285
 
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")
286
+ if result["status"] == "cooldown_set":
287
+ print(f"Device on cooldown until {result['excluded_until']}")
288
+ elif result["status"] == "maintenance_escalated":
289
+ print(f"Escalated after {result['cooldown_count']} cooldowns")
285
290
  ```
286
291
 
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.
292
+ 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
293
 
291
294
  ### Allocated Device Hydration
292
295
 
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.
296
+ Use `hydrate_allocated_device(...)` immediately after reserving a run when a custom plugin needs a stable object instead of raw device-handle JSON.
294
297
 
295
298
  ```python
296
- from gridfleet_testkit import GridFleetClient, hydrate_allocated_device
299
+ from gridfleet_testkit import GridFleetClient, hydrate_allocated_device, resolve_device_handle_from_driver
297
300
 
298
- client = GridFleetClient("http://manager-ip:8000/api")
301
+ client = GridFleetClient()
299
302
  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
303
 
303
- assert allocated.device_id == claim["device_id"]
304
+ # After creating an Appium session, resolve the assigned device handle
305
+ device_handle = resolve_device_handle_from_driver(driver, client=client)
306
+ allocated = hydrate_allocated_device(device_handle, run_id=run_id, client=client)
307
+
308
+ assert allocated.device_id == device_handle["device_id"]
304
309
  assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
305
310
  ```
306
311
 
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.
312
+ 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
313
 
309
314
  ### Run Cleanup Policy
310
315
 
@@ -335,29 +340,33 @@ test_data = get_device_test_data_for_driver(driver)
335
340
 
336
341
  ### Errors and Result Types
337
342
 
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
343
  - `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
344
  - `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.
345
+ - `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
346
 
343
- ### Reduced HTTP round-trips on claim
347
+ ### Reduced HTTP Round-trips on Reserve
344
348
 
345
- The manager can inline device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
349
+ The manager can inline device config and live capabilities into the `reserve_devices` response, eliminating per-worker follow-up GETs.
346
350
 
347
351
  ```python
348
352
  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
353
+ run = client.reserve_devices(
354
+ name="my-test-run",
355
+ requirements=[...],
356
+ include=("config", "capabilities"),
357
+ )
358
+ for device_handle in run["devices"]:
359
+ allocated = hydrate_allocated_device(device_handle, run_id=run["id"], client=client)
360
+ # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
352
361
  ```
353
362
 
354
363
  `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
364
 
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.
365
+ `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
366
 
358
367
  `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
368
 
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.
369
+ `hydrate_allocated_device` accepts device-handle payloads such as `reserve_response["devices"]` entries or rows returned by `get_device_by_connection_target`.
361
370
 
362
371
  ## Examples
363
372
 
@@ -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,6 +155,7 @@ 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 |
154
161
  | `GridFleetClient.replace_device_test_data(device_id, body)` | Replace test_data with the supplied object |
@@ -156,33 +163,33 @@ finally:
156
163
  | `GridFleetClient.resolve_device_id_by_connection_target(connection_target)` | Resolve the backend device id for a runtime connection target |
157
164
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
158
165
  | `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` |
166
+ | `GridFleetClient.signal_ready(run_id)` | Signal that a run is ready |
165
167
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
166
168
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
167
169
  | `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
168
170
  | `GridFleetClient.register_session(fields)` | Register a Grid/Appium session with optional requested capability metadata |
169
171
  | `GridFleetClient.register_session_from_driver(driver, fields)` | Extract session id and capabilities from an Appium driver and register the session |
172
+ | `GridFleetClient.notify_session_finished(session_id)` | Tell the manager the WebDriver session has ended |
170
173
  | `GridFleetClient.update_session_status(session_id, status)` | Report final session status |
171
174
  | `GridFleetClient.complete_run(run_id)` | Complete a run |
172
175
  | `GridFleetClient.cancel_run(run_id)` | Cancel a run |
176
+ | `GridFleetClient.cooldown_device(run_id, device_id, reason=..., ttl_seconds=...)` | Exclude a reserved device from the run with a cooldown TTL |
173
177
  | `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
174
178
  | `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 |
179
+ | `hydrate_allocated_device(device_handle, run_id, client)` | Combine a device handle with optional device config and live capabilities |
176
180
  | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
181
+ | `resolve_device_handle_from_driver(driver, client)` | Resolve the assigned manager device row from a running Appium session |
177
182
  | `get_device_test_data_for_driver(driver, gridfleet_client=None)` | Fetch test_data for a live Appium driver |
178
183
  | `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
184
 
180
185
  ### Worker Identity
181
186
 
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.
187
+ `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
188
 
184
189
  ### Reservation Flow
185
190
 
191
+ 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.
192
+
186
193
  ```python
187
194
  from gridfleet_testkit import GridFleetClient, register_run_cleanup
188
195
 
@@ -224,53 +231,51 @@ client.signal_active(run_id)
224
231
 
225
232
  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
233
 
227
- ### Worker Claim With Cooldown
234
+ 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.
235
+
236
+ ### Cooling Down an Unstable Device
237
+
238
+ 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
239
 
229
240
  ```python
230
241
  from gridfleet_testkit import GridFleetClient
231
242
 
232
- client = GridFleetClient("http://manager-ip:8000/api")
243
+ client = GridFleetClient()
233
244
 
234
- claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
235
- device_id = claim["device_id"]
245
+ result = client.cooldown_device(
246
+ run_id="run-123",
247
+ device_id="device-456",
248
+ reason="Connection dropped mid-test",
249
+ ttl_seconds=120,
250
+ )
236
251
 
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")
252
+ if result["status"] == "cooldown_set":
253
+ print(f"Device on cooldown until {result['excluded_until']}")
254
+ elif result["status"] == "maintenance_escalated":
255
+ print(f"Escalated after {result['cooldown_count']} cooldowns")
251
256
  ```
252
257
 
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.
258
+ 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
259
 
257
260
  ### Allocated Device Hydration
258
261
 
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.
262
+ Use `hydrate_allocated_device(...)` immediately after reserving a run when a custom plugin needs a stable object instead of raw device-handle JSON.
260
263
 
261
264
  ```python
262
- from gridfleet_testkit import GridFleetClient, hydrate_allocated_device
265
+ from gridfleet_testkit import GridFleetClient, hydrate_allocated_device, resolve_device_handle_from_driver
263
266
 
264
- client = GridFleetClient("http://manager-ip:8000/api")
267
+ client = GridFleetClient()
265
268
  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
269
 
269
- assert allocated.device_id == claim["device_id"]
270
+ # After creating an Appium session, resolve the assigned device handle
271
+ device_handle = resolve_device_handle_from_driver(driver, client=client)
272
+ allocated = hydrate_allocated_device(device_handle, run_id=run_id, client=client)
273
+
274
+ assert allocated.device_id == device_handle["device_id"]
270
275
  assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
271
276
  ```
272
277
 
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.
278
+ 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
279
 
275
280
  ### Run Cleanup Policy
276
281
 
@@ -301,29 +306,33 @@ test_data = get_device_test_data_for_driver(driver)
301
306
 
302
307
  ### Errors and Result Types
303
308
 
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
309
  - `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
310
  - `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.
311
+ - `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
312
 
309
- ### Reduced HTTP round-trips on claim
313
+ ### Reduced HTTP Round-trips on Reserve
310
314
 
311
- The manager can inline device config and live capabilities into the claim/reserve response, eliminating per-worker follow-up GETs.
315
+ The manager can inline device config and live capabilities into the `reserve_devices` response, eliminating per-worker follow-up GETs.
312
316
 
313
317
  ```python
314
318
  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
319
+ run = client.reserve_devices(
320
+ name="my-test-run",
321
+ requirements=[...],
322
+ include=("config", "capabilities"),
323
+ )
324
+ for device_handle in run["devices"]:
325
+ allocated = hydrate_allocated_device(device_handle, run_id=run["id"], client=client)
326
+ # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
318
327
  ```
319
328
 
320
329
  `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
330
 
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.
331
+ `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
332
 
324
333
  `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
334
 
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.
335
+ `hydrate_allocated_device` accepts device-handle payloads such as `reserve_response["devices"]` entries or rows returned by `get_device_by_connection_target`.
327
336
 
328
337
  ## Examples
329
338
 
@@ -2,8 +2,7 @@
2
2
 
3
3
  `device_config` values returned by the manager are verbatim; the testkit no
4
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)`.
5
+ the live Appium-side config can use `client.get_device_config(connection_target)`.
7
6
 
8
7
  Environment variables read by the client:
9
8
 
@@ -32,31 +31,33 @@ from .appium import (
32
31
  get_device_test_data_for_driver,
33
32
  )
34
33
  from .client import (
34
+ CooldownEscalatedResult,
35
35
  CooldownResult,
36
+ CooldownSetResult,
36
37
  GridFleetClient,
37
38
  HeartbeatThread,
38
- NoClaimableDevicesError,
39
39
  ReserveCapabilitiesUnsupportedError,
40
40
  UnknownIncludeError,
41
41
  _default_api_url,
42
42
  _default_grid_url,
43
43
  register_run_cleanup,
44
44
  )
45
- from .sessions import build_error_session_payload
45
+ from .sessions import build_error_session_payload, resolve_device_handle_from_driver
46
46
 
47
47
  try:
48
48
  __version__ = version("gridfleet-testkit")
49
49
  except PackageNotFoundError:
50
- __version__ = "0.6.0"
50
+ __version__ = "0.8.0"
51
51
 
52
52
  __all__ = [
53
53
  "GRIDFLEET_API_URL",
54
54
  "GRID_URL",
55
55
  "AllocatedDevice",
56
+ "CooldownEscalatedResult",
56
57
  "CooldownResult",
58
+ "CooldownSetResult",
57
59
  "GridFleetClient",
58
60
  "HeartbeatThread",
59
- "NoClaimableDevicesError",
60
61
  "ReserveCapabilitiesUnsupportedError",
61
62
  "UnavailableInclude",
62
63
  "UnknownIncludeError",
@@ -70,6 +71,7 @@ __all__ = [
70
71
  "hydrate_allocated_device",
71
72
  "hydrate_allocated_device_from_driver",
72
73
  "register_run_cleanup",
74
+ "resolve_device_handle_from_driver",
73
75
  ]
74
76
 
75
77
 
@@ -19,7 +19,7 @@ class UnavailableInclude:
19
19
 
20
20
  @dataclass(frozen=True)
21
21
  class AllocatedDevice:
22
- """Combined view of a claimed device, ready for driver creation."""
22
+ """Combined view of an allocated device, ready for driver creation."""
23
23
 
24
24
  run_id: str
25
25
  device_id: str
@@ -35,8 +35,6 @@ class AllocatedDevice:
35
35
  connection_type: str
36
36
  manufacturer: str | None
37
37
  model: str | None
38
- claimed_by: str
39
- claimed_at: str
40
38
  config: dict[str, Any] | None
41
39
  live_capabilities: dict[str, Any] | None
42
40
  test_data: dict[str, Any] | None = None
@@ -115,7 +113,7 @@ def _parse_unavailable_includes(payload: dict[str, Any]) -> tuple[UnavailableInc
115
113
 
116
114
 
117
115
  def hydrate_allocated_device(
118
- claim_response: dict[str, Any],
116
+ device_handle: dict[str, Any],
119
117
  *,
120
118
  run_id: str,
121
119
  client: GridFleetClient,
@@ -123,15 +121,8 @@ def hydrate_allocated_device(
123
121
  fetch_capabilities: bool = False,
124
122
  fetch_test_data: bool = False,
125
123
  ) -> AllocatedDevice:
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
- """
134
- payload = dict(claim_response)
124
+ """Combine a device handle with optional static config and live capabilities."""
125
+ payload = dict(device_handle)
135
126
  device_id = _string_value(payload, "device_id")
136
127
  if _needs_device_detail(payload):
137
128
  payload = _merge_device_detail(payload, client.get_device(device_id))
@@ -178,8 +169,6 @@ def hydrate_allocated_device(
178
169
  connection_type=_string_value(payload, "connection_type"),
179
170
  manufacturer=_optional_string_value(payload, "manufacturer"),
180
171
  model=_optional_string_value(payload, "model"),
181
- claimed_by=_string_value(payload, "claimed_by"),
182
- claimed_at=_string_value(payload, "claimed_at"),
183
172
  config=config,
184
173
  live_capabilities=live_capabilities,
185
174
  test_data=test_data,
@@ -98,6 +98,7 @@ def build_appium_options(
98
98
  from appium.options.common import AppiumOptions # noqa: PLC0415
99
99
 
100
100
  params = dict(capabilities or {})
101
+ params.setdefault("gridfleet:run_id", os.environ.get("GRIDFLEET_RUN_ID", "free"))
101
102
  explicit_platform_name = params.get("platformName")
102
103
  if explicit_platform_name is not None and (pack_id is not None or platform_id is not None):
103
104
  raise ValueError("Use either pack_id/platform_id or the raw platformName capability, not both.")