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.
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/.gitignore +1 -0
- gridfleet_testkit-0.4.0/CHANGELOG.md +51 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/PKG-INFO +53 -1
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/README.md +52 -0
- gridfleet_testkit-0.4.0/gridfleet_testkit/__init__.py +71 -0
- gridfleet_testkit-0.4.0/gridfleet_testkit/allocation.py +196 -0
- gridfleet_testkit-0.4.0/gridfleet_testkit/client.py +588 -0
- gridfleet_testkit-0.4.0/gridfleet_testkit/pytest_plugin.py +127 -0
- gridfleet_testkit-0.4.0/gridfleet_testkit/sessions.py +76 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/pyproject.toml +4 -2
- gridfleet_testkit-0.4.0/tests/test_allocation.py +330 -0
- gridfleet_testkit-0.4.0/tests/test_client.py +1255 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/tests/test_package_metadata.py +13 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/tests/test_pytest_plugin.py +74 -97
- gridfleet_testkit-0.4.0/tests/test_sessions.py +87 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/uv.lock +1 -1
- gridfleet_testkit-0.2.1/CHANGELOG.md +0 -27
- gridfleet_testkit-0.2.1/gridfleet_testkit/__init__.py +0 -37
- gridfleet_testkit-0.2.1/gridfleet_testkit/client.py +0 -315
- gridfleet_testkit-0.2.1/gridfleet_testkit/pytest_plugin.py +0 -247
- gridfleet_testkit-0.2.1/tests/test_client.py +0 -591
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/__init__.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/_example_helpers.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/assets/hello-world.zip +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_android_mobile_screenshot.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_android_tv_screenshot.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_firetv_screenshot.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_ios_simulator_screenshot.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_roku_screenshot.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_roku_sideload_screenshot.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/examples/test_tvos_screenshot.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/gridfleet_testkit/appium.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/gridfleet_testkit/py.typed +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/tests/test_appium.py +0 -0
- {gridfleet_testkit-0.2.1 → gridfleet_testkit-0.4.0}/tests/test_driver_agnostic_guard.py +0 -0
|
@@ -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.
|
|
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)
|