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.
- testrelic_pytest/__init__.py +35 -0
- testrelic_pytest/_version.py +1 -0
- testrelic_pytest/ci_detector.py +102 -0
- testrelic_pytest/cli.py +59 -0
- testrelic_pytest/cloud/__init__.py +64 -0
- testrelic_pytest/cloud/artifact.py +260 -0
- testrelic_pytest/cloud/auth.py +258 -0
- testrelic_pytest/cloud/client.py +274 -0
- testrelic_pytest/cloud/queue.py +108 -0
- testrelic_pytest/cloud/repo_cache.py +85 -0
- testrelic_pytest/cloud/upload.py +287 -0
- testrelic_pytest/config.py +185 -0
- testrelic_pytest/git_metadata.py +105 -0
- testrelic_pytest/plugin.py +596 -0
- testrelic_pytest/redaction.py +43 -0
- testrelic_pytest/reporter.py +258 -0
- testrelic_pytest/run_type_env.py +30 -0
- testrelic_pytest/schema.py +146 -0
- testrelic_pytest/translator.py +281 -0
- testrelic_pytest/types.py +22 -0
- testrelic_pytest-0.1.1.dist-info/METADATA +147 -0
- testrelic_pytest-0.1.1.dist-info/RECORD +25 -0
- testrelic_pytest-0.1.1.dist-info/WHEEL +4 -0
- testrelic_pytest-0.1.1.dist-info/entry_points.txt +5 -0
- testrelic_pytest-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
testrelic_pytest/cli.py
ADDED
|
@@ -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
|