charisma-cli 0.1.6__tar.gz → 0.2.0__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.1.6 → charisma_cli-0.2.0}/.gitignore +1 -0
  2. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/PKG-INFO +1 -1
  3. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/pyproject.toml +1 -1
  4. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/__init__.py +1 -1
  5. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/main.py +20 -9
  6. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/uploader.py +20 -10
  7. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/watcher.py +27 -2
  8. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_integration.py +132 -2
  9. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_watcher.py +25 -6
  10. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/README.md +0 -0
  11. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/config.py +0 -0
  12. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/launch_url.py +0 -0
  13. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/models.py +0 -0
  14. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/parser.py +0 -0
  15. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/retry.py +0 -0
  16. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/src/charisma_cli/subprocess_mgr.py +0 -0
  17. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/__init__.py +0 -0
  18. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/conftest.py +0 -0
  19. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_cli.py +0 -0
  20. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_config.py +0 -0
  21. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_launch_url.py +0 -0
  22. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_models.py +0 -0
  23. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_parser.py +0 -0
  24. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_retry.py +0 -0
  25. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_silent_mode.py +0 -0
  26. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_subprocess_mgr.py +0 -0
  27. {charisma_cli-0.1.6 → charisma_cli-0.2.0}/tests/test_uploader.py +0 -0
@@ -132,3 +132,4 @@ ltm/snapshots/*
132
132
  backend/coverage.json
133
133
  */__pycache__/*
134
134
  allure-results/*
135
+ allure-results-live/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.1.6
3
+ Version: 0.2.0
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.1.6"
8
+ version = "0.2.0"
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.2"
3
+ __version__ = "0.1.6"
@@ -16,6 +16,15 @@ from charisma_cli.subprocess_mgr import SubprocessManager
16
16
  from charisma_cli.uploader import Uploader
17
17
  from charisma_cli.watcher import ResultsWatcher, classify_file
18
18
 
19
+ # Post-subprocess settle window for the watch command. allure-pytest under heavy
20
+ # xdist can finish flushing its result files to disk seconds after the test
21
+ # subprocess exits; settle_scan keeps polling until the directory is quiet for
22
+ # SETTLE_QUIET_PERIOD or SETTLE_MAX_WAIT elapses. The window is deliberately
23
+ # wider than settle_scan's own defaults (1s/15s) because a real CI burst can
24
+ # land well past 15s — a too-short window is the watch results_sent=0 bug.
25
+ SETTLE_QUIET_PERIOD = 2.0
26
+ SETTLE_MAX_WAIT = 60.0
27
+
19
28
 
20
29
  @click.group()
21
30
  @click.version_option(version=__version__, prog_name="charismactl")
@@ -185,17 +194,19 @@ def watch(
185
194
  mgr.spawn(command)
186
195
  exit_code = mgr.wait()
187
196
 
188
- # After subprocess exits (crash or normal): flush debouncing files, then
189
- # keep scanning until the results directory settles. allure-pytest writes
190
- # its result files in a burst during test-process teardown; under high
191
- # xdist parallelism that burst can land AFTER the subprocess exits, so a
192
- # single scan taken here races the OS flush and captures nothing (the
193
- # watch results_sent=0 bug). settle_scan reconciles until the directory is
194
- # quiet, capturing the whole burst before we drain.
197
+ # Reconcile the results directory after the subprocess exits. allure-pytest
198
+ # writes its result files in a burst during test-process teardown; under
199
+ # heavy xdist parallelism that burst can finish flushing to disk seconds
200
+ # after the subprocess returns. flush_pending drains any files still in the
201
+ # 500ms debounce window; settle_scan then polls the directory until it is
202
+ # quiet, capturing the whole late burst before we drain. Without a wide
203
+ # enough window the queue is drained empty and nothing is sent (the watch
204
+ # results_sent=0 bug). The shared seen-set keeps every file exactly-once.
195
205
  watcher.flush_pending()
196
- watcher.settle_scan()
206
+ watcher.settle_scan(quiet_period=SETTLE_QUIET_PERIOD, max_wait=SETTLE_MAX_WAIT)
197
207
 
198
- # Drain and close — stop watcher AFTER drain so all files are processed
208
+ # Drain and close — stop the watcher AFTER drain so the consumer's final
209
+ # queue flush still sees anything the observer enqueued during drain.
199
210
  uploader.drain(timeout=config.drain_timeout)
200
211
  watcher.stop()
201
212
  uploader.stop()
@@ -26,6 +26,11 @@ _BATCH_SIZE = 50
26
26
  _BATCH_TIMEOUT_SECONDS = 2.0
27
27
  _CONTAINER_ASSOCIATION_TIMEOUT = 60.0
28
28
 
29
+ # Sentinel expectedTests for CLI launches: the total count is unknown at open
30
+ # time (directory-watch streaming), so we send the backend maximum. This keeps
31
+ # the launch in 'receiving' across all appends; close_launch() finalizes it.
32
+ _UNKNOWN_EXPECTED_TESTS = 1_000_000
33
+
29
34
 
30
35
  def _epoch_ms_to_iso(epoch_ms: int) -> str:
31
36
  """Convert epoch milliseconds to ISO 8601 UTC string."""
@@ -173,9 +178,14 @@ class Uploader:
173
178
  """
174
179
  self.ensure_client()
175
180
 
181
+ # The CLI streams from a watched directory and does not know the total
182
+ # test count at open time. Send a high sentinel so the launch stays
183
+ # 'receiving' through every append; finalization is driven by
184
+ # close_launch() at the end of the run (or the stale-launch finalizer as
185
+ # a backstop), never by the append counter reaching expectedTests.
176
186
  payload: dict[str, Any] = {
177
187
  "projectAlias": self._config.project,
178
- "expectedTests": 1000,
188
+ "expectedTests": _UNKNOWN_EXPECTED_TESTS,
179
189
  }
180
190
  if self._config.build_id:
181
191
  payload["buildId"] = self._config.build_id
@@ -306,16 +316,16 @@ class Uploader:
306
316
  if result.uuid:
307
317
  self._known_result_uuids.add(result.uuid)
308
318
 
309
- # Build API payload
319
+ # Build API payload (snake_case wire format — shared TestResultInput contract)
310
320
  payload: dict[str, Any] = {
311
- "testId": result.testId,
321
+ "test_id": result.testId,
312
322
  "outcome": result.outcome,
313
323
  "duration_ms": result.duration_ms,
314
324
  }
315
325
  if result.name:
316
326
  payload["name"] = result.name
317
327
  if result.full_name:
318
- payload["fullName"] = result.full_name
328
+ payload["full_name"] = result.full_name
319
329
  if result.error_message:
320
330
  payload["error_message"] = result.error_message
321
331
  if result.stack_trace:
@@ -344,10 +354,11 @@ class Uploader:
344
354
  if result.ended_at is not None:
345
355
  payload["ended_at"] = _epoch_ms_to_iso(result.ended_at)
346
356
 
347
- # Serialize Allure steps and links into labels dict (per design field mapping)
357
+ # Serialize Allure steps into the first-class `steps` array (StepInput
358
+ # wire shape). Previously packed as a JSON string in labels["steps"] —
359
+ # dropped in favor of the shared TestResultInput contract.
348
360
  steps = raw_data.get("steps")
349
361
  if steps:
350
- # Normalize Allure step format to match the schema _parse_steps expects
351
362
  normalized_steps = []
352
363
  for step in steps:
353
364
  status_details = step.get("statusDetails") or {}
@@ -359,17 +370,16 @@ class Uploader:
359
370
  "duration_ms": (stop - start) if (start and stop) else None,
360
371
  "error_message": status_details.get("message"),
361
372
  })
362
- labels_d = payload.get("labels", {})
363
- labels_d["steps"] = json.dumps(normalized_steps)
364
- payload["labels"] = labels_d
373
+ payload["steps"] = normalized_steps
365
374
 
375
+ # Links and description are label-like metadata not modeled by StepInput;
376
+ # keep them in labels (values are strings, matching labels: dict[str, str]).
366
377
  links = raw_data.get("links")
367
378
  if links:
368
379
  labels_d = payload.get("labels", {})
369
380
  labels_d["links"] = json.dumps(links)
370
381
  payload["labels"] = labels_d
371
382
 
372
- # Add description to labels if present
373
383
  description = raw_data.get("description") or raw_data.get("descriptionHtml")
374
384
  if description:
375
385
  labels_d = payload.get("labels", {})
@@ -1,5 +1,6 @@
1
1
  """Watchdog observer, stability debounce, and file classification for allure-results."""
2
2
 
3
+ import logging
3
4
  import os
4
5
  import time
5
6
  from pathlib import Path
@@ -12,6 +13,8 @@ from watchdog.observers import Observer
12
13
  from charisma_cli.config import Config
13
14
  from charisma_cli.models import FileCategory, FileEvent
14
15
 
16
+ logger = logging.getLogger(__name__)
17
+
15
18
  _TWO_MB = 2 * 1024 * 1024
16
19
  _STABILITY_SECONDS = 0.5
17
20
 
@@ -306,8 +309,10 @@ class ResultsWatcher:
306
309
  Total number of new files enqueued across all iterations.
307
310
  """
308
311
  total = 0
309
- deadline = time.monotonic() + max_wait
310
- last_new_at = time.monotonic()
312
+ started_at = time.monotonic()
313
+ deadline = started_at + max_wait
314
+ last_new_at = started_at
315
+ settled = False
311
316
 
312
317
  while time.monotonic() < deadline:
313
318
  new_count = self.final_scan()
@@ -317,7 +322,27 @@ class ResultsWatcher:
317
322
  last_new_at = now
318
323
  elif now - last_new_at >= quiet_period:
319
324
  # No new files for a full quiet period — directory has settled.
325
+ settled = True
320
326
  break
321
327
  time.sleep(poll_interval)
322
328
 
329
+ elapsed = time.monotonic() - started_at
330
+ if settled:
331
+ logger.info(
332
+ "Results directory settled after %.1fs (%d file(s) captured during settle)",
333
+ elapsed,
334
+ total,
335
+ )
336
+ else:
337
+ # Exited on max_wait, not quiescence: the directory was still
338
+ # producing files when we gave up. Surface this so an operator can
339
+ # see teardown burned the full window rather than settling — a
340
+ # signal that max_wait may be too short for this workload.
341
+ logger.warning(
342
+ "settle_scan hit max_wait (%.0fs) before the results directory "
343
+ "settled; captured %d file(s). Some late results may be unsent.",
344
+ max_wait,
345
+ total,
346
+ )
347
+
323
348
  return total
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import sys
5
+ import threading
5
6
  from pathlib import Path
6
7
 
7
8
  import httpx
@@ -228,10 +229,10 @@ class TestReportingFixesEndToEnd:
228
229
  # The skipped result was sent (not dropped)
229
230
  assert results_route.called
230
231
  sent_results = json.loads(results_route.calls[0].request.content)["results"]
231
- outcomes = {r["testId"]: r["outcome"] for r in sent_results}
232
+ outcomes = {r["test_id"]: r["outcome"] for r in sent_results}
232
233
  assert outcomes.get("hist_skip") == "skipped"
233
234
  # duration defaulted to 0 for the missing stop
234
- skip_payload = next(r for r in sent_results if r["testId"] == "hist_skip")
235
+ skip_payload = next(r for r in sent_results if r["test_id"] == "hist_skip")
235
236
  assert skip_payload["duration_ms"] == 0
236
237
 
237
238
  # environment.properties sent as variables on close
@@ -242,3 +243,132 @@ class TestReportingFixesEndToEnd:
242
243
  "aws_region": "us-west-2",
243
244
  "workers_number": "8",
244
245
  }
246
+
247
+
248
+ class TestSettleScanCapturesLateTeardownBurst:
249
+ """Regression for the `watch results_sent=0` bug — isolates the true fix.
250
+
251
+ The one behavioral property that separates the old teardown from the new is
252
+ settle_scan's max_wait window. allure-pytest under heavy xdist can flush its
253
+ result files to disk SEVERAL SECONDS after the subprocess exits. The old
254
+ teardown used settle_scan()'s default max_wait=15.0; the new watch teardown
255
+ widens it to 60.0. A file that first lands on disk AFTER the window closes is
256
+ never enqueued and never sent — that is the production failure.
257
+
258
+ This test drives ResultsWatcher directly (not the full CLI) with scaled-down
259
+ timings so it is fast and deterministic: a result file is written by a
260
+ background timer at t=0.3s after settle_scan starts. With a SHORT window
261
+ (mimicking the old code) settle_scan gives up before the file lands and
262
+ enqueues nothing. With a LONGER window (mimicking the new code) it is still
263
+ polling when the file lands and enqueues it. No reliance on OS event buffers
264
+ or thread scheduling — only on-disk appearance time vs the window.
265
+ """
266
+
267
+ def _write_result_after(self, results_dir: Path, delay: float) -> threading.Timer:
268
+ """Schedule a valid Allure result file to appear after `delay` seconds."""
269
+
270
+ def _write() -> None:
271
+ (results_dir / "late-result.json").write_text(json.dumps({
272
+ "uuid": "late-1",
273
+ "historyId": "hist_late",
274
+ "fullName": "tests.test_late.test_case",
275
+ "name": "test_case",
276
+ "status": "passed",
277
+ "start": 1000,
278
+ "stop": 2000,
279
+ "labels": [],
280
+ "parameters": [],
281
+ "attachments": [],
282
+ }))
283
+
284
+ timer = threading.Timer(delay, _write)
285
+ timer.daemon = True
286
+ timer.start()
287
+ return timer
288
+
289
+ def test_short_window_misses_late_file_long_window_captures_it(
290
+ self, tmp_path: Path, default_config
291
+ ) -> None:
292
+ """settle_scan with a window shorter than the file's appearance misses it;
293
+ a window longer than the appearance captures it. This is exactly the
294
+ old (max_wait=15) vs new (max_wait=60) separation, scaled to sub-second."""
295
+ from queue import PriorityQueue
296
+
297
+ from charisma_cli.models import FileEvent
298
+ from charisma_cli.watcher import ResultsWatcher
299
+
300
+ # --- OLD behavior: window closes BEFORE the file lands (t=0.3s) ---
301
+ old_dir = tmp_path / "old"
302
+ old_dir.mkdir()
303
+ old_queue: PriorityQueue[FileEvent] = PriorityQueue()
304
+ old_watcher = ResultsWatcher(str(old_dir), old_queue, default_config)
305
+ t1 = self._write_result_after(old_dir, delay=0.3)
306
+ # Do not start the observer — isolate the scan behavior from OS events.
307
+ old_captured = old_watcher.settle_scan(quiet_period=0.1, max_wait=0.2)
308
+ t1.join()
309
+ assert old_captured == 0, "short window should give up before the late file lands"
310
+ assert old_queue.empty()
311
+
312
+ # --- NEW behavior: window stays open PAST the file landing (t=0.3s) ---
313
+ new_dir = tmp_path / "new"
314
+ new_dir.mkdir()
315
+ new_queue: PriorityQueue[FileEvent] = PriorityQueue()
316
+ new_watcher = ResultsWatcher(str(new_dir), new_queue, default_config)
317
+ t2 = self._write_result_after(new_dir, delay=0.3)
318
+ new_captured = new_watcher.settle_scan(quiet_period=0.3, max_wait=2.0)
319
+ t2.join()
320
+ assert new_captured == 1, "long window should still be polling when the late file lands"
321
+ assert new_queue.qsize() == 1
322
+
323
+
324
+ class TestWatchStreamsPreexistingResult:
325
+ """Sanity E2E: watch streams a result already on disk through the launch API.
326
+
327
+ This does NOT isolate the old/new fix (any on-disk file is caught by both).
328
+ It guards the happy path: watch opens a launch, sends the result, closes,
329
+ and reports results_sent in the summary.
330
+ """
331
+
332
+ @respx.mock
333
+ def test_watch_sends_result_and_reports_summary(self, tmp_path: Path, monkeypatch) -> None:
334
+ results_dir = tmp_path / "allure-results"
335
+ results_dir.mkdir()
336
+ (results_dir / "pre-result.json").write_text(json.dumps({
337
+ "uuid": "pre-1",
338
+ "historyId": "hist_pre",
339
+ "fullName": "tests.test_pre.test_case",
340
+ "name": "test_case",
341
+ "status": "passed",
342
+ "start": 1000,
343
+ "stop": 2000,
344
+ "labels": [],
345
+ "parameters": [],
346
+ "attachments": [],
347
+ }))
348
+
349
+ respx.post("https://charisma.test/api/v1/launches").mock(
350
+ return_value=httpx.Response(201, json={"launchId": "launch-pre", "projectId": "proj-uuid"})
351
+ )
352
+ results_route = respx.post(
353
+ "https://charisma.test/api/v1/launches/launch-pre/results"
354
+ ).mock(return_value=httpx.Response(200, json={"accepted": 1}))
355
+ respx.post("https://charisma.test/api/v1/launches/launch-pre/close").mock(
356
+ return_value=httpx.Response(200, json={})
357
+ )
358
+
359
+ monkeypatch.setenv("CHARISMA_ENDPOINT", "https://charisma.test")
360
+ monkeypatch.setenv("CHARISMA_TOKEN", "pre-token")
361
+
362
+ runner = CliRunner()
363
+ result = runner.invoke(cli, [
364
+ "watch",
365
+ "--project", "pre-project",
366
+ "--results", str(results_dir),
367
+ "--", sys.executable, "-c", "import sys; sys.exit(0)",
368
+ ])
369
+
370
+ assert result.exit_code == 0
371
+ assert results_route.called
372
+ sent_results = json.loads(results_route.calls[0].request.content)["results"]
373
+ assert any(r["test_id"] == "hist_pre" for r in sent_results)
374
+ assert "results_sent=1" in result.output
@@ -733,8 +733,14 @@ class TestSettleScan:
733
733
  seen.add(key)
734
734
  assert len(seen) == 10
735
735
 
736
- def test_settles_quickly_when_directory_empty(self, tmp_path: Path) -> None:
737
- """An empty directory settles after one quiet period and enqueues nothing."""
736
+ def test_settles_quickly_when_directory_empty(self, tmp_path: Path, caplog) -> None:
737
+ """An empty directory settles after one quiet period and enqueues nothing.
738
+
739
+ Also asserts the settle path logs at INFO (not WARNING) — settling
740
+ cleanly is the normal case and must not raise a max_wait warning.
741
+ """
742
+ import logging
743
+
738
744
  results_dir = tmp_path / "allure-results"
739
745
  results_dir.mkdir()
740
746
 
@@ -742,19 +748,26 @@ class TestSettleScan:
742
748
  watcher = ResultsWatcher(str(results_dir), queue, _make_config())
743
749
 
744
750
  start = time.monotonic()
745
- enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=5.0, poll_interval=0.1)
751
+ with caplog.at_level(logging.INFO, logger="charisma_cli.watcher"):
752
+ enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=5.0, poll_interval=0.1)
746
753
  elapsed = time.monotonic() - start
747
754
 
748
755
  assert enqueued == 0
749
756
  assert queue.empty()
750
757
  assert elapsed < 4.0 # returned on the quiet-period, not the max_wait cap
758
+ # Clean settle → INFO log, never a max_wait WARNING.
759
+ assert any(r.levelno == logging.INFO for r in caplog.records)
760
+ assert not any(r.levelno == logging.WARNING for r in caplog.records)
751
761
 
752
- def test_respects_max_wait_when_directory_never_settles(self, tmp_path: Path) -> None:
762
+ def test_respects_max_wait_when_directory_never_settles(self, tmp_path: Path, caplog) -> None:
753
763
  """max_wait caps total time even if files keep arriving continuously.
754
764
 
755
765
  This documents the one bounded caveat: a directory that never goes quiet
756
- stops at max_wait rather than blocking teardown forever.
766
+ stops at max_wait rather than blocking teardown forever. Also asserts a
767
+ WARNING is emitted so the up-to-max_wait teardown is observable rather
768
+ than silent.
757
769
  """
770
+ import logging
758
771
  import threading
759
772
 
760
773
  results_dir = tmp_path / "allure-results"
@@ -776,7 +789,8 @@ class TestSettleScan:
776
789
  writer.start()
777
790
  try:
778
791
  start = time.monotonic()
779
- watcher.settle_scan(quiet_period=0.5, max_wait=1.5, poll_interval=0.1)
792
+ with caplog.at_level(logging.WARNING, logger="charisma_cli.watcher"):
793
+ watcher.settle_scan(quiet_period=0.5, max_wait=1.5, poll_interval=0.1)
780
794
  elapsed = time.monotonic() - start
781
795
  finally:
782
796
  stop.set()
@@ -784,6 +798,11 @@ class TestSettleScan:
784
798
 
785
799
  # Should stop at ~max_wait, not run indefinitely.
786
800
  assert 1.5 <= elapsed < 4.0
801
+ # Hitting max_wait must surface a WARNING (observability, not silent).
802
+ assert any(
803
+ r.levelno == logging.WARNING and "max_wait" in r.getMessage()
804
+ for r in caplog.records
805
+ )
787
806
 
788
807
  def test_scales_to_large_burst(self, tmp_path: Path) -> None:
789
808
  """A large number of files (simulating many workers/tests) is fully captured.
File without changes