pytest-devant-cloud 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/PKG-INFO +1 -1
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/pyproject.toml +1 -1
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/src/pytest_devant_cloud/client.py +36 -1
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/src/pytest_devant_cloud/plugin.py +159 -21
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/.gitignore +0 -0
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/LICENSE +0 -0
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/README.md +0 -0
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/src/pytest_devant_cloud/__init__.py +0 -0
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/src/pytest_devant_cloud/mapping.py +0 -0
- {pytest_devant_cloud-0.1.0 → pytest_devant_cloud-0.1.2}/tests/test_mapping.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-devant-cloud
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: pytest plugin that streams runs, results, and step trees to Devant Cloud's /v1/runs API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/devant-net/devq-cloud/tree/main/packages/pytest-devant-cloud
|
|
6
6
|
Project-URL: Repository, https://github.com/devant-net/devq-cloud
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-devant-cloud"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "pytest plugin that streams runs, results, and step trees to Devant Cloud's /v1/runs API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -8,6 +8,7 @@ wire-level concerns (auth, retries, JSON encoding).
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import os
|
|
11
12
|
import time
|
|
12
13
|
from dataclasses import dataclass
|
|
13
14
|
from typing import Any
|
|
@@ -52,10 +53,14 @@ class DevqClient:
|
|
|
52
53
|
# Re-use one Connection pool for the whole session — pytest can
|
|
53
54
|
# easily emit hundreds of POSTs in a fast suite, and TCP setup
|
|
54
55
|
# cost dwarfs the request itself otherwise.
|
|
56
|
+
# Content-Type intentionally NOT set here: httpx infers
|
|
57
|
+
# `application/json` from the `json=` kwarg used by _request(), and
|
|
58
|
+
# leaves multipart boundaries alone for `files=`-based uploads. A
|
|
59
|
+
# static client-level Content-Type would clobber the multipart
|
|
60
|
+
# boundary header on artifact uploads.
|
|
55
61
|
self._http = httpx.Client(
|
|
56
62
|
headers={
|
|
57
63
|
"Authorization": f"Bearer {api_token}",
|
|
58
|
-
"Content-Type": "application/json",
|
|
59
64
|
"Accept": "application/json",
|
|
60
65
|
},
|
|
61
66
|
timeout=timeout,
|
|
@@ -210,6 +215,36 @@ class DevqClient:
|
|
|
210
215
|
body = resp.json()
|
|
211
216
|
return body.get("results", [])
|
|
212
217
|
|
|
218
|
+
def upload_artifact(
|
|
219
|
+
self,
|
|
220
|
+
run_id: int,
|
|
221
|
+
result_id: int,
|
|
222
|
+
attempt_id: int,
|
|
223
|
+
*,
|
|
224
|
+
path: str,
|
|
225
|
+
name: str,
|
|
226
|
+
content_type: str,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Multipart POST a single file to the run/result/attempt's artifacts.
|
|
229
|
+
|
|
230
|
+
Mirrors @devant-net/reporter-core's `uploadArtifacts` wire shape:
|
|
231
|
+
one `file` part with the bytes, one `name` text part with the
|
|
232
|
+
display name shown in the Devant Cloud UI.
|
|
233
|
+
"""
|
|
234
|
+
with open(path, "rb") as fh:
|
|
235
|
+
file_bytes = fh.read()
|
|
236
|
+
# Reuse the configured httpx client (auth header, base url, timeouts)
|
|
237
|
+
# but bypass `_request`'s JSON-only path.
|
|
238
|
+
resp = self._http.post(
|
|
239
|
+
f"{self.api_url}/v1/runs/{run_id}/results/{result_id}/attempts/{attempt_id}/artifacts",
|
|
240
|
+
files={"file": (os.path.basename(path), file_bytes, content_type)},
|
|
241
|
+
data={"name": name},
|
|
242
|
+
)
|
|
243
|
+
if resp.status_code >= 400:
|
|
244
|
+
raise RuntimeError(
|
|
245
|
+
f"[devant-reporter] artifact upload {resp.status_code}: {resp.text[:200]}"
|
|
246
|
+
)
|
|
247
|
+
|
|
213
248
|
def submit_coverage(
|
|
214
249
|
self,
|
|
215
250
|
run_id: int,
|
|
@@ -9,7 +9,7 @@ Why these hooks (sourced from pytest 9.x reference docs):
|
|
|
9
9
|
the run yet — we want to know the test
|
|
10
10
|
count from collection first.
|
|
11
11
|
* pytest_sessionstart — `POST /v1/runs` once collection has happened
|
|
12
|
-
(unless
|
|
12
|
+
(unless DEVANT_CLOUD_RUN_ID is set; then we attach
|
|
13
13
|
to an externally-owned run).
|
|
14
14
|
* pytest_runtest_makereport — wrapper hook that stashes the phase report
|
|
15
15
|
(setup/call/teardown) on the item, so the
|
|
@@ -82,26 +82,26 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
82
82
|
"--devant-api-url",
|
|
83
83
|
action="store",
|
|
84
84
|
default=None,
|
|
85
|
-
help="Devant Cloud API URL (env:
|
|
85
|
+
help="Devant Cloud API URL (env: DEVANT_CLOUD_API_URL)",
|
|
86
86
|
)
|
|
87
87
|
group.addoption(
|
|
88
88
|
"--devant-token",
|
|
89
89
|
action="store",
|
|
90
90
|
default=None,
|
|
91
|
-
help="Bearer token (env:
|
|
91
|
+
help="Bearer token (env: DEVANT_CLOUD_TOKEN)",
|
|
92
92
|
)
|
|
93
93
|
group.addoption(
|
|
94
94
|
"--devant-project-id",
|
|
95
95
|
action="store",
|
|
96
96
|
type=int,
|
|
97
97
|
default=None,
|
|
98
|
-
help="Project id (env:
|
|
98
|
+
help="Project id (env: DEVANT_CLOUD_PROJECT_ID)",
|
|
99
99
|
)
|
|
100
100
|
group.addoption(
|
|
101
101
|
"--devant-run-name",
|
|
102
102
|
action="store",
|
|
103
103
|
default=None,
|
|
104
|
-
help="Run display name (env:
|
|
104
|
+
help="Run display name (env: DEVANT_CLOUD_RUN_NAME)",
|
|
105
105
|
)
|
|
106
106
|
group.addoption(
|
|
107
107
|
"--devant-run-id",
|
|
@@ -109,7 +109,7 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
109
109
|
type=int,
|
|
110
110
|
default=None,
|
|
111
111
|
help="Attach to an externally created run instead of creating one "
|
|
112
|
-
"(env:
|
|
112
|
+
"(env: DEVANT_CLOUD_RUN_ID)",
|
|
113
113
|
)
|
|
114
114
|
group.addoption(
|
|
115
115
|
"--devant-disable",
|
|
@@ -119,13 +119,38 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
119
119
|
)
|
|
120
120
|
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
_warned_legacy_env: set[str] = set()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _opt(
|
|
126
|
+
config: pytest.Config,
|
|
127
|
+
cli: str,
|
|
128
|
+
env: str,
|
|
129
|
+
default: Any,
|
|
130
|
+
*,
|
|
131
|
+
legacy_env: str | None = None,
|
|
132
|
+
) -> Any:
|
|
133
|
+
"""CLI flag wins, then canonical env var, then legacy env var (with a
|
|
134
|
+
one-time deprecation warning), then default.
|
|
135
|
+
|
|
136
|
+
The legacy_env parameter exists so historical DEVQ_* env vars keep working
|
|
137
|
+
while we transition to the DEVANT_CLOUD_* names. Will be dropped in a
|
|
138
|
+
future major release.
|
|
139
|
+
"""
|
|
124
140
|
v = config.getoption(cli, default=None)
|
|
125
141
|
if v is not None:
|
|
126
142
|
return v
|
|
127
143
|
if env in os.environ and os.environ[env] != "":
|
|
128
144
|
return os.environ[env]
|
|
145
|
+
if legacy_env and legacy_env in os.environ and os.environ[legacy_env] != "":
|
|
146
|
+
if legacy_env not in _warned_legacy_env:
|
|
147
|
+
_warned_legacy_env.add(legacy_env)
|
|
148
|
+
sys.stderr.write(
|
|
149
|
+
f'[devant] env var "{legacy_env}" is deprecated; please use '
|
|
150
|
+
f'"{env}" instead. Both work today; the legacy name will be '
|
|
151
|
+
"removed in a future major release.\n"
|
|
152
|
+
)
|
|
153
|
+
return os.environ[legacy_env]
|
|
129
154
|
return default
|
|
130
155
|
|
|
131
156
|
|
|
@@ -145,15 +170,24 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
145
170
|
return
|
|
146
171
|
|
|
147
172
|
api_url = _opt(
|
|
148
|
-
config,
|
|
173
|
+
config,
|
|
174
|
+
"--devant-api-url",
|
|
175
|
+
"DEVANT_CLOUD_API_URL",
|
|
176
|
+
"http://localhost:32124",
|
|
177
|
+
legacy_env="DEVQ_API_URL",
|
|
149
178
|
)
|
|
150
|
-
api_token_explicit = (
|
|
151
|
-
config
|
|
152
|
-
or os.environ.get("DEVQ_TOKEN")
|
|
179
|
+
api_token_explicit = config.getoption("--devant-token", default=None) or _opt(
|
|
180
|
+
config, "--devant-token", "DEVANT_CLOUD_TOKEN", None, legacy_env="DEVQ_TOKEN"
|
|
153
181
|
)
|
|
154
182
|
api_token = api_token_explicit or "dev-admin-token"
|
|
155
183
|
|
|
156
|
-
project_raw = _opt(
|
|
184
|
+
project_raw = _opt(
|
|
185
|
+
config,
|
|
186
|
+
"--devant-project-id",
|
|
187
|
+
"DEVANT_CLOUD_PROJECT_ID",
|
|
188
|
+
1,
|
|
189
|
+
legacy_env="DEVQ_PROJECT_ID",
|
|
190
|
+
)
|
|
157
191
|
try:
|
|
158
192
|
project_id = int(project_raw)
|
|
159
193
|
except (TypeError, ValueError):
|
|
@@ -161,8 +195,8 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
161
195
|
if project_id <= 0:
|
|
162
196
|
# Don't blow up the test run — just refuse to enable the plugin.
|
|
163
197
|
sys.stderr.write(
|
|
164
|
-
"[devant]
|
|
165
|
-
"disabled for this run\n"
|
|
198
|
+
"[devant] DEVANT_CLOUD_PROJECT_ID must be a positive integer; "
|
|
199
|
+
"reporter disabled for this run\n"
|
|
166
200
|
)
|
|
167
201
|
return
|
|
168
202
|
|
|
@@ -185,19 +219,26 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
185
219
|
)
|
|
186
220
|
if not is_local:
|
|
187
221
|
sys.stderr.write(
|
|
188
|
-
f'[devant]
|
|
189
|
-
f"against {api_url}. Set
|
|
190
|
-
"production use.\n"
|
|
222
|
+
f'[devant] DEVANT_CLOUD_TOKEN is not set; falling back to '
|
|
223
|
+
f'"dev-admin-token" against {api_url}. Set DEVANT_CLOUD_TOKEN '
|
|
224
|
+
"to a real CI token for production use.\n"
|
|
191
225
|
)
|
|
192
226
|
|
|
193
227
|
run_name = _opt(
|
|
194
228
|
config,
|
|
195
229
|
"--devant-run-name",
|
|
196
|
-
"
|
|
230
|
+
"DEVANT_CLOUD_RUN_NAME",
|
|
197
231
|
f"pytest — {_dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds')}",
|
|
232
|
+
legacy_env="DEVQ_RUN_NAME",
|
|
198
233
|
)
|
|
199
234
|
|
|
200
|
-
external_run_raw = _opt(
|
|
235
|
+
external_run_raw = _opt(
|
|
236
|
+
config,
|
|
237
|
+
"--devant-run-id",
|
|
238
|
+
"DEVANT_CLOUD_RUN_ID",
|
|
239
|
+
None,
|
|
240
|
+
legacy_env="DEVQ_RUN_ID",
|
|
241
|
+
)
|
|
201
242
|
external_run_id: int | None = None
|
|
202
243
|
if external_run_raw not in (None, ""):
|
|
203
244
|
try:
|
|
@@ -461,4 +502,101 @@ def _submit_one(
|
|
|
461
502
|
}
|
|
462
503
|
],
|
|
463
504
|
}
|
|
464
|
-
state.client.submit_results(state.run_id, [submit_payload])
|
|
505
|
+
inserted = state.client.submit_results(state.run_id, [submit_payload])
|
|
506
|
+
_maybe_upload_playwright_artifacts(state, item, inserted)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ── Playwright (pytest-playwright) artifact integration ─────────────────────
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _maybe_upload_playwright_artifacts(
|
|
513
|
+
state: _State,
|
|
514
|
+
item: pytest.Item,
|
|
515
|
+
inserted: list[dict[str, Any]],
|
|
516
|
+
) -> None:
|
|
517
|
+
"""If pytest-playwright wrote artifacts for this test, upload them.
|
|
518
|
+
|
|
519
|
+
pytest-playwright stores per-test artifacts at:
|
|
520
|
+
<--output dir>/<slugified-nodeid>/
|
|
521
|
+
trace.zip (--tracing on / retain-on-failure)
|
|
522
|
+
video.webm (--video on / retain-on-failure)
|
|
523
|
+
test-failed-N.png (--screenshot only-on-failure / on)
|
|
524
|
+
|
|
525
|
+
We compute the same path using pytest-playwright's own helpers (so the
|
|
526
|
+
slug formula stays in lockstep with upstream) and POST every file
|
|
527
|
+
inside as a multipart artifact attached to attempt 1.
|
|
528
|
+
|
|
529
|
+
Silent no-op when pytest-playwright isn't installed.
|
|
530
|
+
"""
|
|
531
|
+
try:
|
|
532
|
+
from pytest_playwright.pytest_playwright import _truncate_file_name # type: ignore
|
|
533
|
+
from slugify import slugify # type: ignore
|
|
534
|
+
except ImportError:
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
if not inserted:
|
|
538
|
+
return
|
|
539
|
+
record = inserted[0]
|
|
540
|
+
result_id = record.get("result_id")
|
|
541
|
+
attempts = record.get("attempt_ids", [])
|
|
542
|
+
if not result_id or not attempts:
|
|
543
|
+
return
|
|
544
|
+
attempt_id = attempts[0].get("id")
|
|
545
|
+
if not attempt_id:
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
output_root = item.session.config.getoption("--output", default="test-results")
|
|
550
|
+
except (ValueError, AttributeError):
|
|
551
|
+
return
|
|
552
|
+
from pathlib import Path
|
|
553
|
+
|
|
554
|
+
output_dir = Path(output_root).absolute() / _truncate_file_name(slugify(item.nodeid))
|
|
555
|
+
if not output_dir.is_dir():
|
|
556
|
+
return # no artifacts captured for this test (passed cleanly, etc.)
|
|
557
|
+
|
|
558
|
+
for path in sorted(output_dir.iterdir()):
|
|
559
|
+
if not path.is_file():
|
|
560
|
+
continue
|
|
561
|
+
try:
|
|
562
|
+
content_type = _guess_content_type(path)
|
|
563
|
+
display = _artifact_display_name(path)
|
|
564
|
+
state.client.upload_artifact(
|
|
565
|
+
state.run_id or 0,
|
|
566
|
+
int(result_id),
|
|
567
|
+
int(attempt_id),
|
|
568
|
+
path=str(path),
|
|
569
|
+
name=display,
|
|
570
|
+
content_type=content_type,
|
|
571
|
+
)
|
|
572
|
+
except Exception as exc: # noqa: BLE001
|
|
573
|
+
sys.stderr.write(
|
|
574
|
+
f'[devant] failed to upload "{path.name}" for "{item.nodeid}": {exc}\n'
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _guess_content_type(path: "Path") -> str: # noqa: F821 (Path imported above)
|
|
579
|
+
import mimetypes
|
|
580
|
+
|
|
581
|
+
suffix = path.suffix.lower()
|
|
582
|
+
if suffix == ".zip":
|
|
583
|
+
return "application/zip"
|
|
584
|
+
if suffix == ".webm":
|
|
585
|
+
return "video/webm"
|
|
586
|
+
if suffix == ".png":
|
|
587
|
+
return "image/png"
|
|
588
|
+
if suffix in (".jpg", ".jpeg"):
|
|
589
|
+
return "image/jpeg"
|
|
590
|
+
guessed, _ = mimetypes.guess_type(str(path))
|
|
591
|
+
return guessed or "application/octet-stream"
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _artifact_display_name(path: "Path") -> str: # noqa: F821
|
|
595
|
+
"""UI-friendly artifact name. `trace*.zip` gets normalised to `trace.zip`
|
|
596
|
+
so the Devant Cloud UI's trace-viewer detection works regardless of
|
|
597
|
+
upstream filename quirks (Playwright writes `trace-1.zip` for multi-page
|
|
598
|
+
tests)."""
|
|
599
|
+
name = path.name
|
|
600
|
+
if path.suffix.lower() == ".zip" and "trace" in name.lower():
|
|
601
|
+
return "trace.zip"
|
|
602
|
+
return name
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|