pytest-nexus 0.2.4__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_nexus/__init__.py
ADDED
pytest_nexus/plugin.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""pytest plugin: send the session's JUnit XML to Nexus for Jira when the run ends.
|
|
2
|
+
|
|
3
|
+
Enabled when a Nexus URL is configured (``--nexus-url`` or ``NEXUS_URL``); otherwise it
|
|
4
|
+
does nothing. If ``--junitxml`` is not given, the plugin writes its own report to a
|
|
5
|
+
temporary file. Upload problems only warn unless ``--nexus-fail-on-upload-error`` is set.
|
|
6
|
+
The API key is read from ``NEXUS_API_KEY`` (or ``--nexus-api-key``) and never printed.
|
|
7
|
+
Standard library only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Callable, Mapping, Optional
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"NexusConfig",
|
|
29
|
+
"resolve_config",
|
|
30
|
+
"config_problem",
|
|
31
|
+
"transport_problem",
|
|
32
|
+
"build_request_url",
|
|
33
|
+
"transport_body",
|
|
34
|
+
"post_results",
|
|
35
|
+
"ci_build_url",
|
|
36
|
+
"new_idempotency_key",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
VERSION = "0.2.4"
|
|
40
|
+
# 0.2.4 (10/10/2026): first PyPI release (`pip install pytest-nexus`); MIT licence. No behaviour change.
|
|
41
|
+
# 0.2.3 (28/09/2026, parity with the other reporters):
|
|
42
|
+
# - every upload carries an ``Idempotency-Key`` (docs/api-contracts.md section 1 "Durable
|
|
43
|
+
# deliveries"), the same on every resend of that report (continuations, retries and the
|
|
44
|
+
# raw-XML fallback), so Nexus treats all the sends as one delivery however late the last
|
|
45
|
+
# one arrives. ``--nexus-idempotency-key`` / NEXUS_IDEMPOTENCY_KEY sets it (16-128 of
|
|
46
|
+
# A-Z a-z 0-9 . _ : -, as for the nexus-upload CLI); otherwise ``pytest-nexus-<uuid>``,
|
|
47
|
+
# new for each run.
|
|
48
|
+
# - ``&buildUrl=``: the CI job's page, shown on each run as "Open CI job"
|
|
49
|
+
# (``--nexus-build-url`` / NEXUS_BUILD_URL, else the CI system's own variables: GitHub
|
|
50
|
+
# Actions, GitLab, Azure Pipelines, Jenkins, Bitbucket Pipelines, CircleCI). https only.
|
|
51
|
+
# 0.2.2 (28/09/2026, automation e2e F1): the JUnit XML is sent inside a JSON envelope,
|
|
52
|
+
# {"format": "junit-xml", "encoding": "base64", "content": ...} (or "encoding": "text" when
|
|
53
|
+
# base64 would pass the 5 MB cap), because raw line breaks in an XML body did not reach
|
|
54
|
+
# Nexus and stack traces lost their lines. Against an older Nexus (400 to the envelope,
|
|
55
|
+
# nothing recorded) the raw XML is sent once instead.
|
|
56
|
+
# 0.2.1 (28/09/2026, SEC27-M4 transport): the Nexus URL must be https; plain http is
|
|
57
|
+
# refused before the key or the report is sent, except to localhost/127.0.0.1/::1 when
|
|
58
|
+
# NEXUS_ALLOW_INSECURE_LOCALHOST=1 (local tests only). Redirects are never followed, so
|
|
59
|
+
# the bearer key is never re-sent to another address.
|
|
60
|
+
# 0.2.0 (27/09/2026, ingestion idempotency): a report that reaches Nexus's time limit
|
|
61
|
+
# part-way is sent again until Nexus has recorded all of it. Nexus remembers what each
|
|
62
|
+
# upload already recorded, so a resend never records a result twice; a 202 whose body
|
|
63
|
+
# says "partial", a 500, a 502/503/504 and a network error are all resent.
|
|
64
|
+
MAX_ATTEMPTS = 8
|
|
65
|
+
MAX_RETRY_DELAY = 15.0
|
|
66
|
+
_RESEND_STATUSES = (500, 502, 503, 504)
|
|
67
|
+
MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
68
|
+
PROJECT_KEY = re.compile(r"^[A-Z][A-Z0-9_]+$")
|
|
69
|
+
ISSUE_KEY = re.compile(r"^[A-Z][A-Z0-9_]+-\d+$")
|
|
70
|
+
INSECURE_LOCALHOST_ENV = "NEXUS_ALLOW_INSECURE_LOCALHOST"
|
|
71
|
+
_LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
72
|
+
# Nexus's rule for the Idempotency-Key header (docs/api-contracts.md section 1 "Replay protection").
|
|
73
|
+
IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{16,128}$")
|
|
74
|
+
MAX_BUILD_URL_CHARS = 500
|
|
75
|
+
|
|
76
|
+
# option dest -> environment variable
|
|
77
|
+
_ENV = {
|
|
78
|
+
"url": "NEXUS_URL",
|
|
79
|
+
"api_key": "NEXUS_API_KEY",
|
|
80
|
+
"project": "NEXUS_PROJECT",
|
|
81
|
+
"environment": "NEXUS_ENVIRONMENT",
|
|
82
|
+
"test_set": "NEXUS_TEST_SET",
|
|
83
|
+
"build": "NEXUS_BUILD",
|
|
84
|
+
"commit": "NEXUS_COMMIT",
|
|
85
|
+
"component": "NEXUS_COMPONENT",
|
|
86
|
+
"build_url": "NEXUS_BUILD_URL",
|
|
87
|
+
"idempotency_key": "NEXUS_IDEMPOTENCY_KEY",
|
|
88
|
+
}
|
|
89
|
+
_CI_BUILD = ("GITHUB_RUN_ID", "CI_PIPELINE_IID", "BUILD_BUILDNUMBER", "BITBUCKET_BUILD_NUMBER", "BUILD_NUMBER")
|
|
90
|
+
_CI_COMMIT = ("GITHUB_SHA", "CI_COMMIT_SHA", "BUILD_SOURCEVERSION", "BITBUCKET_COMMIT", "GIT_COMMIT")
|
|
91
|
+
|
|
92
|
+
_STATUS_HINT = {
|
|
93
|
+
400: "the report was not accepted (or the test set names nothing in this project)",
|
|
94
|
+
401: 'unauthorized: wrong, revoked or other-project key, or the key lacks the "ingest" scope',
|
|
95
|
+
413: "the report is over 5 MB or 500 results",
|
|
96
|
+
500: "Nexus stopped before the end (time limit) or had an unexpected error; see the delivery log in Project Settings > CI",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_STATE_KEY = pytest.StashKey["_NexusState"]()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class NexusConfig:
|
|
104
|
+
url: Optional[str] = None
|
|
105
|
+
api_key: Optional[str] = None
|
|
106
|
+
project: Optional[str] = None
|
|
107
|
+
environment: Optional[str] = None
|
|
108
|
+
test_set: Optional[str] = None
|
|
109
|
+
build: Optional[str] = None
|
|
110
|
+
commit: Optional[str] = None
|
|
111
|
+
component: Optional[str] = None
|
|
112
|
+
build_url: Optional[str] = None
|
|
113
|
+
idempotency_key: Optional[str] = None
|
|
114
|
+
fail_on_upload_error: bool = False
|
|
115
|
+
timeout: float = 60.0
|
|
116
|
+
# Test switch only: allow plain http to this machine (NEXUS_ALLOW_INSECURE_LOCALHOST=1).
|
|
117
|
+
allow_insecure_localhost: bool = False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class _NexusState:
|
|
122
|
+
config: NexusConfig
|
|
123
|
+
xml_path: str
|
|
124
|
+
temp_dir: Optional[str] = None
|
|
125
|
+
lines: list = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _clean(value: object) -> Optional[str]:
|
|
129
|
+
if value is None:
|
|
130
|
+
return None
|
|
131
|
+
text = str(value).strip()
|
|
132
|
+
return text or None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _first(env: Mapping[str, str], names) -> Optional[str]:
|
|
136
|
+
for name in names:
|
|
137
|
+
value = _clean(env.get(name))
|
|
138
|
+
if value:
|
|
139
|
+
return value
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _https_url(value: Optional[str]) -> Optional[str]:
|
|
144
|
+
"""``value`` when it is a plain https URL of at most 500 characters, else None."""
|
|
145
|
+
if not value or len(value) > MAX_BUILD_URL_CHARS or any(ch.isspace() for ch in value):
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
parsed = urllib.parse.urlsplit(value)
|
|
149
|
+
hostname = parsed.hostname
|
|
150
|
+
except ValueError:
|
|
151
|
+
return None
|
|
152
|
+
if parsed.scheme != "https" or not hostname or parsed.username is not None or parsed.password is not None:
|
|
153
|
+
return None
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ci_build_url(env: Mapping[str, str]) -> Optional[str]:
|
|
158
|
+
"""The CI job's own page from the CI system's standard variables (0.2.3, ``&buildUrl=``),
|
|
159
|
+
https only, else None. Same order as the nexus-upload CLI and the Playwright reporter:
|
|
160
|
+
GitHub Actions, GitLab, Azure Pipelines, Jenkins, Bitbucket Pipelines, CircleCI."""
|
|
161
|
+
|
|
162
|
+
def v(name: str) -> Optional[str]:
|
|
163
|
+
return _clean(env.get(name))
|
|
164
|
+
|
|
165
|
+
candidates = []
|
|
166
|
+
if v("GITHUB_SERVER_URL") and v("GITHUB_REPOSITORY") and v("GITHUB_RUN_ID"):
|
|
167
|
+
server = v("GITHUB_SERVER_URL").rstrip("/")
|
|
168
|
+
candidates.append(f"{server}/{v('GITHUB_REPOSITORY')}/actions/runs/{v('GITHUB_RUN_ID')}")
|
|
169
|
+
candidates.append(v("CI_JOB_URL"))
|
|
170
|
+
if v("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI") and v("SYSTEM_TEAMPROJECT") and v("BUILD_BUILDID"):
|
|
171
|
+
collection = v("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI").rstrip("/")
|
|
172
|
+
project = urllib.parse.quote(v("SYSTEM_TEAMPROJECT"), safe="!*'()")
|
|
173
|
+
build_id = urllib.parse.quote(v("BUILD_BUILDID"), safe="!*'()")
|
|
174
|
+
candidates.append(f"{collection}/{project}/_build/results?buildId={build_id}")
|
|
175
|
+
candidates.append(v("BUILD_URL"))
|
|
176
|
+
if v("BITBUCKET_REPO_FULL_NAME") and v("BITBUCKET_BUILD_NUMBER"):
|
|
177
|
+
candidates.append(f"https://bitbucket.org/{v('BITBUCKET_REPO_FULL_NAME')}/pipelines/results/{v('BITBUCKET_BUILD_NUMBER')}")
|
|
178
|
+
candidates.append(v("CIRCLE_BUILD_URL"))
|
|
179
|
+
for candidate in candidates:
|
|
180
|
+
url = _https_url(candidate)
|
|
181
|
+
if url:
|
|
182
|
+
return url
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def new_idempotency_key() -> str:
|
|
187
|
+
"""A new retry key for one report upload (0.2.3)."""
|
|
188
|
+
return f"pytest-nexus-{uuid.uuid4()}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resolve_config(options: Mapping[str, object], env: Mapping[str, str]) -> NexusConfig:
|
|
192
|
+
"""CLI options over NEXUS_* env vars over the CI system's own build/commit/job-page variables."""
|
|
193
|
+
values = {name: _clean(options.get(name)) or _clean(env.get(var)) for name, var in _ENV.items()}
|
|
194
|
+
values["build"] = values["build"] or _first(env, _CI_BUILD)
|
|
195
|
+
values["commit"] = values["commit"] or _first(env, _CI_COMMIT)
|
|
196
|
+
# As in the Playwright reporter: a given value that is not https is dropped and the CI
|
|
197
|
+
# system's own job page is used instead.
|
|
198
|
+
values["build_url"] = _https_url(values["build_url"]) or ci_build_url(env)
|
|
199
|
+
return NexusConfig(
|
|
200
|
+
**values,
|
|
201
|
+
fail_on_upload_error=bool(options.get("fail_on_upload_error")),
|
|
202
|
+
allow_insecure_localhost=env.get(INSECURE_LOCALHOST_ENV) == "1",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def transport_problem(cfg: NexusConfig) -> Optional[str]:
|
|
207
|
+
"""Why the key must not be sent to ``cfg.url``, or None: https always; http only to
|
|
208
|
+
localhost/127.0.0.1/::1 and only with NEXUS_ALLOW_INSECURE_LOCALHOST=1 (tests)."""
|
|
209
|
+
parsed = urllib.parse.urlparse(cfg.url or "")
|
|
210
|
+
if not parsed.netloc:
|
|
211
|
+
return "NEXUS_URL is not a valid https URL"
|
|
212
|
+
if parsed.scheme == "https":
|
|
213
|
+
return None
|
|
214
|
+
if parsed.scheme == "http" and cfg.allow_insecure_localhost and (parsed.hostname or "").lower() in _LOOPBACK_HOSTS:
|
|
215
|
+
return None
|
|
216
|
+
return "NEXUS_URL is not a valid https URL (plain http would expose the API key)"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
220
|
+
"""Never follow a redirect: urllib would copy the Authorization header onto it."""
|
|
221
|
+
|
|
222
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _open(request, timeout):
|
|
230
|
+
return _OPENER.open(request, timeout=timeout)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def config_problem(cfg: NexusConfig) -> Optional[str]:
|
|
234
|
+
if not cfg.url:
|
|
235
|
+
return "NEXUS_URL is not set"
|
|
236
|
+
problem = transport_problem(cfg)
|
|
237
|
+
if problem:
|
|
238
|
+
return problem
|
|
239
|
+
if not cfg.api_key:
|
|
240
|
+
return "NEXUS_API_KEY is not set"
|
|
241
|
+
if not cfg.project:
|
|
242
|
+
return "NEXUS_PROJECT is not set"
|
|
243
|
+
if not PROJECT_KEY.match(cfg.project):
|
|
244
|
+
return f'"{cfg.project}" is not a Jira project key'
|
|
245
|
+
if cfg.test_set and not ISSUE_KEY.match(cfg.test_set):
|
|
246
|
+
return f'"{cfg.test_set}" is not an issue key (e.g. NEX-25)'
|
|
247
|
+
if cfg.idempotency_key and not IDEMPOTENCY_KEY.match(cfg.idempotency_key):
|
|
248
|
+
return 'the idempotency key takes 16-128 letters, digits, ".", "_", ":" or "-"'
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_request_url(cfg: NexusConfig) -> str:
|
|
253
|
+
parts = urllib.parse.urlsplit(cfg.url)
|
|
254
|
+
query = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
|
|
255
|
+
params = {
|
|
256
|
+
"project": cfg.project,
|
|
257
|
+
"format": "junit-xml",
|
|
258
|
+
"environment": cfg.environment,
|
|
259
|
+
"testSet": cfg.test_set,
|
|
260
|
+
"build": cfg.build,
|
|
261
|
+
"commit": cfg.commit,
|
|
262
|
+
"component": cfg.component,
|
|
263
|
+
"buildUrl": cfg.build_url,
|
|
264
|
+
}
|
|
265
|
+
names = set(params)
|
|
266
|
+
query = [(k, v) for k, v in query if k not in names] + [(k, v) for k, v in params.items() if v]
|
|
267
|
+
return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query)))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _redact(text: str, key: Optional[str]) -> str:
|
|
271
|
+
return text.replace(key, "***") if key else text
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_partial(body: bytes) -> bool:
|
|
275
|
+
"""A 2xx body saying {"status": "partial"}: Nexus recorded part of the upload."""
|
|
276
|
+
try:
|
|
277
|
+
parsed = json.loads(body.decode("utf-8"))
|
|
278
|
+
except (ValueError, UnicodeDecodeError):
|
|
279
|
+
return False
|
|
280
|
+
return isinstance(parsed, dict) and parsed.get("status") == "partial"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def transport_body(body: bytes) -> tuple:
|
|
284
|
+
"""(data, content_type, encoding) for one JUnit XML report (0.2.2, docs/api-contracts.md
|
|
285
|
+
section 1 "Transport: the upload envelope"): base64-wrapped in JSON so its line breaks
|
|
286
|
+
survive; the plain JSON-string form when base64 would pass the 5 MB cap; the raw XML
|
|
287
|
+
only when even that would."""
|
|
288
|
+
for encoding in ("base64", "text"):
|
|
289
|
+
if encoding == "base64":
|
|
290
|
+
content = base64.b64encode(body).decode("ascii")
|
|
291
|
+
else:
|
|
292
|
+
content = body.decode("utf-8", errors="replace")
|
|
293
|
+
wrapped = json.dumps({"format": "junit-xml", "encoding": encoding, "content": content}).encode("utf-8")
|
|
294
|
+
if len(wrapped) <= MAX_BODY_BYTES:
|
|
295
|
+
return wrapped, "application/json", encoding
|
|
296
|
+
return body, "application/xml", "none"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def post_results(
|
|
300
|
+
cfg: NexusConfig,
|
|
301
|
+
body: bytes,
|
|
302
|
+
opener: Callable = _open,
|
|
303
|
+
retry_delay: float = 2.0,
|
|
304
|
+
max_attempts: int = MAX_ATTEMPTS,
|
|
305
|
+
sleep: Callable = time.sleep,
|
|
306
|
+
) -> tuple:
|
|
307
|
+
"""POST the JUnit XML. Returns (ok, message). Sends the same upload again until Nexus
|
|
308
|
+
has recorded all of it: after a 202 "partial", a 500 (time limit reached part-way), a
|
|
309
|
+
502/503/504 or a network error, up to ``max_attempts`` sends with a pause doubling
|
|
310
|
+
from ``retry_delay`` to 15 s. Safe: Nexus never records a result of one upload twice."""
|
|
311
|
+
problem = transport_problem(cfg)
|
|
312
|
+
if problem:
|
|
313
|
+
return False, problem
|
|
314
|
+
last = ""
|
|
315
|
+
data, content_type, encoding = transport_body(body)
|
|
316
|
+
# 0.2.2: a Nexus older than 28/09/2026 does not know the envelope and answers 400
|
|
317
|
+
# (nothing recorded); the raw XML is then sent once instead.
|
|
318
|
+
fell_back = encoding == "none"
|
|
319
|
+
# 0.2.3: one key for this upload, repeated on every send below.
|
|
320
|
+
idempotency_key = cfg.idempotency_key or new_idempotency_key()
|
|
321
|
+
attempt = 0
|
|
322
|
+
while attempt < max_attempts:
|
|
323
|
+
attempt += 1
|
|
324
|
+
request = urllib.request.Request(
|
|
325
|
+
build_request_url(cfg),
|
|
326
|
+
data=data,
|
|
327
|
+
method="POST",
|
|
328
|
+
headers={
|
|
329
|
+
"Authorization": f"Bearer {cfg.api_key}",
|
|
330
|
+
"Content-Type": content_type,
|
|
331
|
+
"Idempotency-Key": idempotency_key,
|
|
332
|
+
"User-Agent": f"pytest-nexus/{VERSION}",
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
try:
|
|
336
|
+
with opener(request, timeout=cfg.timeout) as response:
|
|
337
|
+
status = response.status
|
|
338
|
+
text = response.read()
|
|
339
|
+
if not _is_partial(text or b""):
|
|
340
|
+
return True, f"HTTP {status}"
|
|
341
|
+
last = f"HTTP {status} (Nexus recorded part of the report)"
|
|
342
|
+
except urllib.error.HTTPError as err:
|
|
343
|
+
status = err.code
|
|
344
|
+
if 300 <= status < 400:
|
|
345
|
+
return False, (
|
|
346
|
+
f"HTTP {status} (a redirect, not followed so the API key is never sent elsewhere; "
|
|
347
|
+
"copy the Results URL again from Project Settings > CI)"
|
|
348
|
+
)
|
|
349
|
+
hint = _STATUS_HINT.get(status)
|
|
350
|
+
last = f"HTTP {status}" + (f" ({hint})" if hint else "")
|
|
351
|
+
if status == 400 and not fell_back:
|
|
352
|
+
fell_back = True
|
|
353
|
+
data, content_type = body, "application/xml"
|
|
354
|
+
attempt -= 1
|
|
355
|
+
continue
|
|
356
|
+
if status not in _RESEND_STATUSES:
|
|
357
|
+
return False, last
|
|
358
|
+
except (urllib.error.URLError, OSError) as err:
|
|
359
|
+
reason = getattr(err, "reason", err)
|
|
360
|
+
last = f"network error: {_redact(str(reason), cfg.api_key)}"
|
|
361
|
+
if attempt < max_attempts:
|
|
362
|
+
sleep(min(retry_delay * 2 ** (attempt - 1), MAX_RETRY_DELAY))
|
|
363
|
+
return False, f"{last} (gave up after {max_attempts} sends)"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# --------------------------------------------------------------------------- hooks
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def pytest_addoption(parser):
|
|
370
|
+
group = parser.getgroup("nexus", "send results to Nexus for Jira")
|
|
371
|
+
group.addoption("--nexus-url", dest="nexus_url", help="Results URL from Project Settings > CI (default: NEXUS_URL)")
|
|
372
|
+
group.addoption("--nexus-api-key", dest="nexus_api_key", help="API key with the ingest scope; prefer the NEXUS_API_KEY env var")
|
|
373
|
+
group.addoption("--nexus-project", dest="nexus_project", help="Jira project key (default: NEXUS_PROJECT)")
|
|
374
|
+
group.addoption("--nexus-environment", dest="nexus_environment", help="Environment label (default: NEXUS_ENVIRONMENT)")
|
|
375
|
+
group.addoption("--nexus-test-set", dest="nexus_test_set", help="Test Set (cycle) key, e.g. NEX-25 (default: NEXUS_TEST_SET)")
|
|
376
|
+
group.addoption("--nexus-build", dest="nexus_build", help="Build id (default: NEXUS_BUILD, else the CI build number)")
|
|
377
|
+
group.addoption("--nexus-commit", dest="nexus_commit", help="Commit (default: NEXUS_COMMIT, else the CI commit SHA)")
|
|
378
|
+
group.addoption("--nexus-component", dest="nexus_component", help="Component label (default: NEXUS_COMPONENT)")
|
|
379
|
+
group.addoption(
|
|
380
|
+
"--nexus-build-url",
|
|
381
|
+
dest="nexus_build_url",
|
|
382
|
+
help="CI job page, https (default: NEXUS_BUILD_URL, else the CI system's own variables)",
|
|
383
|
+
)
|
|
384
|
+
group.addoption(
|
|
385
|
+
"--nexus-idempotency-key",
|
|
386
|
+
dest="nexus_idempotency_key",
|
|
387
|
+
help="Retry key sent on every send of the report, 16-128 of A-Z a-z 0-9 . _ : - (default: NEXUS_IDEMPOTENCY_KEY, else a new one per run)",
|
|
388
|
+
)
|
|
389
|
+
group.addoption(
|
|
390
|
+
"--nexus-fail-on-upload-error",
|
|
391
|
+
dest="nexus_fail_on_upload_error",
|
|
392
|
+
action="store_true",
|
|
393
|
+
default=False,
|
|
394
|
+
help="Fail the pytest run when the upload fails (default: warn only)",
|
|
395
|
+
)
|
|
396
|
+
group.addoption("--no-nexus", dest="nexus_disabled", action="store_true", default=False, help="Do not send results to Nexus")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _options(config) -> dict:
|
|
400
|
+
return {
|
|
401
|
+
name: config.getoption(f"nexus_{name}")
|
|
402
|
+
for name in (
|
|
403
|
+
"url",
|
|
404
|
+
"api_key",
|
|
405
|
+
"project",
|
|
406
|
+
"environment",
|
|
407
|
+
"test_set",
|
|
408
|
+
"build",
|
|
409
|
+
"commit",
|
|
410
|
+
"component",
|
|
411
|
+
"build_url",
|
|
412
|
+
"idempotency_key",
|
|
413
|
+
"fail_on_upload_error",
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@pytest.hookimpl(tryfirst=True)
|
|
419
|
+
def pytest_configure(config):
|
|
420
|
+
"""Runs before the junitxml plugin's own configure, so a missing --junitxml can be
|
|
421
|
+
pointed at a temporary file."""
|
|
422
|
+
if config.getoption("nexus_disabled") or hasattr(config, "workerinput"):
|
|
423
|
+
return
|
|
424
|
+
cfg = resolve_config(_options(config), os.environ)
|
|
425
|
+
if not cfg.url:
|
|
426
|
+
return # not configured: stay out of the way
|
|
427
|
+
temp_dir = None
|
|
428
|
+
xml_path = config.option.xmlpath
|
|
429
|
+
if not xml_path:
|
|
430
|
+
temp_dir = tempfile.mkdtemp(prefix="pytest-nexus-")
|
|
431
|
+
xml_path = os.path.join(temp_dir, "results.xml")
|
|
432
|
+
config.option.xmlpath = xml_path
|
|
433
|
+
config.stash[_STATE_KEY] = _NexusState(config=cfg, xml_path=os.path.abspath(xml_path), temp_dir=temp_dir)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@pytest.hookimpl(trylast=True)
|
|
437
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
438
|
+
"""Runs after the junitxml plugin has written its file."""
|
|
439
|
+
config = session.config
|
|
440
|
+
state = config.stash.get(_STATE_KEY, None)
|
|
441
|
+
if state is None:
|
|
442
|
+
return
|
|
443
|
+
cfg = state.config
|
|
444
|
+
ok, message = _send(state)
|
|
445
|
+
say = f"nexus: {_redact(message, cfg.api_key)}"
|
|
446
|
+
state.lines.append(say)
|
|
447
|
+
if not ok and cfg.fail_on_upload_error and session.exitstatus == 0:
|
|
448
|
+
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _send(state: _NexusState) -> tuple:
|
|
452
|
+
cfg = state.config
|
|
453
|
+
problem = config_problem(cfg)
|
|
454
|
+
if problem:
|
|
455
|
+
return False, f"results not sent: {problem}."
|
|
456
|
+
try:
|
|
457
|
+
size = os.path.getsize(state.xml_path)
|
|
458
|
+
except OSError:
|
|
459
|
+
return False, "results not sent: no JUnit XML was written."
|
|
460
|
+
if size > MAX_BODY_BYTES:
|
|
461
|
+
return False, "results not sent: the JUnit XML is over 5 MB. Split the run."
|
|
462
|
+
with open(state.xml_path, "rb") as handle:
|
|
463
|
+
body = handle.read()
|
|
464
|
+
host = urllib.parse.urlparse(cfg.url).netloc
|
|
465
|
+
ok, detail = post_results(cfg, body)
|
|
466
|
+
if ok:
|
|
467
|
+
return True, f"sent results to project {cfg.project} at {host} ({detail}). See Project Settings > CI for the delivery log."
|
|
468
|
+
return False, f"results not sent to {host}: {detail}"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
|
472
|
+
state = config.stash.get(_STATE_KEY, None)
|
|
473
|
+
if state is None:
|
|
474
|
+
return
|
|
475
|
+
for line in state.lines:
|
|
476
|
+
terminalreporter.write_line(line)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def pytest_unconfigure(config):
|
|
480
|
+
state = config.stash.get(_STATE_KEY, None)
|
|
481
|
+
if state is not None and state.temp_dir:
|
|
482
|
+
import shutil
|
|
483
|
+
|
|
484
|
+
shutil.rmtree(state.temp_dir, ignore_errors=True)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-nexus
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: pytest plugin that sends each run's JUnit XML results to Nexus for Jira.
|
|
5
|
+
Project-URL: Homepage, https://nexus.resync.nz
|
|
6
|
+
Project-URL: Documentation, https://nexus.resync.nz/docs/automation/
|
|
7
|
+
Author: Resync Consulting Limited
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: jira,junit,nexus,pytest,test management
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Requires-Dist: pytest>=7.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pytest-nexus
|
|
21
|
+
|
|
22
|
+
A pytest plugin that sends each run's results to **Nexus** for Jira. When the session
|
|
23
|
+
ends it posts the JUnit XML report to your project's Nexus results URL, where each test is
|
|
24
|
+
matched to a Test Case by its Automation Key.
|
|
25
|
+
|
|
26
|
+
Standard library only (plus pytest 7 or later). Python 3.8+.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
From PyPI:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install pytest-nexus
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The same wheel and source archive are also on
|
|
37
|
+
[nexus.resync.nz/integrations](https://nexus.resync.nz/integrations/), with checksums in
|
|
38
|
+
[SHA256SUMS.txt](https://nexus.resync.nz/downloads/SHA256SUMS.txt).
|
|
39
|
+
|
|
40
|
+
The plugin loads automatically. It does nothing until a Nexus URL is configured.
|
|
41
|
+
|
|
42
|
+
## Configure
|
|
43
|
+
|
|
44
|
+
In Jira, open **Project Settings → Nexus → CI**, create an API key with the
|
|
45
|
+
**ingest** scope and copy the results URL. Store both as CI secrets:
|
|
46
|
+
|
|
47
|
+
| Environment variable | CLI option | Meaning |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `NEXUS_URL` | `--nexus-url` | Results URL (required) |
|
|
50
|
+
| `NEXUS_API_KEY` | `--nexus-api-key` | API key (required; prefer the env var) |
|
|
51
|
+
| `NEXUS_PROJECT` | `--nexus-project` | Jira project key, e.g. `NEX` (required) |
|
|
52
|
+
| `NEXUS_ENVIRONMENT` | `--nexus-environment` | Environment label, e.g. `staging` |
|
|
53
|
+
| `NEXUS_TEST_SET` | `--nexus-test-set` | Test Set (cycle) to record into, e.g. `NEX-25` |
|
|
54
|
+
| `NEXUS_BUILD` | `--nexus-build` | Build id. Default: the CI system's build number |
|
|
55
|
+
| `NEXUS_COMMIT` | `--nexus-commit` | Commit. Default: the CI system's commit SHA |
|
|
56
|
+
| `NEXUS_COMPONENT` | `--nexus-component` | Component label, e.g. `api` |
|
|
57
|
+
| `NEXUS_BUILD_URL` | `--nexus-build-url` | CI job page (https), shown on each run as "Open CI job". Default: the CI system's own job page (0.2.3) |
|
|
58
|
+
| `NEXUS_IDEMPOTENCY_KEY` | `--nexus-idempotency-key` | Retry key, 16-128 of `A-Z a-z 0-9 . _ : -`. Default: a new random key per run (0.2.3) |
|
|
59
|
+
|
|
60
|
+
Other options:
|
|
61
|
+
|
|
62
|
+
- `--nexus-fail-on-upload-error`: fail the pytest run (exit code 1) when the upload
|
|
63
|
+
fails. By default an upload problem only prints a warning and the exit code is
|
|
64
|
+
whatever your tests produced.
|
|
65
|
+
- `--no-nexus`: turn the plugin off for one run.
|
|
66
|
+
|
|
67
|
+
Build, commit and the CI job page are filled automatically on GitHub Actions, GitLab,
|
|
68
|
+
Azure Pipelines, Bitbucket Pipelines and Jenkins (the job page also on CircleCI). A job page
|
|
69
|
+
that is not https is never sent.
|
|
70
|
+
|
|
71
|
+
## Name your tests with the Automation Key
|
|
72
|
+
|
|
73
|
+
Put the Test Case's Automation Key in the test name. Python names cannot contain `-`, so
|
|
74
|
+
use `_`; Nexus reads `NEX_11` as `NEX-11`:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
def test_NEX_11_valid_user_signs_in(driver):
|
|
78
|
+
...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Results whose name carries no known key are listed as *unmatched* in the delivery log.
|
|
82
|
+
|
|
83
|
+
## Example
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
export NEXUS_URL=... # from your CI secret store
|
|
87
|
+
export NEXUS_API_KEY=...
|
|
88
|
+
pytest --nexus-project NEX --nexus-environment staging
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If you already pass `--junitxml=path.xml`, that file is the one sent. Otherwise the
|
|
92
|
+
plugin writes a temporary report and removes it afterwards. With pytest-xdist, only the
|
|
93
|
+
controller sends.
|
|
94
|
+
|
|
95
|
+
Output at the end of the run:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
nexus: sent results to project NEX at <your-site>.atlassian-dev.net (HTTP 202). See Project Settings > CI for the delivery log.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Nexus answers `202` with `{"status":"accepted"}` only after it has processed every
|
|
102
|
+
result of the report (SEC27-H9, 28/09/2026: nothing is left running in the background
|
|
103
|
+
behind an "accepted"); the per-test outcome (matched, unmatched, skipped) is in the delivery
|
|
104
|
+
log on the CI page. A `{"status":"partial"}` answer means Nexus holds the report and what it
|
|
105
|
+
recorded so far and finishes the rest itself; the reporter sends it again to confirm.
|
|
106
|
+
The API key is never printed. Reports over 5 MB or 500 results must be split.
|
|
107
|
+
A report that reaches Nexus's time limit part-way (a 202 whose body says "partial", or a
|
|
108
|
+
500), a 502/503/504 or a network error is sent again, up to 8 sends, until Nexus has
|
|
109
|
+
recorded all of it; a resend never records a result twice (0.2.0, 27/09/2026). Since 0.2.3
|
|
110
|
+
every send of one report carries the same `Idempotency-Key` header, so Nexus treats a late
|
|
111
|
+
resend as the same delivery, never as a new run.
|
|
112
|
+
|
|
113
|
+
## Develop
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
python -m pytest # from this folder; uses a local fake Nexus, no network
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Licence
|
|
120
|
+
|
|
121
|
+
MIT. © 2026 Resync Consulting Limited. See `LICENSE`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pytest_nexus/__init__.py,sha256=KR3NPPtg2OoKtfUI4fr1UJF3qhO6OhJTTbasN94LKiE,130
|
|
2
|
+
pytest_nexus/plugin.py,sha256=2RFBVzrETP6sluqgM8p1-JFRU_JNkJHKBalZfJq_ioI,20009
|
|
3
|
+
pytest_nexus-0.2.4.dist-info/METADATA,sha256=0PaeM0AqQscqfnO_SjrEdKi-CBVVXT6CqZ-xEL7Cdj8,4924
|
|
4
|
+
pytest_nexus-0.2.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
pytest_nexus-0.2.4.dist-info/entry_points.txt,sha256=WijT5HT1Z9gpB5F_bL3QvyNmaSVh-5eD-fMqXjAyedQ,39
|
|
6
|
+
pytest_nexus-0.2.4.dist-info/licenses/LICENSE,sha256=XEkfIfI1uCrdzb_3NfYFYGmV3qMFygQvL35QxkAWVNE,1082
|
|
7
|
+
pytest_nexus-0.2.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Resync Consulting Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|