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.
@@ -0,0 +1,5 @@
1
+ """pytest-nexus: send pytest results to Nexus for Jira."""
2
+
3
+ from .plugin import VERSION as __version__
4
+
5
+ __all__ = ["__version__"]
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ nexus = pytest_nexus.plugin
@@ -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.