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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-devant-cloud
3
- Version: 0.1.0
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.0"
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 DEVQ_RUN_ID is set; then we attach
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: DEVQ_API_URL)",
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: DEVQ_TOKEN)",
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: DEVQ_PROJECT_ID)",
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: DEVQ_RUN_NAME)",
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: DEVQ_RUN_ID)",
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
- def _opt(config: pytest.Config, cli: str, env: str, default: Any) -> Any:
123
- """CLI flag wins, then env var, then default."""
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, "--devant-api-url", "DEVQ_API_URL", "http://localhost:32124"
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.getoption("--devant-token", default=None)
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(config, "--devant-project-id", "DEVQ_PROJECT_ID", 1)
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] DEVQ_PROJECT_ID must be a positive integer; reporter "
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] 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"
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
- "DEVQ_RUN_NAME",
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(config, "--devant-run-id", "DEVQ_RUN_ID", None)
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