gridfleet-testkit 0.2.0__tar.gz → 0.3.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.0 → gridfleet_testkit-0.3.0}/.gitignore +1 -0
- gridfleet_testkit-0.3.0/CHANGELOG.md +44 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/PKG-INFO +40 -7
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/README.md +33 -0
- gridfleet_testkit-0.3.0/gridfleet_testkit/__init__.py +54 -0
- gridfleet_testkit-0.3.0/gridfleet_testkit/allocation.py +144 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/gridfleet_testkit/client.py +204 -2
- gridfleet_testkit-0.3.0/gridfleet_testkit/pytest_plugin.py +127 -0
- gridfleet_testkit-0.3.0/gridfleet_testkit/sessions.py +76 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/pyproject.toml +10 -8
- gridfleet_testkit-0.3.0/tests/test_allocation.py +175 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/tests/test_client.py +380 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/tests/test_package_metadata.py +13 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/tests/test_pytest_plugin.py +74 -97
- gridfleet_testkit-0.3.0/tests/test_sessions.py +87 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/uv.lock +7 -11
- gridfleet_testkit-0.2.0/CHANGELOG.md +0 -20
- gridfleet_testkit-0.2.0/gridfleet_testkit/__init__.py +0 -37
- gridfleet_testkit-0.2.0/gridfleet_testkit/pytest_plugin.py +0 -247
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/__init__.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/_example_helpers.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/assets/hello-world.zip +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/test_android_mobile_screenshot.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/test_android_tv_screenshot.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/test_firetv_screenshot.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/test_ios_simulator_screenshot.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/test_roku_screenshot.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/test_roku_sideload_screenshot.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/examples/test_tvos_screenshot.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/gridfleet_testkit/appium.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/gridfleet_testkit/py.typed +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/tests/test_appium.py +0 -0
- {gridfleet_testkit-0.2.0 → gridfleet_testkit-0.3.0}/tests/test_driver_agnostic_guard.py +0 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog — GridFleet Testkit
|
|
2
|
+
|
|
3
|
+
All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.3.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.1...gridfleet-testkit-v0.3.0) (2026-05-05)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### ⚠ BREAKING CHANGES
|
|
9
|
+
|
|
10
|
+
* **testkit:** promote public api helpers ([#92](https://github.com/quidow/gridfleet/issues/92))
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* **testkit:** add xdist recipe primitives ([#93](https://github.com/quidow/gridfleet/issues/93)) ([58fd3c3](https://github.com/quidow/gridfleet/commit/58fd3c3402ba7e735aae55e27abbe65a05c8ffe8))
|
|
15
|
+
* **testkit:** promote public api helpers ([#92](https://github.com/quidow/gridfleet/issues/92)) ([80d4483](https://github.com/quidow/gridfleet/commit/80d44832903f532de3da238d020b5dc27eb8b30e))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **agent:** trigger release for port conflict cleanup ([6a561ca](https://github.com/quidow/gridfleet/commit/6a561ca480c62b9abb2d5141fa98fc4e1a7696b6))
|
|
21
|
+
|
|
22
|
+
## [0.2.1](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.2.0...gridfleet-testkit-v0.2.1) (2026-05-03)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **testkit:** bound supported python metadata ([c5fff86](https://github.com/quidow/gridfleet/commit/c5fff86cbb2a4897ac571c7c5b989f0361e49743))
|
|
28
|
+
|
|
29
|
+
## [0.2.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.1.0...gridfleet-testkit-v0.2.0) (2026-05-03)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Features
|
|
33
|
+
|
|
34
|
+
* **testkit:** add run-scoped device cooldowns ([#54](https://github.com/quidow/gridfleet/issues/54)) ([6163dc9](https://github.com/quidow/gridfleet/commit/6163dc959334e933b43c20a99ad4edcbdae6c98b))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Bug Fixes
|
|
38
|
+
|
|
39
|
+
* idempotent device release after lifecycle cleanup ([#12](https://github.com/quidow/gridfleet/issues/12)) ([7a98a5d](https://github.com/quidow/gridfleet/commit/7a98a5d18330150aab0a852f6b894d1d53de257c))
|
|
40
|
+
|
|
41
|
+
## 0.1.0 — Initial Public Preview
|
|
42
|
+
|
|
43
|
+
- Initial public preview of the GridFleet testkit.
|
|
44
|
+
- 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.3.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
|
|
@@ -21,15 +21,15 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.14
|
|
23
23
|
Classifier: Typing :: Typed
|
|
24
|
-
Requires-Python:
|
|
24
|
+
Requires-Python: <3.15,>=3.10
|
|
25
25
|
Requires-Dist: httpx<1,>=0.27
|
|
26
|
-
Requires-Dist: pytest
|
|
26
|
+
Requires-Dist: pytest<10,>=9.0.3
|
|
27
27
|
Provides-Extra: appium
|
|
28
|
-
Requires-Dist: appium-python-client
|
|
28
|
+
Requires-Dist: appium-python-client<6,>=4.5; extra == 'appium'
|
|
29
29
|
Provides-Extra: dev
|
|
30
|
-
Requires-Dist: mypy
|
|
31
|
-
Requires-Dist: pytest
|
|
32
|
-
Requires-Dist: ruff
|
|
30
|
+
Requires-Dist: mypy<2,>=1.20.2; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest<10,>=9.0.3; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff<1,>=0.15.12; extra == 'dev'
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
35
35
|
# GridFleet Testkit
|
|
@@ -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,26 @@ 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
|
+
|
|
250
283
|
## Examples
|
|
251
284
|
|
|
252
285
|
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,26 @@ 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
|
+
|
|
216
249
|
## Examples
|
|
217
250
|
|
|
218
251
|
Baseline screenshot examples:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Supported Python integration helpers for GridFleet.
|
|
2
|
+
|
|
3
|
+
Environment variables read by the client:
|
|
4
|
+
|
|
5
|
+
- GRID_URL: Selenium Grid URL used by Appium helper defaults.
|
|
6
|
+
- GRIDFLEET_API_URL: GridFleet manager API base URL.
|
|
7
|
+
- GRIDFLEET_TESTKIT_USERNAME: optional Basic auth username.
|
|
8
|
+
- GRIDFLEET_TESTKIT_PASSWORD: optional Basic auth password.
|
|
9
|
+
|
|
10
|
+
Recipe-local run-state sharing variables are intentionally not exported from
|
|
11
|
+
this package because run-state sharing is consumer policy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
from .allocation import AllocatedDevice, hydrate_allocated_device, hydrate_allocated_device_from_driver
|
|
17
|
+
from .appium import (
|
|
18
|
+
build_appium_options,
|
|
19
|
+
create_appium_driver,
|
|
20
|
+
get_connection_target_from_driver,
|
|
21
|
+
get_device_config_for_driver,
|
|
22
|
+
)
|
|
23
|
+
from .client import (
|
|
24
|
+
GRID_URL,
|
|
25
|
+
GRIDFLEET_API_URL,
|
|
26
|
+
GridFleetClient,
|
|
27
|
+
HeartbeatThread,
|
|
28
|
+
NoClaimableDevicesError,
|
|
29
|
+
register_run_cleanup,
|
|
30
|
+
)
|
|
31
|
+
from .sessions import build_error_session_payload
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
__version__ = version("gridfleet-testkit")
|
|
35
|
+
except PackageNotFoundError:
|
|
36
|
+
__version__ = "0.3.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"GRIDFLEET_API_URL",
|
|
40
|
+
"GRID_URL",
|
|
41
|
+
"AllocatedDevice",
|
|
42
|
+
"GridFleetClient",
|
|
43
|
+
"HeartbeatThread",
|
|
44
|
+
"NoClaimableDevicesError",
|
|
45
|
+
"__version__",
|
|
46
|
+
"build_appium_options",
|
|
47
|
+
"build_error_session_payload",
|
|
48
|
+
"create_appium_driver",
|
|
49
|
+
"get_connection_target_from_driver",
|
|
50
|
+
"get_device_config_for_driver",
|
|
51
|
+
"hydrate_allocated_device",
|
|
52
|
+
"hydrate_allocated_device_from_driver",
|
|
53
|
+
"register_run_cleanup",
|
|
54
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
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 AllocatedDevice:
|
|
14
|
+
"""Combined view of a claimed device, ready for driver creation."""
|
|
15
|
+
|
|
16
|
+
run_id: str
|
|
17
|
+
device_id: str
|
|
18
|
+
identity_value: str
|
|
19
|
+
name: str
|
|
20
|
+
pack_id: str
|
|
21
|
+
platform_id: str
|
|
22
|
+
platform_label: str | None
|
|
23
|
+
os_version: str | None
|
|
24
|
+
connection_target: str | None
|
|
25
|
+
host_ip: str | None
|
|
26
|
+
device_type: str
|
|
27
|
+
connection_type: str
|
|
28
|
+
manufacturer: str | None
|
|
29
|
+
model: str | None
|
|
30
|
+
claimed_by: str
|
|
31
|
+
claimed_at: str
|
|
32
|
+
config: dict[str, Any] | None
|
|
33
|
+
live_capabilities: dict[str, Any] | None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_real_device(self) -> bool:
|
|
37
|
+
return self.device_type == "real_device"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_simulator(self) -> bool:
|
|
41
|
+
return self.device_type in {"simulator", "emulator"}
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def udid(self) -> str | None:
|
|
45
|
+
if self.connection_target:
|
|
46
|
+
return self.connection_target
|
|
47
|
+
value = (self.live_capabilities or {}).get("appium:udid")
|
|
48
|
+
return value if isinstance(value, str) and value else None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def device_ip(self) -> str | None:
|
|
52
|
+
"""Best-effort address, preferring host IP before live device/config IP fields."""
|
|
53
|
+
if self.host_ip:
|
|
54
|
+
return self.host_ip
|
|
55
|
+
live_value = (self.live_capabilities or {}).get("appium:deviceIP")
|
|
56
|
+
if isinstance(live_value, str) and live_value:
|
|
57
|
+
return live_value
|
|
58
|
+
config_value = (self.config or {}).get("ip")
|
|
59
|
+
return config_value if isinstance(config_value, str) and config_value else None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def platform_name(self) -> str:
|
|
63
|
+
return self.platform_label or self.platform_id
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _string_value(payload: dict[str, Any], key: str, *, default: str | None = None) -> str:
|
|
67
|
+
value = payload.get(key, default)
|
|
68
|
+
if isinstance(value, str) and value:
|
|
69
|
+
return value
|
|
70
|
+
raise ValueError(f"Allocated device payload is missing {key}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _optional_string_value(payload: dict[str, Any], key: str) -> str | None:
|
|
74
|
+
value = payload.get(key)
|
|
75
|
+
return value if isinstance(value, str) and value else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _needs_device_detail(payload: dict[str, Any]) -> bool:
|
|
79
|
+
return any(payload.get(key) is None for key in ("name", "device_type", "connection_type", "manufacturer", "model"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _merge_device_detail(payload: dict[str, Any], detail: dict[str, Any]) -> dict[str, Any]:
|
|
83
|
+
merged = dict(payload)
|
|
84
|
+
for key in ("name", "device_type", "connection_type", "manufacturer", "model"):
|
|
85
|
+
if merged.get(key) is None and detail.get(key) is not None:
|
|
86
|
+
merged[key] = detail[key]
|
|
87
|
+
if merged.get("host_ip") is None and detail.get("ip_address") is not None:
|
|
88
|
+
merged["host_ip"] = detail["ip_address"]
|
|
89
|
+
return merged
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def hydrate_allocated_device(
|
|
93
|
+
claim_response: dict[str, Any],
|
|
94
|
+
*,
|
|
95
|
+
run_id: str,
|
|
96
|
+
client: GridFleetClient,
|
|
97
|
+
fetch_config: bool = True,
|
|
98
|
+
fetch_capabilities: bool = False,
|
|
99
|
+
) -> AllocatedDevice:
|
|
100
|
+
"""Combine a claim response with optional static config and live capabilities."""
|
|
101
|
+
payload = dict(claim_response)
|
|
102
|
+
device_id = _string_value(payload, "device_id")
|
|
103
|
+
if _needs_device_detail(payload):
|
|
104
|
+
payload = _merge_device_detail(payload, client.get_device(device_id))
|
|
105
|
+
|
|
106
|
+
connection_target = _optional_string_value(payload, "connection_target")
|
|
107
|
+
config = client.get_device_config(connection_target) if fetch_config and connection_target else None
|
|
108
|
+
live_capabilities = client.get_device_capabilities(device_id) if fetch_capabilities else None
|
|
109
|
+
|
|
110
|
+
return AllocatedDevice(
|
|
111
|
+
run_id=run_id,
|
|
112
|
+
device_id=device_id,
|
|
113
|
+
identity_value=_string_value(payload, "identity_value"),
|
|
114
|
+
name=_string_value(payload, "name", default=device_id),
|
|
115
|
+
pack_id=_string_value(payload, "pack_id"),
|
|
116
|
+
platform_id=_string_value(payload, "platform_id"),
|
|
117
|
+
platform_label=_optional_string_value(payload, "platform_label"),
|
|
118
|
+
os_version=_optional_string_value(payload, "os_version"),
|
|
119
|
+
connection_target=connection_target,
|
|
120
|
+
host_ip=_optional_string_value(payload, "host_ip"),
|
|
121
|
+
device_type=_string_value(payload, "device_type"),
|
|
122
|
+
connection_type=_string_value(payload, "connection_type"),
|
|
123
|
+
manufacturer=_optional_string_value(payload, "manufacturer"),
|
|
124
|
+
model=_optional_string_value(payload, "model"),
|
|
125
|
+
claimed_by=_string_value(payload, "claimed_by"),
|
|
126
|
+
claimed_at=_string_value(payload, "claimed_at"),
|
|
127
|
+
config=config,
|
|
128
|
+
live_capabilities=live_capabilities,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def hydrate_allocated_device_from_driver(
|
|
133
|
+
allocated: AllocatedDevice,
|
|
134
|
+
driver: Any,
|
|
135
|
+
*,
|
|
136
|
+
client: GridFleetClient,
|
|
137
|
+
) -> AllocatedDevice:
|
|
138
|
+
"""Refresh live capabilities from a running Appium driver session."""
|
|
139
|
+
capabilities = getattr(driver, "capabilities", None)
|
|
140
|
+
if isinstance(capabilities, dict):
|
|
141
|
+
live_capabilities = dict(capabilities)
|
|
142
|
+
else:
|
|
143
|
+
live_capabilities = client.get_device_capabilities(allocated.device_id)
|
|
144
|
+
return replace(allocated, live_capabilities=live_capabilities)
|
|
@@ -38,14 +38,16 @@ class NoClaimableDevicesError(RuntimeError):
|
|
|
38
38
|
message: str,
|
|
39
39
|
*,
|
|
40
40
|
retry_after_sec: int,
|
|
41
|
+
run_id: str = "",
|
|
41
42
|
next_available_at: str | None = None,
|
|
42
43
|
) -> None:
|
|
44
|
+
self.run_id = run_id
|
|
43
45
|
self.retry_after_sec = retry_after_sec
|
|
44
46
|
self.next_available_at = next_available_at
|
|
45
47
|
super().__init__(message)
|
|
46
48
|
|
|
47
49
|
|
|
48
|
-
def _raise_for_status(resp: Any) -> None:
|
|
50
|
+
def _raise_for_status(resp: Any, *, run_id: str) -> None:
|
|
49
51
|
if resp.status_code == 409:
|
|
50
52
|
try:
|
|
51
53
|
payload = resp.json()
|
|
@@ -61,12 +63,40 @@ def _raise_for_status(resp: Any) -> None:
|
|
|
61
63
|
next_available_at = details.get("next_available_at")
|
|
62
64
|
raise NoClaimableDevicesError(
|
|
63
65
|
str(error.get("message") or "No unclaimed devices available in this run"),
|
|
66
|
+
run_id=run_id,
|
|
64
67
|
retry_after_sec=retry_after,
|
|
65
68
|
next_available_at=next_available_at if isinstance(next_available_at, str) else None,
|
|
66
69
|
)
|
|
67
70
|
resp.raise_for_status()
|
|
68
71
|
|
|
69
72
|
|
|
73
|
+
def _is_safe_release_conflict(resp: Any) -> bool:
|
|
74
|
+
try:
|
|
75
|
+
payload = resp.json()
|
|
76
|
+
except Exception:
|
|
77
|
+
return False
|
|
78
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
79
|
+
return isinstance(detail, str) and "is not claimed" in detail.lower()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _query_params(values: dict[str, Any]) -> list[tuple[str, str | int | float | bool | None]]:
|
|
83
|
+
params: list[tuple[str, str | int | float | bool | None]] = []
|
|
84
|
+
for key, value in values.items():
|
|
85
|
+
if value is None:
|
|
86
|
+
continue
|
|
87
|
+
if isinstance(value, bool):
|
|
88
|
+
params.append((key, str(value).lower()))
|
|
89
|
+
else:
|
|
90
|
+
params.append((key, str(value)))
|
|
91
|
+
return params
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _raise_or_warn(operation: str, suppress_errors: bool, exc: Exception) -> None:
|
|
95
|
+
if not suppress_errors:
|
|
96
|
+
raise exc
|
|
97
|
+
logger.warning("Failed to %s with GridFleet: %s", operation, exc)
|
|
98
|
+
|
|
99
|
+
|
|
70
100
|
class HeartbeatThread(threading.Thread):
|
|
71
101
|
"""Background thread that sends periodic heartbeat pings for an active test run."""
|
|
72
102
|
|
|
@@ -115,6 +145,66 @@ class GridFleetClient:
|
|
|
115
145
|
self.base_url = base_url.rstrip("/")
|
|
116
146
|
self._auth = auth if auth is not None else _default_auth()
|
|
117
147
|
|
|
148
|
+
def list_devices(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
pack_id: str | None = None,
|
|
152
|
+
platform_id: str | None = None,
|
|
153
|
+
status: str | None = None,
|
|
154
|
+
host_id: str | None = None,
|
|
155
|
+
identity_value: str | None = None,
|
|
156
|
+
connection_target: str | None = None,
|
|
157
|
+
device_type: str | None = None,
|
|
158
|
+
connection_type: str | None = None,
|
|
159
|
+
os_version: str | None = None,
|
|
160
|
+
search: str | None = None,
|
|
161
|
+
hardware_health_status: str | None = None,
|
|
162
|
+
hardware_telemetry_state: str | None = None,
|
|
163
|
+
needs_attention: bool | None = None,
|
|
164
|
+
tags: dict[str, str] | None = None,
|
|
165
|
+
) -> list[dict[str, Any]]:
|
|
166
|
+
"""List devices with backend filter passthrough."""
|
|
167
|
+
params = _query_params(
|
|
168
|
+
{
|
|
169
|
+
"pack_id": pack_id,
|
|
170
|
+
"platform_id": platform_id,
|
|
171
|
+
"status": status,
|
|
172
|
+
"host_id": host_id,
|
|
173
|
+
"identity_value": identity_value,
|
|
174
|
+
"connection_target": connection_target,
|
|
175
|
+
"device_type": device_type,
|
|
176
|
+
"connection_type": connection_type,
|
|
177
|
+
"os_version": os_version,
|
|
178
|
+
"search": search,
|
|
179
|
+
"hardware_health_status": hardware_health_status,
|
|
180
|
+
"hardware_telemetry_state": hardware_telemetry_state,
|
|
181
|
+
"needs_attention": needs_attention,
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
if tags:
|
|
185
|
+
params.extend((f"tags.{key}", value) for key, value in tags.items())
|
|
186
|
+
resp = httpx.get(
|
|
187
|
+
f"{self.base_url}/devices",
|
|
188
|
+
params=params,
|
|
189
|
+
timeout=10,
|
|
190
|
+
auth=self._auth,
|
|
191
|
+
)
|
|
192
|
+
resp.raise_for_status()
|
|
193
|
+
payload = resp.json()
|
|
194
|
+
if isinstance(payload, dict) and isinstance(payload.get("items"), list):
|
|
195
|
+
return cast("list[dict[str, Any]]", payload["items"])
|
|
196
|
+
return cast("list[dict[str, Any]]", payload)
|
|
197
|
+
|
|
198
|
+
def get_device(self, device_id: str) -> dict[str, Any]:
|
|
199
|
+
"""Fetch one device detail row by backend device id."""
|
|
200
|
+
resp = httpx.get(
|
|
201
|
+
f"{self.base_url}/devices/{device_id}",
|
|
202
|
+
timeout=10,
|
|
203
|
+
auth=self._auth,
|
|
204
|
+
)
|
|
205
|
+
resp.raise_for_status()
|
|
206
|
+
return cast("dict[str, Any]", resp.json())
|
|
207
|
+
|
|
118
208
|
def get_device_config(self, connection_target: str, reveal: bool = True) -> dict[str, Any]:
|
|
119
209
|
"""Fetch device config by looking up the current runtime connection target."""
|
|
120
210
|
resp = httpx.get(
|
|
@@ -211,7 +301,7 @@ class GridFleetClient:
|
|
|
211
301
|
timeout=10,
|
|
212
302
|
auth=self._auth,
|
|
213
303
|
)
|
|
214
|
-
_raise_for_status(resp)
|
|
304
|
+
_raise_for_status(resp, run_id=run_id)
|
|
215
305
|
return cast("dict[str, Any]", resp.json())
|
|
216
306
|
|
|
217
307
|
def release_device(self, run_id: str, *, device_id: str, worker_id: str) -> None:
|
|
@@ -223,6 +313,24 @@ class GridFleetClient:
|
|
|
223
313
|
)
|
|
224
314
|
resp.raise_for_status()
|
|
225
315
|
|
|
316
|
+
def release_device_safe(self, run_id: str, *, device_id: str, worker_id: str) -> bool:
|
|
317
|
+
"""Release a claim while tolerating already-terminal run/device states.
|
|
318
|
+
|
|
319
|
+
Returns True when the manager accepts the release, False when the run is
|
|
320
|
+
gone or the claim is explicitly already unclaimed. Other HTTP errors,
|
|
321
|
+
including wrong-worker conflicts, still raise.
|
|
322
|
+
"""
|
|
323
|
+
resp = httpx.post(
|
|
324
|
+
f"{self.base_url}/runs/{run_id}/release",
|
|
325
|
+
json={"device_id": device_id, "worker_id": worker_id},
|
|
326
|
+
timeout=10,
|
|
327
|
+
auth=self._auth,
|
|
328
|
+
)
|
|
329
|
+
if resp.status_code == 404 or (resp.status_code == 409 and _is_safe_release_conflict(resp)):
|
|
330
|
+
return False
|
|
331
|
+
resp.raise_for_status()
|
|
332
|
+
return True
|
|
333
|
+
|
|
226
334
|
def release_device_with_cooldown(
|
|
227
335
|
self,
|
|
228
336
|
run_id: str,
|
|
@@ -274,6 +382,100 @@ class GridFleetClient:
|
|
|
274
382
|
resp.raise_for_status()
|
|
275
383
|
return cast("dict[str, Any]", resp.json())
|
|
276
384
|
|
|
385
|
+
def register_session(
|
|
386
|
+
self,
|
|
387
|
+
*,
|
|
388
|
+
session_id: str,
|
|
389
|
+
test_name: str | None = None,
|
|
390
|
+
device_id: str | None = None,
|
|
391
|
+
connection_target: str | None = None,
|
|
392
|
+
status: str = "running",
|
|
393
|
+
requested_pack_id: str | None = None,
|
|
394
|
+
requested_platform_id: str | None = None,
|
|
395
|
+
requested_device_type: str | None = None,
|
|
396
|
+
requested_connection_type: str | None = None,
|
|
397
|
+
requested_capabilities: dict[str, Any] | None = None,
|
|
398
|
+
error_type: str | None = None,
|
|
399
|
+
error_message: str | None = None,
|
|
400
|
+
run_id: str | None = None,
|
|
401
|
+
suppress_errors: bool = True,
|
|
402
|
+
) -> dict[str, Any] | None:
|
|
403
|
+
"""Register a Grid/Appium session with the manager."""
|
|
404
|
+
try:
|
|
405
|
+
resp = httpx.post(
|
|
406
|
+
f"{self.base_url}/sessions",
|
|
407
|
+
json={
|
|
408
|
+
"session_id": session_id,
|
|
409
|
+
"test_name": test_name,
|
|
410
|
+
"device_id": device_id,
|
|
411
|
+
"connection_target": connection_target,
|
|
412
|
+
"status": status,
|
|
413
|
+
"requested_pack_id": requested_pack_id,
|
|
414
|
+
"requested_platform_id": requested_platform_id,
|
|
415
|
+
"requested_device_type": requested_device_type,
|
|
416
|
+
"requested_connection_type": requested_connection_type,
|
|
417
|
+
"requested_capabilities": requested_capabilities,
|
|
418
|
+
"error_type": error_type,
|
|
419
|
+
"error_message": error_message,
|
|
420
|
+
"run_id": run_id,
|
|
421
|
+
},
|
|
422
|
+
timeout=5,
|
|
423
|
+
auth=self._auth,
|
|
424
|
+
)
|
|
425
|
+
resp.raise_for_status()
|
|
426
|
+
except (httpx.HTTPError, TypeError, ValueError) as exc:
|
|
427
|
+
_raise_or_warn("register session", suppress_errors, exc)
|
|
428
|
+
return None
|
|
429
|
+
return cast("dict[str, Any]", resp.json())
|
|
430
|
+
|
|
431
|
+
def update_session_status(
|
|
432
|
+
self,
|
|
433
|
+
session_id: str,
|
|
434
|
+
status: str,
|
|
435
|
+
*,
|
|
436
|
+
suppress_errors: bool = True,
|
|
437
|
+
) -> dict[str, Any] | None:
|
|
438
|
+
"""Update a registered session status."""
|
|
439
|
+
try:
|
|
440
|
+
resp = httpx.patch(
|
|
441
|
+
f"{self.base_url}/sessions/{session_id}/status",
|
|
442
|
+
json={"status": status},
|
|
443
|
+
timeout=5,
|
|
444
|
+
auth=self._auth,
|
|
445
|
+
)
|
|
446
|
+
resp.raise_for_status()
|
|
447
|
+
except (httpx.HTTPError, TypeError, ValueError) as exc:
|
|
448
|
+
_raise_or_warn("report session status", suppress_errors, exc)
|
|
449
|
+
return None
|
|
450
|
+
return cast("dict[str, Any]", resp.json())
|
|
451
|
+
|
|
452
|
+
def register_session_from_driver(
|
|
453
|
+
self,
|
|
454
|
+
driver: Any,
|
|
455
|
+
*,
|
|
456
|
+
test_name: str | None = None,
|
|
457
|
+
run_id: str | None = None,
|
|
458
|
+
suppress_errors: bool = True,
|
|
459
|
+
) -> dict[str, Any] | None:
|
|
460
|
+
"""Extract session metadata from an Appium driver and register it."""
|
|
461
|
+
capabilities = getattr(driver, "capabilities", {})
|
|
462
|
+
if not isinstance(capabilities, dict):
|
|
463
|
+
capabilities = {}
|
|
464
|
+
session_id = getattr(driver, "session_id", None)
|
|
465
|
+
if not isinstance(session_id, str) or not session_id:
|
|
466
|
+
raise RuntimeError("Created Appium driver did not expose a session ID")
|
|
467
|
+
device_id = capabilities.get("appium:gridfleet:deviceId") or capabilities.get("gridfleet:deviceId")
|
|
468
|
+
connection_target = capabilities.get("appium:udid") or capabilities.get("appium:deviceName")
|
|
469
|
+
return self.register_session(
|
|
470
|
+
session_id=session_id,
|
|
471
|
+
test_name=test_name,
|
|
472
|
+
device_id=device_id if isinstance(device_id, str) and device_id else None,
|
|
473
|
+
connection_target=connection_target if isinstance(connection_target, str) and connection_target else None,
|
|
474
|
+
requested_capabilities=capabilities,
|
|
475
|
+
run_id=run_id,
|
|
476
|
+
suppress_errors=suppress_errors,
|
|
477
|
+
)
|
|
478
|
+
|
|
277
479
|
def complete_run(self, run_id: str) -> None:
|
|
278
480
|
httpx.post(
|
|
279
481
|
f"{self.base_url}/runs/{run_id}/complete",
|