charisma-cli 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/.gitignore +4 -1
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/PKG-INFO +1 -1
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/pyproject.toml +1 -1
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/__init__.py +1 -1
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/models.py +49 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/parser.py +71 -15
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/uploader.py +195 -29
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_parser.py +213 -0
- charisma_cli-0.2.2/tests/test_uploader.py +826 -0
- charisma_cli-0.2.0/tests/test_uploader.py +0 -333
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/README.md +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/main.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/watcher.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/__init__.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/conftest.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_cli.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_config.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_integration.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_models.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_retry.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_watcher.py +0 -0
|
@@ -55,7 +55,8 @@ dist/
|
|
|
55
55
|
|
|
56
56
|
# Python cache
|
|
57
57
|
*.pyc
|
|
58
|
-
|
|
58
|
+
*.pyc.*
|
|
59
|
+
**/__pycache__/
|
|
59
60
|
|
|
60
61
|
# Python virtual environments
|
|
61
62
|
.venv/
|
|
@@ -133,3 +134,5 @@ backend/coverage.json
|
|
|
133
134
|
*/__pycache__/*
|
|
134
135
|
allure-results/*
|
|
135
136
|
allure-results-live/
|
|
137
|
+
*tasks.meta.json
|
|
138
|
+
*.code-workspace
|
|
@@ -3,6 +3,33 @@
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
from enum import IntEnum
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import NAMESPACE_URL, uuid5
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def backend_result_id(launch_id: str, test_id: str) -> str:
|
|
11
|
+
"""Derive the result identity the Charisma backend stores and validates against.
|
|
12
|
+
|
|
13
|
+
The backend computes ``test_results.id`` as
|
|
14
|
+
``uuid5(NAMESPACE_URL, f"{launch_id}/{test_id}")`` (see backend
|
|
15
|
+
``models/testresults.py`` ``to_canonical``). Attachment uploads are validated
|
|
16
|
+
against that stored id, so the CLI MUST send the same value as ``resultId`` —
|
|
17
|
+
not the raw Allure ``uuid``, which the backend discards. This function is the
|
|
18
|
+
single CLI-side mirror of that contract; keep the namespace, separator, and
|
|
19
|
+
field order byte-identical to the backend.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
launch_id: The open launch UUID returned by the backend.
|
|
23
|
+
test_id: The result's test identity — the SAME value the CLI sends as
|
|
24
|
+
``test_id`` in the results batch. This is the Allure ``historyId``,
|
|
25
|
+
falling back to ``fullName`` when ``historyId`` is absent (see
|
|
26
|
+
``parser.parse_result_file``). Matching the backend requires only
|
|
27
|
+
that both sides key off this same value, whichever it is.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The deterministic result id string matching the backend's stored id.
|
|
31
|
+
"""
|
|
32
|
+
return str(uuid5(NAMESPACE_URL, f"{launch_id}/{test_id}"))
|
|
6
33
|
|
|
7
34
|
|
|
8
35
|
class FileCategory(IntEnum):
|
|
@@ -52,6 +79,28 @@ class AttachmentRef:
|
|
|
52
79
|
mime_type: str # content-type
|
|
53
80
|
result_uuid: str # owning result UUID
|
|
54
81
|
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_allure(cls, attachment: dict[str, Any], result_uuid: str) -> "AttachmentRef | None":
|
|
84
|
+
"""Build an AttachmentRef from a single Allure attachment entry.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
attachment: An Allure attachment dict (``source``, optional ``name``
|
|
88
|
+
and ``type``).
|
|
89
|
+
result_uuid: The UUID of the owning result.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
An AttachmentRef, or None if the attachment has no usable ``source``.
|
|
93
|
+
"""
|
|
94
|
+
source = attachment.get("source")
|
|
95
|
+
if not source:
|
|
96
|
+
return None
|
|
97
|
+
return cls(
|
|
98
|
+
source=source,
|
|
99
|
+
name=attachment.get("name", source),
|
|
100
|
+
mime_type=attachment.get("type", "application/octet-stream"),
|
|
101
|
+
result_uuid=result_uuid,
|
|
102
|
+
)
|
|
103
|
+
|
|
55
104
|
|
|
56
105
|
@dataclass
|
|
57
106
|
class ContainerData:
|
|
@@ -7,6 +7,7 @@ for streaming to the Charisma ingestion API.
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
10
11
|
|
|
11
12
|
from charisma_cli.models import AttachmentRef, CharismaResult, ContainerData
|
|
12
13
|
|
|
@@ -185,31 +186,86 @@ def parse_environment_properties(path: Path) -> dict[str, str]:
|
|
|
185
186
|
return variables
|
|
186
187
|
|
|
187
188
|
|
|
188
|
-
def
|
|
189
|
+
def _collect_step_attachments(steps: list[dict[str, Any]], result_uuid: str) -> list[AttachmentRef]:
|
|
190
|
+
"""Recursively collect attachment refs from a list of Allure steps.
|
|
191
|
+
|
|
192
|
+
Allure nests attachments inside steps, and steps inside steps. A single
|
|
193
|
+
``result_data["attachments"]`` read only sees attachments added directly to
|
|
194
|
+
the test body — the minority case. Screenshots and logs captured inside a
|
|
195
|
+
step (the common case) live in ``step["attachments"]`` at arbitrary depth.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
steps: A list of Allure step dicts (may be empty or contain nested steps).
|
|
199
|
+
result_uuid: The UUID of the owning result, applied to every ref.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Flat list of AttachmentRef for attachments found at every step depth.
|
|
203
|
+
"""
|
|
204
|
+
refs: list[AttachmentRef] = []
|
|
205
|
+
for step in steps:
|
|
206
|
+
if not isinstance(step, dict):
|
|
207
|
+
continue
|
|
208
|
+
for attachment in step.get("attachments") or []:
|
|
209
|
+
ref = AttachmentRef.from_allure(attachment, result_uuid)
|
|
210
|
+
if ref is not None:
|
|
211
|
+
refs.append(ref)
|
|
212
|
+
refs.extend(_collect_step_attachments(step.get("steps") or [], result_uuid))
|
|
213
|
+
return refs
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def extract_attachment_refs(result_data: dict[str, Any], result_uuid: str) -> list[AttachmentRef]:
|
|
189
217
|
"""Extract attachment references from an Allure result data dict.
|
|
190
218
|
|
|
219
|
+
Collects attachments from the result's top-level ``attachments`` array AND
|
|
220
|
+
from every step (recursively through nested sub-steps), since Allure stores
|
|
221
|
+
most attachments inside steps rather than at the top level.
|
|
222
|
+
|
|
191
223
|
Args:
|
|
192
224
|
result_data: The raw parsed Allure result JSON dict.
|
|
193
225
|
result_uuid: The UUID of the owning result (passed as argument,
|
|
194
226
|
not taken from result_data).
|
|
195
227
|
|
|
196
228
|
Returns:
|
|
197
|
-
List of AttachmentRef objects for each attachment in the result
|
|
229
|
+
List of AttachmentRef objects for each attachment in the result,
|
|
230
|
+
including those nested inside steps.
|
|
198
231
|
"""
|
|
199
|
-
|
|
200
|
-
|
|
232
|
+
refs: list[AttachmentRef] = []
|
|
233
|
+
for attachment in result_data.get("attachments") or []:
|
|
234
|
+
ref = AttachmentRef.from_allure(attachment, result_uuid)
|
|
235
|
+
if ref is not None:
|
|
236
|
+
refs.append(ref)
|
|
237
|
+
|
|
238
|
+
refs.extend(_collect_step_attachments(result_data.get("steps") or [], result_uuid))
|
|
239
|
+
return refs
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def extract_container_attachment_refs(container: ContainerData) -> list[AttachmentRef]:
|
|
243
|
+
"""Extract fixture attachment references from an Allure container.
|
|
244
|
+
|
|
245
|
+
Setup/teardown attachments (logs and screenshots captured in fixtures) live
|
|
246
|
+
in the container's ``befores``/``afters`` fixture steps, which nest ``steps``
|
|
247
|
+
and ``attachments`` just like result steps. These attachments belong to the
|
|
248
|
+
container's child results; without this extraction they are never registered
|
|
249
|
+
and every fixture attachment is skipped on upload.
|
|
250
|
+
|
|
251
|
+
A container may own multiple children (suite/class/session-scoped fixtures
|
|
252
|
+
run once for many tests). A fixture attachment applies to every test the
|
|
253
|
+
fixture served, so each attachment is bound to ALL of the container's
|
|
254
|
+
children — one AttachmentRef per (attachment, child) pair. Containers with
|
|
255
|
+
no children cannot bind their attachments and yield an empty list.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
container: The parsed container holding fixture steps and child UUIDs.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List of AttachmentRef for fixture attachments, one per child result.
|
|
262
|
+
Empty if there are no children.
|
|
263
|
+
"""
|
|
264
|
+
if not container.children:
|
|
201
265
|
return []
|
|
202
266
|
|
|
267
|
+
fixture_steps = list(container.befores) + list(container.afters)
|
|
203
268
|
refs: list[AttachmentRef] = []
|
|
204
|
-
for
|
|
205
|
-
|
|
206
|
-
refs.append(
|
|
207
|
-
AttachmentRef(
|
|
208
|
-
source=source,
|
|
209
|
-
name=attachment.get("name", source),
|
|
210
|
-
mime_type=attachment.get("type", "application/octet-stream"),
|
|
211
|
-
result_uuid=result_uuid,
|
|
212
|
-
)
|
|
213
|
-
)
|
|
214
|
-
|
|
269
|
+
for child_uuid in container.children:
|
|
270
|
+
refs.extend(_collect_step_attachments(fixture_steps, child_uuid))
|
|
215
271
|
return refs
|
|
@@ -12,12 +12,19 @@ import httpx
|
|
|
12
12
|
|
|
13
13
|
from charisma_cli.config import Config
|
|
14
14
|
from charisma_cli.models import (
|
|
15
|
+
AttachmentRef,
|
|
15
16
|
ContainerData,
|
|
16
17
|
FileCategory,
|
|
17
18
|
FileEvent,
|
|
18
19
|
UploadSummary,
|
|
20
|
+
backend_result_id,
|
|
21
|
+
)
|
|
22
|
+
from charisma_cli.parser import (
|
|
23
|
+
extract_attachment_refs,
|
|
24
|
+
extract_container_attachment_refs,
|
|
25
|
+
parse_container_file,
|
|
26
|
+
parse_result_file,
|
|
19
27
|
)
|
|
20
|
-
from charisma_cli.parser import extract_attachment_refs, parse_container_file, parse_result_file
|
|
21
28
|
from charisma_cli.retry import MAX_RETRIES, RETRYABLE_STATUS_CODES, exponential_backoff
|
|
22
29
|
|
|
23
30
|
logger = logging.getLogger(__name__)
|
|
@@ -68,8 +75,22 @@ class Uploader:
|
|
|
68
75
|
self._batch_start: float = 0.0
|
|
69
76
|
self._pending_containers: list[ContainerData] = []
|
|
70
77
|
self._known_result_uuids: set[str] = set()
|
|
71
|
-
# Map source filename →
|
|
78
|
+
# Map source filename → owning result's Allure uuid (join key from
|
|
79
|
+
# Allure's own structure: results own attachments and have a uuid;
|
|
80
|
+
# containers reference their children by uuid).
|
|
72
81
|
self._attachment_result_map: dict[str, str] = {}
|
|
82
|
+
# Map Allure result uuid → historyId. The backend keys stored results by
|
|
83
|
+
# uuid5(launch_id/historyId), NOT the Allure uuid, so an attachment's
|
|
84
|
+
# owning uuid must be translated to its historyId to compute the
|
|
85
|
+
# resultId the backend will accept. Populated as result files are parsed.
|
|
86
|
+
self._result_uuid_to_history: dict[str, str] = {}
|
|
87
|
+
# Attachments dequeued before their owning result/container was seen.
|
|
88
|
+
# In watch mode the PriorityQueue orders items co-present at dequeue time
|
|
89
|
+
# but gives no global temporal guarantee: an attachment can be processed
|
|
90
|
+
# before the result that registers its source→uuid mapping. These are
|
|
91
|
+
# held here and retried as mappings arrive; unresolved ones are counted
|
|
92
|
+
# as skipped at flush time.
|
|
93
|
+
self._pending_attachments: list[Path] = []
|
|
73
94
|
|
|
74
95
|
@property
|
|
75
96
|
def summary(self) -> UploadSummary:
|
|
@@ -315,6 +336,13 @@ class Uploader:
|
|
|
315
336
|
# Track UUID for container association
|
|
316
337
|
if result.uuid:
|
|
317
338
|
self._known_result_uuids.add(result.uuid)
|
|
339
|
+
# Record uuid → historyId so attachments (which resolve to their
|
|
340
|
+
# owning Allure uuid) can be uploaded with the historyId-based id the
|
|
341
|
+
# backend actually stores. A newly-learned mapping may unblock
|
|
342
|
+
# attachments deferred because their owner's historyId wasn't known.
|
|
343
|
+
if result.testId:
|
|
344
|
+
self._result_uuid_to_history[result.uuid] = result.testId
|
|
345
|
+
self._retry_pending_attachments()
|
|
318
346
|
|
|
319
347
|
# Build API payload (snake_case wire format — shared TestResultInput contract)
|
|
320
348
|
payload: dict[str, Any] = {
|
|
@@ -396,58 +424,191 @@ class Uploader:
|
|
|
396
424
|
|
|
397
425
|
# Extract attachment refs and register them for upload with correct result_uuid
|
|
398
426
|
try:
|
|
399
|
-
|
|
400
|
-
for ref in refs:
|
|
401
|
-
# Register source → result_uuid mapping so _handle_attachment knows the owner
|
|
402
|
-
self._attachment_result_map[ref.source] = ref.result_uuid
|
|
427
|
+
self._register_attachment_refs(extract_attachment_refs(raw_data, result.uuid or ""))
|
|
403
428
|
except Exception:
|
|
404
429
|
logger.debug("Failed to extract attachment refs from %s", path, exc_info=True)
|
|
405
430
|
|
|
406
431
|
def _handle_container(self, path: Path) -> None:
|
|
407
|
-
"""Parse a container file and buffer for deferred association.
|
|
432
|
+
"""Parse a container file and buffer for deferred association.
|
|
433
|
+
|
|
434
|
+
Also registers any fixture attachments (setup/teardown logs and
|
|
435
|
+
screenshots nested in befores/afters) so they upload with the correct
|
|
436
|
+
owning result. Without this, fixture attachments have no source→result
|
|
437
|
+
mapping and are skipped.
|
|
438
|
+
"""
|
|
408
439
|
container = parse_container_file(path)
|
|
409
440
|
if container is None:
|
|
410
441
|
return
|
|
411
442
|
|
|
443
|
+
try:
|
|
444
|
+
self._register_attachment_refs(extract_container_attachment_refs(container))
|
|
445
|
+
except Exception:
|
|
446
|
+
logger.debug("Failed to extract fixture attachment refs from %s", path, exc_info=True)
|
|
447
|
+
|
|
412
448
|
container.received_at = time.monotonic()
|
|
413
449
|
self._pending_containers.append(container)
|
|
414
450
|
self._try_associate_containers()
|
|
415
451
|
|
|
452
|
+
def _register_attachment_refs(self, refs: list[AttachmentRef]) -> None:
|
|
453
|
+
"""Register source→result_uuid mappings so _handle_attachment finds owners.
|
|
454
|
+
|
|
455
|
+
Thread affinity: ``_attachment_result_map`` is written here (from both
|
|
456
|
+
_handle_result and _handle_container) and read by _handle_attachment.
|
|
457
|
+
All three run on the single consumer thread (_run), so no lock is needed;
|
|
458
|
+
this single-consumer invariant is what keeps the map consistent.
|
|
459
|
+
"""
|
|
460
|
+
if not refs:
|
|
461
|
+
return
|
|
462
|
+
for ref in refs:
|
|
463
|
+
self._attachment_result_map[ref.source] = ref.result_uuid
|
|
464
|
+
# A newly-registered mapping may unblock attachments that were dequeued
|
|
465
|
+
# before their owning result/container arrived (watch-mode ordering).
|
|
466
|
+
self._retry_pending_attachments()
|
|
467
|
+
|
|
416
468
|
def _handle_attachment(self, path: Path) -> None:
|
|
417
|
-
"""Upload an attachment
|
|
469
|
+
"""Upload an attachment, or defer it if its owning result isn't known yet.
|
|
470
|
+
|
|
471
|
+
The owning result/container may not have been processed at the moment
|
|
472
|
+
this attachment is dequeued (watch mode streams files in arrival order,
|
|
473
|
+
not owner-first). When the mapping is missing, the attachment is buffered
|
|
474
|
+
in ``_pending_attachments`` and retried once a mapping arrives, rather
|
|
475
|
+
than being immediately counted as skipped.
|
|
476
|
+
"""
|
|
418
477
|
if not self._launch_id or not path.exists():
|
|
419
478
|
self._summary.attachments_skipped += 1
|
|
420
479
|
return
|
|
421
480
|
|
|
422
|
-
|
|
423
|
-
|
|
481
|
+
history_id = self._resolve_history_id(path.name)
|
|
482
|
+
if not history_id:
|
|
483
|
+
# Owner (or its historyId) not known yet — defer and retry when a
|
|
484
|
+
# result registers the source→uuid and uuid→historyId mappings.
|
|
485
|
+
self._pending_attachments.append(path)
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
self._upload_attachment(path, history_id)
|
|
489
|
+
|
|
490
|
+
def _resolve_history_id(self, source_name: str) -> str:
|
|
491
|
+
"""Resolve an attachment's source filename to its owning result's historyId.
|
|
492
|
+
|
|
493
|
+
Two hops: source filename → owning Allure result uuid → historyId. Both
|
|
494
|
+
are learned from result/container files, which may arrive after the
|
|
495
|
+
attachment (watch-mode ordering). Returns "" when either hop is missing,
|
|
496
|
+
signalling the caller to defer rather than skip.
|
|
497
|
+
"""
|
|
498
|
+
result_uuid = self._attachment_result_map.get(source_name, "")
|
|
424
499
|
if not result_uuid:
|
|
425
|
-
|
|
500
|
+
return ""
|
|
501
|
+
return self._result_uuid_to_history.get(result_uuid, "")
|
|
502
|
+
|
|
503
|
+
def _retry_pending_attachments(self) -> None:
|
|
504
|
+
"""Upload any buffered attachments whose owner mapping is now known.
|
|
505
|
+
|
|
506
|
+
Attachments still without a mapping remain pending; they are only
|
|
507
|
+
counted as skipped by _flush_pending_attachments() at flush time, once
|
|
508
|
+
no further mappings can arrive.
|
|
509
|
+
"""
|
|
510
|
+
if not self._pending_attachments:
|
|
511
|
+
return
|
|
512
|
+
still_pending: list[Path] = []
|
|
513
|
+
for path in self._pending_attachments:
|
|
514
|
+
history_id = self._resolve_history_id(path.name)
|
|
515
|
+
if history_id:
|
|
516
|
+
self._upload_attachment(path, history_id)
|
|
517
|
+
else:
|
|
518
|
+
still_pending.append(path)
|
|
519
|
+
self._pending_attachments = still_pending
|
|
520
|
+
|
|
521
|
+
def _flush_pending_attachments(self) -> None:
|
|
522
|
+
"""Resolve remaining deferred attachments; count the truly orphaned as skipped.
|
|
523
|
+
|
|
524
|
+
Called at end-of-run when no further result/container mappings can
|
|
525
|
+
arrive. Any attachment still without an owner is genuinely unreferenced
|
|
526
|
+
and is counted as skipped.
|
|
527
|
+
"""
|
|
528
|
+
self._retry_pending_attachments()
|
|
529
|
+
for path in self._pending_attachments:
|
|
426
530
|
self._summary.attachments_skipped += 1
|
|
427
|
-
logger.
|
|
531
|
+
logger.warning(
|
|
532
|
+
"Skipping attachment with no matching result: %s", path.name
|
|
533
|
+
)
|
|
534
|
+
self._pending_attachments = []
|
|
535
|
+
|
|
536
|
+
def _upload_attachment(self, path: Path, history_id: str) -> None:
|
|
537
|
+
"""POST a single attachment to the Charisma attachment API.
|
|
538
|
+
|
|
539
|
+
``resultId`` is the backend-derived id (uuid5 of launch/historyId), the
|
|
540
|
+
same value the backend stores as ``test_results.id``. Sending the raw
|
|
541
|
+
Allure uuid here was the attachments-skipped bug: the backend validates
|
|
542
|
+
the resultId against the stored id and returns 404 on a mismatch.
|
|
543
|
+
|
|
544
|
+
Transient failures (5xx, 429, connect/timeout) are retried with the same
|
|
545
|
+
exponential backoff as result batches — attachments were previously the
|
|
546
|
+
only backend call with no retry, so a momentary blip permanently dropped
|
|
547
|
+
an attachment a single retry would have delivered. On terminal failure the
|
|
548
|
+
attachment is counted skipped and logged at WARNING (not DEBUG): a silent
|
|
549
|
+
DEBUG-only skip is exactly what hid the original bug.
|
|
550
|
+
"""
|
|
551
|
+
# Fail fast rather than fabricate a wrong id: the backend derives the
|
|
552
|
+
# stored result id from launch_id, so an empty launch_id computes a
|
|
553
|
+
# well-formed but wrong id that always 404s. Never send that.
|
|
554
|
+
if not self._launch_id:
|
|
555
|
+
self._summary.attachments_skipped += 1
|
|
556
|
+
logger.warning(
|
|
557
|
+
"Cannot upload attachment %s: no open launch", path.name
|
|
558
|
+
)
|
|
428
559
|
return
|
|
429
560
|
|
|
430
561
|
try:
|
|
431
562
|
content = path.read_bytes()
|
|
432
|
-
|
|
433
|
-
|
|
563
|
+
except OSError as e:
|
|
564
|
+
self._summary.attachments_skipped += 1
|
|
565
|
+
logger.warning("Cannot read attachment %s: %s", path.name, e)
|
|
566
|
+
return
|
|
434
567
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
self.
|
|
442
|
-
|
|
568
|
+
result_id = backend_result_id(self._launch_id, history_id)
|
|
569
|
+
files = {"file": (path.name, content)}
|
|
570
|
+
data = {"name": path.name, "resultId": result_id}
|
|
571
|
+
|
|
572
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
573
|
+
try:
|
|
574
|
+
response = self._client.post(
|
|
575
|
+
f"/api/v1/launches/{self._launch_id}/attachments",
|
|
576
|
+
files=files,
|
|
577
|
+
data=data,
|
|
578
|
+
)
|
|
579
|
+
if response.status_code == 201:
|
|
580
|
+
self._summary.attachments_sent += 1
|
|
581
|
+
return
|
|
582
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
583
|
+
time.sleep(exponential_backoff(attempt))
|
|
584
|
+
continue
|
|
585
|
+
# Non-retryable status (e.g. 404) or retries exhausted.
|
|
443
586
|
self._summary.attachments_skipped += 1
|
|
444
|
-
logger.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
587
|
+
logger.warning(
|
|
588
|
+
"Attachment upload failed: HTTP %d for %s",
|
|
589
|
+
response.status_code,
|
|
590
|
+
path.name,
|
|
591
|
+
)
|
|
592
|
+
return
|
|
593
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
594
|
+
if attempt < MAX_RETRIES:
|
|
595
|
+
time.sleep(exponential_backoff(attempt))
|
|
596
|
+
continue
|
|
597
|
+
self._summary.attachments_skipped += 1
|
|
598
|
+
logger.warning(
|
|
599
|
+
"Attachment upload failed after %d retries: %s for %s",
|
|
600
|
+
MAX_RETRIES,
|
|
601
|
+
e,
|
|
602
|
+
path.name,
|
|
603
|
+
)
|
|
604
|
+
return
|
|
605
|
+
except Exception:
|
|
606
|
+
# Non-network error (e.g. malformed client state) — not retryable.
|
|
607
|
+
self._summary.attachments_skipped += 1
|
|
608
|
+
logger.warning(
|
|
609
|
+
"Attachment upload unexpected error for %s", path.name, exc_info=True
|
|
610
|
+
)
|
|
611
|
+
return
|
|
451
612
|
|
|
452
613
|
def _try_associate_containers(self) -> None:
|
|
453
614
|
"""Try to associate buffered containers with known results."""
|
|
@@ -549,5 +710,10 @@ class Uploader:
|
|
|
549
710
|
if self._batch:
|
|
550
711
|
self._flush_batch()
|
|
551
712
|
|
|
713
|
+
# All queued events are processed and no further mappings can arrive:
|
|
714
|
+
# resolve any deferred attachments now, counting the truly orphaned as
|
|
715
|
+
# skipped. Runs before container finalization for symmetry with results.
|
|
716
|
+
self._flush_pending_attachments()
|
|
717
|
+
|
|
552
718
|
self._summary.containers_sent += len(self._pending_containers)
|
|
553
719
|
self._pending_containers.clear()
|