pytest-devant-cloud 0.1.0__py3-none-any.whl
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.
- pytest_devant_cloud/__init__.py +12 -0
- pytest_devant_cloud/client.py +238 -0
- pytest_devant_cloud/mapping.py +374 -0
- pytest_devant_cloud/plugin.py +464 -0
- pytest_devant_cloud-0.1.0.dist-info/METADATA +189 -0
- pytest_devant_cloud-0.1.0.dist-info/RECORD +9 -0
- pytest_devant_cloud-0.1.0.dist-info/WHEEL +4 -0
- pytest_devant_cloud-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_devant_cloud-0.1.0.dist-info/licenses/LICENSE +88 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""pytest plugin that streams runs, results, and step trees to Devant Cloud.
|
|
2
|
+
|
|
3
|
+
Public API is intentionally tiny — the plugin is registered via the
|
|
4
|
+
`pytest11` entry-point group and configured via env vars or CLI flags. See
|
|
5
|
+
the README for the contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import mapping
|
|
9
|
+
from .client import DevqClient
|
|
10
|
+
|
|
11
|
+
__all__ = ["DevqClient", "mapping"]
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""HTTP client for Devant Cloud's `/v1/runs/*` endpoints.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around `httpx` with bearer auth, retry on 5xx/429/network
|
|
4
|
+
errors, and the resolve-test-case fallback chain. The plugin owns the
|
|
5
|
+
lifecycle (createRun → submitResults → completeRun); the client owns the
|
|
6
|
+
wire-level concerns (auth, retries, JSON encoding).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .mapping import CIInfo
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── data classes ─────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ResolvedCase:
|
|
26
|
+
"""Result of resolveTestCase — matches JS reporter-core's ResolvedCase."""
|
|
27
|
+
|
|
28
|
+
id: int
|
|
29
|
+
key: str
|
|
30
|
+
minted: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── client ───────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DevqClient:
|
|
37
|
+
"""Devant Cloud REST client. One instance per pytest session."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
api_url: str,
|
|
42
|
+
api_token: str,
|
|
43
|
+
project_id: int,
|
|
44
|
+
*,
|
|
45
|
+
timeout: float = 30.0,
|
|
46
|
+
max_retries: int = 3,
|
|
47
|
+
) -> None:
|
|
48
|
+
# Strip trailing slash so url joins don't double up.
|
|
49
|
+
self.api_url = api_url.rstrip("/")
|
|
50
|
+
self.project_id = project_id
|
|
51
|
+
self.max_retries = max_retries
|
|
52
|
+
# Re-use one Connection pool for the whole session — pytest can
|
|
53
|
+
# easily emit hundreds of POSTs in a fast suite, and TCP setup
|
|
54
|
+
# cost dwarfs the request itself otherwise.
|
|
55
|
+
self._http = httpx.Client(
|
|
56
|
+
headers={
|
|
57
|
+
"Authorization": f"Bearer {api_token}",
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"Accept": "application/json",
|
|
60
|
+
},
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ── lifecycle ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._http.close()
|
|
68
|
+
|
|
69
|
+
def __enter__(self) -> "DevqClient":
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *_: object) -> None:
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
# ── low-level request with retry ─────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def _request(
|
|
78
|
+
self,
|
|
79
|
+
method: str,
|
|
80
|
+
path: str,
|
|
81
|
+
*,
|
|
82
|
+
json_body: Any | None = None,
|
|
83
|
+
) -> httpx.Response:
|
|
84
|
+
"""One request with bounded retry.
|
|
85
|
+
|
|
86
|
+
Retries on 5xx, 429, and network errors with exponential backoff
|
|
87
|
+
(0.25s, 0.5s, 1s). 4xx errors fail fast so callers see schema
|
|
88
|
+
problems immediately.
|
|
89
|
+
"""
|
|
90
|
+
url = f"{self.api_url}{path}"
|
|
91
|
+
last_exc: Exception | None = None
|
|
92
|
+
for attempt in range(self.max_retries + 1):
|
|
93
|
+
try:
|
|
94
|
+
resp = self._http.request(method, url, json=json_body)
|
|
95
|
+
except httpx.RequestError as exc:
|
|
96
|
+
last_exc = exc
|
|
97
|
+
if attempt >= self.max_retries:
|
|
98
|
+
raise
|
|
99
|
+
time.sleep(0.25 * (2 ** attempt))
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if resp.status_code >= 500 or resp.status_code == 429:
|
|
103
|
+
if attempt >= self.max_retries:
|
|
104
|
+
resp.raise_for_status()
|
|
105
|
+
time.sleep(0.25 * (2 ** attempt))
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
return resp
|
|
110
|
+
|
|
111
|
+
# Should be unreachable — every path above either returns or raises.
|
|
112
|
+
if last_exc:
|
|
113
|
+
raise last_exc
|
|
114
|
+
raise RuntimeError("retry loop exited without response")
|
|
115
|
+
|
|
116
|
+
# ── API surface ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def create_run(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
name: str,
|
|
122
|
+
framework: str = "pytest",
|
|
123
|
+
ci: CIInfo | None = None,
|
|
124
|
+
mode: str = "automated",
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
payload: dict[str, Any] = {
|
|
127
|
+
"project_id": self.project_id,
|
|
128
|
+
"mode": mode,
|
|
129
|
+
"name": name,
|
|
130
|
+
"framework": framework,
|
|
131
|
+
}
|
|
132
|
+
if ci:
|
|
133
|
+
payload["ci"] = ci
|
|
134
|
+
resp = self._request("POST", "/v1/runs", json_body=payload)
|
|
135
|
+
return resp.json()
|
|
136
|
+
|
|
137
|
+
def resolve_test_case(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
explicit_key: str | None,
|
|
141
|
+
full_name: str,
|
|
142
|
+
) -> ResolvedCase:
|
|
143
|
+
"""Mirrors reporter-core/src/resolve.ts.
|
|
144
|
+
|
|
145
|
+
Order: explicit @KEY → exact name search → auto-create.
|
|
146
|
+
"""
|
|
147
|
+
# 1. explicit @KEY.
|
|
148
|
+
if explicit_key:
|
|
149
|
+
try:
|
|
150
|
+
resp = self._http.get(
|
|
151
|
+
f"{self.api_url}/v1/test-cases/by-key/{quote(explicit_key)}",
|
|
152
|
+
params={"project_id": self.project_id},
|
|
153
|
+
)
|
|
154
|
+
if resp.status_code == 200:
|
|
155
|
+
body = resp.json()
|
|
156
|
+
return ResolvedCase(
|
|
157
|
+
id=int(body["id"]),
|
|
158
|
+
key=str(body["key"]),
|
|
159
|
+
minted=False,
|
|
160
|
+
)
|
|
161
|
+
except httpx.HTTPError:
|
|
162
|
+
# Fall through to search → mint.
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
# 2. exact name search.
|
|
166
|
+
try:
|
|
167
|
+
resp = self._http.get(
|
|
168
|
+
f"{self.api_url}/v1/test-cases",
|
|
169
|
+
params={"project_id": self.project_id, "search": full_name},
|
|
170
|
+
)
|
|
171
|
+
if resp.status_code == 200:
|
|
172
|
+
items = resp.json().get("items", [])
|
|
173
|
+
for it in items:
|
|
174
|
+
if it.get("name") == full_name:
|
|
175
|
+
return ResolvedCase(
|
|
176
|
+
id=int(it["id"]),
|
|
177
|
+
key=str(it["key"]),
|
|
178
|
+
minted=False,
|
|
179
|
+
)
|
|
180
|
+
except httpx.HTTPError:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
# 3. auto-create.
|
|
184
|
+
resp = self._request(
|
|
185
|
+
"POST",
|
|
186
|
+
"/v1/test-cases",
|
|
187
|
+
json_body={
|
|
188
|
+
"project_id": self.project_id,
|
|
189
|
+
"name": full_name,
|
|
190
|
+
"is_automated": True,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
body = resp.json()
|
|
194
|
+
return ResolvedCase(
|
|
195
|
+
id=int(body["id"]),
|
|
196
|
+
key=str(body["key"]),
|
|
197
|
+
minted=True,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def submit_results(
|
|
201
|
+
self,
|
|
202
|
+
run_id: int,
|
|
203
|
+
results: list[dict[str, Any]],
|
|
204
|
+
) -> list[dict[str, Any]]:
|
|
205
|
+
resp = self._request(
|
|
206
|
+
"POST",
|
|
207
|
+
f"/v1/runs/{run_id}/results",
|
|
208
|
+
json_body={"results": results},
|
|
209
|
+
)
|
|
210
|
+
body = resp.json()
|
|
211
|
+
return body.get("results", [])
|
|
212
|
+
|
|
213
|
+
def submit_coverage(
|
|
214
|
+
self,
|
|
215
|
+
run_id: int,
|
|
216
|
+
summary: dict[str, Any],
|
|
217
|
+
) -> None:
|
|
218
|
+
self._request(
|
|
219
|
+
"POST",
|
|
220
|
+
f"/v1/runs/{run_id}/coverage",
|
|
221
|
+
json_body=summary,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def complete_run(
|
|
225
|
+
self,
|
|
226
|
+
run_id: int,
|
|
227
|
+
*,
|
|
228
|
+
status: str = "complete",
|
|
229
|
+
html_report_url: str | None = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
payload: dict[str, Any] = {"status": status}
|
|
232
|
+
if html_report_url is not None:
|
|
233
|
+
payload["html_report_url"] = html_report_url
|
|
234
|
+
self._request(
|
|
235
|
+
"POST",
|
|
236
|
+
f"/v1/runs/{run_id}/complete",
|
|
237
|
+
json_body=payload,
|
|
238
|
+
)
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Pure functions for translating pytest's runtime data into Devant Cloud's
|
|
2
|
+
wire shapes.
|
|
3
|
+
|
|
4
|
+
These are kept side-effect-free (no HTTP, no logging) so they can be unit-
|
|
5
|
+
tested without spinning up a server. The plugin module composes them with
|
|
6
|
+
the HTTP client to actually stream events.
|
|
7
|
+
|
|
8
|
+
Mirrors the JS helpers in `@devant-net/reporter-core` (status mapping, key
|
|
9
|
+
extraction, CI detection) — keep the two in sync if you change either.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any, Literal, TypedDict
|
|
17
|
+
|
|
18
|
+
# ── types (mirror packages/reporter-core/src/types.ts) ────────────────────
|
|
19
|
+
|
|
20
|
+
ResultStatus = Literal["pass", "fail", "blocked", "skipped", "in_progress"]
|
|
21
|
+
StepStatus = Literal["pass", "fail", "blocked", "skipped"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SubmitStep(TypedDict, total=False):
|
|
25
|
+
title: str
|
|
26
|
+
status: StepStatus
|
|
27
|
+
duration_ms: int
|
|
28
|
+
category: str | None
|
|
29
|
+
error_message: str | None
|
|
30
|
+
children: list["SubmitStep"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CIInfo(TypedDict, total=False):
|
|
34
|
+
provider: str
|
|
35
|
+
branch: str
|
|
36
|
+
commit_sha: str
|
|
37
|
+
commit_msg: str
|
|
38
|
+
author: str
|
|
39
|
+
pr_number: int
|
|
40
|
+
pr_url: str
|
|
41
|
+
repo_url: str
|
|
42
|
+
ci_link: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# API caps error_message at 8192 chars (see api/src/runs.ts schema). We
|
|
46
|
+
# truncate client-side so an oversized longrepr doesn't 4xx the whole
|
|
47
|
+
# submit-results call.
|
|
48
|
+
_ERROR_MESSAGE_MAX = 8192
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── status normalization ─────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def map_status(outcome: str) -> ResultStatus:
|
|
55
|
+
"""Pytest TestReport.outcome → Devant Cloud ResultStatus.
|
|
56
|
+
|
|
57
|
+
pytest emits a small fixed vocabulary on `TestReport.outcome`:
|
|
58
|
+
* passed / failed / skipped — normal outcomes
|
|
59
|
+
* error — setup/teardown error; we treat it as a failure since the
|
|
60
|
+
case did not pass
|
|
61
|
+
|
|
62
|
+
We also accept "xfailed"/"xpassed" as pytest internally sometimes uses
|
|
63
|
+
these for marked-xfail tests. xfail-that-failed is the *expected*
|
|
64
|
+
outcome → pass-equivalent. xfail-that-passed is unexpected and
|
|
65
|
+
surfaces as a fail so dashboards flag it.
|
|
66
|
+
"""
|
|
67
|
+
table: dict[str, ResultStatus] = {
|
|
68
|
+
"passed": "pass",
|
|
69
|
+
"failed": "fail",
|
|
70
|
+
"error": "fail",
|
|
71
|
+
"skipped": "skipped",
|
|
72
|
+
"xfailed": "pass",
|
|
73
|
+
"xpassed": "fail",
|
|
74
|
+
}
|
|
75
|
+
return table.get(outcome, "blocked")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def map_step_status(outcome: str) -> StepStatus:
|
|
79
|
+
"""Step-level subset of map_status (no in_progress for steps)."""
|
|
80
|
+
table: dict[str, StepStatus] = {
|
|
81
|
+
"passed": "pass",
|
|
82
|
+
"failed": "fail",
|
|
83
|
+
"error": "fail",
|
|
84
|
+
"skipped": "skipped",
|
|
85
|
+
}
|
|
86
|
+
return table.get(outcome, "blocked")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── @KEY extraction ──────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
# Same shape as the JS regex in reporter-core/src/resolve.ts:
|
|
92
|
+
# prefix: uppercase alpha, then any uppercase alphanumeric
|
|
93
|
+
# suffix: uppercase alphanumeric
|
|
94
|
+
# Match against individual tokens after splitting on whitespace.
|
|
95
|
+
_KEY_TOKEN_RE = re.compile(r"^@?([A-Z][A-Z0-9]*-[A-Z0-9]+)$")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_key(*candidates: str | None) -> str | None:
|
|
99
|
+
"""Pluck the first `@KEY` style token out of the candidate strings.
|
|
100
|
+
|
|
101
|
+
Callers pass anything that might contain a key — marker args, the
|
|
102
|
+
nodeid, the test function's title path, etc. Returns None if no
|
|
103
|
+
token matches.
|
|
104
|
+
"""
|
|
105
|
+
for c in candidates:
|
|
106
|
+
if not c:
|
|
107
|
+
continue
|
|
108
|
+
for tok in c.split():
|
|
109
|
+
m = _KEY_TOKEN_RE.match(tok)
|
|
110
|
+
if m is not None:
|
|
111
|
+
return m.group(1)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── full_name ────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def full_name(nodeid: str) -> str:
|
|
119
|
+
"""Display name + identity key for the test case row.
|
|
120
|
+
|
|
121
|
+
pytest nodeids already include parametrize variants and class scope
|
|
122
|
+
(`tests/test_x.py::TestThing::test_y[a-1]`), so we use them verbatim —
|
|
123
|
+
just trimmed.
|
|
124
|
+
"""
|
|
125
|
+
return nodeid.strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── error formatting ─────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def format_error(longrepr: Any) -> str | None:
|
|
132
|
+
"""Turn pytest's longrepr (string-or-rich-object) into a bounded string.
|
|
133
|
+
|
|
134
|
+
pytest's `TestReport.longrepr` is either None, a string, or a rich
|
|
135
|
+
ReprExceptionInfo-like object with a `__str__` that renders the
|
|
136
|
+
traceback. We coerce everything to a string and truncate to the API's
|
|
137
|
+
8192-char cap (see api/src/runs.ts attempt schema).
|
|
138
|
+
"""
|
|
139
|
+
if longrepr is None:
|
|
140
|
+
return None
|
|
141
|
+
s = longrepr if isinstance(longrepr, str) else str(longrepr)
|
|
142
|
+
if not s:
|
|
143
|
+
return None
|
|
144
|
+
if len(s) > _ERROR_MESSAGE_MAX:
|
|
145
|
+
return s[: _ERROR_MESSAGE_MAX - 1] + "…" # ellipsis
|
|
146
|
+
return s
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── duration ─────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def duration_ms(seconds: float | int | None) -> int:
|
|
153
|
+
"""Pytest reports durations in fractional seconds. The API wants int ms."""
|
|
154
|
+
if seconds is None:
|
|
155
|
+
return 0
|
|
156
|
+
if seconds <= 0:
|
|
157
|
+
return 0
|
|
158
|
+
return int(round(seconds * 1000))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── phase aggregation ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def overall_status_from_phases(phases: dict[str, Any]) -> ResultStatus:
|
|
165
|
+
"""Collapse setup/call/teardown reports into a single ResultStatus.
|
|
166
|
+
|
|
167
|
+
Decision order:
|
|
168
|
+
1. setup.failed → blocked (the test never ran, so we don't call it
|
|
169
|
+
a fail — that would taint failure-rate charts with infra issues).
|
|
170
|
+
2. call.failed → fail
|
|
171
|
+
3. teardown.failed → fail (the test code ran, but resources leaked
|
|
172
|
+
or finalizers blew up; surface it)
|
|
173
|
+
4. call.skipped → skipped
|
|
174
|
+
5. setup or call passed → pass
|
|
175
|
+
6. anything else → blocked
|
|
176
|
+
"""
|
|
177
|
+
setup = phases.get("setup")
|
|
178
|
+
call = phases.get("call")
|
|
179
|
+
teardown = phases.get("teardown")
|
|
180
|
+
|
|
181
|
+
if setup is not None and setup.outcome == "failed":
|
|
182
|
+
return "blocked"
|
|
183
|
+
# @pytest.mark.skip / skipif / collection-time skip puts outcome='skipped'
|
|
184
|
+
# on the SETUP report (the test body never runs). This must be checked
|
|
185
|
+
# before the "anything else" fallback or every @skip lands as 'blocked'.
|
|
186
|
+
if setup is not None and setup.outcome == "skipped":
|
|
187
|
+
return "skipped"
|
|
188
|
+
if call is not None and call.outcome in ("failed", "error"):
|
|
189
|
+
return "fail"
|
|
190
|
+
if teardown is not None and teardown.outcome in ("failed", "error"):
|
|
191
|
+
return "fail"
|
|
192
|
+
if call is not None and call.outcome == "skipped":
|
|
193
|
+
return "skipped"
|
|
194
|
+
if call is not None and call.outcome == "passed":
|
|
195
|
+
return "pass"
|
|
196
|
+
if setup is not None and setup.outcome == "passed" and call is None:
|
|
197
|
+
# setup ran clean but call was never reported — typically a
|
|
198
|
+
# collection-time skip. Treat as skipped, not blocked.
|
|
199
|
+
return "skipped"
|
|
200
|
+
return "blocked"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def build_steps(phases: dict[str, Any]) -> list[SubmitStep]:
|
|
204
|
+
"""Build the per-phase step tree from setup/call/teardown reports.
|
|
205
|
+
|
|
206
|
+
Each phase that actually fired becomes one step at the top level. We
|
|
207
|
+
emit them in lifecycle order so the UI shows setup → call → teardown.
|
|
208
|
+
"""
|
|
209
|
+
out: list[SubmitStep] = []
|
|
210
|
+
for phase in ("setup", "call", "teardown"):
|
|
211
|
+
rep = phases.get(phase)
|
|
212
|
+
if rep is None:
|
|
213
|
+
continue
|
|
214
|
+
step: SubmitStep = {
|
|
215
|
+
"title": phase,
|
|
216
|
+
"status": map_step_status(rep.outcome),
|
|
217
|
+
"duration_ms": duration_ms(getattr(rep, "duration", 0.0)),
|
|
218
|
+
"category": "hook",
|
|
219
|
+
"error_message": format_error(getattr(rep, "longrepr", None)),
|
|
220
|
+
"children": [],
|
|
221
|
+
}
|
|
222
|
+
out.append(step)
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── CI detection (parity with reporter-core/src/ci.ts) ───────────────────
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _env() -> dict[str, str]:
|
|
230
|
+
return dict(os.environ)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def detect_ci() -> CIInfo | None:
|
|
234
|
+
"""Auto-populate the `ci` block on POST /v1/runs.
|
|
235
|
+
|
|
236
|
+
Mirrors reporter-core/src/ci.ts so Python and JS reporters produce
|
|
237
|
+
indistinguishable run rows for the same CI build. Every field is
|
|
238
|
+
optional — partial detection (e.g. raw `CI=true`) still yields a
|
|
239
|
+
usable record.
|
|
240
|
+
"""
|
|
241
|
+
env = _env()
|
|
242
|
+
|
|
243
|
+
if env.get("GITHUB_ACTIONS"):
|
|
244
|
+
ref = env.get("GITHUB_REF", "")
|
|
245
|
+
m = re.search(r"refs/pull/(\d+)", ref)
|
|
246
|
+
pr_number = int(m.group(1)) if m else None
|
|
247
|
+
repo = env.get("GITHUB_REPOSITORY")
|
|
248
|
+
server = env.get("GITHUB_SERVER_URL", "https://github.com")
|
|
249
|
+
repo_url = f"{server}/{repo}" if repo else None
|
|
250
|
+
run_id = env.get("GITHUB_RUN_ID")
|
|
251
|
+
run_attempt = env.get("GITHUB_RUN_ATTEMPT")
|
|
252
|
+
ci_link: str | None = None
|
|
253
|
+
if repo_url and run_id:
|
|
254
|
+
ci_link = f"{repo_url}/actions/runs/{run_id}"
|
|
255
|
+
if run_attempt:
|
|
256
|
+
ci_link = f"{ci_link}/attempts/{run_attempt}"
|
|
257
|
+
out: CIInfo = {"provider": "github"}
|
|
258
|
+
branch = (
|
|
259
|
+
env.get("GITHUB_HEAD_REF")
|
|
260
|
+
or env.get("GITHUB_REF_NAME")
|
|
261
|
+
or env.get("GITHUB_REF")
|
|
262
|
+
)
|
|
263
|
+
if branch:
|
|
264
|
+
out["branch"] = branch
|
|
265
|
+
if env.get("GITHUB_SHA"):
|
|
266
|
+
out["commit_sha"] = env["GITHUB_SHA"]
|
|
267
|
+
if env.get("GITHUB_ACTOR"):
|
|
268
|
+
out["author"] = env["GITHUB_ACTOR"]
|
|
269
|
+
if pr_number is not None:
|
|
270
|
+
out["pr_number"] = pr_number
|
|
271
|
+
if repo_url:
|
|
272
|
+
out["pr_url"] = f"{repo_url}/pull/{pr_number}"
|
|
273
|
+
if repo_url:
|
|
274
|
+
out["repo_url"] = repo_url
|
|
275
|
+
if ci_link:
|
|
276
|
+
out["ci_link"] = ci_link
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
if env.get("GITLAB_CI"):
|
|
280
|
+
out = {"provider": "gitlab"}
|
|
281
|
+
branch = env.get("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") or env.get(
|
|
282
|
+
"CI_COMMIT_BRANCH"
|
|
283
|
+
)
|
|
284
|
+
if branch:
|
|
285
|
+
out["branch"] = branch
|
|
286
|
+
if env.get("CI_COMMIT_SHA"):
|
|
287
|
+
out["commit_sha"] = env["CI_COMMIT_SHA"]
|
|
288
|
+
if env.get("CI_COMMIT_MESSAGE"):
|
|
289
|
+
out["commit_msg"] = env["CI_COMMIT_MESSAGE"]
|
|
290
|
+
if env.get("GITLAB_USER_NAME"):
|
|
291
|
+
out["author"] = env["GITLAB_USER_NAME"]
|
|
292
|
+
if env.get("CI_MERGE_REQUEST_IID"):
|
|
293
|
+
try:
|
|
294
|
+
out["pr_number"] = int(env["CI_MERGE_REQUEST_IID"])
|
|
295
|
+
except ValueError:
|
|
296
|
+
pass
|
|
297
|
+
if env.get("CI_MERGE_REQUEST_URL"):
|
|
298
|
+
out["pr_url"] = env["CI_MERGE_REQUEST_URL"]
|
|
299
|
+
if env.get("CI_PROJECT_URL"):
|
|
300
|
+
out["repo_url"] = env["CI_PROJECT_URL"]
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
if env.get("CIRCLECI"):
|
|
304
|
+
out = {"provider": "circleci"}
|
|
305
|
+
if env.get("CIRCLE_BRANCH"):
|
|
306
|
+
out["branch"] = env["CIRCLE_BRANCH"]
|
|
307
|
+
if env.get("CIRCLE_SHA1"):
|
|
308
|
+
out["commit_sha"] = env["CIRCLE_SHA1"]
|
|
309
|
+
if env.get("CIRCLE_USERNAME"):
|
|
310
|
+
out["author"] = env["CIRCLE_USERNAME"]
|
|
311
|
+
pr_url = env.get("CIRCLE_PULL_REQUEST")
|
|
312
|
+
if pr_url:
|
|
313
|
+
m = re.search(r"/pull/(\d+)", pr_url)
|
|
314
|
+
if m:
|
|
315
|
+
out["pr_number"] = int(m.group(1))
|
|
316
|
+
out["pr_url"] = pr_url
|
|
317
|
+
if env.get("CIRCLE_REPOSITORY_URL"):
|
|
318
|
+
out["repo_url"] = env["CIRCLE_REPOSITORY_URL"]
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
if env.get("JENKINS_URL"):
|
|
322
|
+
out = {"provider": "jenkins"}
|
|
323
|
+
branch = env.get("GIT_BRANCH") or env.get("BRANCH_NAME")
|
|
324
|
+
if branch:
|
|
325
|
+
out["branch"] = branch
|
|
326
|
+
if env.get("GIT_COMMIT"):
|
|
327
|
+
out["commit_sha"] = env["GIT_COMMIT"]
|
|
328
|
+
if env.get("BUILD_USER"):
|
|
329
|
+
out["author"] = env["BUILD_USER"]
|
|
330
|
+
if env.get("CHANGE_ID"):
|
|
331
|
+
try:
|
|
332
|
+
out["pr_number"] = int(env["CHANGE_ID"])
|
|
333
|
+
except ValueError:
|
|
334
|
+
pass
|
|
335
|
+
if env.get("CHANGE_URL"):
|
|
336
|
+
out["pr_url"] = env["CHANGE_URL"]
|
|
337
|
+
if env.get("GIT_URL"):
|
|
338
|
+
out["repo_url"] = env["GIT_URL"]
|
|
339
|
+
return out
|
|
340
|
+
|
|
341
|
+
if env.get("TF_BUILD"):
|
|
342
|
+
out = {"provider": "azure"}
|
|
343
|
+
branch = env.get("SYSTEM_PULLREQUEST_SOURCEBRANCH") or env.get(
|
|
344
|
+
"BUILD_SOURCEBRANCH"
|
|
345
|
+
)
|
|
346
|
+
if branch:
|
|
347
|
+
out["branch"] = branch
|
|
348
|
+
if env.get("BUILD_SOURCEVERSION"):
|
|
349
|
+
out["commit_sha"] = env["BUILD_SOURCEVERSION"]
|
|
350
|
+
if env.get("BUILD_SOURCEVERSIONMESSAGE"):
|
|
351
|
+
out["commit_msg"] = env["BUILD_SOURCEVERSIONMESSAGE"]
|
|
352
|
+
if env.get("BUILD_REQUESTEDFOR"):
|
|
353
|
+
out["author"] = env["BUILD_REQUESTEDFOR"]
|
|
354
|
+
pr_number = env.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")
|
|
355
|
+
if pr_number:
|
|
356
|
+
try:
|
|
357
|
+
out["pr_number"] = int(pr_number)
|
|
358
|
+
except ValueError:
|
|
359
|
+
pass
|
|
360
|
+
if env.get("BUILD_REPOSITORY_URI"):
|
|
361
|
+
out["repo_url"] = env["BUILD_REPOSITORY_URI"]
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
if env.get("CI"):
|
|
365
|
+
out = {"provider": "unknown"}
|
|
366
|
+
branch = env.get("BRANCH") or env.get("GIT_BRANCH")
|
|
367
|
+
if branch:
|
|
368
|
+
out["branch"] = branch
|
|
369
|
+
commit = env.get("COMMIT_SHA") or env.get("GIT_COMMIT")
|
|
370
|
+
if commit:
|
|
371
|
+
out["commit_sha"] = commit
|
|
372
|
+
return out
|
|
373
|
+
|
|
374
|
+
return None
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""pytest plugin entrypoint — streams every test into Devant Cloud.
|
|
2
|
+
|
|
3
|
+
Why these hooks (sourced from pytest 9.x reference docs):
|
|
4
|
+
|
|
5
|
+
* pytest_addoption — register CLI flags (--devant-api-url, etc.)
|
|
6
|
+
and the custom `devant` marker.
|
|
7
|
+
* pytest_configure — read env/CLI options, register the marker,
|
|
8
|
+
and stand up the HTTP client. Doesn't open
|
|
9
|
+
the run yet — we want to know the test
|
|
10
|
+
count from collection first.
|
|
11
|
+
* pytest_sessionstart — `POST /v1/runs` once collection has happened
|
|
12
|
+
(unless DEVQ_RUN_ID is set; then we attach
|
|
13
|
+
to an externally-owned run).
|
|
14
|
+
* pytest_runtest_makereport — wrapper hook that stashes the phase report
|
|
15
|
+
(setup/call/teardown) on the item, so the
|
|
16
|
+
later logreport hook can see all three
|
|
17
|
+
phases at once. This is the documented
|
|
18
|
+
pattern in pytest's "make test result info
|
|
19
|
+
available in fixtures" example.
|
|
20
|
+
* pytest_runtest_logreport — central reporting hook. We fire on the
|
|
21
|
+
*teardown* report (which is always last)
|
|
22
|
+
so we have setup+call+teardown in hand
|
|
23
|
+
when we POST /v1/runs/:id/results.
|
|
24
|
+
* pytest_sessionfinish — `POST /v1/runs/:id/complete`.
|
|
25
|
+
* pytest_unconfigure — close the HTTP client.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import datetime as _dt
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import pytest
|
|
36
|
+
|
|
37
|
+
from . import mapping
|
|
38
|
+
from .client import DevqClient, ResolvedCase
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── pytest stash keys ───────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
# Stashed on each Item so the wrapper makereport can accumulate phase reports
|
|
44
|
+
# across setup/call/teardown without leaking module-level state.
|
|
45
|
+
_PHASES_KEY: pytest.StashKey[dict[str, pytest.TestReport]] = pytest.StashKey()
|
|
46
|
+
|
|
47
|
+
# Stashed on the Config so every hook gets at the live plugin state.
|
|
48
|
+
_STATE_KEY: pytest.StashKey["_State"] = pytest.StashKey()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _State:
|
|
52
|
+
"""Plugin runtime state — one per pytest session."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
client: DevqClient,
|
|
58
|
+
run_name: str,
|
|
59
|
+
external_run_id: int | None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.client = client
|
|
62
|
+
self.run_name = run_name
|
|
63
|
+
self.run_id: int | None = external_run_id
|
|
64
|
+
self.external_run = external_run_id is not None
|
|
65
|
+
# Per-fullName cache so retries (e.g. pytest-rerunfailures) share
|
|
66
|
+
# one resolveTestCase call instead of minting duplicates.
|
|
67
|
+
self.resolved_cases: dict[str, ResolvedCase] = {}
|
|
68
|
+
# Track every nodeid we've already POSTed for. pytest emits a
|
|
69
|
+
# logreport per phase; we only want to submit one combined result
|
|
70
|
+
# per test, on teardown.
|
|
71
|
+
self.submitted: set[str] = set()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── CLI options + marker ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
78
|
+
group = parser.getgroup(
|
|
79
|
+
"devant_cloud", "stream test results to Devant Cloud"
|
|
80
|
+
)
|
|
81
|
+
group.addoption(
|
|
82
|
+
"--devant-api-url",
|
|
83
|
+
action="store",
|
|
84
|
+
default=None,
|
|
85
|
+
help="Devant Cloud API URL (env: DEVQ_API_URL)",
|
|
86
|
+
)
|
|
87
|
+
group.addoption(
|
|
88
|
+
"--devant-token",
|
|
89
|
+
action="store",
|
|
90
|
+
default=None,
|
|
91
|
+
help="Bearer token (env: DEVQ_TOKEN)",
|
|
92
|
+
)
|
|
93
|
+
group.addoption(
|
|
94
|
+
"--devant-project-id",
|
|
95
|
+
action="store",
|
|
96
|
+
type=int,
|
|
97
|
+
default=None,
|
|
98
|
+
help="Project id (env: DEVQ_PROJECT_ID)",
|
|
99
|
+
)
|
|
100
|
+
group.addoption(
|
|
101
|
+
"--devant-run-name",
|
|
102
|
+
action="store",
|
|
103
|
+
default=None,
|
|
104
|
+
help="Run display name (env: DEVQ_RUN_NAME)",
|
|
105
|
+
)
|
|
106
|
+
group.addoption(
|
|
107
|
+
"--devant-run-id",
|
|
108
|
+
action="store",
|
|
109
|
+
type=int,
|
|
110
|
+
default=None,
|
|
111
|
+
help="Attach to an externally created run instead of creating one "
|
|
112
|
+
"(env: DEVQ_RUN_ID)",
|
|
113
|
+
)
|
|
114
|
+
group.addoption(
|
|
115
|
+
"--devant-disable",
|
|
116
|
+
action="store_true",
|
|
117
|
+
default=False,
|
|
118
|
+
help="Disable the Devant Cloud reporter for this run",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _opt(config: pytest.Config, cli: str, env: str, default: Any) -> Any:
|
|
123
|
+
"""CLI flag wins, then env var, then default."""
|
|
124
|
+
v = config.getoption(cli, default=None)
|
|
125
|
+
if v is not None:
|
|
126
|
+
return v
|
|
127
|
+
if env in os.environ and os.environ[env] != "":
|
|
128
|
+
return os.environ[env]
|
|
129
|
+
return default
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── configure ───────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
136
|
+
# Register the `@pytest.mark.devant("KEY")` marker so pytest doesn't
|
|
137
|
+
# warn about "unknown marker" when users adopt it.
|
|
138
|
+
config.addinivalue_line(
|
|
139
|
+
"markers",
|
|
140
|
+
'devant(key): bind this test to a Devant Cloud test_case by key '
|
|
141
|
+
'(e.g. @pytest.mark.devant("DEF-AB12"))',
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if config.getoption("--devant-disable", default=False):
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
api_url = _opt(
|
|
148
|
+
config, "--devant-api-url", "DEVQ_API_URL", "http://localhost:32124"
|
|
149
|
+
)
|
|
150
|
+
api_token_explicit = (
|
|
151
|
+
config.getoption("--devant-token", default=None)
|
|
152
|
+
or os.environ.get("DEVQ_TOKEN")
|
|
153
|
+
)
|
|
154
|
+
api_token = api_token_explicit or "dev-admin-token"
|
|
155
|
+
|
|
156
|
+
project_raw = _opt(config, "--devant-project-id", "DEVQ_PROJECT_ID", 1)
|
|
157
|
+
try:
|
|
158
|
+
project_id = int(project_raw)
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
project_id = 0
|
|
161
|
+
if project_id <= 0:
|
|
162
|
+
# Don't blow up the test run — just refuse to enable the plugin.
|
|
163
|
+
sys.stderr.write(
|
|
164
|
+
"[devant] DEVQ_PROJECT_ID must be a positive integer; reporter "
|
|
165
|
+
"disabled for this run\n"
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Warn loudly when shipping the dev fallback token at a non-local URL —
|
|
170
|
+
# same footgun guard the playwright reporter has.
|
|
171
|
+
if not api_token_explicit:
|
|
172
|
+
lowered = api_url.lower()
|
|
173
|
+
is_local = any(
|
|
174
|
+
lowered.startswith(p)
|
|
175
|
+
for p in (
|
|
176
|
+
"http://localhost",
|
|
177
|
+
"https://localhost",
|
|
178
|
+
"http://127.0.0.1",
|
|
179
|
+
"https://127.0.0.1",
|
|
180
|
+
"http://0.0.0.0",
|
|
181
|
+
"https://0.0.0.0",
|
|
182
|
+
"http://[::1]",
|
|
183
|
+
"https://[::1]",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
if not is_local:
|
|
187
|
+
sys.stderr.write(
|
|
188
|
+
f'[devant] DEVQ_TOKEN is not set; falling back to "dev-admin-token" '
|
|
189
|
+
f"against {api_url}. Set DEVQ_TOKEN to a real CI token for "
|
|
190
|
+
"production use.\n"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
run_name = _opt(
|
|
194
|
+
config,
|
|
195
|
+
"--devant-run-name",
|
|
196
|
+
"DEVQ_RUN_NAME",
|
|
197
|
+
f"pytest — {_dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds')}",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
external_run_raw = _opt(config, "--devant-run-id", "DEVQ_RUN_ID", None)
|
|
201
|
+
external_run_id: int | None = None
|
|
202
|
+
if external_run_raw not in (None, ""):
|
|
203
|
+
try:
|
|
204
|
+
n = int(external_run_raw)
|
|
205
|
+
if n > 0:
|
|
206
|
+
external_run_id = n
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
client = DevqClient(
|
|
211
|
+
api_url=api_url,
|
|
212
|
+
api_token=api_token,
|
|
213
|
+
project_id=project_id,
|
|
214
|
+
)
|
|
215
|
+
state = _State(
|
|
216
|
+
client=client,
|
|
217
|
+
run_name=str(run_name),
|
|
218
|
+
external_run_id=external_run_id,
|
|
219
|
+
)
|
|
220
|
+
config.stash[_STATE_KEY] = state
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ── session lifecycle ───────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def pytest_sessionstart(session: pytest.Session) -> None:
|
|
227
|
+
state = session.config.stash.get(_STATE_KEY, None)
|
|
228
|
+
if state is None or state.external_run:
|
|
229
|
+
return
|
|
230
|
+
ci = mapping.detect_ci()
|
|
231
|
+
try:
|
|
232
|
+
row = state.client.create_run(
|
|
233
|
+
name=state.run_name,
|
|
234
|
+
framework="pytest",
|
|
235
|
+
ci=ci,
|
|
236
|
+
)
|
|
237
|
+
state.run_id = int(row["id"])
|
|
238
|
+
sys.stderr.write(
|
|
239
|
+
f'[devant] created run #{state.run_id} "{state.run_name}"\n'
|
|
240
|
+
)
|
|
241
|
+
except Exception as exc: # noqa: BLE001 — never break the test run
|
|
242
|
+
sys.stderr.write(f"[devant] failed to create run: {exc}\n")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def pytest_sessionfinish(
|
|
246
|
+
session: pytest.Session, exitstatus: int
|
|
247
|
+
) -> None:
|
|
248
|
+
state = session.config.stash.get(_STATE_KEY, None)
|
|
249
|
+
if state is None or state.run_id is None:
|
|
250
|
+
return
|
|
251
|
+
if state.external_run:
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
state.client.complete_run(state.run_id)
|
|
255
|
+
sys.stderr.write(f"[devant] closed run #{state.run_id}\n")
|
|
256
|
+
except Exception as exc: # noqa: BLE001
|
|
257
|
+
sys.stderr.write(f"[devant] failed to complete run: {exc}\n")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def pytest_unconfigure(config: pytest.Config) -> None:
|
|
261
|
+
state = config.stash.get(_STATE_KEY, None)
|
|
262
|
+
if state is None:
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
state.client.close()
|
|
266
|
+
except Exception: # noqa: BLE001
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ── per-test phase capture ──────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.hookimpl(wrapper=True, tryfirst=True)
|
|
274
|
+
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]):
|
|
275
|
+
"""Stash each phase report on the item so logreport can see them all.
|
|
276
|
+
|
|
277
|
+
This is the canonical pattern from pytest's "make test result info
|
|
278
|
+
available in fixtures" example (doc/en/example/simple.rst). We need
|
|
279
|
+
setup + call + teardown in one place to build the step tree.
|
|
280
|
+
"""
|
|
281
|
+
rep: pytest.TestReport = yield
|
|
282
|
+
phases = item.stash.setdefault(_PHASES_KEY, {})
|
|
283
|
+
phases[rep.when] = rep
|
|
284
|
+
return rep
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# Note: pytest_runtest_logreport fires once per phase (setup, call, teardown)
|
|
288
|
+
# but only receives the TestReport, not the Item — so it can't see the
|
|
289
|
+
# stashed phase dict. We instead use pytest_runtest_logfinish, which fires
|
|
290
|
+
# once per test node *after* all three phases, and resolve back to the Item
|
|
291
|
+
# via session.items. This matches the pytest 9 hook reference for plugins
|
|
292
|
+
# that need cross-phase visibility.
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@pytest.hookimpl(trylast=True)
|
|
296
|
+
def pytest_runtest_logfinish(
|
|
297
|
+
nodeid: str, location: tuple[str, int | None, str]
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Fires after teardown's logreport, once per test node.
|
|
300
|
+
|
|
301
|
+
By the time we get here, makereport has stashed setup/call/teardown
|
|
302
|
+
on the item's stash. We look up the item via the active session and
|
|
303
|
+
submit one combined result.
|
|
304
|
+
"""
|
|
305
|
+
session = _active_session()
|
|
306
|
+
if session is None:
|
|
307
|
+
return
|
|
308
|
+
state: _State | None = session.config.stash.get(_STATE_KEY, None)
|
|
309
|
+
if state is None or state.run_id is None:
|
|
310
|
+
return
|
|
311
|
+
if nodeid in state.submitted:
|
|
312
|
+
return
|
|
313
|
+
state.submitted.add(nodeid)
|
|
314
|
+
|
|
315
|
+
item = _item_for_nodeid(session, nodeid)
|
|
316
|
+
if item is None:
|
|
317
|
+
return
|
|
318
|
+
phases: dict[str, pytest.TestReport] = item.stash.get(_PHASES_KEY, {})
|
|
319
|
+
if not phases:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
_submit_one(state, item, phases)
|
|
324
|
+
except Exception as exc: # noqa: BLE001
|
|
325
|
+
sys.stderr.write(
|
|
326
|
+
f'[devant] failed to submit result for "{nodeid}": {exc}\n'
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ── helpers ──────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_SESSION: pytest.Session | None = None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _active_session() -> pytest.Session | None:
|
|
337
|
+
return _SESSION
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@pytest.hookimpl(tryfirst=True)
|
|
341
|
+
def pytest_collection(session: pytest.Session) -> None:
|
|
342
|
+
"""Side-channel: keep a reference to the session for the logfinish hook,
|
|
343
|
+
which doesn't receive it. pytest_collection fires once per session,
|
|
344
|
+
before any test runs, and is the earliest hook that hands us the Session.
|
|
345
|
+
"""
|
|
346
|
+
global _SESSION
|
|
347
|
+
_SESSION = session
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _item_for_nodeid(
|
|
351
|
+
session: pytest.Session, nodeid: str
|
|
352
|
+
) -> pytest.Item | None:
|
|
353
|
+
for it in session.items:
|
|
354
|
+
if it.nodeid == nodeid:
|
|
355
|
+
return it
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _explicit_key_from_item(item: pytest.Item) -> str | None:
|
|
360
|
+
"""Try every place a key might live: marker args, item.name, nodeid."""
|
|
361
|
+
candidates: list[str | None] = []
|
|
362
|
+
for marker in item.iter_markers(name="devant"):
|
|
363
|
+
for a in marker.args:
|
|
364
|
+
if isinstance(a, str):
|
|
365
|
+
candidates.append(a)
|
|
366
|
+
candidates.append(item.name)
|
|
367
|
+
candidates.append(item.nodeid)
|
|
368
|
+
return mapping.extract_key(*candidates)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _submit_one(
|
|
372
|
+
state: _State,
|
|
373
|
+
item: pytest.Item,
|
|
374
|
+
phases: dict[str, pytest.TestReport],
|
|
375
|
+
) -> None:
|
|
376
|
+
full_name = mapping.full_name(item.nodeid)
|
|
377
|
+
|
|
378
|
+
# Cache by full_name so retries collapse into one resolveTestCase call.
|
|
379
|
+
resolved = state.resolved_cases.get(full_name)
|
|
380
|
+
if resolved is None:
|
|
381
|
+
explicit_key = _explicit_key_from_item(item)
|
|
382
|
+
try:
|
|
383
|
+
resolved = state.client.resolve_test_case(
|
|
384
|
+
explicit_key=explicit_key,
|
|
385
|
+
full_name=full_name,
|
|
386
|
+
)
|
|
387
|
+
except Exception as exc: # noqa: BLE001
|
|
388
|
+
sys.stderr.write(
|
|
389
|
+
f'[devant] could not resolve test case for "{full_name}": {exc}\n'
|
|
390
|
+
)
|
|
391
|
+
return
|
|
392
|
+
state.resolved_cases[full_name] = resolved
|
|
393
|
+
if resolved.minted:
|
|
394
|
+
sys.stderr.write(
|
|
395
|
+
f'[devant] minted {resolved.key} for "{full_name}" '
|
|
396
|
+
f"— add @pytest.mark.devant(\"{resolved.key}\") to bind it\n"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
status = mapping.overall_status_from_phases(phases)
|
|
400
|
+
total_duration = sum(
|
|
401
|
+
mapping.duration_ms(getattr(rep, "duration", 0.0))
|
|
402
|
+
for rep in phases.values()
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Pull the most informative longrepr for the result-level error_message
|
|
406
|
+
# (call > teardown > setup) so the run table shows the test failure,
|
|
407
|
+
# not the cleanup error that followed it.
|
|
408
|
+
error_message: str | None = None
|
|
409
|
+
for phase in ("call", "teardown", "setup"):
|
|
410
|
+
rep = phases.get(phase)
|
|
411
|
+
if rep is None or rep.outcome == "passed":
|
|
412
|
+
continue
|
|
413
|
+
msg = mapping.format_error(getattr(rep, "longrepr", None))
|
|
414
|
+
if msg:
|
|
415
|
+
error_message = msg
|
|
416
|
+
break
|
|
417
|
+
|
|
418
|
+
# Captured stdout/stderr — pytest stashes these on the report when
|
|
419
|
+
# capturing is enabled (the default). Concat across phases so the
|
|
420
|
+
# attempt has the full picture.
|
|
421
|
+
stdout_parts: list[str] = []
|
|
422
|
+
stderr_parts: list[str] = []
|
|
423
|
+
for phase in ("setup", "call", "teardown"):
|
|
424
|
+
rep = phases.get(phase)
|
|
425
|
+
if rep is None:
|
|
426
|
+
continue
|
|
427
|
+
cap_out = getattr(rep, "capstdout", "") or ""
|
|
428
|
+
cap_err = getattr(rep, "capstderr", "") or ""
|
|
429
|
+
if cap_out:
|
|
430
|
+
stdout_parts.append(cap_out)
|
|
431
|
+
if cap_err:
|
|
432
|
+
stderr_parts.append(cap_err)
|
|
433
|
+
stdout = "".join(stdout_parts) or None
|
|
434
|
+
stderr = "".join(stderr_parts) or None
|
|
435
|
+
|
|
436
|
+
# Bound captured output to the API caps (65k chars) so a noisy test
|
|
437
|
+
# doesn't 4xx the entire submit.
|
|
438
|
+
if stdout and len(stdout) > 65_000:
|
|
439
|
+
stdout = stdout[:65_000]
|
|
440
|
+
if stderr and len(stderr) > 65_000:
|
|
441
|
+
stderr = stderr[:65_000]
|
|
442
|
+
|
|
443
|
+
started_at = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
|
|
444
|
+
steps = mapping.build_steps(phases)
|
|
445
|
+
|
|
446
|
+
submit_payload: dict[str, Any] = {
|
|
447
|
+
"test_case_id": resolved.id,
|
|
448
|
+
"status": status,
|
|
449
|
+
"duration_ms": total_duration,
|
|
450
|
+
"error_message": error_message,
|
|
451
|
+
"attempts": [
|
|
452
|
+
{
|
|
453
|
+
"attempt_no": 1,
|
|
454
|
+
"status": status,
|
|
455
|
+
"duration_ms": total_duration,
|
|
456
|
+
"started_at": started_at,
|
|
457
|
+
"error_message": error_message,
|
|
458
|
+
"stdout": stdout,
|
|
459
|
+
"stderr": stderr,
|
|
460
|
+
"steps": steps,
|
|
461
|
+
}
|
|
462
|
+
],
|
|
463
|
+
}
|
|
464
|
+
state.client.submit_results(state.run_id, [submit_payload])
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-devant-cloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest plugin that streams runs, results, and step trees to Devant Cloud's /v1/runs API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/devant-net/devq-cloud/tree/main/packages/pytest-devant-cloud
|
|
6
|
+
Project-URL: Repository, https://github.com/devant-net/devq-cloud
|
|
7
|
+
Author: Devant Cloud
|
|
8
|
+
License: DevQ Cloud Enterprise License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 DevQ Cloud. All rights reserved.
|
|
11
|
+
|
|
12
|
+
================================================================================
|
|
13
|
+
THIS IS A TEMPLATE. Review with legal counsel before publishing to npm.
|
|
14
|
+
================================================================================
|
|
15
|
+
|
|
16
|
+
1. DEFINITIONS
|
|
17
|
+
|
|
18
|
+
"Software" means this npm package and its source code, including all
|
|
19
|
+
modifications and derivative works.
|
|
20
|
+
|
|
21
|
+
"Service" means the DevQ Cloud hosted product made available by DevQ Cloud
|
|
22
|
+
to its customers.
|
|
23
|
+
|
|
24
|
+
"Subscription" means a current, paid commercial agreement between you and
|
|
25
|
+
DevQ Cloud authorising use of the Service, OR a free-tier registration
|
|
26
|
+
accepted by DevQ Cloud.
|
|
27
|
+
|
|
28
|
+
"You" means the individual or legal entity exercising rights under this
|
|
29
|
+
License.
|
|
30
|
+
|
|
31
|
+
2. GRANT OF USE
|
|
32
|
+
|
|
33
|
+
Subject to the terms below and the existence of an active Subscription,
|
|
34
|
+
DevQ Cloud grants You a non-exclusive, non-transferable, revocable license
|
|
35
|
+
to:
|
|
36
|
+
|
|
37
|
+
(a) install and run the Software on Your own systems and continuous
|
|
38
|
+
integration infrastructure;
|
|
39
|
+
(b) use the Software solely to connect to and interact with the Service;
|
|
40
|
+
(c) make modifications to the Software for Your own internal use, provided
|
|
41
|
+
such modifications are not distributed.
|
|
42
|
+
|
|
43
|
+
3. RESTRICTIONS
|
|
44
|
+
|
|
45
|
+
You may not, except to the extent expressly permitted by applicable law:
|
|
46
|
+
|
|
47
|
+
(a) redistribute, sublicense, sell, rent, lease, or otherwise transfer the
|
|
48
|
+
Software or any modified version of the Software to any third party;
|
|
49
|
+
(b) use the Software to provide a managed, hosted, or commercial service
|
|
50
|
+
that competes with the Service;
|
|
51
|
+
(c) remove, alter, or obscure any proprietary notices in the Software;
|
|
52
|
+
(d) reverse engineer, decompile, or disassemble the Software, except as
|
|
53
|
+
expressly permitted by applicable law notwithstanding this limitation;
|
|
54
|
+
(e) use the Software in violation of any applicable law or regulation.
|
|
55
|
+
|
|
56
|
+
4. NO TRANSFER OF OWNERSHIP
|
|
57
|
+
|
|
58
|
+
The Software is licensed, not sold. DevQ Cloud retains all right, title,
|
|
59
|
+
and interest in and to the Software, including all intellectual property
|
|
60
|
+
rights.
|
|
61
|
+
|
|
62
|
+
5. TERMINATION
|
|
63
|
+
|
|
64
|
+
This License terminates automatically and immediately if Your Subscription
|
|
65
|
+
ends, expires, or is terminated for any reason. Upon termination, You must
|
|
66
|
+
cease all use of the Software and destroy all copies in Your possession.
|
|
67
|
+
|
|
68
|
+
6. NO WARRANTY
|
|
69
|
+
|
|
70
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
71
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
72
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
73
|
+
|
|
74
|
+
7. LIMITATION OF LIABILITY
|
|
75
|
+
|
|
76
|
+
IN NO EVENT SHALL DEVQ CLOUD OR ITS CONTRIBUTORS BE LIABLE FOR ANY CLAIM,
|
|
77
|
+
DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
|
|
78
|
+
OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
79
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE. DEVQ CLOUD'S TOTAL LIABILITY FOR
|
|
80
|
+
ALL CLAIMS RELATED TO THE SOFTWARE SHALL NOT EXCEED THE FEES PAID BY YOU
|
|
81
|
+
FOR THE SUBSCRIPTION IN THE TWELVE (12) MONTHS PRECEDING THE CLAIM.
|
|
82
|
+
|
|
83
|
+
8. GOVERNING LAW
|
|
84
|
+
|
|
85
|
+
This License is governed by the laws of the jurisdiction in which DevQ
|
|
86
|
+
Cloud is incorporated, without regard to its conflict of law principles.
|
|
87
|
+
|
|
88
|
+
9. ENTIRE AGREEMENT
|
|
89
|
+
|
|
90
|
+
This License, together with the Subscription terms, constitutes the
|
|
91
|
+
entire agreement between You and DevQ Cloud concerning the Software and
|
|
92
|
+
supersedes all prior or contemporaneous agreements, proposals, or
|
|
93
|
+
communications.
|
|
94
|
+
|
|
95
|
+
For commercial licensing inquiries, contact: licensing@devq.cloud
|
|
96
|
+
License-File: LICENSE
|
|
97
|
+
Keywords: ci,devant,devq,pytest,reporter,test-reporting
|
|
98
|
+
Classifier: Framework :: Pytest
|
|
99
|
+
Classifier: Intended Audience :: Developers
|
|
100
|
+
Classifier: Operating System :: OS Independent
|
|
101
|
+
Classifier: Programming Language :: Python
|
|
102
|
+
Classifier: Programming Language :: Python :: 3
|
|
103
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
104
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
105
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
106
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
107
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
108
|
+
Classifier: Topic :: Software Development :: Testing
|
|
109
|
+
Requires-Python: >=3.10
|
|
110
|
+
Requires-Dist: httpx>=0.24
|
|
111
|
+
Requires-Dist: pytest>=7.0
|
|
112
|
+
Description-Content-Type: text/markdown
|
|
113
|
+
|
|
114
|
+
# pytest-devant-cloud
|
|
115
|
+
|
|
116
|
+
pytest plugin that streams runs, results, and per-test step trees into
|
|
117
|
+
[Devant Cloud](https://github.com/devant-net/devq-cloud) as your suite
|
|
118
|
+
executes — the Python sibling of `@devant-net/playwright-reporter`.
|
|
119
|
+
|
|
120
|
+
## Install
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install pytest-devant-cloud
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The plugin auto-registers via the `pytest11` entry-point group. No
|
|
127
|
+
`conftest.py` changes are needed.
|
|
128
|
+
|
|
129
|
+
## Configure
|
|
130
|
+
|
|
131
|
+
Set env vars before invoking pytest:
|
|
132
|
+
|
|
133
|
+
| Var | Default | Notes |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `DEVQ_API_URL` | `http://localhost:32124` | Your tenant's URL |
|
|
136
|
+
| `DEVQ_TOKEN` | `dev-admin-token` | Bearer token (CI/CD settings) |
|
|
137
|
+
| `DEVQ_PROJECT_ID` | `1` | Devant Cloud project id |
|
|
138
|
+
| `DEVQ_RUN_NAME` | `pytest — <ISO date>` | Display name on the run |
|
|
139
|
+
| `DEVQ_RUN_ID` | _(unset)_ | Attach to an externally-created run instead of creating one |
|
|
140
|
+
|
|
141
|
+
Or with CLI flags:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pytest \
|
|
145
|
+
--devant-api-url=https://acme.devq.cloud \
|
|
146
|
+
--devant-token=$DEVQ_TOKEN \
|
|
147
|
+
--devant-project-id=1
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
To disable the plugin for one run without uninstalling:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pytest -p no:devant_cloud
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## How tests bind to test cases
|
|
157
|
+
|
|
158
|
+
Each pytest item resolves to a Devant Cloud `test_case` row in this order:
|
|
159
|
+
|
|
160
|
+
1. **`@pytest.mark.devant("DEF-AB12")` marker** on the test — looked up
|
|
161
|
+
via `GET /v1/test-cases/by-key/DEF-AB12`.
|
|
162
|
+
2. **Exact name match** (`<file>::<test>` nodeid) in the project →
|
|
163
|
+
`GET /v1/test-cases?search=…`.
|
|
164
|
+
3. **Auto-create** → `POST /v1/test-cases`. The plugin prints the new key:
|
|
165
|
+
```
|
|
166
|
+
[devant] minted DEF-XYZ9 for "tests/test_auth.py::test_login"
|
|
167
|
+
— add @pytest.mark.devant("DEF-XYZ9") to bind it
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Bind the key in source once and future runs (even with renames) reuse the
|
|
171
|
+
same case.
|
|
172
|
+
|
|
173
|
+
## What gets sent
|
|
174
|
+
|
|
175
|
+
| pytest hook | Devant Cloud call |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `pytest_sessionstart` | `POST /v1/runs` (skipped if `DEVQ_RUN_ID` is set) |
|
|
178
|
+
| `pytest_runtest_makereport` (wrapper) | stashes setup/call/teardown reports on the item |
|
|
179
|
+
| `pytest_runtest_logreport` (teardown phase) | resolve test case → `POST /v1/runs/:id/results` with step tree |
|
|
180
|
+
| `pytest_sessionfinish` | `POST /v1/runs/:id/complete` (skipped if `DEVQ_RUN_ID` is set) |
|
|
181
|
+
|
|
182
|
+
The step tree includes one node per phase (setup / call / teardown) with
|
|
183
|
+
status, duration, and longrepr captured as `error_message`.
|
|
184
|
+
|
|
185
|
+
## CI metadata
|
|
186
|
+
|
|
187
|
+
Auto-detected for GitHub Actions, GitLab CI, CircleCI, Jenkins, and Azure
|
|
188
|
+
DevOps, plus a generic `CI=true` fallback. Populates the `ci_*` columns
|
|
189
|
+
on the run so the dashboard can deep-link to commits and PRs.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pytest_devant_cloud/__init__.py,sha256=sz4Rh-0ayx1hg_8v5Si1QAgRyRH5ipCkoSj1SBS_LQU,369
|
|
2
|
+
pytest_devant_cloud/client.py,sha256=oFF1uq620QLkNeN2HEsm6NdJTSJJSEvw_vikJ7kyi-8,7559
|
|
3
|
+
pytest_devant_cloud/mapping.py,sha256=uy0RGadoxI-YIg9FrxAoLWljYK4pYTjNj76ICQncjfM,13553
|
|
4
|
+
pytest_devant_cloud/plugin.py,sha256=drvVGqvDka--lznrN8FuTIslzCcPGueviWPhuZNaPbg,16279
|
|
5
|
+
pytest_devant_cloud-0.1.0.dist-info/METADATA,sha256=zokO2oRWKj1qjRkn3AOaw4K8HJt-t-uvZm0pvTL3LMM,7914
|
|
6
|
+
pytest_devant_cloud-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
pytest_devant_cloud-0.1.0.dist-info/entry_points.txt,sha256=4s6QpFgmv3P_kVcfd4qW_BPTlTIgR6i7eWPW5D15YIY,53
|
|
8
|
+
pytest_devant_cloud-0.1.0.dist-info/licenses/LICENSE,sha256=NGeEdLb0IImvgOt1Yoh8iXxDPpqBHGiRHjTTONweVwE,3608
|
|
9
|
+
pytest_devant_cloud-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
DevQ Cloud Enterprise License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DevQ Cloud. All rights reserved.
|
|
4
|
+
|
|
5
|
+
================================================================================
|
|
6
|
+
THIS IS A TEMPLATE. Review with legal counsel before publishing to npm.
|
|
7
|
+
================================================================================
|
|
8
|
+
|
|
9
|
+
1. DEFINITIONS
|
|
10
|
+
|
|
11
|
+
"Software" means this npm package and its source code, including all
|
|
12
|
+
modifications and derivative works.
|
|
13
|
+
|
|
14
|
+
"Service" means the DevQ Cloud hosted product made available by DevQ Cloud
|
|
15
|
+
to its customers.
|
|
16
|
+
|
|
17
|
+
"Subscription" means a current, paid commercial agreement between you and
|
|
18
|
+
DevQ Cloud authorising use of the Service, OR a free-tier registration
|
|
19
|
+
accepted by DevQ Cloud.
|
|
20
|
+
|
|
21
|
+
"You" means the individual or legal entity exercising rights under this
|
|
22
|
+
License.
|
|
23
|
+
|
|
24
|
+
2. GRANT OF USE
|
|
25
|
+
|
|
26
|
+
Subject to the terms below and the existence of an active Subscription,
|
|
27
|
+
DevQ Cloud grants You a non-exclusive, non-transferable, revocable license
|
|
28
|
+
to:
|
|
29
|
+
|
|
30
|
+
(a) install and run the Software on Your own systems and continuous
|
|
31
|
+
integration infrastructure;
|
|
32
|
+
(b) use the Software solely to connect to and interact with the Service;
|
|
33
|
+
(c) make modifications to the Software for Your own internal use, provided
|
|
34
|
+
such modifications are not distributed.
|
|
35
|
+
|
|
36
|
+
3. RESTRICTIONS
|
|
37
|
+
|
|
38
|
+
You may not, except to the extent expressly permitted by applicable law:
|
|
39
|
+
|
|
40
|
+
(a) redistribute, sublicense, sell, rent, lease, or otherwise transfer the
|
|
41
|
+
Software or any modified version of the Software to any third party;
|
|
42
|
+
(b) use the Software to provide a managed, hosted, or commercial service
|
|
43
|
+
that competes with the Service;
|
|
44
|
+
(c) remove, alter, or obscure any proprietary notices in the Software;
|
|
45
|
+
(d) reverse engineer, decompile, or disassemble the Software, except as
|
|
46
|
+
expressly permitted by applicable law notwithstanding this limitation;
|
|
47
|
+
(e) use the Software in violation of any applicable law or regulation.
|
|
48
|
+
|
|
49
|
+
4. NO TRANSFER OF OWNERSHIP
|
|
50
|
+
|
|
51
|
+
The Software is licensed, not sold. DevQ Cloud retains all right, title,
|
|
52
|
+
and interest in and to the Software, including all intellectual property
|
|
53
|
+
rights.
|
|
54
|
+
|
|
55
|
+
5. TERMINATION
|
|
56
|
+
|
|
57
|
+
This License terminates automatically and immediately if Your Subscription
|
|
58
|
+
ends, expires, or is terminated for any reason. Upon termination, You must
|
|
59
|
+
cease all use of the Software and destroy all copies in Your possession.
|
|
60
|
+
|
|
61
|
+
6. NO WARRANTY
|
|
62
|
+
|
|
63
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
64
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
65
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
66
|
+
|
|
67
|
+
7. LIMITATION OF LIABILITY
|
|
68
|
+
|
|
69
|
+
IN NO EVENT SHALL DEVQ CLOUD OR ITS CONTRIBUTORS BE LIABLE FOR ANY CLAIM,
|
|
70
|
+
DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
|
|
71
|
+
OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
72
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE. DEVQ CLOUD'S TOTAL LIABILITY FOR
|
|
73
|
+
ALL CLAIMS RELATED TO THE SOFTWARE SHALL NOT EXCEED THE FEES PAID BY YOU
|
|
74
|
+
FOR THE SUBSCRIPTION IN THE TWELVE (12) MONTHS PRECEDING THE CLAIM.
|
|
75
|
+
|
|
76
|
+
8. GOVERNING LAW
|
|
77
|
+
|
|
78
|
+
This License is governed by the laws of the jurisdiction in which DevQ
|
|
79
|
+
Cloud is incorporated, without regard to its conflict of law principles.
|
|
80
|
+
|
|
81
|
+
9. ENTIRE AGREEMENT
|
|
82
|
+
|
|
83
|
+
This License, together with the Subscription terms, constitutes the
|
|
84
|
+
entire agreement between You and DevQ Cloud concerning the Software and
|
|
85
|
+
supersedes all prior or contemporaneous agreements, proposals, or
|
|
86
|
+
communications.
|
|
87
|
+
|
|
88
|
+
For commercial licensing inquiries, contact: licensing@devq.cloud
|