testrelic-pytest 0.1.1__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,35 @@
1
+ """TestRelic generic pytest reporter — pytest plugin and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from testrelic_pytest._version import __version__
6
+ from testrelic_pytest.config import (
7
+ CloudConfig,
8
+ PytestPluginConfig,
9
+ resolve_config,
10
+ )
11
+ from testrelic_pytest.schema import (
12
+ CapturedOutput,
13
+ CIMetadata,
14
+ FailureDiagnostic,
15
+ PhaseResult,
16
+ PytestResult,
17
+ PytestRunReport,
18
+ Status,
19
+ Summary,
20
+ )
21
+
22
+ __all__ = [
23
+ "CIMetadata",
24
+ "CapturedOutput",
25
+ "CloudConfig",
26
+ "FailureDiagnostic",
27
+ "PhaseResult",
28
+ "PytestPluginConfig",
29
+ "PytestResult",
30
+ "PytestRunReport",
31
+ "Status",
32
+ "Summary",
33
+ "__version__",
34
+ "resolve_config",
35
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,102 @@
1
+ """Detect CI provider and pull build metadata from environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ from testrelic_pytest.schema import CIMetadata
9
+
10
+
11
+ def detect_ci() -> Optional[CIMetadata]:
12
+ """Return a populated `CIMetadata` if we recognise the CI provider."""
13
+ env = os.environ
14
+
15
+ if env.get("GITHUB_ACTIONS") == "true":
16
+ return CIMetadata(
17
+ provider="github-actions",
18
+ buildId=env.get("GITHUB_RUN_ID"),
19
+ commitSha=env.get("GITHUB_SHA"),
20
+ branch=env.get("GITHUB_REF_NAME"),
21
+ prNumber=_extract_pr_number(env.get("GITHUB_REF")),
22
+ workflow=env.get("GITHUB_WORKFLOW"),
23
+ runUrl=_github_run_url(dict(env)),
24
+ )
25
+
26
+ if env.get("GITLAB_CI") == "true":
27
+ return CIMetadata(
28
+ provider="gitlab-ci",
29
+ buildId=env.get("CI_PIPELINE_ID"),
30
+ commitSha=env.get("CI_COMMIT_SHA"),
31
+ branch=env.get("CI_COMMIT_REF_NAME"),
32
+ prNumber=env.get("CI_MERGE_REQUEST_IID"),
33
+ workflow=env.get("CI_JOB_NAME"),
34
+ runUrl=env.get("CI_PIPELINE_URL"),
35
+ )
36
+
37
+ if env.get("JENKINS_URL"):
38
+ return CIMetadata(
39
+ provider="jenkins",
40
+ buildId=env.get("BUILD_ID") or env.get("BUILD_NUMBER"),
41
+ commitSha=env.get("GIT_COMMIT"),
42
+ branch=env.get("GIT_BRANCH"),
43
+ workflow=env.get("JOB_NAME"),
44
+ runUrl=env.get("BUILD_URL"),
45
+ )
46
+
47
+ if env.get("CIRCLECI") == "true":
48
+ return CIMetadata(
49
+ provider="circleci",
50
+ buildId=env.get("CIRCLE_BUILD_NUM"),
51
+ commitSha=env.get("CIRCLE_SHA1"),
52
+ branch=env.get("CIRCLE_BRANCH"),
53
+ prNumber=_pr_from_url(env.get("CIRCLE_PULL_REQUEST")),
54
+ workflow=env.get("CIRCLE_JOB"),
55
+ runUrl=env.get("CIRCLE_BUILD_URL"),
56
+ )
57
+
58
+ if env.get("BITBUCKET_PIPELINE_UUID"):
59
+ return CIMetadata(
60
+ provider="bitbucket-pipelines",
61
+ buildId=env.get("BITBUCKET_BUILD_NUMBER"),
62
+ commitSha=env.get("BITBUCKET_COMMIT"),
63
+ branch=env.get("BITBUCKET_BRANCH"),
64
+ prNumber=env.get("BITBUCKET_PR_ID"),
65
+ workflow=env.get("BITBUCKET_PIPELINE_UUID"),
66
+ runUrl=_bitbucket_run_url(dict(env)),
67
+ )
68
+
69
+ return None
70
+
71
+
72
+ def _extract_pr_number(ref: Optional[str]) -> Optional[str]:
73
+ if not ref:
74
+ return None
75
+ parts = ref.split("/")
76
+ if len(parts) >= 3 and parts[1] == "pull":
77
+ return parts[2]
78
+ return None
79
+
80
+
81
+ def _github_run_url(env: dict[str, str]) -> Optional[str]:
82
+ server = env.get("GITHUB_SERVER_URL")
83
+ repo = env.get("GITHUB_REPOSITORY")
84
+ run = env.get("GITHUB_RUN_ID")
85
+ if server and repo and run:
86
+ return f"{server}/{repo}/actions/runs/{run}"
87
+ return None
88
+
89
+
90
+ def _bitbucket_run_url(env: dict[str, str]) -> Optional[str]:
91
+ workspace = env.get("BITBUCKET_WORKSPACE")
92
+ repo = env.get("BITBUCKET_REPO_SLUG")
93
+ build = env.get("BITBUCKET_BUILD_NUMBER")
94
+ if workspace and repo and build:
95
+ return f"https://bitbucket.org/{workspace}/{repo}/pipelines/results/{build}"
96
+ return None
97
+
98
+
99
+ def _pr_from_url(url: Optional[str]) -> Optional[str]:
100
+ if not url:
101
+ return None
102
+ return url.rstrip("/").split("/")[-1] or None
@@ -0,0 +1,59 @@
1
+ """`testrelic-pytest` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from testrelic_pytest._version import __version__
12
+
13
+ app = typer.Typer(
14
+ name="testrelic-pytest",
15
+ help="TestRelic generic pytest reporter CLI",
16
+ add_completion=False,
17
+ )
18
+
19
+
20
+ @app.command()
21
+ def version() -> None:
22
+ """Print the package version."""
23
+ typer.echo(__version__)
24
+
25
+
26
+ @app.command(name="drain")
27
+ def drain(
28
+ queue_dir: Optional[Path] = typer.Option(
29
+ None,
30
+ "--queue-dir",
31
+ help="Override the queue directory. Defaults to .testrelic/queue/.",
32
+ ),
33
+ ) -> None:
34
+ """Retry queued failed uploads."""
35
+ from testrelic_pytest.cloud.queue import drain_queue
36
+
37
+ counts = drain_queue(queue_dir)
38
+ typer.echo(json.dumps(counts, indent=2))
39
+
40
+
41
+ @app.command()
42
+ def status(
43
+ queue_dir: Optional[Path] = typer.Option(
44
+ None,
45
+ "--queue-dir",
46
+ help="Queue directory to inspect. Defaults to .testrelic/queue/.",
47
+ ),
48
+ ) -> None:
49
+ """Show how many uploads are pending replay."""
50
+ target = Path(queue_dir) if queue_dir else Path(".testrelic") / "queue"
51
+ if not target.exists():
52
+ typer.echo(json.dumps({"queued": 0, "queue_dir": str(target)}, indent=2))
53
+ return
54
+ queued = len(list(target.glob("*.json")))
55
+ typer.echo(json.dumps({"queued": queued, "queue_dir": str(target)}, indent=2))
56
+
57
+
58
+ if __name__ == "__main__":
59
+ app()
@@ -0,0 +1,64 @@
1
+ """Cloud upload package — session/auth lifecycle, request layer, queue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from testrelic_pytest.cloud.artifact import (
6
+ ArtifactType,
7
+ ArtifactUploadRequest,
8
+ ArtifactUploadResult,
9
+ upload_artifact,
10
+ upload_artifacts,
11
+ )
12
+ from testrelic_pytest.cloud.auth import (
13
+ AuthError,
14
+ RepoResolveResult,
15
+ TokenExchangeResult,
16
+ TokenRefreshResult,
17
+ enforce_https,
18
+ exchange_token,
19
+ health_check,
20
+ is_auth_error,
21
+ refresh_access_token,
22
+ resolve_repo,
23
+ )
24
+ from testrelic_pytest.cloud.client import CloudClient
25
+ from testrelic_pytest.cloud.queue import drain_queue, enqueue
26
+ from testrelic_pytest.cloud.upload import (
27
+ RealtimeInitResult,
28
+ UploadFailure,
29
+ UploadResult,
30
+ build_batch_payload,
31
+ finalize_run,
32
+ init_realtime_run,
33
+ upload_batch_run,
34
+ upload_test_result,
35
+ )
36
+
37
+ __all__ = [
38
+ "ArtifactType",
39
+ "ArtifactUploadRequest",
40
+ "ArtifactUploadResult",
41
+ "AuthError",
42
+ "CloudClient",
43
+ "RealtimeInitResult",
44
+ "RepoResolveResult",
45
+ "TokenExchangeResult",
46
+ "TokenRefreshResult",
47
+ "UploadFailure",
48
+ "UploadResult",
49
+ "build_batch_payload",
50
+ "drain_queue",
51
+ "enforce_https",
52
+ "enqueue",
53
+ "exchange_token",
54
+ "finalize_run",
55
+ "health_check",
56
+ "init_realtime_run",
57
+ "is_auth_error",
58
+ "refresh_access_token",
59
+ "resolve_repo",
60
+ "upload_artifact",
61
+ "upload_artifacts",
62
+ "upload_batch_run",
63
+ "upload_test_result",
64
+ ]
@@ -0,0 +1,260 @@
1
+ """Binary artifact upload via presigned S3 PUT URLs.
2
+
3
+ Wire protocol verified against cloud-platform-app:
4
+
5
+ POST /api/v1/artifacts/upload-url -> { artifactId, uploadUrl, storageKey }
6
+ PUT {uploadUrl} -> S3 PUT with binary body
7
+ POST /api/v1/artifacts/confirm -> { artifactId }
8
+
9
+ Concurrency-bounded by a ThreadPoolExecutor (5 workers). Never raises — returns
10
+ structured results.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import time
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Literal, Optional
21
+
22
+ import httpx
23
+
24
+ logger = logging.getLogger("testrelic")
25
+
26
+ ArtifactType = Literal["screenshot", "video", "trace", "other"]
27
+
28
+ UPLOAD_CONCURRENCY = 5
29
+ RETRY_DELAYS_SECONDS = (1.0, 3.0, 9.0)
30
+ MAX_RETRIES = 3
31
+
32
+ _CONTENT_TYPE_MAP: dict[str, str] = {
33
+ ".png": "image/png",
34
+ ".jpg": "image/jpeg",
35
+ ".jpeg": "image/jpeg",
36
+ ".webp": "image/webp",
37
+ ".webm": "video/webm",
38
+ ".mp4": "video/mp4",
39
+ ".zip": "application/zip",
40
+ ".log": "text/plain",
41
+ ".txt": "text/plain",
42
+ }
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ArtifactUploadRequest:
47
+ file_path: Path
48
+ run_id: str
49
+ test_id: str
50
+ type: ArtifactType
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ArtifactUploadResult:
55
+ success: bool
56
+ storage_key: Optional[str]
57
+ artifact_id: Optional[str]
58
+ error: Optional[str]
59
+
60
+
61
+ def _content_type_for(path: Path) -> str:
62
+ return _CONTENT_TYPE_MAP.get(path.suffix.lower(), "application/octet-stream")
63
+
64
+
65
+ def _file_size(path: Path) -> int:
66
+ try:
67
+ return path.stat().st_size
68
+ except OSError:
69
+ return 0
70
+
71
+
72
+ def _request_upload_url(
73
+ endpoint: str,
74
+ access_token: str,
75
+ request: ArtifactUploadRequest,
76
+ size_bytes: int,
77
+ content_type: str,
78
+ *,
79
+ timeout_seconds: float,
80
+ ) -> Optional[dict[str, str]]:
81
+ url = f"{endpoint.rstrip('/')}/artifacts/upload-url"
82
+ body = {
83
+ "runId": request.run_id,
84
+ "testId": request.test_id,
85
+ "fileName": request.file_path.name,
86
+ "contentType": content_type,
87
+ "type": request.type,
88
+ "sizeBytes": size_bytes,
89
+ }
90
+ headers = {
91
+ "Content-Type": "application/json",
92
+ "Authorization": f"Bearer {access_token}",
93
+ }
94
+ for attempt in range(MAX_RETRIES):
95
+ try:
96
+ with httpx.Client(timeout=timeout_seconds) as client:
97
+ response = client.post(url, json=body, headers=headers)
98
+ except httpx.HTTPError:
99
+ if attempt < MAX_RETRIES - 1:
100
+ time.sleep(RETRY_DELAYS_SECONDS[attempt])
101
+ continue
102
+ return None
103
+
104
+ if 200 <= response.status_code < 300:
105
+ try:
106
+ data = response.json()
107
+ except ValueError:
108
+ return None
109
+ return {
110
+ "artifactId": data["artifactId"],
111
+ "uploadUrl": data["uploadUrl"],
112
+ "storageKey": data["storageKey"],
113
+ }
114
+ if response.status_code >= 500 and attempt < MAX_RETRIES - 1:
115
+ time.sleep(RETRY_DELAYS_SECONDS[attempt])
116
+ continue
117
+ return None
118
+ return None
119
+
120
+
121
+ def _put_file(
122
+ presigned_url: str,
123
+ file_path: Path,
124
+ content_type: str,
125
+ size_bytes: int,
126
+ *,
127
+ timeout_seconds: float,
128
+ ) -> bool:
129
+ headers = {
130
+ "Content-Type": content_type,
131
+ "Content-Length": str(size_bytes),
132
+ }
133
+ for attempt in range(MAX_RETRIES):
134
+ try:
135
+ with file_path.open("rb") as fh:
136
+ with httpx.Client(timeout=timeout_seconds) as client:
137
+ response = client.put(presigned_url, content=fh.read(), headers=headers)
138
+ except (httpx.HTTPError, OSError):
139
+ if attempt < MAX_RETRIES - 1:
140
+ time.sleep(RETRY_DELAYS_SECONDS[attempt])
141
+ continue
142
+ return False
143
+ if 200 <= response.status_code < 300:
144
+ return True
145
+ if response.status_code >= 500 and attempt < MAX_RETRIES - 1:
146
+ time.sleep(RETRY_DELAYS_SECONDS[attempt])
147
+ continue
148
+ return False
149
+ return False
150
+
151
+
152
+ def _confirm_upload(
153
+ endpoint: str,
154
+ access_token: str,
155
+ artifact_id: str,
156
+ *,
157
+ timeout_seconds: float,
158
+ ) -> bool:
159
+ url = f"{endpoint.rstrip('/')}/artifacts/confirm"
160
+ try:
161
+ with httpx.Client(timeout=timeout_seconds) as client:
162
+ response = client.post(
163
+ url,
164
+ json={"artifactId": artifact_id},
165
+ headers={
166
+ "Content-Type": "application/json",
167
+ "Authorization": f"Bearer {access_token}",
168
+ },
169
+ )
170
+ return 200 <= response.status_code < 300
171
+ except httpx.HTTPError:
172
+ return False
173
+
174
+
175
+ def upload_artifact(
176
+ endpoint: str,
177
+ access_token: str,
178
+ request: ArtifactUploadRequest,
179
+ *,
180
+ max_size_mb: int = 50,
181
+ timeout_seconds: float = 60.0,
182
+ ) -> ArtifactUploadResult:
183
+ """Upload one artifact end-to-end. Never raises."""
184
+ size_bytes = _file_size(request.file_path)
185
+ if size_bytes == 0:
186
+ return ArtifactUploadResult(False, None, None, "file_not_found_or_empty")
187
+ if size_bytes > max_size_mb * 1024 * 1024:
188
+ return ArtifactUploadResult(False, None, None, "file_too_large")
189
+
190
+ content_type = _content_type_for(request.file_path)
191
+
192
+ url_result = _request_upload_url(
193
+ endpoint,
194
+ access_token,
195
+ request,
196
+ size_bytes,
197
+ content_type,
198
+ timeout_seconds=timeout_seconds,
199
+ )
200
+ if url_result is None:
201
+ return ArtifactUploadResult(False, None, None, "upload_url_request_failed")
202
+
203
+ uploaded = _put_file(
204
+ url_result["uploadUrl"],
205
+ request.file_path,
206
+ content_type,
207
+ size_bytes,
208
+ timeout_seconds=timeout_seconds,
209
+ )
210
+ if not uploaded:
211
+ return ArtifactUploadResult(
212
+ False, url_result["storageKey"], url_result["artifactId"], "presigned_put_failed"
213
+ )
214
+
215
+ confirmed = _confirm_upload(
216
+ endpoint, access_token, url_result["artifactId"], timeout_seconds=timeout_seconds
217
+ )
218
+ return ArtifactUploadResult(
219
+ success=confirmed,
220
+ storage_key=url_result["storageKey"],
221
+ artifact_id=url_result["artifactId"],
222
+ error=None if confirmed else "confirm_failed",
223
+ )
224
+
225
+
226
+ def upload_artifacts(
227
+ endpoint: str,
228
+ access_token: str,
229
+ requests: list[ArtifactUploadRequest],
230
+ *,
231
+ max_size_mb: int = 50,
232
+ timeout_seconds: float = 60.0,
233
+ ) -> dict[Path, ArtifactUploadResult]:
234
+ """Upload multiple artifacts with bounded concurrency."""
235
+ results: dict[Path, ArtifactUploadResult] = {}
236
+ if not requests:
237
+ return results
238
+
239
+ with ThreadPoolExecutor(max_workers=UPLOAD_CONCURRENCY) as pool:
240
+ futures = {
241
+ pool.submit(
242
+ upload_artifact,
243
+ endpoint,
244
+ access_token,
245
+ req,
246
+ max_size_mb=max_size_mb,
247
+ timeout_seconds=timeout_seconds,
248
+ ): req
249
+ for req in requests
250
+ }
251
+ for future in as_completed(futures):
252
+ req = futures[future]
253
+ try:
254
+ results[req.file_path] = future.result()
255
+ except Exception as exc:
256
+ logger.debug(
257
+ "[testrelic] artifact upload exception for %s: %s", req.file_path, exc
258
+ )
259
+ results[req.file_path] = ArtifactUploadResult(False, None, None, str(exc))
260
+ return results