charisma-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ """Queue consumer, batch accumulation, HTTP client, and retry logic for streaming uploads."""
2
+
3
+ import json
4
+ import logging
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from queue import Empty, PriorityQueue
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from charisma_cli.config import Config
14
+ from charisma_cli.models import (
15
+ ContainerData,
16
+ FileCategory,
17
+ FileEvent,
18
+ UploadSummary,
19
+ )
20
+ from charisma_cli.parser import extract_attachment_refs, parse_container_file, parse_result_file
21
+ from charisma_cli.retry import MAX_RETRIES, RETRYABLE_STATUS_CODES, exponential_backoff
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _BATCH_SIZE = 50
26
+ _BATCH_TIMEOUT_SECONDS = 2.0
27
+ _CONTAINER_ASSOCIATION_TIMEOUT = 60.0
28
+
29
+
30
+ def _epoch_ms_to_iso(epoch_ms: int) -> str:
31
+ """Convert epoch milliseconds to ISO 8601 UTC string."""
32
+ from datetime import datetime, timezone
33
+ dt = datetime.fromtimestamp(epoch_ms / 1000.0, tz=timezone.utc)
34
+ return dt.isoformat()
35
+
36
+
37
+ class Uploader:
38
+ """Consumes file events from a PriorityQueue, parses them, and streams to the Charisma API.
39
+
40
+ Runs in a background thread. Accumulates parsed results into batches
41
+ (flush at 50 items or 2s timeout). Handles retries, circuit breaking,
42
+ and 401 detection. All exceptions are caught internally.
43
+
44
+ Client lifecycle: The Uploader owns its httpx.Client. Call `ensure_client()`
45
+ before `open_launch()`/`close_launch()` if calling them outside of start/stop.
46
+ `start()` creates the client, `stop()` closes it.
47
+ """
48
+
49
+ def __init__(self, config: Config, queue: PriorityQueue[FileEvent]) -> None:
50
+ self._config = config
51
+ self._queue = queue
52
+ self._summary = UploadSummary()
53
+ self._launch_id: str | None = None
54
+ self._disabled = False
55
+ self._auth_failed = False
56
+ self._stop_event = threading.Event()
57
+ self._drain_event = threading.Event()
58
+ self._thread: threading.Thread | None = None
59
+ self._client: httpx.Client | None = None
60
+ self._batch: list[dict[str, Any]] = []
61
+ self._batch_start: float = 0.0
62
+ self._pending_containers: list[ContainerData] = []
63
+ self._known_result_uuids: set[str] = set()
64
+ # Map source filename → result_uuid for attachment uploads
65
+ self._attachment_result_map: dict[str, str] = {}
66
+
67
+ @property
68
+ def summary(self) -> UploadSummary:
69
+ """Return current upload statistics."""
70
+ return self._summary
71
+
72
+ def configure_logging(self) -> None:
73
+ """Configure logging based on silent mode.
74
+
75
+ When silent=True, upload-related errors are routed to DEBUG level only
76
+ (no stderr output). When silent=False, errors are logged at ERROR level.
77
+ """
78
+ cli_logger = logging.getLogger("charisma_cli")
79
+ if self._config.silent:
80
+ cli_logger.setLevel(logging.CRITICAL)
81
+ else:
82
+ cli_logger.setLevel(logging.WARNING)
83
+ if not cli_logger.handlers:
84
+ handler = logging.StreamHandler()
85
+ handler.setLevel(logging.WARNING)
86
+ cli_logger.addHandler(handler)
87
+
88
+ def ensure_client(self) -> None:
89
+ """Create the httpx client if not already created.
90
+
91
+ Idempotent — safe to call multiple times.
92
+ """
93
+ if self._client is None:
94
+ self._client = httpx.Client(
95
+ base_url=self._config.endpoint,
96
+ headers={"Authorization": f"Bearer {self._config.token}"},
97
+ timeout=30.0,
98
+ )
99
+
100
+ def start(self) -> None:
101
+ """Start the uploader consumer thread."""
102
+ self.ensure_client()
103
+ self._thread = threading.Thread(target=self._run, daemon=True, name="charisma-uploader")
104
+ self._thread.start()
105
+
106
+ def stop(self) -> None:
107
+ """Signal the uploader thread to exit and wait for it."""
108
+ self._stop_event.set()
109
+ if self._thread is not None:
110
+ self._thread.join(timeout=10.0)
111
+ if self._client is not None:
112
+ self._client.close()
113
+ self._client = None
114
+
115
+ def drain(self, timeout: float) -> None:
116
+ """Signal drain and wait for queue to empty or timeout to expire.
117
+
118
+ Args:
119
+ timeout: Maximum seconds to wait for the queue to drain.
120
+ """
121
+ self._drain_event.set()
122
+ deadline = time.monotonic() + timeout
123
+ while not self._queue.empty() and time.monotonic() < deadline:
124
+ time.sleep(0.1)
125
+ if not self._queue.empty():
126
+ logger.warning("Drain timeout expired with %d items remaining in queue", self._queue.qsize())
127
+
128
+ def open_launch(self) -> str | None:
129
+ """Open a streaming launch via the API.
130
+
131
+ Returns:
132
+ The launch ID if successful, None if the open failed.
133
+ """
134
+ self.ensure_client()
135
+
136
+ payload: dict[str, Any] = {
137
+ "projectAlias": self._config.project,
138
+ "expectedTests": 1000,
139
+ }
140
+ if self._config.build_id:
141
+ payload["buildId"] = self._config.build_id
142
+ if self._config.commit_sha:
143
+ payload["commitSha"] = self._config.commit_sha
144
+ if self._config.branch:
145
+ payload["branch"] = self._config.branch
146
+
147
+ for attempt in range(MAX_RETRIES + 1):
148
+ try:
149
+ response = self._client.post("/api/v1/launches", json=payload)
150
+ if response.status_code == 201:
151
+ data = response.json()
152
+ self._launch_id = data["launchId"]
153
+ return self._launch_id
154
+ if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
155
+ time.sleep(exponential_backoff(attempt))
156
+ continue
157
+ logger.error("Failed to open launch: HTTP %d", response.status_code)
158
+ return None
159
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
160
+ if attempt < MAX_RETRIES:
161
+ time.sleep(exponential_backoff(attempt))
162
+ continue
163
+ logger.error("Failed to open launch after retries: %s", e)
164
+ return None
165
+
166
+ return None
167
+
168
+ def close_launch(self) -> None:
169
+ """Close the streaming launch via the API."""
170
+ self.ensure_client()
171
+ if self._launch_id is None:
172
+ return
173
+
174
+ for attempt in range(MAX_RETRIES + 1):
175
+ try:
176
+ response = self._client.post(f"/api/v1/launches/{self._launch_id}/close")
177
+ if response.status_code in (200, 201, 204):
178
+ return
179
+ if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
180
+ time.sleep(exponential_backoff(attempt))
181
+ continue
182
+ logger.warning("Failed to close launch: HTTP %d", response.status_code)
183
+ return
184
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
185
+ if attempt < MAX_RETRIES:
186
+ time.sleep(exponential_backoff(attempt))
187
+ continue
188
+ logger.warning("Failed to close launch after retries: %s", e)
189
+ return
190
+
191
+ def _run(self) -> None:
192
+ """Main consumer loop running in the background thread."""
193
+ try:
194
+ while not self._stop_event.is_set():
195
+ self._process_queue_item()
196
+ self._check_batch_timeout()
197
+ # On stop, flush remaining
198
+ self._flush_remaining()
199
+ except Exception:
200
+ logger.exception("Unexpected error in uploader thread")
201
+
202
+ def _process_queue_item(self) -> None:
203
+ """Try to dequeue and process one item."""
204
+ try:
205
+ event: FileEvent = self._queue.get(timeout=0.1)
206
+ except Empty:
207
+ return
208
+
209
+ if self._disabled or self._auth_failed:
210
+ self._queue.task_done()
211
+ return
212
+
213
+ try:
214
+ self._handle_event(event)
215
+ except Exception:
216
+ logger.exception("Error processing file: %s", event.path)
217
+ finally:
218
+ self._queue.task_done()
219
+
220
+ def _handle_event(self, event: FileEvent) -> None:
221
+ """Route a file event to the appropriate handler."""
222
+ if event.category == FileCategory.RESULT:
223
+ self._handle_result(event.path)
224
+ elif event.category == FileCategory.CONTAINER:
225
+ self._handle_container(event.path)
226
+ elif event.category == FileCategory.ATTACHMENT:
227
+ self._handle_attachment(event.path)
228
+
229
+ def _handle_result(self, path: Path) -> None:
230
+ """Parse a result file and add to the current batch.
231
+
232
+ Reads the file once, uses parsed data for both the batch payload
233
+ and attachment reference extraction.
234
+ """
235
+ # Read file once
236
+ try:
237
+ raw_text = path.read_text(encoding="utf-8")
238
+ except (OSError, FileNotFoundError):
239
+ logger.warning("Cannot read result file: %s", path)
240
+ self._summary.results_failed += 1
241
+ return
242
+
243
+ try:
244
+ raw_data = json.loads(raw_text)
245
+ except (json.JSONDecodeError, ValueError):
246
+ logger.warning("Malformed JSON in result file: %s", path)
247
+ self._summary.results_failed += 1
248
+ return
249
+
250
+ result = parse_result_file(path)
251
+ if result is None:
252
+ self._summary.results_failed += 1
253
+ return
254
+
255
+ # Track UUID for container association
256
+ if result.uuid:
257
+ self._known_result_uuids.add(result.uuid)
258
+
259
+ # Build API payload
260
+ payload: dict[str, Any] = {
261
+ "testId": result.testId,
262
+ "outcome": result.outcome,
263
+ "duration_ms": result.duration_ms,
264
+ }
265
+ if result.name:
266
+ payload["name"] = result.name
267
+ if result.full_name:
268
+ payload["fullName"] = result.full_name
269
+ if result.error_message:
270
+ payload["error_message"] = result.error_message
271
+ if result.stack_trace:
272
+ payload["stack_trace"] = result.stack_trace
273
+ if result.labels:
274
+ # Convert Allure's [{name, value}] array to {name: value} dict
275
+ # (matches batch ingestion format expected by frontend)
276
+ labels_dict: dict[str, str] = {}
277
+ for label in result.labels:
278
+ name_key = label.get("name", "")
279
+ value_str = label.get("value", "")
280
+ if name_key and value_str:
281
+ labels_dict[name_key] = value_str
282
+ payload["labels"] = labels_dict
283
+ if result.parameters:
284
+ # Convert Allure's [{name, value}] array to {name: value} dict
285
+ params_dict: dict[str, str] = {}
286
+ for param in result.parameters:
287
+ name_key = param.get("name", "")
288
+ value_str = param.get("value", "")
289
+ if name_key:
290
+ params_dict[name_key] = value_str
291
+ payload["parameters"] = params_dict
292
+ if result.started_at is not None:
293
+ payload["started_at"] = _epoch_ms_to_iso(result.started_at)
294
+ if result.ended_at is not None:
295
+ payload["ended_at"] = _epoch_ms_to_iso(result.ended_at)
296
+
297
+ # Serialize Allure steps and links into labels dict (per design field mapping)
298
+ steps = raw_data.get("steps")
299
+ if steps:
300
+ # Normalize Allure step format to match the schema _parse_steps expects
301
+ normalized_steps = []
302
+ for step in steps:
303
+ status_details = step.get("statusDetails") or {}
304
+ start = step.get("start") or 0
305
+ stop = step.get("stop") or 0
306
+ normalized_steps.append({
307
+ "name": step.get("name", ""),
308
+ "status": step.get("status", ""),
309
+ "duration_ms": (stop - start) if (start and stop) else None,
310
+ "error_message": status_details.get("message"),
311
+ })
312
+ labels_d = payload.get("labels", {})
313
+ labels_d["steps"] = json.dumps(normalized_steps)
314
+ payload["labels"] = labels_d
315
+
316
+ links = raw_data.get("links")
317
+ if links:
318
+ labels_d = payload.get("labels", {})
319
+ labels_d["links"] = json.dumps(links)
320
+ payload["labels"] = labels_d
321
+
322
+ # Add description to labels if present
323
+ description = raw_data.get("description") or raw_data.get("descriptionHtml")
324
+ if description:
325
+ labels_d = payload.get("labels", {})
326
+ labels_d["description"] = description
327
+ payload["labels"] = labels_d
328
+
329
+ self._batch.append(payload)
330
+ if not self._batch_start:
331
+ self._batch_start = time.monotonic()
332
+
333
+ # Flush if batch is full
334
+ if len(self._batch) >= _BATCH_SIZE:
335
+ self._flush_batch()
336
+
337
+ # Extract attachment refs and register them for upload with correct result_uuid
338
+ try:
339
+ refs = extract_attachment_refs(raw_data, result.uuid or "")
340
+ for ref in refs:
341
+ # Register source → result_uuid mapping so _handle_attachment knows the owner
342
+ self._attachment_result_map[ref.source] = ref.result_uuid
343
+ except Exception:
344
+ logger.debug("Failed to extract attachment refs from %s", path, exc_info=True)
345
+
346
+ def _handle_container(self, path: Path) -> None:
347
+ """Parse a container file and buffer for deferred association."""
348
+ container = parse_container_file(path)
349
+ if container is None:
350
+ return
351
+
352
+ container.received_at = time.monotonic()
353
+ self._pending_containers.append(container)
354
+ self._try_associate_containers()
355
+
356
+ def _handle_attachment(self, path: Path) -> None:
357
+ """Upload an attachment file to the Charisma attachment API."""
358
+ if not self._launch_id or not path.exists():
359
+ self._summary.attachments_skipped += 1
360
+ return
361
+
362
+ # Look up the result_uuid for this attachment
363
+ result_uuid = self._attachment_result_map.get(path.name, "")
364
+ if not result_uuid:
365
+ # Attachment not referenced by any result — skip
366
+ self._summary.attachments_skipped += 1
367
+ logger.debug("Skipping attachment with no result reference: %s", path.name)
368
+ return
369
+
370
+ try:
371
+ content = path.read_bytes()
372
+ files = {"file": (path.name, content)}
373
+ data = {"name": path.name, "resultId": result_uuid}
374
+
375
+ response = self._client.post(
376
+ f"/api/v1/launches/{self._launch_id}/attachments",
377
+ files=files,
378
+ data=data,
379
+ )
380
+ if response.status_code == 201:
381
+ self._summary.attachments_sent += 1
382
+ else:
383
+ self._summary.attachments_skipped += 1
384
+ logger.debug("Attachment upload failed: HTTP %d for %s", response.status_code, path.name)
385
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
386
+ self._summary.attachments_skipped += 1
387
+ logger.debug("Attachment upload connection error: %s for %s", e, path.name)
388
+ except Exception:
389
+ self._summary.attachments_skipped += 1
390
+ logger.debug("Attachment upload unexpected error for %s", path.name, exc_info=True)
391
+
392
+ def _try_associate_containers(self) -> None:
393
+ """Try to associate buffered containers with known results."""
394
+ now = time.monotonic()
395
+ still_pending: list[ContainerData] = []
396
+ for container in self._pending_containers:
397
+ if now - container.received_at > _CONTAINER_ASSOCIATION_TIMEOUT:
398
+ self._summary.containers_sent += 1
399
+ continue
400
+ if all(child in self._known_result_uuids for child in container.children):
401
+ self._summary.containers_sent += 1
402
+ else:
403
+ still_pending.append(container)
404
+ self._pending_containers = still_pending
405
+
406
+ def _check_batch_timeout(self) -> None:
407
+ """Flush batch if timeout has elapsed."""
408
+ if self._batch and self._batch_start:
409
+ elapsed = time.monotonic() - self._batch_start
410
+ if elapsed >= _BATCH_TIMEOUT_SECONDS:
411
+ self._flush_batch()
412
+
413
+ def _flush_batch(self) -> None:
414
+ """Send the current batch to the API."""
415
+ if not self._batch or not self._launch_id:
416
+ return
417
+
418
+ batch = self._batch[:]
419
+ self._batch = []
420
+ self._batch_start = 0.0
421
+
422
+ success = self._send_batch(batch)
423
+ if success:
424
+ self._summary.results_sent += len(batch)
425
+ else:
426
+ self._summary.results_failed += len(batch)
427
+
428
+ def _send_batch(self, batch: list[dict[str, Any]]) -> bool:
429
+ """Send a batch of results to the API with retry logic.
430
+
431
+ Retries on transient errors (5xx, 429, timeout, ConnectError).
432
+ Engages circuit breaker only after all retries are exhausted for ConnectError.
433
+
434
+ Returns:
435
+ True if the batch was accepted, False otherwise.
436
+ """
437
+ if self._client is None:
438
+ return False
439
+
440
+ for attempt in range(MAX_RETRIES + 1):
441
+ try:
442
+ response = self._client.post(
443
+ f"/api/v1/launches/{self._launch_id}/results",
444
+ json={"results": batch},
445
+ )
446
+ if response.status_code in (200, 201):
447
+ return True
448
+ if response.status_code == 401:
449
+ logger.error("Authentication failed (401) — disabling uploads")
450
+ self._auth_failed = True
451
+ return False
452
+ if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
453
+ time.sleep(exponential_backoff(attempt))
454
+ continue
455
+ # Non-retryable failure
456
+ logger.error(
457
+ "Batch upload failed: HTTP %d — %s",
458
+ response.status_code,
459
+ response.text[:500],
460
+ )
461
+ return False
462
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
463
+ if attempt < MAX_RETRIES:
464
+ time.sleep(exponential_backoff(attempt))
465
+ continue
466
+ # All retries exhausted — engage circuit breaker
467
+ logger.error("API unreachable after %d retries: %s — engaging circuit breaker", MAX_RETRIES, e)
468
+ self._disabled = True
469
+ return False
470
+
471
+ return False
472
+
473
+ def _flush_remaining(self) -> None:
474
+ """Flush any remaining batch and process remaining queue items."""
475
+ while not self._queue.empty():
476
+ try:
477
+ event = self._queue.get_nowait()
478
+ if not self._disabled and not self._auth_failed:
479
+ self._handle_event(event)
480
+ self._queue.task_done()
481
+ except Empty:
482
+ break
483
+
484
+ if self._batch:
485
+ self._flush_batch()
486
+
487
+ self._summary.containers_sent += len(self._pending_containers)
488
+ self._pending_containers.clear()