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.
Files changed (28) hide show
  1. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/.gitignore +4 -1
  2. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/PKG-INFO +1 -1
  3. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/pyproject.toml +1 -1
  4. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/__init__.py +1 -1
  5. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/models.py +49 -0
  6. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/parser.py +71 -15
  7. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/uploader.py +195 -29
  8. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_parser.py +213 -0
  9. charisma_cli-0.2.2/tests/test_uploader.py +826 -0
  10. charisma_cli-0.2.0/tests/test_uploader.py +0 -333
  11. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/README.md +0 -0
  12. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/config.py +0 -0
  13. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/launch_url.py +0 -0
  14. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/main.py +0 -0
  15. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/retry.py +0 -0
  16. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/subprocess_mgr.py +0 -0
  17. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/src/charisma_cli/watcher.py +0 -0
  18. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/__init__.py +0 -0
  19. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/conftest.py +0 -0
  20. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_cli.py +0 -0
  21. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_config.py +0 -0
  22. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_integration.py +0 -0
  23. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_launch_url.py +0 -0
  24. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_models.py +0 -0
  25. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_retry.py +0 -0
  26. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_silent_mode.py +0 -0
  27. {charisma_cli-0.2.0 → charisma_cli-0.2.2}/tests/test_subprocess_mgr.py +0 -0
  28. {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
- */__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.0
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.0"
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.1.6"
3
+ __version__ = "0.2.2"
@@ -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 extract_attachment_refs(result_data: dict, result_uuid: str) -> list[AttachmentRef]:
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
- attachments = result_data.get("attachments")
200
- if not attachments:
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 attachment in attachments:
205
- source = attachment["source"]
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 → 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).
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
- refs = extract_attachment_refs(raw_data, result.uuid or "")
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 file to the Charisma attachment API."""
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
- # Look up the result_uuid for this attachment
423
- result_uuid = self._attachment_result_map.get(path.name, "")
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
- # Attachment not referenced by any result — skip
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.debug("Skipping attachment with no result reference: %s", path.name)
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
- files = {"file": (path.name, content)}
433
- 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
434
567
 
435
- response = self._client.post(
436
- f"/api/v1/launches/{self._launch_id}/attachments",
437
- files=files,
438
- data=data,
439
- )
440
- if response.status_code == 201:
441
- self._summary.attachments_sent += 1
442
- 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.
443
586
  self._summary.attachments_skipped += 1
444
- logger.debug("Attachment upload failed: HTTP %d for %s", response.status_code, path.name)
445
- except (httpx.ConnectError, httpx.TimeoutException) as e:
446
- self._summary.attachments_skipped += 1
447
- logger.debug("Attachment upload connection error: %s for %s", e, path.name)
448
- except Exception:
449
- self._summary.attachments_skipped += 1
450
- 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
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()