gridfleet-testkit 0.6.0__tar.gz → 0.7.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.6.0 → gridfleet_testkit-0.7.0}/CHANGELOG.md +29 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/PKG-INFO +1 -1
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/__init__.py +4 -8
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/allocation.py +4 -15
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/appium.py +1 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/client.py +14 -182
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/pytest_plugin.py +11 -1
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/sessions.py +16 -2
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/pyproject.toml +1 -1
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_allocation.py +20 -38
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_appium.py +1 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_client.py +3 -501
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_package_metadata.py +0 -2
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_pytest_plugin.py +1 -0
- gridfleet_testkit-0.7.0/tests/test_pytest_plugin_grid_run_id_injection.py +56 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_sessions.py +1 -1
- gridfleet_testkit-0.7.0/tests/test_sessions_resolve_device_handle.py +25 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/uv.lock +4 -4
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/.gitignore +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/README.md +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/__init__.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/_example_helpers.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/assets/hello-world.zip +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/test_android_mobile_screenshot.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/test_android_tv_screenshot.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/test_firetv_screenshot.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/test_ios_simulator_screenshot.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/test_roku_screenshot.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/test_roku_sideload_screenshot.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/examples/test_tvos_screenshot.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/gridfleet_testkit/py.typed +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_client_test_data.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_docs_contract.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_driver_agnostic_guard.py +0 -0
- {gridfleet_testkit-0.6.0 → gridfleet_testkit-0.7.0}/tests/test_pytest_plugin_test_data.py +0 -0
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the GridFleet testkit (`gridfleet-testkit` on PyPI) are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.7.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.6.0...gridfleet-testkit-v0.7.0) (2026-05-11)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### ⚠ BREAKING CHANGES
|
|
9
|
+
|
|
10
|
+
* **testkit:** AllocatedDevice hydration now accepts device handles, not claim payloads.
|
|
11
|
+
* **testkit:** GridFleetClient claim/release helpers and NoClaimableDevicesError are removed.
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **testkit:** drop claim release client api ([8d1f295](https://github.com/quidow/gridfleet/commit/8d1f29504e6d640ce9d85c70594a515868045be8))
|
|
16
|
+
* **testkit:** inject grid run id capability ([b4ae38c](https://github.com/quidow/gridfleet/commit/b4ae38ce9969ae325bffdb9716ba7d6c52a699ac))
|
|
17
|
+
* **testkit:** resolve device handle by connection target ([22b4299](https://github.com/quidow/gridfleet/commit/22b4299d6bc4302503a2c2b6f17018ceebc03084))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* **agent:** release adapter-owned doctor refactor ([#165](https://github.com/quidow/gridfleet/issues/165)) ([f3ae257](https://github.com/quidow/gridfleet/commit/f3ae25787e2c8ef926312f11d2313c6513f8bfa9))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Dependencies
|
|
26
|
+
|
|
27
|
+
* **deps:** bump urllib3 from 2.6.3 to 2.7.0 in /testkit ([#186](https://github.com/quidow/gridfleet/issues/186)) ([dd7a1df](https://github.com/quidow/gridfleet/commit/dd7a1df8fb29ae76f618abab947db2027471b536))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Code Refactoring
|
|
31
|
+
|
|
32
|
+
* **testkit:** drop claim response allocation metadata ([f0eec3e](https://github.com/quidow/gridfleet/commit/f0eec3e9c9f804439241ddbbb56b196bc467effd))
|
|
33
|
+
|
|
5
34
|
## [0.6.0](https://github.com/quidow/gridfleet/compare/gridfleet-testkit-v0.5.0...gridfleet-testkit-v0.6.0) (2026-05-10)
|
|
6
35
|
|
|
7
36
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gridfleet-testkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`device_config` values returned by the manager are verbatim; the testkit no
|
|
4
4
|
longer distinguishes between masked and revealed payloads. Code that wants
|
|
5
|
-
the live Appium-side config can use
|
|
6
|
-
`claim_device(include=("config",))` or `client.get_device_config(connection_target)`.
|
|
5
|
+
the live Appium-side config can use `client.get_device_config(connection_target)`.
|
|
7
6
|
|
|
8
7
|
Environment variables read by the client:
|
|
9
8
|
|
|
@@ -32,31 +31,27 @@ from .appium import (
|
|
|
32
31
|
get_device_test_data_for_driver,
|
|
33
32
|
)
|
|
34
33
|
from .client import (
|
|
35
|
-
CooldownResult,
|
|
36
34
|
GridFleetClient,
|
|
37
35
|
HeartbeatThread,
|
|
38
|
-
NoClaimableDevicesError,
|
|
39
36
|
ReserveCapabilitiesUnsupportedError,
|
|
40
37
|
UnknownIncludeError,
|
|
41
38
|
_default_api_url,
|
|
42
39
|
_default_grid_url,
|
|
43
40
|
register_run_cleanup,
|
|
44
41
|
)
|
|
45
|
-
from .sessions import build_error_session_payload
|
|
42
|
+
from .sessions import build_error_session_payload, resolve_device_handle_from_driver
|
|
46
43
|
|
|
47
44
|
try:
|
|
48
45
|
__version__ = version("gridfleet-testkit")
|
|
49
46
|
except PackageNotFoundError:
|
|
50
|
-
__version__ = "0.
|
|
47
|
+
__version__ = "0.7.0"
|
|
51
48
|
|
|
52
49
|
__all__ = [
|
|
53
50
|
"GRIDFLEET_API_URL",
|
|
54
51
|
"GRID_URL",
|
|
55
52
|
"AllocatedDevice",
|
|
56
|
-
"CooldownResult",
|
|
57
53
|
"GridFleetClient",
|
|
58
54
|
"HeartbeatThread",
|
|
59
|
-
"NoClaimableDevicesError",
|
|
60
55
|
"ReserveCapabilitiesUnsupportedError",
|
|
61
56
|
"UnavailableInclude",
|
|
62
57
|
"UnknownIncludeError",
|
|
@@ -70,6 +65,7 @@ __all__ = [
|
|
|
70
65
|
"hydrate_allocated_device",
|
|
71
66
|
"hydrate_allocated_device_from_driver",
|
|
72
67
|
"register_run_cleanup",
|
|
68
|
+
"resolve_device_handle_from_driver",
|
|
73
69
|
]
|
|
74
70
|
|
|
75
71
|
|
|
@@ -19,7 +19,7 @@ class UnavailableInclude:
|
|
|
19
19
|
|
|
20
20
|
@dataclass(frozen=True)
|
|
21
21
|
class AllocatedDevice:
|
|
22
|
-
"""Combined view of
|
|
22
|
+
"""Combined view of an allocated device, ready for driver creation."""
|
|
23
23
|
|
|
24
24
|
run_id: str
|
|
25
25
|
device_id: str
|
|
@@ -35,8 +35,6 @@ class AllocatedDevice:
|
|
|
35
35
|
connection_type: str
|
|
36
36
|
manufacturer: str | None
|
|
37
37
|
model: str | None
|
|
38
|
-
claimed_by: str
|
|
39
|
-
claimed_at: str
|
|
40
38
|
config: dict[str, Any] | None
|
|
41
39
|
live_capabilities: dict[str, Any] | None
|
|
42
40
|
test_data: dict[str, Any] | None = None
|
|
@@ -115,7 +113,7 @@ def _parse_unavailable_includes(payload: dict[str, Any]) -> tuple[UnavailableInc
|
|
|
115
113
|
|
|
116
114
|
|
|
117
115
|
def hydrate_allocated_device(
|
|
118
|
-
|
|
116
|
+
device_handle: dict[str, Any],
|
|
119
117
|
*,
|
|
120
118
|
run_id: str,
|
|
121
119
|
client: GridFleetClient,
|
|
@@ -123,15 +121,8 @@ def hydrate_allocated_device(
|
|
|
123
121
|
fetch_capabilities: bool = False,
|
|
124
122
|
fetch_test_data: bool = False,
|
|
125
123
|
) -> AllocatedDevice:
|
|
126
|
-
"""Combine a
|
|
127
|
-
|
|
128
|
-
Accepts a ``ClaimResponse`` payload from ``GridFleetClient.claim_device`` only.
|
|
129
|
-
Reserve responses (``RunCreateResponse.devices`` entries before any worker
|
|
130
|
-
has claimed) lack ``claimed_by`` / ``claimed_at`` and will raise
|
|
131
|
-
``ValueError``. Iterate ``reserve_response['devices']`` and call
|
|
132
|
-
``claim_device`` per worker before hydrating.
|
|
133
|
-
"""
|
|
134
|
-
payload = dict(claim_response)
|
|
124
|
+
"""Combine a device handle with optional static config and live capabilities."""
|
|
125
|
+
payload = dict(device_handle)
|
|
135
126
|
device_id = _string_value(payload, "device_id")
|
|
136
127
|
if _needs_device_detail(payload):
|
|
137
128
|
payload = _merge_device_detail(payload, client.get_device(device_id))
|
|
@@ -178,8 +169,6 @@ def hydrate_allocated_device(
|
|
|
178
169
|
connection_type=_string_value(payload, "connection_type"),
|
|
179
170
|
manufacturer=_optional_string_value(payload, "manufacturer"),
|
|
180
171
|
model=_optional_string_value(payload, "model"),
|
|
181
|
-
claimed_by=_string_value(payload, "claimed_by"),
|
|
182
|
-
claimed_at=_string_value(payload, "claimed_at"),
|
|
183
172
|
config=config,
|
|
184
173
|
live_capabilities=live_capabilities,
|
|
185
174
|
test_data=test_data,
|
|
@@ -98,6 +98,7 @@ def build_appium_options(
|
|
|
98
98
|
from appium.options.common import AppiumOptions # noqa: PLC0415
|
|
99
99
|
|
|
100
100
|
params = dict(capabilities or {})
|
|
101
|
+
params.setdefault("gridfleet:run_id", os.environ.get("GRIDFLEET_RUN_ID", "free"))
|
|
101
102
|
explicit_platform_name = params.get("platformName")
|
|
102
103
|
if explicit_platform_name is not None and (pack_id is not None or platform_id is not None):
|
|
103
104
|
raise ValueError("Use either pack_id/platform_id or the raw platformName capability, not both.")
|
|
@@ -7,10 +7,9 @@ import logging
|
|
|
7
7
|
import os
|
|
8
8
|
import signal
|
|
9
9
|
import threading
|
|
10
|
-
import time
|
|
11
10
|
from collections.abc import Callable
|
|
12
|
-
from
|
|
13
|
-
from
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
12
|
+
from urllib.parse import quote
|
|
14
13
|
|
|
15
14
|
if TYPE_CHECKING:
|
|
16
15
|
from collections.abc import Sequence
|
|
@@ -40,31 +39,6 @@ def __getattr__(name: str) -> str:
|
|
|
40
39
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
41
40
|
|
|
42
41
|
|
|
43
|
-
class CooldownSetResult(TypedDict):
|
|
44
|
-
"""Response shape when a cooldown is applied without reaching the escalation threshold."""
|
|
45
|
-
|
|
46
|
-
status: Literal["cooldown_set"]
|
|
47
|
-
reservation: dict[str, Any]
|
|
48
|
-
device_operational_state: str
|
|
49
|
-
device_hold: str | None
|
|
50
|
-
retry_after_sec: int
|
|
51
|
-
excluded_until: str
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class CooldownEscalatedResult(TypedDict):
|
|
55
|
-
"""Response shape when the cooldown count reaches the threshold and the device is escalated to maintenance."""
|
|
56
|
-
|
|
57
|
-
status: Literal["maintenance_escalated"]
|
|
58
|
-
reservation: dict[str, Any]
|
|
59
|
-
device_operational_state: str
|
|
60
|
-
device_hold: str | None
|
|
61
|
-
cooldown_count: int
|
|
62
|
-
threshold: int
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
CooldownResult = CooldownSetResult | CooldownEscalatedResult
|
|
66
|
-
|
|
67
|
-
|
|
68
42
|
def _default_auth() -> httpx.BasicAuth | None:
|
|
69
43
|
"""Build httpx Basic auth from env vars, or return None when unset."""
|
|
70
44
|
username = os.getenv("GRIDFLEET_TESTKIT_USERNAME")
|
|
@@ -74,23 +48,6 @@ def _default_auth() -> httpx.BasicAuth | None:
|
|
|
74
48
|
return httpx.BasicAuth(username, password)
|
|
75
49
|
|
|
76
50
|
|
|
77
|
-
class NoClaimableDevicesError(RuntimeError):
|
|
78
|
-
"""Raised when the manager reports that no run devices are claimable yet."""
|
|
79
|
-
|
|
80
|
-
def __init__(
|
|
81
|
-
self,
|
|
82
|
-
message: str,
|
|
83
|
-
*,
|
|
84
|
-
retry_after_sec: int,
|
|
85
|
-
run_id: str = "",
|
|
86
|
-
next_available_at: str | None = None,
|
|
87
|
-
) -> None:
|
|
88
|
-
self.run_id = run_id
|
|
89
|
-
self.retry_after_sec = retry_after_sec
|
|
90
|
-
self.next_available_at = next_available_at
|
|
91
|
-
super().__init__(message)
|
|
92
|
-
|
|
93
|
-
|
|
94
51
|
class UnknownIncludeError(ValueError):
|
|
95
52
|
"""Backend rejected one or more `?include=` keys."""
|
|
96
53
|
|
|
@@ -103,29 +60,11 @@ class ReserveCapabilitiesUnsupportedError(ValueError):
|
|
|
103
60
|
"""`?include=capabilities` is not supported on reserve."""
|
|
104
61
|
|
|
105
62
|
def __init__(self, message: str | None = None) -> None:
|
|
106
|
-
super().__init__(message or "include=capabilities is not supported on reserve
|
|
63
|
+
super().__init__(message or "include=capabilities is not supported on reserve")
|
|
107
64
|
|
|
108
65
|
|
|
109
66
|
def _raise_for_status(resp: Any, *, run_id: str) -> None:
|
|
110
|
-
|
|
111
|
-
try:
|
|
112
|
-
payload = resp.json()
|
|
113
|
-
except Exception:
|
|
114
|
-
payload = None
|
|
115
|
-
error = payload.get("error") if isinstance(payload, dict) else None
|
|
116
|
-
if isinstance(error, dict):
|
|
117
|
-
details = error.get("details")
|
|
118
|
-
if isinstance(details, dict) and details.get("error") == "no_claimable_devices":
|
|
119
|
-
retry_after = details.get("retry_after_sec")
|
|
120
|
-
if not isinstance(retry_after, int):
|
|
121
|
-
retry_after = 5
|
|
122
|
-
next_available_at = details.get("next_available_at")
|
|
123
|
-
raise NoClaimableDevicesError(
|
|
124
|
-
str(error.get("message") or "No unclaimed devices available in this run"),
|
|
125
|
-
run_id=run_id,
|
|
126
|
-
retry_after_sec=retry_after,
|
|
127
|
-
next_available_at=next_available_at if isinstance(next_available_at, str) else None,
|
|
128
|
-
)
|
|
67
|
+
del run_id
|
|
129
68
|
if resp.status_code == 422:
|
|
130
69
|
try:
|
|
131
70
|
payload = resp.json()
|
|
@@ -144,15 +83,6 @@ def _raise_for_status(resp: Any, *, run_id: str) -> None:
|
|
|
144
83
|
resp.raise_for_status()
|
|
145
84
|
|
|
146
85
|
|
|
147
|
-
def _is_safe_release_conflict(resp: Any) -> bool:
|
|
148
|
-
try:
|
|
149
|
-
payload = resp.json()
|
|
150
|
-
except Exception:
|
|
151
|
-
return False
|
|
152
|
-
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
153
|
-
return isinstance(detail, str) and "is not claimed" in detail.lower()
|
|
154
|
-
|
|
155
|
-
|
|
156
86
|
def _query_params(values: dict[str, Any]) -> list[tuple[str, str | int | float | bool | None]]:
|
|
157
87
|
params: list[tuple[str, str | int | float | bool | None]] = []
|
|
158
88
|
for key, value in values.items():
|
|
@@ -191,20 +121,6 @@ def _raise_or_warn(operation: str, suppress_errors: bool, exc: Exception) -> Non
|
|
|
191
121
|
logger.warning("Failed to %s with GridFleet: %s", operation, exc)
|
|
192
122
|
|
|
193
123
|
|
|
194
|
-
def _parse_next_available_delay(next_available_at: str | None) -> int | None:
|
|
195
|
-
if not next_available_at:
|
|
196
|
-
return None
|
|
197
|
-
value = next_available_at.replace("Z", "+00:00")
|
|
198
|
-
try:
|
|
199
|
-
parsed = datetime.fromisoformat(value)
|
|
200
|
-
except ValueError:
|
|
201
|
-
return None
|
|
202
|
-
if parsed.tzinfo is None:
|
|
203
|
-
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
204
|
-
delay = int(parsed.timestamp() - time.time())
|
|
205
|
-
return max(1, delay) if delay > 0 else 1
|
|
206
|
-
|
|
207
|
-
|
|
208
124
|
class HeartbeatThread(threading.Thread):
|
|
209
125
|
"""Background thread that sends periodic heartbeat pings for an active test run."""
|
|
210
126
|
|
|
@@ -313,19 +229,20 @@ class GridFleetClient:
|
|
|
313
229
|
resp.raise_for_status()
|
|
314
230
|
return cast("dict[str, Any]", resp.json())
|
|
315
231
|
|
|
316
|
-
def
|
|
317
|
-
"""
|
|
232
|
+
def get_device_by_connection_target(self, target: str) -> dict[str, Any]:
|
|
233
|
+
"""Fetch one device detail row by runtime connection target."""
|
|
318
234
|
resp = httpx.get(
|
|
319
|
-
f"{self.base_url}/devices",
|
|
320
|
-
params={"connection_target": connection_target},
|
|
235
|
+
f"{self.base_url}/devices/by-connection-target/{quote(target, safe='')}",
|
|
321
236
|
timeout=10,
|
|
322
237
|
auth=self._auth,
|
|
323
238
|
)
|
|
324
239
|
resp.raise_for_status()
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
240
|
+
return cast("dict[str, Any]", resp.json())
|
|
241
|
+
|
|
242
|
+
def resolve_device_id_by_connection_target(self, connection_target: str) -> str:
|
|
243
|
+
"""Look up the backend device id for a runtime connection target."""
|
|
244
|
+
device = self.get_device_by_connection_target(connection_target)
|
|
245
|
+
return cast("str", device["id"])
|
|
329
246
|
|
|
330
247
|
def get_device_config(self, connection_target: str) -> dict[str, Any]:
|
|
331
248
|
"""Fetch device config by looking up the current runtime connection target."""
|
|
@@ -403,9 +320,7 @@ class GridFleetClient:
|
|
|
403
320
|
"""Reserve devices for a test run and return the manager response."""
|
|
404
321
|
include_tuple = _normalize_include(include)
|
|
405
322
|
if include_tuple is not None and "capabilities" in include_tuple:
|
|
406
|
-
raise ReserveCapabilitiesUnsupportedError(
|
|
407
|
-
"include='capabilities' is not supported on reserve; use include on claim_device instead"
|
|
408
|
-
)
|
|
323
|
+
raise ReserveCapabilitiesUnsupportedError("include='capabilities' is not supported on reserve")
|
|
409
324
|
resp = httpx.post(
|
|
410
325
|
f"{self.base_url}/runs",
|
|
411
326
|
json={
|
|
@@ -449,89 +364,6 @@ class GridFleetClient:
|
|
|
449
364
|
resp.raise_for_status()
|
|
450
365
|
return cast("dict[str, Any]", resp.json())
|
|
451
366
|
|
|
452
|
-
def claim_device(
|
|
453
|
-
self,
|
|
454
|
-
run_id: str,
|
|
455
|
-
*,
|
|
456
|
-
worker_id: str,
|
|
457
|
-
include: Sequence[str] | None = None,
|
|
458
|
-
) -> dict[str, Any]:
|
|
459
|
-
include_tuple = _normalize_include(include)
|
|
460
|
-
resp = httpx.post(
|
|
461
|
-
f"{self.base_url}/runs/{run_id}/claim",
|
|
462
|
-
json={"worker_id": worker_id},
|
|
463
|
-
params=_include_param(include_tuple),
|
|
464
|
-
timeout=10,
|
|
465
|
-
auth=self._auth,
|
|
466
|
-
)
|
|
467
|
-
_raise_for_status(resp, run_id=run_id)
|
|
468
|
-
return cast("dict[str, Any]", resp.json())
|
|
469
|
-
|
|
470
|
-
def release_device(self, run_id: str, *, device_id: str, worker_id: str) -> None:
|
|
471
|
-
resp = httpx.post(
|
|
472
|
-
f"{self.base_url}/runs/{run_id}/release",
|
|
473
|
-
json={"device_id": device_id, "worker_id": worker_id},
|
|
474
|
-
timeout=10,
|
|
475
|
-
auth=self._auth,
|
|
476
|
-
)
|
|
477
|
-
resp.raise_for_status()
|
|
478
|
-
|
|
479
|
-
def release_device_safe(self, run_id: str, *, device_id: str, worker_id: str) -> bool:
|
|
480
|
-
"""Release a claim while tolerating already-terminal run/device states.
|
|
481
|
-
|
|
482
|
-
Returns True when the manager accepts the release, False when the run is
|
|
483
|
-
gone or the claim is explicitly already unclaimed. Other HTTP errors,
|
|
484
|
-
including wrong-worker conflicts, still raise.
|
|
485
|
-
"""
|
|
486
|
-
resp = httpx.post(
|
|
487
|
-
f"{self.base_url}/runs/{run_id}/release",
|
|
488
|
-
json={"device_id": device_id, "worker_id": worker_id},
|
|
489
|
-
timeout=10,
|
|
490
|
-
auth=self._auth,
|
|
491
|
-
)
|
|
492
|
-
if resp.status_code == 404 or (resp.status_code == 409 and _is_safe_release_conflict(resp)):
|
|
493
|
-
return False
|
|
494
|
-
resp.raise_for_status()
|
|
495
|
-
return True
|
|
496
|
-
|
|
497
|
-
def release_device_with_cooldown(
|
|
498
|
-
self,
|
|
499
|
-
run_id: str,
|
|
500
|
-
*,
|
|
501
|
-
device_id: str,
|
|
502
|
-
worker_id: str,
|
|
503
|
-
reason: str,
|
|
504
|
-
ttl_seconds: int,
|
|
505
|
-
) -> CooldownResult:
|
|
506
|
-
resp = httpx.post(
|
|
507
|
-
f"{self.base_url}/runs/{run_id}/devices/{device_id}/release-with-cooldown",
|
|
508
|
-
json={"worker_id": worker_id, "reason": reason, "ttl_seconds": ttl_seconds},
|
|
509
|
-
timeout=10,
|
|
510
|
-
auth=self._auth,
|
|
511
|
-
)
|
|
512
|
-
resp.raise_for_status()
|
|
513
|
-
return cast("CooldownResult", resp.json())
|
|
514
|
-
|
|
515
|
-
def claim_device_with_retry(
|
|
516
|
-
self,
|
|
517
|
-
run_id: str,
|
|
518
|
-
*,
|
|
519
|
-
worker_id: str,
|
|
520
|
-
max_wait_sec: int = 300,
|
|
521
|
-
include: Sequence[str] | None = None,
|
|
522
|
-
) -> dict[str, Any]:
|
|
523
|
-
deadline = time.monotonic() + max_wait_sec
|
|
524
|
-
include_tuple = _normalize_include(include)
|
|
525
|
-
while True:
|
|
526
|
-
try:
|
|
527
|
-
return self.claim_device(run_id, worker_id=worker_id, include=include_tuple)
|
|
528
|
-
except NoClaimableDevicesError as exc:
|
|
529
|
-
remaining = deadline - time.monotonic()
|
|
530
|
-
if remaining <= 0:
|
|
531
|
-
raise
|
|
532
|
-
sleep_for = _parse_next_available_delay(exc.next_available_at) or exc.retry_after_sec
|
|
533
|
-
time.sleep(min(sleep_for, max(1, int(remaining))))
|
|
534
|
-
|
|
535
367
|
def report_preparation_failure(
|
|
536
368
|
self,
|
|
537
369
|
run_id: str,
|
|
@@ -13,7 +13,7 @@ from .appium import (
|
|
|
13
13
|
get_device_test_data_for_driver,
|
|
14
14
|
)
|
|
15
15
|
from .client import GridFleetClient, _default_grid_url
|
|
16
|
-
from .sessions import build_error_session_payload
|
|
16
|
+
from .sessions import build_error_session_payload, resolve_device_handle_from_driver
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
19
19
|
from collections.abc import Generator
|
|
@@ -147,6 +147,16 @@ def device_test_data(appium_driver: Any, gridfleet_client: GridFleetClient) -> d
|
|
|
147
147
|
raise RuntimeError("unreachable: pytest.skip did not raise") from exc
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
@pytest.fixture
|
|
151
|
+
def device_handle(appium_driver: Any, gridfleet_client: GridFleetClient) -> dict[str, Any]:
|
|
152
|
+
"""Fetch the canonical manager device row after Grid assigns a runtime target."""
|
|
153
|
+
try:
|
|
154
|
+
return resolve_device_handle_from_driver(appium_driver, client=gridfleet_client)
|
|
155
|
+
except RuntimeError as exc:
|
|
156
|
+
pytest.skip(str(exc))
|
|
157
|
+
raise RuntimeError("unreachable: pytest.skip did not raise") from exc
|
|
158
|
+
|
|
159
|
+
|
|
150
160
|
def _gridfleet_worker_id(request: pytest.FixtureRequest) -> str:
|
|
151
161
|
"""Return pytest-xdist worker id, or controller for non-worker processes."""
|
|
152
162
|
workerinput = getattr(request.config, "workerinput", None)
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .client import GridFleetClient
|
|
9
|
+
|
|
10
|
+
__all__ = ["build_error_session_payload", "resolve_device_handle_from_driver"]
|
|
8
11
|
|
|
9
12
|
_KNOWN_DEVICE_TYPES = {"real_device", "emulator", "simulator"}
|
|
10
13
|
_KNOWN_CONNECTION_TYPES = {"usb", "network", "virtual"}
|
|
@@ -74,3 +77,14 @@ def build_error_session_payload(
|
|
|
74
77
|
"error_type": type(exc).__name__,
|
|
75
78
|
"error_message": str(exc),
|
|
76
79
|
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_device_handle_from_driver(driver: Any, *, client: GridFleetClient) -> dict[str, Any]:
|
|
83
|
+
"""Resolve a canonical device handle from a running WebDriver session."""
|
|
84
|
+
caps = getattr(driver, "capabilities", None) or {}
|
|
85
|
+
if not isinstance(caps, dict):
|
|
86
|
+
raise RuntimeError("driver capabilities missing appium:udid; cannot resolve device handle")
|
|
87
|
+
target = caps.get("appium:udid") or caps.get("appium:deviceName")
|
|
88
|
+
if not target:
|
|
89
|
+
raise RuntimeError("driver capabilities missing appium:udid; cannot resolve device handle")
|
|
90
|
+
return client.get_device_by_connection_target(str(target))
|