gridfleet-testkit 0.2.1__tar.gz → 0.4.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.2.1 → gridfleet_testkit-0.4.0}/.gitignore +1 -0
  2. gridfleet_testkit-0.4.0/CHANGELOG.md +51 -0
  3. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/PKG-INFO +53 -1
  4. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/README.md +52 -0
  5. gridfleet_testkit-0.4.0/gridfleet_testkit/__init__.py +71 -0
  6. gridfleet_testkit-0.4.0/gridfleet_testkit/allocation.py +196 -0
  7. gridfleet_testkit-0.4.0/gridfleet_testkit/client.py +588 -0
  8. gridfleet_testkit-0.4.0/gridfleet_testkit/pytest_plugin.py +127 -0
  9. gridfleet_testkit-0.4.0/gridfleet_testkit/sessions.py +76 -0
  10. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/pyproject.toml +4 -2
  11. gridfleet_testkit-0.4.0/tests/test_allocation.py +330 -0
  12. gridfleet_testkit-0.4.0/tests/test_client.py +1255 -0
  13. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/tests/test_package_metadata.py +13 -0
  14. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/tests/test_pytest_plugin.py +74 -97
  15. gridfleet_testkit-0.4.0/tests/test_sessions.py +87 -0
  16. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/uv.lock +1 -1
  17. gridfleet_testkit-0.2.1/CHANGELOG.md +0 -27
  18. gridfleet_testkit-0.2.1/gridfleet_testkit/__init__.py +0 -37
  19. gridfleet_testkit-0.2.1/gridfleet_testkit/client.py +0 -315
  20. gridfleet_testkit-0.2.1/gridfleet_testkit/pytest_plugin.py +0 -247
  21. gridfleet_testkit-0.2.1/tests/test_client.py +0 -591
  22. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/__init__.py +0 -0
  23. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/_example_helpers.py +0 -0
  24. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/assets/hello-world.zip +0 -0
  25. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_android_mobile_screenshot.py +0 -0
  26. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_android_tv_screenshot.py +0 -0
  27. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_firetv_screenshot.py +0 -0
  28. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_ios_simulator_screenshot.py +0 -0
  29. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_roku_screenshot.py +0 -0
  30. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_roku_sideload_screenshot.py +0 -0
  31. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_tvos_screenshot.py +0 -0
  32. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/gridfleet_testkit/appium.py +0 -0
  33. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/gridfleet_testkit/py.typed +0 -0
  34. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/tests/test_appium.py +0 -0
  35. {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/tests/test_driver_agnostic_guard.py +0 -0
@@ -14,6 +14,7 @@ venv/
14
14
  htmlcov/
15
15
 
16
16
  # Frontend
17
+ node_modules/
17
18
  frontend/node_modules/
18
19
  frontend/dist/
19
20
 
@@ -0,0 +1,51 @@
1
+ # Changelog — GridFleet Testkit
2
+
3
+ All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
+
5
+ ## [0.4.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.3.0...gridfleet-testkit-v0.4.0) (2026-05-06)
6
+
7
+
8
+ ### Features
9
+
10
+ * **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))
11
+
12
+ ## [0.3.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.1...gridfleet-testkit-v0.3.0) (2026-05-05)
13
+
14
+
15
+ ### ⚠ BREAKING CHANGES
16
+
17
+ * **testkit:** promote public api helpers ([#92](https://github.com/quidow/gridfleet/issues/92))
18
+
19
+ ### Features
20
+
21
+ * **testkit:** add xdist recipe primitives ([#93](https://github.com/quidow/gridfleet/issues/93)) ([58fd3c3](https://github.com/quidow/gridfleet/commit/58fd3c3402ba7e735aae55e27abbe65a05c8ffe8))
22
+ * **testkit:** promote public api helpers ([#92](https://github.com/quidow/gridfleet/issues/92)) ([80d4483](https://github.com/quidow/gridfleet/commit/80d44832903f532de3da238d020b5dc27eb8b30e))
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **agent:** trigger release for port conflict cleanup ([6a561ca](https://github.com/quidow/gridfleet/commit/6a561ca480c62b9abb2d5141fa98fc4e1a7696b6))
28
+
29
+ ## [0.2.1](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.0...gridfleet-testkit-v0.2.1) (2026-05-03)
30
+
31
+
32
+ ### Bug Fixes
33
+
34
+ * **testkit:** bound supported python metadata ([c5fff86](https://github.com/quidow/gridfleet/commit/c5fff86cbb2a4897ac571c7c5b989f0361e49743))
35
+
36
+ ## [0.2.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.1.0...gridfleet-testkit-v0.2.0) (2026-05-03)
37
+
38
+
39
+ ### Features
40
+
41
+ * **testkit:** add run-scoped device cooldowns ([#54](https://github.com/quidow/gridfleet/issues/54)) ([6163dc9](https://github.com/quidow/gridfleet/commit/6163dc959334e933b43c20a99ad4edcbdae6c98b))
42
+
43
+
44
+ ### Bug Fixes
45
+
46
+ * idempotent device release after lifecycle cleanup ([#12](https://github.com/quidow/gridfleet/issues/12)) ([7a98a5d](https://github.com/quidow/gridfleet/commit/7a98a5d18330150aab0a852f6b894d1d53de257c))
47
+
48
+ ## 0.1.0 — Initial Public Preview
49
+
50
+ - Initial public preview of the GridFleet testkit.
51
+ - 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.2.1
3
+ Version: 0.4.0
4
4
  Summary: Supported pytest and run-orchestration helpers for GridFleet integrations
5
5
  Project-URL: Homepage, https://github.com/quidow/gridfleet
6
6
  Project-URL: Repository, https://github.com/quidow/gridfleet
@@ -162,22 +162,35 @@ finally:
162
162
 
163
163
  | Helper | Purpose |
164
164
  | --- | --- |
165
+ | `GridFleetClient.list_devices(filters)` | List devices using backend filters such as `status`, `pack_id`, `platform_id`, `host_id`, `connection_target`, and `tags.*` |
166
+ | `GridFleetClient.get_device(device_id)` | Fetch one full device detail row by backend device id |
165
167
  | `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
166
168
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
167
169
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
168
170
  | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
169
171
  | `GridFleetClient.claim_device_with_retry(run_id, worker_id=..., max_wait_sec=300)` | Claim one reserved device, sleeping according to server `Retry-After` responses |
170
172
  | `GridFleetClient.release_device(run_id, device_id=..., worker_id=...)` | Release a worker claim without cooldown |
173
+ | `GridFleetClient.release_device_safe(run_id, device_id=..., worker_id=...)` | Release a worker claim and tolerate 404/409 when cleanup races with run finalization or a prior release |
171
174
  | `GridFleetClient.release_device_with_cooldown(run_id, device_id=..., worker_id=..., reason=..., ttl_seconds=...)` | Release a worker claim and keep that run from reclaiming the device until cooldown expires |
172
175
  | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
173
176
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
174
177
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
175
178
  | `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
179
+ | `GridFleetClient.register_session(fields)` | Register a Grid/Appium session with optional requested capability metadata |
180
+ | `GridFleetClient.register_session_from_driver(driver, fields)` | Extract session id and capabilities from an Appium driver and register the session |
181
+ | `GridFleetClient.update_session_status(session_id, status)` | Report final session status |
176
182
  | `GridFleetClient.complete_run(run_id)` | Complete a run |
177
183
  | `GridFleetClient.cancel_run(run_id)` | Cancel a run |
178
184
  | `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
185
+ | `build_error_session_payload(fields)` | Build a `/api/sessions` payload for driver-creation failures without importing pytest |
186
+ | `hydrate_allocated_device(claim_response, run_id, client)` | Combine a claim response with optional device config and live capabilities |
187
+ | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
179
188
  | `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` and signal cleanup that completes or cancels a run |
180
189
 
190
+ ### Worker Identity
191
+
192
+ `worker_id` is an arbitrary string used for claim ownership, telemetry, and cooldown attribution. For pytest-xdist, pass `request.config.workerinput["workerid"]` from worker processes; values are normally `gw0`, `gw1`, and so on. For controller-only flows, use `"controller"` or a stable hostname. For custom schedulers, use a UUID or job-specific worker name.
193
+
181
194
  ### Reservation Flow
182
195
 
183
196
  ```python
@@ -247,6 +260,45 @@ else:
247
260
 
248
261
  Cooldowns are scoped to the active run. They prevent the same run from reclaiming the device until `ttl_seconds` expires, but completing or cancelling the run releases the physical device normally.
249
262
 
263
+ For pytest-xdist controller/worker orchestration, see [Testkit xdist recipe](../docs/guides/testkit-xdist-recipe.md). The recipe is copyable guidance, not a public testkit abstraction.
264
+
265
+ ### Allocated Device Hydration
266
+
267
+ Use `hydrate_allocated_device(claim_response, run_id=run_id, client=client)` immediately after a worker claim when a custom plugin needs a stable object instead of raw claim JSON.
268
+
269
+ ```python
270
+ from gridfleet_testkit import GridFleetClient, hydrate_allocated_device
271
+
272
+ client = GridFleetClient("http://manager-ip:8000/api")
273
+ run_id = "run-123"
274
+ claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
275
+ allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
276
+
277
+ assert allocated.device_id == claim["device_id"]
278
+ assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
279
+ ```
280
+
281
+ The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`.
282
+
283
+ ### Reduced HTTP round-trips on claim
284
+
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.
286
+
287
+ ```python
288
+ client = GridFleetClient()
289
+ claim = client.claim_device(run_id, worker_id="w0", include=("config", "capabilities"))
290
+ allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
291
+ # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
292
+ ```
293
+
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.
295
+
296
+ `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
+
298
+ `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.
299
+
300
+ `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.
301
+
250
302
  ## Examples
251
303
 
252
304
  Baseline screenshot examples:
@@ -128,22 +128,35 @@ finally:
128
128
 
129
129
  | Helper | Purpose |
130
130
  | --- | --- |
131
+ | `GridFleetClient.list_devices(filters)` | List devices using backend filters such as `status`, `pack_id`, `platform_id`, `host_id`, `connection_target`, and `tags.*` |
132
+ | `GridFleetClient.get_device(device_id)` | Fetch one full device detail row by backend device id |
131
133
  | `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
132
134
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
133
135
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
134
136
  | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
135
137
  | `GridFleetClient.claim_device_with_retry(run_id, worker_id=..., max_wait_sec=300)` | Claim one reserved device, sleeping according to server `Retry-After` responses |
136
138
  | `GridFleetClient.release_device(run_id, device_id=..., worker_id=...)` | Release a worker claim without cooldown |
139
+ | `GridFleetClient.release_device_safe(run_id, device_id=..., worker_id=...)` | Release a worker claim and tolerate 404/409 when cleanup races with run finalization or a prior release |
137
140
  | `GridFleetClient.release_device_with_cooldown(run_id, device_id=..., worker_id=..., reason=..., ttl_seconds=...)` | Release a worker claim and keep that run from reclaiming the device until cooldown expires |
138
141
  | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
139
142
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
140
143
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
141
144
  | `GridFleetClient.report_preparation_failure(run_id, device_id, message, source="ci_preparation")` | Exclude one reserved device after setup fails |
145
+ | `GridFleetClient.register_session(fields)` | Register a Grid/Appium session with optional requested capability metadata |
146
+ | `GridFleetClient.register_session_from_driver(driver, fields)` | Extract session id and capabilities from an Appium driver and register the session |
147
+ | `GridFleetClient.update_session_status(session_id, status)` | Report final session status |
142
148
  | `GridFleetClient.complete_run(run_id)` | Complete a run |
143
149
  | `GridFleetClient.cancel_run(run_id)` | Cancel a run |
144
150
  | `GridFleetClient.start_heartbeat(run_id, interval=30)` | Start a background heartbeat thread |
151
+ | `build_error_session_payload(fields)` | Build a `/api/sessions` payload for driver-creation failures without importing pytest |
152
+ | `hydrate_allocated_device(claim_response, run_id, client)` | Combine a claim response with optional device config and live capabilities |
153
+ | `hydrate_allocated_device_from_driver(allocated, driver, client)` | Return a new allocated-device object with capabilities from a running driver |
145
154
  | `register_run_cleanup(client, run_id, heartbeat_thread=None)` | Register `atexit` and signal cleanup that completes or cancels a run |
146
155
 
156
+ ### Worker Identity
157
+
158
+ `worker_id` is an arbitrary string used for claim ownership, telemetry, and cooldown attribution. For pytest-xdist, pass `request.config.workerinput["workerid"]` from worker processes; values are normally `gw0`, `gw1`, and so on. For controller-only flows, use `"controller"` or a stable hostname. For custom schedulers, use a UUID or job-specific worker name.
159
+
147
160
  ### Reservation Flow
148
161
 
149
162
  ```python
@@ -213,6 +226,45 @@ else:
213
226
 
214
227
  Cooldowns are scoped to the active run. They prevent the same run from reclaiming the device until `ttl_seconds` expires, but completing or cancelling the run releases the physical device normally.
215
228
 
229
+ For pytest-xdist controller/worker orchestration, see [Testkit xdist recipe](../docs/guides/testkit-xdist-recipe.md). The recipe is copyable guidance, not a public testkit abstraction.
230
+
231
+ ### Allocated Device Hydration
232
+
233
+ Use `hydrate_allocated_device(claim_response, run_id=run_id, client=client)` immediately after a worker claim when a custom plugin needs a stable object instead of raw claim JSON.
234
+
235
+ ```python
236
+ from gridfleet_testkit import GridFleetClient, hydrate_allocated_device
237
+
238
+ client = GridFleetClient("http://manager-ip:8000/api")
239
+ run_id = "run-123"
240
+ claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
241
+ allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
242
+
243
+ assert allocated.device_id == claim["device_id"]
244
+ assert allocated.platform_name in {"Android", "iOS", "tvOS", "Roku"}
245
+ ```
246
+
247
+ The helper fetches static device config by default when `connection_target` is present. It fetches live capabilities only when `fetch_capabilities=True`.
248
+
249
+ ### Reduced HTTP round-trips on claim
250
+
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.
252
+
253
+ ```python
254
+ client = GridFleetClient()
255
+ claim = client.claim_device(run_id, worker_id="w0", include=("config", "capabilities"))
256
+ allocated = hydrate_allocated_device(claim, run_id=run_id, client=client)
257
+ # zero follow-up GETs; allocated.config / allocated.live_capabilities populated inline
258
+ ```
259
+
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.
261
+
262
+ `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
+
264
+ `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.
265
+
266
+ `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.
267
+
216
268
  ## Examples
217
269
 
218
270
  Baseline screenshot examples:
@@ -0,0 +1,71 @@
1
+ """Supported Python integration helpers for GridFleet.
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.
9
+
10
+ Environment variables read by the client:
11
+
12
+ - GRID_URL: Selenium Grid URL used by Appium helper defaults.
13
+ - GRIDFLEET_API_URL: GridFleet manager API base URL.
14
+ - GRIDFLEET_TESTKIT_USERNAME: optional Basic auth username.
15
+ - GRIDFLEET_TESTKIT_PASSWORD: optional Basic auth password.
16
+
17
+ Recipe-local run-state sharing variables are intentionally not exported from
18
+ this package because run-state sharing is consumer policy.
19
+ """
20
+
21
+ from importlib.metadata import PackageNotFoundError, version
22
+
23
+ from .allocation import (
24
+ AllocatedDevice,
25
+ UnavailableInclude,
26
+ hydrate_allocated_device,
27
+ hydrate_allocated_device_from_driver,
28
+ )
29
+ from .appium import (
30
+ build_appium_options,
31
+ create_appium_driver,
32
+ get_connection_target_from_driver,
33
+ get_device_config_for_driver,
34
+ )
35
+ from .client import (
36
+ GRID_URL,
37
+ GRIDFLEET_API_URL,
38
+ GridFleetClient,
39
+ HeartbeatThread,
40
+ NoClaimableDevicesError,
41
+ ReserveCapabilitiesUnsupportedError,
42
+ UnknownIncludeError,
43
+ register_run_cleanup,
44
+ )
45
+ from .sessions import build_error_session_payload
46
+
47
+ try:
48
+ __version__ = version("gridfleet-testkit")
49
+ except PackageNotFoundError:
50
+ __version__ = "0.4.0"
51
+
52
+ __all__ = [
53
+ "GRIDFLEET_API_URL",
54
+ "GRID_URL",
55
+ "AllocatedDevice",
56
+ "GridFleetClient",
57
+ "HeartbeatThread",
58
+ "NoClaimableDevicesError",
59
+ "ReserveCapabilitiesUnsupportedError",
60
+ "UnavailableInclude",
61
+ "UnknownIncludeError",
62
+ "__version__",
63
+ "build_appium_options",
64
+ "build_error_session_payload",
65
+ "create_appium_driver",
66
+ "get_connection_target_from_driver",
67
+ "get_device_config_for_driver",
68
+ "hydrate_allocated_device",
69
+ "hydrate_allocated_device_from_driver",
70
+ "register_run_cleanup",
71
+ ]
@@ -0,0 +1,196 @@
1
+ """Allocated-device hydration helpers for GridFleet testkit consumers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from .client import GridFleetClient
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class UnavailableInclude:
14
+ """One include key the backend could not satisfy on this allocation."""
15
+
16
+ include: str
17
+ reason: str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class AllocatedDevice:
22
+ """Combined view of a claimed device, ready for driver creation."""
23
+
24
+ run_id: str
25
+ device_id: str
26
+ identity_value: str
27
+ name: str
28
+ pack_id: str
29
+ platform_id: str
30
+ platform_label: str | None
31
+ os_version: str | None
32
+ connection_target: str | None
33
+ host_ip: str | None
34
+ device_type: str
35
+ connection_type: str
36
+ manufacturer: str | None
37
+ model: str | None
38
+ claimed_by: str
39
+ claimed_at: str
40
+ config: dict[str, Any] | None
41
+ live_capabilities: dict[str, Any] | None
42
+ unavailable_includes: tuple[UnavailableInclude, ...] = ()
43
+ config_is_masked: bool = False
44
+
45
+ @property
46
+ def is_real_device(self) -> bool:
47
+ return self.device_type == "real_device"
48
+
49
+ @property
50
+ def is_simulator(self) -> bool:
51
+ return self.device_type in {"simulator", "emulator"}
52
+
53
+ @property
54
+ def udid(self) -> str | None:
55
+ if self.connection_target:
56
+ return self.connection_target
57
+ value = (self.live_capabilities or {}).get("appium:udid")
58
+ return value if isinstance(value, str) and value else None
59
+
60
+ @property
61
+ def device_ip(self) -> str | None:
62
+ """Best-effort address, preferring host IP before live device/config IP fields."""
63
+ if self.host_ip:
64
+ return self.host_ip
65
+ live_value = (self.live_capabilities or {}).get("appium:deviceIP")
66
+ if isinstance(live_value, str) and live_value:
67
+ return live_value
68
+ config_value = (self.config or {}).get("ip")
69
+ return config_value if isinstance(config_value, str) and config_value else None
70
+
71
+ @property
72
+ def platform_name(self) -> str:
73
+ return self.platform_label or self.platform_id
74
+
75
+
76
+ def _string_value(payload: dict[str, Any], key: str, *, default: str | None = None) -> str:
77
+ value = payload.get(key, default)
78
+ if isinstance(value, str) and value:
79
+ return value
80
+ raise ValueError(f"Allocated device payload is missing {key}")
81
+
82
+
83
+ def _optional_string_value(payload: dict[str, Any], key: str) -> str | None:
84
+ value = payload.get(key)
85
+ return value if isinstance(value, str) and value else None
86
+
87
+
88
+ def _needs_device_detail(payload: dict[str, Any]) -> bool:
89
+ return any(payload.get(key) is None for key in ("name", "device_type", "connection_type", "manufacturer", "model"))
90
+
91
+
92
+ def _merge_device_detail(payload: dict[str, Any], detail: dict[str, Any]) -> dict[str, Any]:
93
+ merged = dict(payload)
94
+ for key in ("name", "device_type", "connection_type", "manufacturer", "model"):
95
+ if merged.get(key) is None and detail.get(key) is not None:
96
+ merged[key] = detail[key]
97
+ if merged.get("host_ip") is None and detail.get("ip_address") is not None:
98
+ merged["host_ip"] = detail["ip_address"]
99
+ return merged
100
+
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
+
117
+ def hydrate_allocated_device(
118
+ claim_response: dict[str, Any],
119
+ *,
120
+ run_id: str,
121
+ client: GridFleetClient,
122
+ fetch_config: bool = True,
123
+ fetch_capabilities: bool = False,
124
+ ) -> AllocatedDevice:
125
+ """Combine a claim response with optional static config and live capabilities.
126
+
127
+ Accepts a ``ClaimResponse`` payload from ``GridFleetClient.claim_device`` only.
128
+ Reserve responses (``RunCreateResponse.devices`` entries before any worker
129
+ has claimed) lack ``claimed_by`` / ``claimed_at`` and will raise
130
+ ``ValueError``. Iterate ``reserve_response['devices']`` and call
131
+ ``claim_device`` per worker before hydrating.
132
+ """
133
+ payload = dict(claim_response)
134
+ device_id = _string_value(payload, "device_id")
135
+ if _needs_device_detail(payload):
136
+ payload = _merge_device_detail(payload, client.get_device(device_id))
137
+
138
+ unavailable_includes = _parse_unavailable_includes(payload)
139
+ unavailable_set = {entry.include for entry in unavailable_includes}
140
+
141
+ connection_target = _optional_string_value(payload, "connection_target")
142
+ inline_config = payload.get("config")
143
+ if isinstance(inline_config, dict):
144
+ config: dict[str, Any] | None = inline_config
145
+ config_is_masked = True
146
+ elif fetch_config and connection_target and "config" not in unavailable_set:
147
+ config = client.get_device_config(connection_target)
148
+ config_is_masked = False
149
+ else:
150
+ config = None
151
+ config_is_masked = False
152
+ inline_capabilities = payload.get("live_capabilities")
153
+ if isinstance(inline_capabilities, dict):
154
+ live_capabilities: dict[str, Any] | None = inline_capabilities
155
+ elif fetch_capabilities and "capabilities" not in unavailable_set:
156
+ live_capabilities = client.get_device_capabilities(device_id)
157
+ else:
158
+ live_capabilities = None
159
+
160
+ return AllocatedDevice(
161
+ run_id=run_id,
162
+ device_id=device_id,
163
+ identity_value=_string_value(payload, "identity_value"),
164
+ name=_string_value(payload, "name", default=device_id),
165
+ pack_id=_string_value(payload, "pack_id"),
166
+ platform_id=_string_value(payload, "platform_id"),
167
+ platform_label=_optional_string_value(payload, "platform_label"),
168
+ os_version=_optional_string_value(payload, "os_version"),
169
+ connection_target=connection_target,
170
+ host_ip=_optional_string_value(payload, "host_ip"),
171
+ device_type=_string_value(payload, "device_type"),
172
+ connection_type=_string_value(payload, "connection_type"),
173
+ manufacturer=_optional_string_value(payload, "manufacturer"),
174
+ model=_optional_string_value(payload, "model"),
175
+ claimed_by=_string_value(payload, "claimed_by"),
176
+ claimed_at=_string_value(payload, "claimed_at"),
177
+ config=config,
178
+ config_is_masked=config_is_masked,
179
+ live_capabilities=live_capabilities,
180
+ unavailable_includes=unavailable_includes,
181
+ )
182
+
183
+
184
+ def hydrate_allocated_device_from_driver(
185
+ allocated: AllocatedDevice,
186
+ driver: Any,
187
+ *,
188
+ client: GridFleetClient,
189
+ ) -> AllocatedDevice:
190
+ """Refresh live capabilities from a running Appium driver session."""
191
+ capabilities = getattr(driver, "capabilities", None)
192
+ if isinstance(capabilities, dict):
193
+ live_capabilities = dict(capabilities)
194
+ else:
195
+ live_capabilities = client.get_device_capabilities(allocated.device_id)
196
+ return replace(allocated, live_capabilities=live_capabilities)