charisma-cli 0.1.5__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 (28) hide show
  1. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/.gitignore +1 -0
  2. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/PKG-INFO +1 -1
  3. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/pyproject.toml +1 -1
  4. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/__init__.py +1 -1
  5. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/main.py +20 -5
  6. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/uploader.py +20 -10
  7. charisma_cli-0.2.0/src/charisma_cli/watcher.py +348 -0
  8. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_integration.py +132 -2
  9. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_watcher.py +257 -4
  10. charisma_cli-0.1.5/src/charisma_cli/watcher.py +0 -223
  11. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/README.md +0 -0
  12. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/config.py +0 -0
  13. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/launch_url.py +0 -0
  14. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/models.py +0 -0
  15. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/parser.py +0 -0
  16. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/retry.py +0 -0
  17. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/subprocess_mgr.py +0 -0
  18. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/__init__.py +0 -0
  19. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/conftest.py +0 -0
  20. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_cli.py +0 -0
  21. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_config.py +0 -0
  22. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_launch_url.py +0 -0
  23. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_models.py +0 -0
  24. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_parser.py +0 -0
  25. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_retry.py +0 -0
  26. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_silent_mode.py +0 -0
  27. {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_subprocess_mgr.py +0 -0
  28. {charisma_cli-0.1.5 → 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.5
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.5"
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,13 +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 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.
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.
191
205
  watcher.flush_pending()
192
- watcher.final_scan()
206
+ watcher.settle_scan(quiet_period=SETTLE_QUIET_PERIOD, max_wait=SETTLE_MAX_WAIT)
193
207
 
194
- # 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.
195
210
  uploader.drain(timeout=config.drain_timeout)
196
211
  watcher.stop()
197
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", {})
@@ -0,0 +1,348 @@
1
+ """Watchdog observer, stability debounce, and file classification for allure-results."""
2
+
3
+ import logging
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from queue import PriorityQueue
8
+ from threading import Lock, Timer
9
+
10
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
11
+ from watchdog.observers import Observer
12
+
13
+ from charisma_cli.config import Config
14
+ from charisma_cli.models import FileCategory, FileEvent
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _TWO_MB = 2 * 1024 * 1024
19
+ _STABILITY_SECONDS = 0.5
20
+
21
+
22
+ class _SeenSet:
23
+ """Thread-safe set of absolute paths already enqueued.
24
+
25
+ Shared by every producer (live observer, flush_pending, final_scan,
26
+ settle_scan) so each file is enqueued exactly once. ``add`` returns True
27
+ only the first time a path is seen, letting callers gate the enqueue.
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._paths: set[str] = set()
32
+ self._lock = Lock()
33
+
34
+ def add(self, path: str) -> bool:
35
+ """Record a path. Returns True if newly added, False if already present."""
36
+ with self._lock:
37
+ if path in self._paths:
38
+ return False
39
+ self._paths.add(path)
40
+ return True
41
+
42
+ def __contains__(self, path: str) -> bool:
43
+ with self._lock:
44
+ return path in self._paths
45
+
46
+
47
+ def classify_file(filename: str) -> FileCategory | None:
48
+ """Classify a filename into a FileCategory or None if excluded.
49
+
50
+ Args:
51
+ filename: The base filename (not full path).
52
+
53
+ Returns:
54
+ FileCategory.RESULT for *-result.json,
55
+ FileCategory.CONTAINER for *-container.json,
56
+ FileCategory.ATTACHMENT for all other valid files,
57
+ None for excluded files (dot-prefix or .tmp suffix).
58
+ """
59
+ if filename.startswith("."):
60
+ return None
61
+ if filename.endswith(".tmp"):
62
+ return None
63
+ if filename.endswith("-result.json"):
64
+ return FileCategory.RESULT
65
+ if filename.endswith("-container.json"):
66
+ return FileCategory.CONTAINER
67
+ return FileCategory.ATTACHMENT
68
+
69
+
70
+ def should_skip(path: Path, config: Config) -> bool:
71
+ """Check whether a file should be skipped based on size.
72
+
73
+ Args:
74
+ path: Path to the file on disk.
75
+ config: Resolved CLI configuration.
76
+
77
+ Returns:
78
+ True if the file exceeds 2MB and skip_too_big is enabled.
79
+ """
80
+ if not config.skip_too_big:
81
+ return False
82
+ return path.stat().st_size > _TWO_MB
83
+
84
+
85
+ def _enqueue_once(
86
+ queue: "PriorityQueue[FileEvent]",
87
+ config: Config,
88
+ enqueued: "_SeenSet",
89
+ path: Path,
90
+ category: FileCategory,
91
+ ) -> bool:
92
+ """Enqueue a file exactly once, guarding all filesystem access.
93
+
94
+ Single owner of the enqueue-decision pipeline shared by every producer
95
+ (the live observer's debounce handler, flush_pending, and the scans):
96
+ resolve the path, apply the size-skip guard, gate on the shared seen-set,
97
+ then put the event. Keeping this in one place ensures the exactly-once
98
+ invariant (skip BEFORE marking seen, one canonical resolved-path key)
99
+ stays consistent across all callers.
100
+
101
+ All filesystem calls (``resolve``, ``stat`` via ``should_skip``) are guarded:
102
+ the scans run on the main thread during teardown, so an unhandled OSError
103
+ here would abort launch-close and the summary. On any filesystem error the
104
+ file is skipped rather than raised.
105
+
106
+ Returns:
107
+ True if a FileEvent was enqueued, False if skipped (too big, already
108
+ seen, or unreadable).
109
+ """
110
+ try:
111
+ if should_skip(path, config):
112
+ return False
113
+ abs_path = str(path.resolve())
114
+ except (OSError, ValueError):
115
+ # File vanished, permission/symlink error, or unresolvable path —
116
+ # skip it rather than crash the caller (main-thread teardown).
117
+ return False
118
+
119
+ if not enqueued.add(abs_path):
120
+ return False
121
+ queue.put(FileEvent(path=path, category=category))
122
+ return True
123
+
124
+
125
+ class _StabilityHandler(FileSystemEventHandler):
126
+ """Debounces filesystem events, enqueuing files after 500ms of stability."""
127
+
128
+ def __init__(self, queue: "PriorityQueue[FileEvent]", config: Config, enqueued: "_SeenSet") -> None:
129
+ super().__init__()
130
+ self._queue = queue
131
+ self._config = config
132
+ self._timers: dict[str, Timer] = {}
133
+ # Shared across the live observer, flush_pending, and the scans so a
134
+ # given file is enqueued exactly once no matter which producer sees it
135
+ # first. Without this, the end-of-run settle_scan re-enqueues files the
136
+ # live observer already delivered, doubling results_sent.
137
+ self._enqueued = enqueued
138
+
139
+ def on_created(self, event: FileSystemEvent) -> None:
140
+ """Handle file creation events."""
141
+ if not event.is_directory:
142
+ self._handle_event(event.src_path)
143
+
144
+ def on_modified(self, event: FileSystemEvent) -> None:
145
+ """Handle file modification events."""
146
+ if not event.is_directory:
147
+ self._handle_event(event.src_path)
148
+
149
+ def _handle_event(self, src_path: str) -> None:
150
+ """Reset the stability timer for a file path."""
151
+ path = Path(src_path)
152
+ filename = path.name
153
+
154
+ category = classify_file(filename)
155
+ if category is None:
156
+ return
157
+
158
+ # Cancel existing timer for this path
159
+ existing = self._timers.get(src_path)
160
+ if existing is not None:
161
+ existing.cancel()
162
+
163
+ # Set a new timer that enqueues after stability period
164
+ timer = Timer(_STABILITY_SECONDS, self._enqueue, args=(path, category))
165
+ timer.daemon = True
166
+ self._timers[src_path] = timer
167
+ timer.start()
168
+
169
+ def _enqueue(self, path: Path, category: FileCategory) -> None:
170
+ """Enqueue a file event after stability check passes."""
171
+ # Remove from timer dict
172
+ self._timers.pop(str(path), None)
173
+ _enqueue_once(self._queue, self._config, self._enqueued, path, category)
174
+
175
+ def cancel_all(self) -> None:
176
+ """Cancel all pending timers."""
177
+ for timer in self._timers.values():
178
+ timer.cancel()
179
+ self._timers.clear()
180
+
181
+ def flush_pending(self) -> None:
182
+ """Immediately enqueue all files currently in debounce windows.
183
+
184
+ Called after subprocess exit to ensure no result files are lost due
185
+ to pending debounce timers. Cancels all timers and enqueues their
186
+ associated files directly.
187
+ """
188
+ # Snapshot and clear timers atomically
189
+ pending = dict(self._timers)
190
+ self._timers.clear()
191
+
192
+ for src_path, timer in pending.items():
193
+ timer.cancel()
194
+ path = Path(src_path)
195
+
196
+ category = classify_file(path.name)
197
+ if category is None:
198
+ continue
199
+
200
+ _enqueue_once(self._queue, self._config, self._enqueued, path, category)
201
+
202
+
203
+ class ResultsWatcher:
204
+ """Watches the allure-results directory for new files using watchdog.
205
+
206
+ Creates the directory if it doesn't exist, observes file creation/modification,
207
+ debounces events for 500ms of stability, then enqueues FileEvent objects
208
+ into the provided PriorityQueue.
209
+ """
210
+
211
+ def __init__(self, results_dir: str, queue: "PriorityQueue[FileEvent]", config: Config) -> None:
212
+ self._results_dir = results_dir
213
+ self._queue = queue
214
+ self._config = config
215
+ # One shared seen-set gates every producer (live observer, flush_pending,
216
+ # final_scan, settle_scan) so each file is enqueued exactly once.
217
+ self._enqueued = _SeenSet()
218
+ self._handler = _StabilityHandler(queue, config, self._enqueued)
219
+ self._observer = Observer()
220
+
221
+ def start(self) -> None:
222
+ """Create the results directory and start observing for file events."""
223
+ os.makedirs(self._results_dir, exist_ok=True)
224
+ self._observer.schedule(self._handler, self._results_dir, recursive=False)
225
+ self._observer.start()
226
+
227
+ def stop(self) -> None:
228
+ """Stop the observer and cancel all pending stability timers."""
229
+ self._observer.stop()
230
+ self._observer.join()
231
+ self._handler.cancel_all()
232
+
233
+ def flush_pending(self) -> None:
234
+ """Flush all files currently in debounce windows into the queue.
235
+
236
+ Should be called BEFORE stop() when you want to capture all pending
237
+ files rather than discard them.
238
+ """
239
+ self._handler.flush_pending()
240
+
241
+ def final_scan(self) -> int:
242
+ """Scan the results directory and enqueue any files not already processed.
243
+
244
+ Performs a one-time sweep of all files in the results directory,
245
+ enqueuing any that pass classification and size checks. This catches
246
+ files that the filesystem watcher may have missed entirely (e.g., written
247
+ between watcher setup and observation start, or during high-throughput
248
+ bursts that overwhelm OS event buffers). Files already enqueued by any
249
+ producer are skipped via the shared seen-set.
250
+
251
+ Runs on the main thread during teardown, so all filesystem access is
252
+ guarded: a transient error listing or reading a file skips that file
253
+ rather than aborting the scan (and the launch-close that follows it).
254
+
255
+ Returns:
256
+ Number of new files enqueued by this scan.
257
+ """
258
+ results_path = Path(self._results_dir)
259
+ try:
260
+ entries = list(results_path.iterdir())
261
+ except (OSError, FileNotFoundError):
262
+ # Directory missing or unreadable — nothing to enqueue this pass.
263
+ return 0
264
+
265
+ enqueued = 0
266
+ for filepath in entries:
267
+ try:
268
+ if not filepath.is_file():
269
+ continue
270
+ except OSError:
271
+ continue
272
+
273
+ category = classify_file(filepath.name)
274
+ if category is None:
275
+ continue
276
+
277
+ if _enqueue_once(self._queue, self._config, self._enqueued, filepath, category):
278
+ enqueued += 1
279
+
280
+ return enqueued
281
+
282
+ def settle_scan(
283
+ self,
284
+ quiet_period: float = 1.0,
285
+ max_wait: float = 15.0,
286
+ poll_interval: float = 0.25,
287
+ ) -> int:
288
+ """Repeatedly scan until the results directory stops producing new files.
289
+
290
+ Under high parallelism (e.g. pytest-xdist with many workers), allure
291
+ writes its result files in a burst during the test process's teardown.
292
+ A single ``final_scan()`` taken the instant the subprocess exits can run
293
+ before the OS has finished flushing that burst to disk, capturing zero
294
+ or a partial set — the ``watch results_sent=0`` bug. This method keeps
295
+ scanning until no new files have appeared for ``quiet_period`` seconds
296
+ (the directory has "settled"), or until ``max_wait`` seconds elapse.
297
+
298
+ Idempotent with ``final_scan()``: both share the watcher's internal
299
+ ``_enqueued`` set, so files already enqueued are never enqueued again.
300
+
301
+ Args:
302
+ quiet_period: Seconds with no new files before the directory is
303
+ considered settled.
304
+ max_wait: Absolute cap on total wait time, so a directory that keeps
305
+ changing can't block teardown forever.
306
+ poll_interval: Delay between scans.
307
+
308
+ Returns:
309
+ Total number of new files enqueued across all iterations.
310
+ """
311
+ total = 0
312
+ started_at = time.monotonic()
313
+ deadline = started_at + max_wait
314
+ last_new_at = started_at
315
+ settled = False
316
+
317
+ while time.monotonic() < deadline:
318
+ new_count = self.final_scan()
319
+ total += new_count
320
+ now = time.monotonic()
321
+ if new_count > 0:
322
+ last_new_at = now
323
+ elif now - last_new_at >= quiet_period:
324
+ # No new files for a full quiet period — directory has settled.
325
+ settled = True
326
+ break
327
+ time.sleep(poll_interval)
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
+
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
@@ -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,249 @@ 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, 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
+
744
+ results_dir = tmp_path / "allure-results"
745
+ results_dir.mkdir()
746
+
747
+ queue: PriorityQueue = PriorityQueue()
748
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
749
+
750
+ start = time.monotonic()
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)
753
+ elapsed = time.monotonic() - start
754
+
755
+ assert enqueued == 0
756
+ assert queue.empty()
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)
761
+
762
+ def test_respects_max_wait_when_directory_never_settles(self, tmp_path: Path, caplog) -> None:
763
+ """max_wait caps total time even if files keep arriving continuously.
764
+
765
+ This documents the one bounded caveat: a directory that never goes quiet
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.
769
+ """
770
+ import logging
771
+ import threading
772
+
773
+ results_dir = tmp_path / "allure-results"
774
+ results_dir.mkdir()
775
+
776
+ queue: PriorityQueue = PriorityQueue()
777
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
778
+
779
+ stop = threading.Event()
780
+
781
+ def write_forever() -> None:
782
+ i = 0
783
+ while not stop.is_set():
784
+ (results_dir / f"c{i}-result.json").write_text("{}")
785
+ i += 1
786
+ time.sleep(0.05)
787
+
788
+ writer = threading.Thread(target=write_forever)
789
+ writer.start()
790
+ try:
791
+ start = time.monotonic()
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)
794
+ elapsed = time.monotonic() - start
795
+ finally:
796
+ stop.set()
797
+ writer.join()
798
+
799
+ # Should stop at ~max_wait, not run indefinitely.
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
+ )
806
+
807
+ def test_scales_to_large_burst(self, tmp_path: Path) -> None:
808
+ """A large number of files (simulating many workers/tests) is fully captured.
809
+
810
+ Correctness is independent of file count — settle_scan keeps scanning
811
+ until quiet, so 500 files enqueue exactly once each.
812
+ """
813
+ results_dir = tmp_path / "allure-results"
814
+ results_dir.mkdir()
815
+ for i in range(500):
816
+ (results_dir / f"r{i}-result.json").write_text("{}")
817
+
818
+ queue: PriorityQueue = PriorityQueue()
819
+ watcher = ResultsWatcher(str(results_dir), queue, _make_config())
820
+
821
+ enqueued = watcher.settle_scan(quiet_period=0.3, max_wait=10.0, poll_interval=0.1)
822
+
823
+ assert enqueued == 500
824
+ 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