charisma-cli 0.1.5__tar.gz → 0.1.6__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.1.5 → charisma_cli-0.1.6}/PKG-INFO +1 -1
  2. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/pyproject.toml +1 -1
  3. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/main.py +8 -4
  4. charisma_cli-0.1.6/src/charisma_cli/watcher.py +323 -0
  5. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_watcher.py +238 -4
  6. charisma_cli-0.1.5/src/charisma_cli/watcher.py +0 -223
  7. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/.gitignore +0 -0
  8. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/README.md +0 -0
  9. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/__init__.py +0 -0
  10. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/config.py +0 -0
  11. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/launch_url.py +0 -0
  12. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/models.py +0 -0
  13. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/parser.py +0 -0
  14. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/retry.py +0 -0
  15. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/subprocess_mgr.py +0 -0
  16. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/src/charisma_cli/uploader.py +0 -0
  17. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/__init__.py +0 -0
  18. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/conftest.py +0 -0
  19. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_cli.py +0 -0
  20. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_config.py +0 -0
  21. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_integration.py +0 -0
  22. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_launch_url.py +0 -0
  23. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_models.py +0 -0
  24. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_parser.py +0 -0
  25. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_retry.py +0 -0
  26. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_silent_mode.py +0 -0
  27. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_subprocess_mgr.py +0 -0
  28. {charisma_cli-0.1.5 → charisma_cli-0.1.6}/tests/test_uploader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.1.5
3
+ Version: 0.1.6
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.5"
8
+ version = "0.1.6"
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"
@@ -185,11 +185,15 @@ def watch(
185
185
  mgr.spawn(command)
186
186
  exit_code = mgr.wait()
187
187
 
188
- # After subprocess exits (crash or normal): flush debouncing files and scan
189
- # for anything the watcher missed entirely. This prevents data loss when
190
- # parallel test workers write results in bursts just before crash.
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.
191
195
  watcher.flush_pending()
192
- watcher.final_scan()
196
+ watcher.settle_scan()
193
197
 
194
198
  # Drain and close — stop watcher AFTER drain so all files are processed
195
199
  uploader.drain(timeout=config.drain_timeout)
@@ -0,0 +1,323 @@
1
+ """Watchdog observer, stability debounce, and file classification for allure-results."""
2
+
3
+ import os
4
+ import time
5
+ from pathlib import Path
6
+ from queue import PriorityQueue
7
+ from threading import Lock, Timer
8
+
9
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
10
+ from watchdog.observers import Observer
11
+
12
+ from charisma_cli.config import Config
13
+ from charisma_cli.models import FileCategory, FileEvent
14
+
15
+ _TWO_MB = 2 * 1024 * 1024
16
+ _STABILITY_SECONDS = 0.5
17
+
18
+
19
+ class _SeenSet:
20
+ """Thread-safe set of absolute paths already enqueued.
21
+
22
+ Shared by every producer (live observer, flush_pending, final_scan,
23
+ settle_scan) so each file is enqueued exactly once. ``add`` returns True
24
+ only the first time a path is seen, letting callers gate the enqueue.
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ self._paths: set[str] = set()
29
+ self._lock = Lock()
30
+
31
+ def add(self, path: str) -> bool:
32
+ """Record a path. Returns True if newly added, False if already present."""
33
+ with self._lock:
34
+ if path in self._paths:
35
+ return False
36
+ self._paths.add(path)
37
+ return True
38
+
39
+ def __contains__(self, path: str) -> bool:
40
+ with self._lock:
41
+ return path in self._paths
42
+
43
+
44
+ def classify_file(filename: str) -> FileCategory | None:
45
+ """Classify a filename into a FileCategory or None if excluded.
46
+
47
+ Args:
48
+ filename: The base filename (not full path).
49
+
50
+ Returns:
51
+ FileCategory.RESULT for *-result.json,
52
+ FileCategory.CONTAINER for *-container.json,
53
+ FileCategory.ATTACHMENT for all other valid files,
54
+ None for excluded files (dot-prefix or .tmp suffix).
55
+ """
56
+ if filename.startswith("."):
57
+ return None
58
+ if filename.endswith(".tmp"):
59
+ return None
60
+ if filename.endswith("-result.json"):
61
+ return FileCategory.RESULT
62
+ if filename.endswith("-container.json"):
63
+ return FileCategory.CONTAINER
64
+ return FileCategory.ATTACHMENT
65
+
66
+
67
+ def should_skip(path: Path, config: Config) -> bool:
68
+ """Check whether a file should be skipped based on size.
69
+
70
+ Args:
71
+ path: Path to the file on disk.
72
+ config: Resolved CLI configuration.
73
+
74
+ Returns:
75
+ True if the file exceeds 2MB and skip_too_big is enabled.
76
+ """
77
+ if not config.skip_too_big:
78
+ return False
79
+ return path.stat().st_size > _TWO_MB
80
+
81
+
82
+ def _enqueue_once(
83
+ queue: "PriorityQueue[FileEvent]",
84
+ config: Config,
85
+ enqueued: "_SeenSet",
86
+ path: Path,
87
+ category: FileCategory,
88
+ ) -> bool:
89
+ """Enqueue a file exactly once, guarding all filesystem access.
90
+
91
+ Single owner of the enqueue-decision pipeline shared by every producer
92
+ (the live observer's debounce handler, flush_pending, and the scans):
93
+ resolve the path, apply the size-skip guard, gate on the shared seen-set,
94
+ then put the event. Keeping this in one place ensures the exactly-once
95
+ invariant (skip BEFORE marking seen, one canonical resolved-path key)
96
+ stays consistent across all callers.
97
+
98
+ All filesystem calls (``resolve``, ``stat`` via ``should_skip``) are guarded:
99
+ the scans run on the main thread during teardown, so an unhandled OSError
100
+ here would abort launch-close and the summary. On any filesystem error the
101
+ file is skipped rather than raised.
102
+
103
+ Returns:
104
+ True if a FileEvent was enqueued, False if skipped (too big, already
105
+ seen, or unreadable).
106
+ """
107
+ try:
108
+ if should_skip(path, config):
109
+ return False
110
+ abs_path = str(path.resolve())
111
+ except (OSError, ValueError):
112
+ # File vanished, permission/symlink error, or unresolvable path —
113
+ # skip it rather than crash the caller (main-thread teardown).
114
+ return False
115
+
116
+ if not enqueued.add(abs_path):
117
+ return False
118
+ queue.put(FileEvent(path=path, category=category))
119
+ return True
120
+
121
+
122
+ class _StabilityHandler(FileSystemEventHandler):
123
+ """Debounces filesystem events, enqueuing files after 500ms of stability."""
124
+
125
+ def __init__(self, queue: "PriorityQueue[FileEvent]", config: Config, enqueued: "_SeenSet") -> None:
126
+ super().__init__()
127
+ self._queue = queue
128
+ self._config = config
129
+ self._timers: dict[str, Timer] = {}
130
+ # Shared across the live observer, flush_pending, and the scans so a
131
+ # given file is enqueued exactly once no matter which producer sees it
132
+ # first. Without this, the end-of-run settle_scan re-enqueues files the
133
+ # live observer already delivered, doubling results_sent.
134
+ self._enqueued = enqueued
135
+
136
+ def on_created(self, event: FileSystemEvent) -> None:
137
+ """Handle file creation events."""
138
+ if not event.is_directory:
139
+ self._handle_event(event.src_path)
140
+
141
+ def on_modified(self, event: FileSystemEvent) -> None:
142
+ """Handle file modification events."""
143
+ if not event.is_directory:
144
+ self._handle_event(event.src_path)
145
+
146
+ def _handle_event(self, src_path: str) -> None:
147
+ """Reset the stability timer for a file path."""
148
+ path = Path(src_path)
149
+ filename = path.name
150
+
151
+ category = classify_file(filename)
152
+ if category is None:
153
+ return
154
+
155
+ # Cancel existing timer for this path
156
+ existing = self._timers.get(src_path)
157
+ if existing is not None:
158
+ existing.cancel()
159
+
160
+ # Set a new timer that enqueues after stability period
161
+ timer = Timer(_STABILITY_SECONDS, self._enqueue, args=(path, category))
162
+ timer.daemon = True
163
+ self._timers[src_path] = timer
164
+ timer.start()
165
+
166
+ def _enqueue(self, path: Path, category: FileCategory) -> None:
167
+ """Enqueue a file event after stability check passes."""
168
+ # Remove from timer dict
169
+ self._timers.pop(str(path), None)
170
+ _enqueue_once(self._queue, self._config, self._enqueued, path, category)
171
+
172
+ def cancel_all(self) -> None:
173
+ """Cancel all pending timers."""
174
+ for timer in self._timers.values():
175
+ timer.cancel()
176
+ self._timers.clear()
177
+
178
+ def flush_pending(self) -> None:
179
+ """Immediately enqueue all files currently in debounce windows.
180
+
181
+ Called after subprocess exit to ensure no result files are lost due
182
+ to pending debounce timers. Cancels all timers and enqueues their
183
+ associated files directly.
184
+ """
185
+ # Snapshot and clear timers atomically
186
+ pending = dict(self._timers)
187
+ self._timers.clear()
188
+
189
+ for src_path, timer in pending.items():
190
+ timer.cancel()
191
+ path = Path(src_path)
192
+
193
+ category = classify_file(path.name)
194
+ if category is None:
195
+ continue
196
+
197
+ _enqueue_once(self._queue, self._config, self._enqueued, path, category)
198
+
199
+
200
+ class ResultsWatcher:
201
+ """Watches the allure-results directory for new files using watchdog.
202
+
203
+ Creates the directory if it doesn't exist, observes file creation/modification,
204
+ debounces events for 500ms of stability, then enqueues FileEvent objects
205
+ into the provided PriorityQueue.
206
+ """
207
+
208
+ def __init__(self, results_dir: str, queue: "PriorityQueue[FileEvent]", config: Config) -> None:
209
+ self._results_dir = results_dir
210
+ self._queue = queue
211
+ self._config = config
212
+ # One shared seen-set gates every producer (live observer, flush_pending,
213
+ # final_scan, settle_scan) so each file is enqueued exactly once.
214
+ self._enqueued = _SeenSet()
215
+ self._handler = _StabilityHandler(queue, config, self._enqueued)
216
+ self._observer = Observer()
217
+
218
+ def start(self) -> None:
219
+ """Create the results directory and start observing for file events."""
220
+ os.makedirs(self._results_dir, exist_ok=True)
221
+ self._observer.schedule(self._handler, self._results_dir, recursive=False)
222
+ self._observer.start()
223
+
224
+ def stop(self) -> None:
225
+ """Stop the observer and cancel all pending stability timers."""
226
+ self._observer.stop()
227
+ self._observer.join()
228
+ self._handler.cancel_all()
229
+
230
+ def flush_pending(self) -> None:
231
+ """Flush all files currently in debounce windows into the queue.
232
+
233
+ Should be called BEFORE stop() when you want to capture all pending
234
+ files rather than discard them.
235
+ """
236
+ self._handler.flush_pending()
237
+
238
+ def final_scan(self) -> int:
239
+ """Scan the results directory and enqueue any files not already processed.
240
+
241
+ Performs a one-time sweep of all files in the results directory,
242
+ enqueuing any that pass classification and size checks. This catches
243
+ files that the filesystem watcher may have missed entirely (e.g., written
244
+ between watcher setup and observation start, or during high-throughput
245
+ bursts that overwhelm OS event buffers). Files already enqueued by any
246
+ producer are skipped via the shared seen-set.
247
+
248
+ Runs on the main thread during teardown, so all filesystem access is
249
+ guarded: a transient error listing or reading a file skips that file
250
+ rather than aborting the scan (and the launch-close that follows it).
251
+
252
+ Returns:
253
+ Number of new files enqueued by this scan.
254
+ """
255
+ results_path = Path(self._results_dir)
256
+ try:
257
+ entries = list(results_path.iterdir())
258
+ except (OSError, FileNotFoundError):
259
+ # Directory missing or unreadable — nothing to enqueue this pass.
260
+ return 0
261
+
262
+ enqueued = 0
263
+ for filepath in entries:
264
+ try:
265
+ if not filepath.is_file():
266
+ continue
267
+ except OSError:
268
+ continue
269
+
270
+ category = classify_file(filepath.name)
271
+ if category is None:
272
+ continue
273
+
274
+ if _enqueue_once(self._queue, self._config, self._enqueued, filepath, category):
275
+ enqueued += 1
276
+
277
+ return enqueued
278
+
279
+ def settle_scan(
280
+ self,
281
+ quiet_period: float = 1.0,
282
+ max_wait: float = 15.0,
283
+ poll_interval: float = 0.25,
284
+ ) -> int:
285
+ """Repeatedly scan until the results directory stops producing new files.
286
+
287
+ Under high parallelism (e.g. pytest-xdist with many workers), allure
288
+ writes its result files in a burst during the test process's teardown.
289
+ A single ``final_scan()`` taken the instant the subprocess exits can run
290
+ before the OS has finished flushing that burst to disk, capturing zero
291
+ or a partial set — the ``watch results_sent=0`` bug. This method keeps
292
+ scanning until no new files have appeared for ``quiet_period`` seconds
293
+ (the directory has "settled"), or until ``max_wait`` seconds elapse.
294
+
295
+ Idempotent with ``final_scan()``: both share the watcher's internal
296
+ ``_enqueued`` set, so files already enqueued are never enqueued again.
297
+
298
+ Args:
299
+ quiet_period: Seconds with no new files before the directory is
300
+ considered settled.
301
+ max_wait: Absolute cap on total wait time, so a directory that keeps
302
+ changing can't block teardown forever.
303
+ poll_interval: Delay between scans.
304
+
305
+ Returns:
306
+ Total number of new files enqueued across all iterations.
307
+ """
308
+ total = 0
309
+ deadline = time.monotonic() + max_wait
310
+ last_new_at = time.monotonic()
311
+
312
+ while time.monotonic() < deadline:
313
+ new_count = self.final_scan()
314
+ total += new_count
315
+ now = time.monotonic()
316
+ if new_count > 0:
317
+ last_new_at = now
318
+ elif now - last_new_at >= quiet_period:
319
+ # No new files for a full quiet period — directory has settled.
320
+ break
321
+ time.sleep(poll_interval)
322
+
323
+ return total
@@ -520,8 +520,12 @@ class TestFinalScan:
520
520
  event = queue.get_nowait()
521
521
  assert event.path.name == "valid-result.json"
522
522
 
523
- def test_skips_already_seen_files(self, tmp_path: Path) -> None:
524
- """final_scan skips files in the already_seen set."""
523
+ def test_skips_files_already_enqueued(self, tmp_path: Path) -> None:
524
+ """final_scan skips files already enqueued by a prior producer.
525
+
526
+ Dedup is via the watcher's shared seen-set: a file the live observer
527
+ already claimed is not re-enqueued by a subsequent scan.
528
+ """
525
529
  results_dir = tmp_path / "allure-results"
526
530
  results_dir.mkdir()
527
531
 
@@ -534,8 +538,11 @@ class TestFinalScan:
534
538
  config = _make_config()
535
539
  watcher = ResultsWatcher(str(results_dir), queue, config)
536
540
 
537
- already_seen = {str(file_a.resolve())}
538
- count = watcher.final_scan(already_seen=already_seen)
541
+ # Simulate file_a already enqueued via the live-observer path.
542
+ watcher._handler._enqueue(file_a, FileCategory.RESULT)
543
+ queue.get_nowait() # drain the observer's enqueue
544
+
545
+ count = watcher.final_scan()
539
546
 
540
547
  assert count == 1
541
548
  event = queue.get_nowait()
@@ -569,3 +576,230 @@ class TestFinalScan:
569
576
 
570
577
  assert count == 0
571
578
  assert queue.empty()
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # settle_scan() tests — the fix for the `watch results_sent=0` bug under
583
+ # high pytest-xdist parallelism (burst of result files written at teardown).
584
+ # ---------------------------------------------------------------------------
585
+
586
+
587
+ class TestSettleScan:
588
+ """settle_scan rescans until the results directory stops changing.
589
+
590
+ A single final_scan() taken the instant the test subprocess exits can run
591
+ before the OS finishes flushing allure's end-of-session burst, capturing
592
+ nothing (the results_sent=0 bug). settle_scan keeps scanning until the
593
+ directory is quiet, so the whole burst is captured regardless of how many
594
+ files or workers produced it.
595
+ """
596
+
597
+ def test_captures_files_present_before_scan(self, tmp_path: Path) -> None:
598
+ """Files already on disk are enqueued by the first settle_scan pass."""
599
+ results_dir = tmp_path / "allure-results"
600
+ results_dir.mkdir()
601
+ for i in range(5):
602
+ (results_dir / f"r{i}-result.json").write_text("{}")
603
+
604
+ queue: PriorityQueue = PriorityQueue()
605
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
606
+
607
+ enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=3.0, poll_interval=0.1)
608
+
609
+ assert enqueued == 5
610
+ assert queue.qsize() == 5
611
+
612
+ def test_captures_single_result_flushed_after_first_scan(self, tmp_path: Path) -> None:
613
+ """Single-test case: one result file flushed AFTER the initial scan.
614
+
615
+ Reproduces the observed 1-test / 1-worker `results_sent=0` failure —
616
+ allure-pytest flushes its one result file during process teardown, which
617
+ surfaces to the filesystem just after the subprocess exits. A single
618
+ final_scan taken at that instant captures 0; settle_scan waits and
619
+ captures the 1 late file. This is the N=1 boundary of the burst race.
620
+ """
621
+ import threading
622
+
623
+ results_dir = tmp_path / "allure-results"
624
+ results_dir.mkdir()
625
+
626
+ queue: PriorityQueue = PriorityQueue()
627
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
628
+
629
+ def write_single_late() -> None:
630
+ time.sleep(0.3)
631
+ (results_dir / "only-result.json").write_text("{}")
632
+
633
+ writer = threading.Thread(target=write_single_late)
634
+ writer.start()
635
+
636
+ # Directory is empty when the first scan runs (the old bug: captures 0).
637
+ first = watcher.final_scan()
638
+ assert first == 0
639
+
640
+ settled = watcher.settle_scan(quiet_period=0.5, max_wait=10.0, poll_interval=0.1)
641
+ writer.join()
642
+
643
+ assert first + settled == 1
644
+ assert queue.qsize() == 1
645
+
646
+ def test_captures_burst_written_after_first_scan(self, tmp_path: Path) -> None:
647
+ """The core fix: files that appear AFTER the initial scan are still captured.
648
+
649
+ Reproduces the teardown-burst race — a single final_scan would have
650
+ captured only the one baseline file; settle_scan waits for the rest.
651
+ """
652
+ import threading
653
+
654
+ results_dir = tmp_path / "allure-results"
655
+ results_dir.mkdir()
656
+ (results_dir / "baseline-result.json").write_text("{}")
657
+
658
+ queue: PriorityQueue = PriorityQueue()
659
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
660
+
661
+ def write_burst() -> None:
662
+ time.sleep(0.2)
663
+ for i in range(30):
664
+ (results_dir / f"burst{i}-result.json").write_text("{}")
665
+ time.sleep(0.02)
666
+
667
+ writer = threading.Thread(target=write_burst)
668
+ writer.start()
669
+
670
+ # A single scan would see only the baseline file.
671
+ first = watcher.final_scan()
672
+ assert first == 1
673
+
674
+ # settle_scan reconciles the burst that lands afterward.
675
+ settled = watcher.settle_scan(quiet_period=0.5, max_wait=10.0, poll_interval=0.1)
676
+ writer.join()
677
+
678
+ assert first + settled == 31
679
+ assert queue.qsize() == 31
680
+
681
+ def test_no_duplicate_enqueue_across_scans(self, tmp_path: Path) -> None:
682
+ """Repeated scans never enqueue the same file twice (shared seen-set)."""
683
+ results_dir = tmp_path / "allure-results"
684
+ results_dir.mkdir()
685
+ for i in range(4):
686
+ (results_dir / f"r{i}-result.json").write_text("{}")
687
+
688
+ queue: PriorityQueue = PriorityQueue()
689
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
690
+
691
+ first = watcher.settle_scan(quiet_period=0.3, max_wait=3.0, poll_interval=0.1)
692
+ second = watcher.settle_scan(quiet_period=0.3, max_wait=3.0, poll_interval=0.1)
693
+
694
+ assert first == 4
695
+ assert second == 0 # nothing new — no double-enqueue
696
+ assert queue.qsize() == 4
697
+
698
+ def test_observer_enqueue_and_settle_scan_dedup(self, tmp_path: Path) -> None:
699
+ """Files the live observer already enqueued are not re-enqueued by settle_scan.
700
+
701
+ The live-observer path (_StabilityHandler._enqueue) and settle_scan share
702
+ the watcher's seen-set. Without this, settle_scan would double-count files
703
+ the observer already delivered — the results_sent doubling regression.
704
+ """
705
+ results_dir = tmp_path / "allure-results"
706
+ results_dir.mkdir()
707
+ files = []
708
+ for i in range(10):
709
+ f = results_dir / f"r{i}-result.json"
710
+ f.write_text("{}")
711
+ files.append(f)
712
+
713
+ queue: PriorityQueue = PriorityQueue()
714
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
715
+
716
+ # Simulate the live observer having already enqueued the first 4 files
717
+ # by driving the handler's enqueue path directly.
718
+ for f in files[:4]:
719
+ watcher._handler._enqueue(f, FileCategory.RESULT)
720
+ assert queue.qsize() == 4
721
+
722
+ # settle_scan should add only the remaining 6, not re-add the first 4.
723
+ added = watcher.settle_scan(quiet_period=0.3, max_wait=3.0, poll_interval=0.1)
724
+
725
+ assert added == 6
726
+ assert queue.qsize() == 10 # 10 unique, zero duplicates
727
+
728
+ seen: set[str] = set()
729
+ while not queue.empty():
730
+ ev: FileEvent = queue.get_nowait()
731
+ key = str(ev.path.resolve())
732
+ assert key not in seen, "duplicate file enqueued"
733
+ seen.add(key)
734
+ assert len(seen) == 10
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."""
738
+ results_dir = tmp_path / "allure-results"
739
+ results_dir.mkdir()
740
+
741
+ queue: PriorityQueue = PriorityQueue()
742
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
743
+
744
+ start = time.monotonic()
745
+ enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=5.0, poll_interval=0.1)
746
+ elapsed = time.monotonic() - start
747
+
748
+ assert enqueued == 0
749
+ assert queue.empty()
750
+ assert elapsed < 4.0 # returned on the quiet-period, not the max_wait cap
751
+
752
+ def test_respects_max_wait_when_directory_never_settles(self, tmp_path: Path) -> None:
753
+ """max_wait caps total time even if files keep arriving continuously.
754
+
755
+ This documents the one bounded caveat: a directory that never goes quiet
756
+ stops at max_wait rather than blocking teardown forever.
757
+ """
758
+ import threading
759
+
760
+ results_dir = tmp_path / "allure-results"
761
+ results_dir.mkdir()
762
+
763
+ queue: PriorityQueue = PriorityQueue()
764
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
765
+
766
+ stop = threading.Event()
767
+
768
+ def write_forever() -> None:
769
+ i = 0
770
+ while not stop.is_set():
771
+ (results_dir / f"c{i}-result.json").write_text("{}")
772
+ i += 1
773
+ time.sleep(0.05)
774
+
775
+ writer = threading.Thread(target=write_forever)
776
+ writer.start()
777
+ try:
778
+ start = time.monotonic()
779
+ watcher.settle_scan(quiet_period=0.5, max_wait=1.5, poll_interval=0.1)
780
+ elapsed = time.monotonic() - start
781
+ finally:
782
+ stop.set()
783
+ writer.join()
784
+
785
+ # Should stop at ~max_wait, not run indefinitely.
786
+ assert 1.5 <= elapsed < 4.0
787
+
788
+ def test_scales_to_large_burst(self, tmp_path: Path) -> None:
789
+ """A large number of files (simulating many workers/tests) is fully captured.
790
+
791
+ Correctness is independent of file count — settle_scan keeps scanning
792
+ until quiet, so 500 files enqueue exactly once each.
793
+ """
794
+ results_dir = tmp_path / "allure-results"
795
+ results_dir.mkdir()
796
+ for i in range(500):
797
+ (results_dir / f"r{i}-result.json").write_text("{}")
798
+
799
+ queue: PriorityQueue = PriorityQueue()
800
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
801
+
802
+ enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=10.0, poll_interval=0.1)
803
+
804
+ assert enqueued == 500
805
+ assert queue.qsize() == 500
@@ -1,223 +0,0 @@
1
- """Watchdog observer, stability debounce, and file classification for allure-results."""
2
-
3
- import os
4
- from pathlib import Path
5
- from queue import PriorityQueue
6
- from threading import Timer
7
-
8
- from watchdog.events import FileSystemEvent, FileSystemEventHandler
9
- from watchdog.observers import Observer
10
-
11
- from charisma_cli.config import Config
12
- from charisma_cli.models import FileCategory, FileEvent
13
-
14
- _TWO_MB = 2 * 1024 * 1024
15
- _STABILITY_SECONDS = 0.5
16
-
17
-
18
- def classify_file(filename: str) -> FileCategory | None:
19
- """Classify a filename into a FileCategory or None if excluded.
20
-
21
- Args:
22
- filename: The base filename (not full path).
23
-
24
- Returns:
25
- FileCategory.RESULT for *-result.json,
26
- FileCategory.CONTAINER for *-container.json,
27
- FileCategory.ATTACHMENT for all other valid files,
28
- None for excluded files (dot-prefix or .tmp suffix).
29
- """
30
- if filename.startswith("."):
31
- return None
32
- if filename.endswith(".tmp"):
33
- return None
34
- if filename.endswith("-result.json"):
35
- return FileCategory.RESULT
36
- if filename.endswith("-container.json"):
37
- return FileCategory.CONTAINER
38
- return FileCategory.ATTACHMENT
39
-
40
-
41
- def should_skip(path: Path, config: Config) -> bool:
42
- """Check whether a file should be skipped based on size.
43
-
44
- Args:
45
- path: Path to the file on disk.
46
- config: Resolved CLI configuration.
47
-
48
- Returns:
49
- True if the file exceeds 2MB and skip_too_big is enabled.
50
- """
51
- if not config.skip_too_big:
52
- return False
53
- return path.stat().st_size > _TWO_MB
54
-
55
-
56
- class _StabilityHandler(FileSystemEventHandler):
57
- """Debounces filesystem events, enqueuing files after 500ms of stability."""
58
-
59
- def __init__(self, queue: PriorityQueue, config: Config) -> None:
60
- super().__init__()
61
- self._queue = queue
62
- self._config = config
63
- self._timers: dict[str, Timer] = {}
64
-
65
- def on_created(self, event: FileSystemEvent) -> None:
66
- """Handle file creation events."""
67
- if not event.is_directory:
68
- self._handle_event(event.src_path)
69
-
70
- def on_modified(self, event: FileSystemEvent) -> None:
71
- """Handle file modification events."""
72
- if not event.is_directory:
73
- self._handle_event(event.src_path)
74
-
75
- def _handle_event(self, src_path: str) -> None:
76
- """Reset the stability timer for a file path."""
77
- path = Path(src_path)
78
- filename = path.name
79
-
80
- category = classify_file(filename)
81
- if category is None:
82
- return
83
-
84
- # Cancel existing timer for this path
85
- existing = self._timers.get(src_path)
86
- if existing is not None:
87
- existing.cancel()
88
-
89
- # Set a new timer that enqueues after stability period
90
- timer = Timer(_STABILITY_SECONDS, self._enqueue, args=(path, category))
91
- timer.daemon = True
92
- self._timers[src_path] = timer
93
- timer.start()
94
-
95
- def _enqueue(self, path: Path, category: FileCategory) -> None:
96
- """Enqueue a file event after stability check passes."""
97
- # Remove from timer dict
98
- self._timers.pop(str(path), None)
99
-
100
- # Check file still exists and size at enqueue time
101
- try:
102
- if should_skip(path, self._config):
103
- return
104
- except (OSError, FileNotFoundError):
105
- return # File was deleted between debounce and enqueue
106
-
107
- self._queue.put(FileEvent(path=path, category=category))
108
-
109
- def cancel_all(self) -> None:
110
- """Cancel all pending timers."""
111
- for timer in self._timers.values():
112
- timer.cancel()
113
- self._timers.clear()
114
-
115
- def flush_pending(self) -> None:
116
- """Immediately enqueue all files currently in debounce windows.
117
-
118
- Called after subprocess exit to ensure no result files are lost due
119
- to pending debounce timers. Cancels all timers and enqueues their
120
- associated files directly.
121
- """
122
- # Snapshot and clear timers atomically
123
- pending = dict(self._timers)
124
- self._timers.clear()
125
-
126
- for src_path, timer in pending.items():
127
- timer.cancel()
128
- path = Path(src_path)
129
- filename = path.name
130
-
131
- category = classify_file(filename)
132
- if category is None:
133
- continue
134
-
135
- try:
136
- if should_skip(path, self._config):
137
- continue
138
- except (OSError, FileNotFoundError):
139
- continue
140
-
141
- self._queue.put(FileEvent(path=path, category=category))
142
-
143
-
144
- class ResultsWatcher:
145
- """Watches the allure-results directory for new files using watchdog.
146
-
147
- Creates the directory if it doesn't exist, observes file creation/modification,
148
- debounces events for 500ms of stability, then enqueues FileEvent objects
149
- into the provided PriorityQueue.
150
- """
151
-
152
- def __init__(self, results_dir: str, queue: PriorityQueue, config: Config) -> None:
153
- self._results_dir = results_dir
154
- self._queue = queue
155
- self._config = config
156
- self._handler = _StabilityHandler(queue, config)
157
- self._observer = Observer()
158
-
159
- def start(self) -> None:
160
- """Create the results directory and start observing for file events."""
161
- os.makedirs(self._results_dir, exist_ok=True)
162
- self._observer.schedule(self._handler, self._results_dir, recursive=False)
163
- self._observer.start()
164
-
165
- def stop(self) -> None:
166
- """Stop the observer and cancel all pending stability timers."""
167
- self._observer.stop()
168
- self._observer.join()
169
- self._handler.cancel_all()
170
-
171
- def flush_pending(self) -> None:
172
- """Flush all files currently in debounce windows into the queue.
173
-
174
- Should be called BEFORE stop() when you want to capture all pending
175
- files rather than discard them.
176
- """
177
- self._handler.flush_pending()
178
-
179
- def final_scan(self, already_seen: set[str] | None = None) -> int:
180
- """Scan the results directory and enqueue any files not already processed.
181
-
182
- Performs a one-time sweep of all files in the results directory,
183
- enqueuing any that pass classification and size checks. This catches
184
- files that the filesystem watcher may have missed entirely (e.g., written
185
- between watcher setup and observation start, or during high-throughput
186
- bursts that overwhelm OS event buffers).
187
-
188
- Args:
189
- already_seen: Optional set of absolute path strings that were
190
- already enqueued. Files in this set are skipped.
191
-
192
- Returns:
193
- Number of new files enqueued by this scan.
194
- """
195
- results_path = Path(self._results_dir)
196
- if not results_path.exists():
197
- return 0
198
-
199
- enqueued = 0
200
- seen = already_seen or set()
201
-
202
- for filepath in results_path.iterdir():
203
- if not filepath.is_file():
204
- continue
205
-
206
- abs_path = str(filepath.resolve())
207
- if abs_path in seen:
208
- continue
209
-
210
- category = classify_file(filepath.name)
211
- if category is None:
212
- continue
213
-
214
- try:
215
- if should_skip(filepath, self._config):
216
- continue
217
- except (OSError, FileNotFoundError):
218
- continue
219
-
220
- self._queue.put(FileEvent(path=filepath, category=category))
221
- enqueued += 1
222
-
223
- return enqueued
File without changes
File without changes