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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ devant_cloud = pytest_devant_cloud.plugin
@@ -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