charisma-cli 0.2.1__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.
Files changed (27) hide show
  1. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/.gitignore +4 -1
  2. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/PKG-INFO +1 -1
  3. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/pyproject.toml +1 -1
  4. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/__init__.py +1 -1
  5. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/models.py +26 -0
  6. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/uploader.py +112 -28
  7. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_uploader.py +304 -0
  8. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/README.md +0 -0
  9. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/config.py +0 -0
  10. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/launch_url.py +0 -0
  11. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/main.py +0 -0
  12. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/parser.py +0 -0
  13. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/retry.py +0 -0
  14. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/subprocess_mgr.py +0 -0
  15. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/src/charisma_cli/watcher.py +0 -0
  16. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/__init__.py +0 -0
  17. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/conftest.py +0 -0
  18. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_cli.py +0 -0
  19. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_config.py +0 -0
  20. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_integration.py +0 -0
  21. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_launch_url.py +0 -0
  22. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_models.py +0 -0
  23. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_parser.py +0 -0
  24. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_retry.py +0 -0
  25. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_silent_mode.py +0 -0
  26. {charisma_cli-0.2.1 → charisma_cli-0.2.2}/tests/test_subprocess_mgr.py +0 -0
  27. {charisma_cli-0.2.1 → 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
- */__pycache__/
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: CLI tool that watches allure-results and streams test results + attachments to Charisma.
5
5
  Author: Charisma Team
6
6
  License-Expression: MIT
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "charisma-cli"
8
- version = "0.2.1"
8
+ version = "0.2.2"
9
9
  description = "CLI tool that watches allure-results and streams test results + attachments to Charisma."
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """charisma-cli: Stream allure results to Charisma in real time."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
@@ -4,6 +4,32 @@ from dataclasses import dataclass, field
4
4
  from enum import IntEnum
5
5
  from pathlib import Path
6
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}"))
7
33
 
8
34
 
9
35
  class FileCategory(IntEnum):
@@ -17,6 +17,7 @@ from charisma_cli.models import (
17
17
  FileCategory,
18
18
  FileEvent,
19
19
  UploadSummary,
20
+ backend_result_id,
20
21
  )
21
22
  from charisma_cli.parser import (
22
23
  extract_attachment_refs,
@@ -74,8 +75,15 @@ class Uploader:
74
75
  self._batch_start: float = 0.0
75
76
  self._pending_containers: list[ContainerData] = []
76
77
  self._known_result_uuids: set[str] = set()
77
- # Map source filename → result_uuid for attachment uploads
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).
78
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] = {}
79
87
  # Attachments dequeued before their owning result/container was seen.
80
88
  # In watch mode the PriorityQueue orders items co-present at dequeue time
81
89
  # but gives no global temporal guarantee: an attachment can be processed
@@ -328,6 +336,13 @@ class Uploader:
328
336
  # Track UUID for container association
329
337
  if result.uuid:
330
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()
331
346
 
332
347
  # Build API payload (snake_case wire format — shared TestResultInput contract)
333
348
  payload: dict[str, Any] = {
@@ -463,13 +478,27 @@ class Uploader:
463
478
  self._summary.attachments_skipped += 1
464
479
  return
465
480
 
466
- result_uuid = self._attachment_result_map.get(path.name, "")
467
- if not result_uuid:
468
- # Owner not known yet — defer and retry when a mapping is registered.
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.
469
485
  self._pending_attachments.append(path)
470
486
  return
471
487
 
472
- self._upload_attachment(path, result_uuid)
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, "")
499
+ if not result_uuid:
500
+ return ""
501
+ return self._result_uuid_to_history.get(result_uuid, "")
473
502
 
474
503
  def _retry_pending_attachments(self) -> None:
475
504
  """Upload any buffered attachments whose owner mapping is now known.
@@ -482,9 +511,9 @@ class Uploader:
482
511
  return
483
512
  still_pending: list[Path] = []
484
513
  for path in self._pending_attachments:
485
- result_uuid = self._attachment_result_map.get(path.name, "")
486
- if result_uuid:
487
- self._upload_attachment(path, result_uuid)
514
+ history_id = self._resolve_history_id(path.name)
515
+ if history_id:
516
+ self._upload_attachment(path, history_id)
488
517
  else:
489
518
  still_pending.append(path)
490
519
  self._pending_attachments = still_pending
@@ -499,32 +528,87 @@ class Uploader:
499
528
  self._retry_pending_attachments()
500
529
  for path in self._pending_attachments:
501
530
  self._summary.attachments_skipped += 1
502
- logger.debug("Skipping attachment with no result reference: %s", path.name)
531
+ logger.warning(
532
+ "Skipping attachment with no matching result: %s", path.name
533
+ )
503
534
  self._pending_attachments = []
504
535
 
505
- def _upload_attachment(self, path: Path, result_uuid: str) -> None:
506
- """POST a single attachment to the Charisma attachment API."""
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
+ )
559
+ return
560
+
507
561
  try:
508
562
  content = path.read_bytes()
509
- files = {"file": (path.name, content)}
510
- data = {"name": path.name, "resultId": result_uuid}
563
+ except OSError as e:
564
+ self._summary.attachments_skipped += 1
565
+ logger.warning("Cannot read attachment %s: %s", path.name, e)
566
+ return
511
567
 
512
- response = self._client.post(
513
- f"/api/v1/launches/{self._launch_id}/attachments",
514
- files=files,
515
- data=data,
516
- )
517
- if response.status_code == 201:
518
- self._summary.attachments_sent += 1
519
- else:
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.
520
586
  self._summary.attachments_skipped += 1
521
- logger.debug("Attachment upload failed: HTTP %d for %s", response.status_code, path.name)
522
- except (httpx.ConnectError, httpx.TimeoutException) as e:
523
- self._summary.attachments_skipped += 1
524
- logger.debug("Attachment upload connection error: %s for %s", e, path.name)
525
- except Exception:
526
- self._summary.attachments_skipped += 1
527
- logger.debug("Attachment upload unexpected error for %s", path.name, exc_info=True)
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
528
612
 
529
613
  def _try_associate_containers(self) -> None:
530
614
  """Try to associate buffered containers with known results."""
@@ -520,3 +520,307 @@ class TestUploaderDrainFlushesBatch:
520
520
  assert results_route.called
521
521
 
522
522
  uploader.stop()
523
+
524
+
525
+ class TestAttachmentResultIdContract:
526
+ """The attachment `resultId` must match the backend's stored test_results.id.
527
+
528
+ Regression for the `attachments_sent=0, attachments_skipped=N` production bug.
529
+ The backend derives test_results.id as uuid5(NAMESPACE_URL, f"{launch_id}/{test_id}")
530
+ where test_id is the Allure historyId (see backend models/testresults.py). The CLI
531
+ previously sent the raw Allure `uuid` as resultId, which never matches that stored
532
+ id, so the backend returned 404 RESULT_NOT_FOUND and every attachment was skipped.
533
+
534
+ These tests pin the on-the-wire resultId to the backend contract so the mismatch
535
+ cannot silently return.
536
+ """
537
+
538
+ def _write_json(self, path: Path, data: dict) -> None:
539
+ import json
540
+ path.write_text(json.dumps(data), encoding="utf-8")
541
+
542
+ @staticmethod
543
+ def _expected_result_id(launch_id: str, history_id: str) -> str:
544
+ from uuid import NAMESPACE_URL, uuid5
545
+ return str(uuid5(NAMESPACE_URL, f"{launch_id}/{history_id}"))
546
+
547
+ @staticmethod
548
+ def _sent_result_id(route) -> str:
549
+ """Extract the resultId form field from the recorded multipart request."""
550
+ request = route.calls[0].request
551
+ body = request.content.decode("utf-8", errors="replace")
552
+ # multipart body contains: name="resultId"\r\n\r\n<value>\r\n
553
+ marker = 'name="resultId"'
554
+ idx = body.find(marker)
555
+ assert idx != -1, f"resultId field not found in request body:\n{body}"
556
+ after = body[idx + len(marker):]
557
+ # skip the two CRLFs separating headers from the value
558
+ value = after.split("\r\n\r\n", 1)[1]
559
+ return value.split("\r\n", 1)[0]
560
+
561
+ @respx.mock
562
+ def test_top_level_attachment_result_id_matches_backend(self, tmp_path: Path) -> None:
563
+ """A top-level attachment sends resultId = uuid5(launch/historyId), not the Allure uuid.
564
+
565
+ Mirrors the real repro: the result's file uuid differs from its historyId,
566
+ and attachments are declared at the result top level.
567
+ """
568
+ config = _make_config()
569
+ queue: PriorityQueue = PriorityQueue()
570
+ launch_id = "launch-1"
571
+ history_id = "601485468354fb8daff78d8e009a0714"
572
+ allure_uuid = "f7451cdd-7ad3-48fa-bb88-61b65e408645"
573
+
574
+ respx.post(f"https://charisma.test/api/v1/launches/{launch_id}/results").mock(
575
+ return_value=httpx.Response(200, json={"accepted": 1})
576
+ )
577
+ attach_route = respx.post(
578
+ f"https://charisma.test/api/v1/launches/{launch_id}/attachments"
579
+ ).mock(return_value=httpx.Response(201, json={"id": "att-1"}))
580
+
581
+ result_file = tmp_path / "09d4-result.json"
582
+ self._write_json(result_file, {
583
+ "uuid": allure_uuid,
584
+ "historyId": history_id,
585
+ "fullName": "tests.emea.dsp.test_x",
586
+ "status": "broken",
587
+ "start": 1000,
588
+ "stop": 2000,
589
+ "attachments": [
590
+ {"source": "shot.png", "name": "screenshot", "type": "image/png"}
591
+ ],
592
+ })
593
+ attach_file = tmp_path / "shot.png"
594
+ attach_file.write_bytes(b"\x89PNG fake")
595
+
596
+ uploader = Uploader(config, queue)
597
+ uploader._launch_id = launch_id
598
+ queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
599
+ queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
600
+
601
+ uploader.start()
602
+ uploader.drain(timeout=5.0)
603
+ uploader.stop()
604
+
605
+ assert uploader.summary.attachments_sent == 1
606
+ assert uploader.summary.attachments_skipped == 0
607
+ assert attach_route.called
608
+
609
+ sent = self._sent_result_id(attach_route)
610
+ expected = self._expected_result_id(launch_id, history_id)
611
+ assert sent == expected, (
612
+ f"resultId sent={sent} but backend stores id={expected} "
613
+ f"(uuid5 of launch/historyId). Sending the Allure uuid ({allure_uuid}) "
614
+ "is the attachments-skipped bug."
615
+ )
616
+ assert sent != allure_uuid
617
+
618
+ @respx.mock
619
+ def test_fixture_attachment_result_id_matches_backend(self, tmp_path: Path) -> None:
620
+ """A container/fixture attachment resolves to its child's historyId-based id."""
621
+ config = _make_config()
622
+ queue: PriorityQueue = PriorityQueue()
623
+ launch_id = "launch-1"
624
+ child_history = "hist-child-777"
625
+ child_uuid = "res1-uuid"
626
+
627
+ respx.post(f"https://charisma.test/api/v1/launches/{launch_id}/results").mock(
628
+ return_value=httpx.Response(200, json={"accepted": 1})
629
+ )
630
+ attach_route = respx.post(
631
+ f"https://charisma.test/api/v1/launches/{launch_id}/attachments"
632
+ ).mock(return_value=httpx.Response(201, json={"id": "att-2"}))
633
+
634
+ result_file = tmp_path / "res1-result.json"
635
+ self._write_json(result_file, {
636
+ "uuid": child_uuid,
637
+ "historyId": child_history,
638
+ "fullName": "tests.test_fix",
639
+ "status": "passed",
640
+ "start": 1000,
641
+ "stop": 2000,
642
+ })
643
+ container_file = tmp_path / "c1-container.json"
644
+ self._write_json(container_file, {
645
+ "uuid": "c1",
646
+ "name": "fixture",
647
+ "children": [child_uuid],
648
+ "befores": [
649
+ {"name": "setup", "attachments": [
650
+ {"source": "setup.log", "name": "setup log", "type": "text/plain"}
651
+ ]}
652
+ ],
653
+ "afters": [],
654
+ })
655
+ attach_file = tmp_path / "setup.log"
656
+ attach_file.write_text("fixture output")
657
+
658
+ uploader = Uploader(config, queue)
659
+ uploader._launch_id = launch_id
660
+ queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
661
+ queue.put(FileEvent(path=container_file, category=FileCategory.CONTAINER))
662
+ queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
663
+
664
+ uploader.start()
665
+ uploader.drain(timeout=5.0)
666
+ uploader.stop()
667
+
668
+ assert uploader.summary.attachments_sent == 1
669
+ assert uploader.summary.attachments_skipped == 0
670
+
671
+ sent = self._sent_result_id(attach_route)
672
+ expected = self._expected_result_id(launch_id, child_history)
673
+ assert sent == expected, (
674
+ f"fixture attachment resultId sent={sent} but backend stores {expected}"
675
+ )
676
+
677
+
678
+ class TestAttachmentUploadResilience:
679
+ """Attachment uploads must be retried on transient failures and fail visibly.
680
+
681
+ Review findings: attachment uploads were the only backend call with no retry
682
+ (unlike _send_batch and launch open/close), and every failure/skip was logged
683
+ at DEBUG — below the CLI's effective WARNING floor — so failures were invisible,
684
+ recreating the very 'attachments silently skipped' bug this work targets.
685
+ """
686
+
687
+ def _write_json(self, path: Path, data: dict) -> None:
688
+ import json
689
+ path.write_text(json.dumps(data), encoding="utf-8")
690
+
691
+ def _result_with_attachment(self, tmp_path: Path) -> tuple[Path, Path]:
692
+ result_file = tmp_path / "r1-result.json"
693
+ self._write_json(result_file, {
694
+ "uuid": "r1",
695
+ "historyId": "hist-r1",
696
+ "fullName": "tests.test_it",
697
+ "status": "passed",
698
+ "start": 1000,
699
+ "stop": 2000,
700
+ "attachments": [
701
+ {"source": "shot.png", "name": "shot", "type": "image/png"}
702
+ ],
703
+ })
704
+ attach_file = tmp_path / "shot.png"
705
+ attach_file.write_bytes(b"\x89PNG fake")
706
+ return result_file, attach_file
707
+
708
+ @respx.mock
709
+ def test_attachment_retries_transient_5xx_then_succeeds(self, tmp_path: Path, monkeypatch) -> None:
710
+ """A 503 followed by a 201 must retry and ultimately count as sent."""
711
+ # Neutralize backoff sleeps so the test is fast.
712
+ monkeypatch.setattr("charisma_cli.uploader.exponential_backoff", lambda attempt: 0.0)
713
+
714
+ config = _make_config()
715
+ queue: PriorityQueue = PriorityQueue()
716
+
717
+ respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
718
+ return_value=httpx.Response(200, json={"accepted": 1})
719
+ )
720
+ attach_route = respx.post(
721
+ "https://charisma.test/api/v1/launches/launch-1/attachments"
722
+ ).mock(side_effect=[
723
+ httpx.Response(503),
724
+ httpx.Response(201, json={"id": "att-1"}),
725
+ ])
726
+
727
+ result_file, attach_file = self._result_with_attachment(tmp_path)
728
+
729
+ uploader = Uploader(config, queue)
730
+ uploader._launch_id = "launch-1"
731
+ uploader.ensure_client()
732
+ uploader._handle_result(result_file)
733
+ uploader._handle_attachment(attach_file)
734
+
735
+ assert uploader.summary.attachments_sent == 1
736
+ assert uploader.summary.attachments_skipped == 0
737
+ assert attach_route.call_count == 2 # retried once
738
+
739
+ uploader._client.close()
740
+
741
+ @respx.mock
742
+ def test_attachment_gives_up_after_max_retries_and_warns(self, tmp_path: Path, monkeypatch, caplog) -> None:
743
+ """Persistent 5xx exhausts retries, counts skipped once, and logs at WARNING."""
744
+ import logging
745
+ monkeypatch.setattr("charisma_cli.uploader.exponential_backoff", lambda attempt: 0.0)
746
+
747
+ config = _make_config()
748
+ queue: PriorityQueue = PriorityQueue()
749
+
750
+ respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
751
+ return_value=httpx.Response(200, json={"accepted": 1})
752
+ )
753
+ attach_route = respx.post(
754
+ "https://charisma.test/api/v1/launches/launch-1/attachments"
755
+ ).mock(return_value=httpx.Response(503))
756
+
757
+ result_file, attach_file = self._result_with_attachment(tmp_path)
758
+
759
+ uploader = Uploader(config, queue)
760
+ uploader._launch_id = "launch-1"
761
+ uploader.ensure_client()
762
+ uploader._handle_result(result_file)
763
+
764
+ with caplog.at_level(logging.WARNING, logger="charisma_cli.uploader"):
765
+ uploader._handle_attachment(attach_file)
766
+
767
+ assert uploader.summary.attachments_sent == 0
768
+ assert uploader.summary.attachments_skipped == 1 # counted exactly once
769
+ # MAX_RETRIES(3) + initial = 4 attempts
770
+ assert attach_route.call_count == 4
771
+ assert any(r.levelno >= logging.WARNING for r in caplog.records), (
772
+ "a persistent attachment upload failure must be visible at WARNING+, not DEBUG"
773
+ )
774
+
775
+ uploader._client.close()
776
+
777
+ @respx.mock
778
+ def test_attachment_non_retryable_404_warns_and_does_not_retry(self, tmp_path: Path, caplog) -> None:
779
+ """A 404 (non-retryable) is skipped once, logged at WARNING, not retried."""
780
+ import logging
781
+ config = _make_config()
782
+ queue: PriorityQueue = PriorityQueue()
783
+
784
+ respx.post("https://charisma.test/api/v1/launches/launch-1/results").mock(
785
+ return_value=httpx.Response(200, json={"accepted": 1})
786
+ )
787
+ attach_route = respx.post(
788
+ "https://charisma.test/api/v1/launches/launch-1/attachments"
789
+ ).mock(return_value=httpx.Response(404, json={"error": "RESULT_NOT_FOUND"}))
790
+
791
+ result_file, attach_file = self._result_with_attachment(tmp_path)
792
+
793
+ uploader = Uploader(config, queue)
794
+ uploader._launch_id = "launch-1"
795
+ uploader.ensure_client()
796
+ uploader._handle_result(result_file)
797
+
798
+ with caplog.at_level(logging.WARNING, logger="charisma_cli.uploader"):
799
+ uploader._handle_attachment(attach_file)
800
+
801
+ assert uploader.summary.attachments_skipped == 1
802
+ assert attach_route.call_count == 1 # NOT retried
803
+ assert any(r.levelno >= logging.WARNING for r in caplog.records)
804
+
805
+ uploader._client.close()
806
+
807
+ def test_upload_attachment_without_launch_id_fails_fast(self, tmp_path: Path) -> None:
808
+ """_upload_attachment must not fabricate an id from a missing launch_id.
809
+
810
+ The backend derives test_results.id from launch_id/historyId; computing it
811
+ from an empty launch_id yields a well-formed-but-wrong id that 404s. Guard
812
+ instead of masking None with 'or ""'.
813
+ """
814
+ config = _make_config()
815
+ queue: PriorityQueue = PriorityQueue()
816
+
817
+ attach_file = tmp_path / "shot.png"
818
+ attach_file.write_bytes(b"\x89PNG fake")
819
+
820
+ uploader = Uploader(config, queue)
821
+ uploader._launch_id = None # no launch
822
+
823
+ # Directly invoking the upload with no launch must count skipped, not POST.
824
+ uploader._upload_attachment(attach_file, "hist-r1")
825
+ assert uploader.summary.attachments_sent == 0
826
+ assert uploader.summary.attachments_skipped == 1
File without changes