gridfleet-testkit 0.1.0__tar.gz → 0.2.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 (26) hide show
  1. gridfleet_testkit-0.2.0/CHANGELOG.md +20 -0
  2. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/PKG-INFO +33 -1
  3. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/README.md +32 -0
  4. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/gridfleet_testkit/__init__.py +10 -2
  5. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/gridfleet_testkit/client.py +74 -1
  6. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/pyproject.toml +1 -1
  7. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/tests/test_client.py +113 -0
  8. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/uv.lock +1 -1
  9. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/.gitignore +0 -0
  10. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/__init__.py +0 -0
  11. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/_example_helpers.py +0 -0
  12. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/assets/hello-world.zip +0 -0
  13. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/test_android_mobile_screenshot.py +0 -0
  14. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/test_android_tv_screenshot.py +0 -0
  15. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/test_firetv_screenshot.py +0 -0
  16. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/test_ios_simulator_screenshot.py +0 -0
  17. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/test_roku_screenshot.py +0 -0
  18. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/test_roku_sideload_screenshot.py +0 -0
  19. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/examples/test_tvos_screenshot.py +0 -0
  20. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/gridfleet_testkit/appium.py +0 -0
  21. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/gridfleet_testkit/py.typed +0 -0
  22. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/gridfleet_testkit/pytest_plugin.py +0 -0
  23. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/tests/test_appium.py +0 -0
  24. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/tests/test_driver_agnostic_guard.py +0 -0
  25. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/tests/test_package_metadata.py +0 -0
  26. {gridfleet_testkit-0.1.0 → gridfleet_testkit-0.2.0}/tests/test_pytest_plugin.py +0 -0
@@ -0,0 +1,20 @@
1
+ # Changelog — GridFleet Testkit
2
+
3
+ All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
4
+
5
+ ## [0.2.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.1.0...gridfleet-testkit-v0.2.0) (2026-05-03)
6
+
7
+
8
+ ### Features
9
+
10
+ * **testkit:** add run-scoped device cooldowns ([#54](https://github.com/quidow/gridfleet/issues/54)) ([6163dc9](https://github.com/quidow/gridfleet/commit/6163dc959334e933b43c20a99ad4edcbdae6c98b))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * idempotent device release after lifecycle cleanup ([#12](https://github.com/quidow/gridfleet/issues/12)) ([7a98a5d](https://github.com/quidow/gridfleet/commit/7a98a5d18330150aab0a852f6b894d1d53de257c))
16
+
17
+ ## 0.1.0 — Initial Public Preview
18
+
19
+ - Initial public preview of the GridFleet testkit.
20
+ - 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.1.0
3
+ Version: 0.2.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
@@ -165,6 +165,10 @@ finally:
165
165
  | `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
166
166
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
167
167
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
168
+ | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
169
+ | `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
+ | `GridFleetClient.release_device(run_id, device_id=..., worker_id=...)` | Release a worker claim without cooldown |
171
+ | `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 |
168
172
  | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
169
173
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
170
174
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
@@ -215,6 +219,34 @@ client.signal_active(run_id)
215
219
 
216
220
  Use `count` for exact reservations. Use `allocation: "all_available"` when CI should reserve every currently eligible matching device and size its worker pool from `len(run["devices"])`.
217
221
 
222
+ ### Worker Claim With Cooldown
223
+
224
+ ```python
225
+ from gridfleet_testkit import GridFleetClient
226
+
227
+ client = GridFleetClient("http://manager-ip:8000/api")
228
+
229
+ claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
230
+ device_id = claim["device_id"]
231
+
232
+ try:
233
+ # Create the Appium session and run test setup for this worker.
234
+ ...
235
+ except RuntimeError as exc:
236
+ client.release_device_with_cooldown(
237
+ run_id,
238
+ device_id=device_id,
239
+ worker_id="gw0",
240
+ reason=str(exc),
241
+ ttl_seconds=60,
242
+ )
243
+ raise
244
+ else:
245
+ client.release_device(run_id, device_id=device_id, worker_id="gw0")
246
+ ```
247
+
248
+ 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
+
218
250
  ## Examples
219
251
 
220
252
  Baseline screenshot examples:
@@ -131,6 +131,10 @@ finally:
131
131
  | `GridFleetClient.get_device_config(connection_target, reveal=True)` | Look up a device by runtime connection target and fetch its config |
132
132
  | `GridFleetClient.get_driver_pack_catalog()` | Fetch enabled driver-pack catalog data for Appium platform selection |
133
133
  | `GridFleetClient.reserve_devices(...)` | Create a run/reservation and return the manager response |
134
+ | `GridFleetClient.claim_device(run_id, worker_id=...)` | Claim one reserved device for a worker |
135
+ | `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
+ | `GridFleetClient.release_device(run_id, device_id=..., worker_id=...)` | Release a worker claim without cooldown |
137
+ | `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 |
134
138
  | `GridFleetClient.signal_ready(run_id)` | Move a run to `ready` |
135
139
  | `GridFleetClient.signal_active(run_id)` | Move a run to `active` |
136
140
  | `GridFleetClient.heartbeat(run_id)` | Send a run heartbeat and read current state |
@@ -181,6 +185,34 @@ client.signal_active(run_id)
181
185
 
182
186
  Use `count` for exact reservations. Use `allocation: "all_available"` when CI should reserve every currently eligible matching device and size its worker pool from `len(run["devices"])`.
183
187
 
188
+ ### Worker Claim With Cooldown
189
+
190
+ ```python
191
+ from gridfleet_testkit import GridFleetClient
192
+
193
+ client = GridFleetClient("http://manager-ip:8000/api")
194
+
195
+ claim = client.claim_device_with_retry(run_id, worker_id="gw0", max_wait_sec=300)
196
+ device_id = claim["device_id"]
197
+
198
+ try:
199
+ # Create the Appium session and run test setup for this worker.
200
+ ...
201
+ except RuntimeError as exc:
202
+ client.release_device_with_cooldown(
203
+ run_id,
204
+ device_id=device_id,
205
+ worker_id="gw0",
206
+ reason=str(exc),
207
+ ttl_seconds=60,
208
+ )
209
+ raise
210
+ else:
211
+ client.release_device(run_id, device_id=device_id, worker_id="gw0")
212
+ ```
213
+
214
+ 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
+
184
216
  ## Examples
185
217
 
186
218
  Baseline screenshot examples:
@@ -8,18 +8,26 @@ from .appium import (
8
8
  get_connection_target_from_driver,
9
9
  get_device_config_for_driver,
10
10
  )
11
- from .client import GRID_URL, GRIDFLEET_API_URL, GridFleetClient, HeartbeatThread, register_run_cleanup
11
+ from .client import (
12
+ GRID_URL,
13
+ GRIDFLEET_API_URL,
14
+ GridFleetClient,
15
+ HeartbeatThread,
16
+ NoClaimableDevicesError,
17
+ register_run_cleanup,
18
+ )
12
19
 
13
20
  try:
14
21
  __version__ = version("gridfleet-testkit")
15
22
  except PackageNotFoundError:
16
- __version__ = "0.0.0"
23
+ __version__ = "0.2.0"
17
24
 
18
25
  __all__ = [
19
26
  "GRIDFLEET_API_URL",
20
27
  "GRID_URL",
21
28
  "GridFleetClient",
22
29
  "HeartbeatThread",
30
+ "NoClaimableDevicesError",
23
31
  "__version__",
24
32
  "build_appium_options",
25
33
  "create_appium_driver",
@@ -8,6 +8,7 @@ import logging
8
8
  import os
9
9
  import signal
10
10
  import threading
11
+ import time
11
12
  from typing import Any, cast
12
13
 
13
14
  import httpx
@@ -29,6 +30,43 @@ def _default_auth() -> httpx.BasicAuth | None:
29
30
  return httpx.BasicAuth(username, password)
30
31
 
31
32
 
33
+ class NoClaimableDevicesError(RuntimeError):
34
+ """Raised when the manager reports that no run devices are claimable yet."""
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ *,
40
+ retry_after_sec: int,
41
+ next_available_at: str | None = None,
42
+ ) -> None:
43
+ self.retry_after_sec = retry_after_sec
44
+ self.next_available_at = next_available_at
45
+ super().__init__(message)
46
+
47
+
48
+ def _raise_for_status(resp: Any) -> None:
49
+ if resp.status_code == 409:
50
+ try:
51
+ payload = resp.json()
52
+ except Exception:
53
+ payload = None
54
+ error = payload.get("error") if isinstance(payload, dict) else None
55
+ if isinstance(error, dict):
56
+ details = error.get("details")
57
+ if isinstance(details, dict) and details.get("error") == "no_claimable_devices":
58
+ retry_after = details.get("retry_after_sec")
59
+ if not isinstance(retry_after, int):
60
+ retry_after = 5
61
+ next_available_at = details.get("next_available_at")
62
+ raise NoClaimableDevicesError(
63
+ str(error.get("message") or "No unclaimed devices available in this run"),
64
+ retry_after_sec=retry_after,
65
+ next_available_at=next_available_at if isinstance(next_available_at, str) else None,
66
+ )
67
+ resp.raise_for_status()
68
+
69
+
32
70
  class HeartbeatThread(threading.Thread):
33
71
  """Background thread that sends periodic heartbeat pings for an active test run."""
34
72
 
@@ -173,7 +211,7 @@ class GridFleetClient:
173
211
  timeout=10,
174
212
  auth=self._auth,
175
213
  )
176
- resp.raise_for_status()
214
+ _raise_for_status(resp)
177
215
  return cast("dict[str, Any]", resp.json())
178
216
 
179
217
  def release_device(self, run_id: str, *, device_id: str, worker_id: str) -> None:
@@ -185,6 +223,41 @@ class GridFleetClient:
185
223
  )
186
224
  resp.raise_for_status()
187
225
 
226
+ def release_device_with_cooldown(
227
+ self,
228
+ run_id: str,
229
+ *,
230
+ device_id: str,
231
+ worker_id: str,
232
+ reason: str,
233
+ ttl_seconds: int,
234
+ ) -> dict[str, Any]:
235
+ resp = httpx.post(
236
+ f"{self.base_url}/runs/{run_id}/devices/{device_id}/release-with-cooldown",
237
+ json={"worker_id": worker_id, "reason": reason, "ttl_seconds": ttl_seconds},
238
+ timeout=10,
239
+ auth=self._auth,
240
+ )
241
+ resp.raise_for_status()
242
+ return cast("dict[str, Any]", resp.json())
243
+
244
+ def claim_device_with_retry(
245
+ self,
246
+ run_id: str,
247
+ *,
248
+ worker_id: str,
249
+ max_wait_sec: int = 300,
250
+ ) -> dict[str, Any]:
251
+ deadline = time.monotonic() + max_wait_sec
252
+ while True:
253
+ try:
254
+ return self.claim_device(run_id, worker_id=worker_id)
255
+ except NoClaimableDevicesError as exc:
256
+ remaining = deadline - time.monotonic()
257
+ if remaining <= 0:
258
+ raise
259
+ time.sleep(min(exc.retry_after_sec, max(1, int(remaining))))
260
+
188
261
  def report_preparation_failure(
189
262
  self,
190
263
  run_id: str,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gridfleet-testkit"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Supported pytest and run-orchestration helpers for GridFleet integrations"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -9,6 +9,7 @@ import pytest
9
9
  from gridfleet_testkit.client import (
10
10
  GridFleetClient,
11
11
  HeartbeatThread,
12
+ NoClaimableDevicesError,
12
13
  _default_auth,
13
14
  register_run_cleanup,
14
15
  )
@@ -256,6 +257,84 @@ def test_claim_device_calls_api(monkeypatch):
256
257
  }
257
258
 
258
259
 
260
+ def test_claim_device_raises_no_claimable_devices_with_retry_metadata(monkeypatch):
261
+ def fake_post(
262
+ url: str,
263
+ *,
264
+ json: dict[str, Any],
265
+ timeout: int,
266
+ auth: Any = None,
267
+ ) -> DummyResponse:
268
+ return DummyResponse(
269
+ {
270
+ "error": {
271
+ "code": "CONFLICT",
272
+ "message": "No unclaimed devices available in this run",
273
+ "request_id": "req-1",
274
+ "details": {
275
+ "error": "no_claimable_devices",
276
+ "retry_after_sec": 7,
277
+ "next_available_at": "2026-05-03T20:00:00Z",
278
+ },
279
+ }
280
+ },
281
+ status_code=409,
282
+ )
283
+
284
+ monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
285
+
286
+ client = GridFleetClient("http://manager/api")
287
+ with pytest.raises(NoClaimableDevicesError) as exc_info:
288
+ client.claim_device("run-123", worker_id="gw0")
289
+
290
+ assert exc_info.value.retry_after_sec == 7
291
+ assert exc_info.value.next_available_at == "2026-05-03T20:00:00Z"
292
+
293
+
294
+ def test_claim_device_with_retry_sleeps_and_retries(monkeypatch):
295
+ calls: list[str] = []
296
+ sleeps: list[int] = []
297
+ responses = iter(
298
+ [
299
+ DummyResponse(
300
+ {
301
+ "error": {
302
+ "code": "CONFLICT",
303
+ "message": "No unclaimed devices available in this run",
304
+ "request_id": "req-1",
305
+ "details": {"error": "no_claimable_devices", "retry_after_sec": 2, "next_available_at": None},
306
+ }
307
+ },
308
+ status_code=409,
309
+ ),
310
+ DummyResponse({"device_id": "dev-1", "claimed_by": "gw0", "claimed_at": "2026-05-03T20:00:00Z"}),
311
+ ]
312
+ )
313
+
314
+ def fake_post(
315
+ url: str,
316
+ *,
317
+ json: dict[str, Any],
318
+ timeout: int,
319
+ auth: Any = None,
320
+ ) -> DummyResponse:
321
+ calls.append(url)
322
+ return next(responses)
323
+
324
+ monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
325
+ monkeypatch.setattr("gridfleet_testkit.client.time.sleep", lambda seconds: sleeps.append(seconds))
326
+
327
+ client = GridFleetClient("http://manager/api")
328
+ result = client.claim_device_with_retry("run-123", worker_id="gw0", max_wait_sec=5)
329
+
330
+ assert result["device_id"] == "dev-1"
331
+ assert sleeps == [2]
332
+ assert calls == [
333
+ "http://manager/api/runs/run-123/claim",
334
+ "http://manager/api/runs/run-123/claim",
335
+ ]
336
+
337
+
259
338
  def test_release_device_calls_api(monkeypatch):
260
339
  recorded: dict[str, Any] = {}
261
340
 
@@ -283,6 +362,40 @@ def test_release_device_calls_api(monkeypatch):
283
362
  }
284
363
 
285
364
 
365
+ def test_release_device_with_cooldown_calls_api(monkeypatch):
366
+ recorded: dict[str, Any] = {}
367
+
368
+ def fake_post(
369
+ url: str,
370
+ *,
371
+ json: dict[str, Any],
372
+ timeout: int,
373
+ auth: Any = None,
374
+ ) -> DummyResponse:
375
+ recorded["url"] = url
376
+ recorded["json"] = json
377
+ recorded["timeout"] = timeout
378
+ return DummyResponse({"status": "cooldown_set", "excluded_until": "2026-05-03T20:00:00Z"})
379
+
380
+ monkeypatch.setattr("gridfleet_testkit.client.httpx.post", fake_post)
381
+
382
+ client = GridFleetClient("http://manager/api")
383
+ result = client.release_device_with_cooldown(
384
+ "run-123",
385
+ device_id="dev-1",
386
+ worker_id="gw0",
387
+ reason="appium launch timeout",
388
+ ttl_seconds=60,
389
+ )
390
+
391
+ assert result["status"] == "cooldown_set"
392
+ assert recorded == {
393
+ "url": "http://manager/api/runs/run-123/devices/dev-1/release-with-cooldown",
394
+ "json": {"worker_id": "gw0", "reason": "appium launch timeout", "ttl_seconds": 60},
395
+ "timeout": 10,
396
+ }
397
+
398
+
286
399
  def test_release_device_raises_for_conflict(monkeypatch):
287
400
  def fake_post(
288
401
  url: str,
@@ -102,7 +102,7 @@ wheels = [
102
102
 
103
103
  [[package]]
104
104
  name = "gridfleet-testkit"
105
- version = "0.1.0"
105
+ version = "0.2.0"
106
106
  source = { editable = "." }
107
107
  dependencies = [
108
108
  { name = "httpx" },