charisma-cli 0.1.4__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.4 → charisma_cli-0.1.6}/PKG-INFO +1 -1
  2. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/pyproject.toml +1 -1
  3. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/main.py +23 -6
  4. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/parser.py +47 -7
  5. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/uploader.py +12 -3
  6. charisma_cli-0.1.6/src/charisma_cli/watcher.py +323 -0
  7. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_integration.py +72 -0
  8. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_parser.py +118 -3
  9. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_watcher.py +238 -4
  10. charisma_cli-0.1.4/src/charisma_cli/watcher.py +0 -223
  11. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/.gitignore +0 -0
  12. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/README.md +0 -0
  13. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/__init__.py +0 -0
  14. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/config.py +0 -0
  15. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/launch_url.py +0 -0
  16. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/models.py +0 -0
  17. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/retry.py +0 -0
  18. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/src/charisma_cli/subprocess_mgr.py +0 -0
  19. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/__init__.py +0 -0
  20. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/conftest.py +0 -0
  21. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_cli.py +0 -0
  22. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_config.py +0 -0
  23. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_launch_url.py +0 -0
  24. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_models.py +0 -0
  25. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_retry.py +0 -0
  26. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_silent_mode.py +0 -0
  27. {charisma_cli-0.1.4 → charisma_cli-0.1.6}/tests/test_subprocess_mgr.py +0 -0
  28. {charisma_cli-0.1.4 → 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.4
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.4"
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"
@@ -11,6 +11,7 @@ from charisma_cli import __version__
11
11
  from charisma_cli.config import Config
12
12
  from charisma_cli.launch_url import build_launch_url, emit_github_output
13
13
  from charisma_cli.models import FileEvent
14
+ from charisma_cli.parser import parse_environment_properties
14
15
  from charisma_cli.subprocess_mgr import SubprocessManager
15
16
  from charisma_cli.uploader import Uploader
16
17
  from charisma_cli.watcher import ResultsWatcher, classify_file
@@ -60,6 +61,18 @@ def _open_launch_or_exit(uploader: Uploader, config: Config) -> str:
60
61
  return launch_id
61
62
 
62
63
 
64
+ def _read_launch_variables(config: Config) -> dict[str, str]:
65
+ """Read Allure environment.properties from the results dir, if present.
66
+
67
+ Returns the parsed key/value variables (empty dict if the file is absent),
68
+ to be attached to the launch at close time.
69
+ """
70
+ props_path = Path(config.results_dir) / "environment.properties"
71
+ if not props_path.is_file():
72
+ return {}
73
+ return parse_environment_properties(props_path)
74
+
75
+
63
76
  def _surface_launch_url(uploader: Uploader) -> None:
64
77
  """Print the Charisma launch URL and expose it to GitHub Actions.
65
78
 
@@ -172,11 +185,15 @@ def watch(
172
185
  mgr.spawn(command)
173
186
  exit_code = mgr.wait()
174
187
 
175
- # After subprocess exits (crash or normal): flush debouncing files and scan
176
- # for anything the watcher missed entirely. This prevents data loss when
177
- # 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.
178
195
  watcher.flush_pending()
179
- watcher.final_scan()
196
+ watcher.settle_scan()
180
197
 
181
198
  # Drain and close — stop watcher AFTER drain so all files are processed
182
199
  uploader.drain(timeout=config.drain_timeout)
@@ -184,7 +201,7 @@ def watch(
184
201
  uploader.stop()
185
202
 
186
203
  uploader.ensure_client()
187
- uploader.close_launch()
204
+ uploader.close_launch(variables=_read_launch_variables(config))
188
205
  if uploader._client is not None:
189
206
  uploader._client.close()
190
207
  uploader._client = None
@@ -247,7 +264,7 @@ def upload(
247
264
 
248
265
  # Close launch
249
266
  uploader.ensure_client()
250
- uploader.close_launch()
267
+ uploader.close_launch(variables=_read_launch_variables(config))
251
268
  uploader.stop()
252
269
 
253
270
  _print_summary(uploader)
@@ -54,15 +54,18 @@ def parse_result_file(path: Path) -> CharismaResult | None:
54
54
  logger.warning("Result file is not a JSON object: %s", path)
55
55
  return None
56
56
 
57
- # Required fields
57
+ # uuid is the only strictly required field. start/stop are optional:
58
+ # skipped tests (and some setup/teardown-only results) never execute, so
59
+ # Allure omits `stop` (and sometimes `start`). Requiring them here silently
60
+ # dropped skipped results before they were ever sent to Charisma.
58
61
  uuid = data.get("uuid")
62
+ if uuid is None:
63
+ logger.warning("Result file missing required field (uuid): %s", path)
64
+ return None
65
+
59
66
  start = data.get("start")
60
67
  stop = data.get("stop")
61
68
 
62
- if uuid is None or start is None or stop is None:
63
- logger.warning("Result file missing required fields (uuid/start/stop): %s", path)
64
- return None
65
-
66
69
  # testId: prefer historyId, fallback to fullName
67
70
  history_id = data.get("historyId")
68
71
  full_name = data.get("fullName")
@@ -72,8 +75,9 @@ def parse_result_file(path: Path) -> CharismaResult | None:
72
75
  logger.warning("Result file missing both historyId and fullName: %s", path)
73
76
  return None
74
77
 
75
- # Compute duration
76
- duration_ms = stop - start
78
+ # Compute duration only when both timestamps are present; otherwise 0
79
+ # (e.g. skipped tests that never ran).
80
+ duration_ms = (stop - start) if (start is not None and stop is not None) else 0
77
81
 
78
82
  # Status mapping
79
83
  status = data.get("status", "")
@@ -145,6 +149,42 @@ def parse_container_file(path: Path) -> ContainerData | None:
145
149
  )
146
150
 
147
151
 
152
+ def parse_environment_properties(path: Path) -> dict[str, str]:
153
+ """Parse an Allure ``environment.properties`` file into a key/value dict.
154
+
155
+ The file is a standard Java-style properties file: ``key=value`` per line,
156
+ with ``#`` / ``!`` comment lines and blank lines ignored. Values may contain
157
+ ``=`` (only the first ``=`` splits key from value). These become the
158
+ launch-level "Variables" shown in Charisma.
159
+
160
+ Args:
161
+ path: Path to the environment.properties file.
162
+
163
+ Returns:
164
+ Dict of variable name → value (both stripped). Empty dict if the file
165
+ is missing, unreadable, or contains no valid entries.
166
+ """
167
+ try:
168
+ text = path.read_text(encoding="utf-8")
169
+ except (OSError, FileNotFoundError):
170
+ logger.warning("Cannot read environment.properties: %s", path)
171
+ return {}
172
+
173
+ variables: dict[str, str] = {}
174
+ for line in text.splitlines():
175
+ stripped = line.strip()
176
+ if not stripped or stripped.startswith("#") or stripped.startswith("!"):
177
+ continue
178
+ if "=" not in stripped:
179
+ continue
180
+ key, _, value = stripped.partition("=")
181
+ key = key.strip()
182
+ if key:
183
+ variables[key] = value.strip()
184
+
185
+ return variables
186
+
187
+
148
188
  def extract_attachment_refs(result_data: dict, result_uuid: str) -> list[AttachmentRef]:
149
189
  """Extract attachment references from an Allure result data dict.
150
190
 
@@ -206,15 +206,24 @@ class Uploader:
206
206
 
207
207
  return None
208
208
 
209
- def close_launch(self) -> None:
210
- """Close the streaming launch via the API."""
209
+ def close_launch(self, variables: dict[str, str] | None = None) -> None:
210
+ """Close the streaming launch via the API.
211
+
212
+ Args:
213
+ variables: Optional launch-level key/value metadata (parsed from
214
+ Allure environment.properties) to persist on the launch.
215
+ """
211
216
  self.ensure_client()
212
217
  if self._launch_id is None:
213
218
  return
214
219
 
220
+ body = {"variables": variables} if variables else None
221
+
215
222
  for attempt in range(MAX_RETRIES + 1):
216
223
  try:
217
- response = self._client.post(f"/api/v1/launches/{self._launch_id}/close")
224
+ response = self._client.post(
225
+ f"/api/v1/launches/{self._launch_id}/close", json=body
226
+ )
218
227
  if response.status_code in (200, 201, 204):
219
228
  return
220
229
  if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
@@ -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
@@ -170,3 +170,75 @@ class TestUploadCommand:
170
170
 
171
171
  assert result.exit_code == 0
172
172
  assert "results_sent" in result.output or "Summary" in result.output or result.exit_code == 0
173
+
174
+
175
+ class TestReportingFixesEndToEnd:
176
+ """E2E: skipped tests are sent, and environment.properties → close variables."""
177
+
178
+ @respx.mock
179
+ def test_skipped_result_and_variables_are_sent(self, tmp_path: Path, monkeypatch) -> None:
180
+ """A skipped result (no 'stop') is uploaded, and environment.properties
181
+ is sent as `variables` in the close-launch body."""
182
+ results_dir = tmp_path / "allure-results"
183
+ results_dir.mkdir()
184
+
185
+ # Skipped result — no "stop" (Allure omits it for tests that never ran)
186
+ (results_dir / "skip-result.json").write_text(json.dumps({
187
+ "uuid": "skip-1",
188
+ "historyId": "hist_skip",
189
+ "fullName": "tests.test_x.test_skipped",
190
+ "name": "test_skipped",
191
+ "status": "skipped",
192
+ "statusDetails": {"message": "conditional skip"},
193
+ "start": 1000,
194
+ "labels": [],
195
+ "parameters": [],
196
+ }))
197
+
198
+ # Allure environment.properties → launch variables
199
+ (results_dir / "environment.properties").write_text(
200
+ "environment_name=dv1\naws_region=us-west-2\nworkers_number=8\n"
201
+ )
202
+
203
+ respx.post("https://charisma.test/api/v1/launches").mock(
204
+ return_value=httpx.Response(
205
+ 201, json={"launchId": "launch-x", "projectId": "proj-uuid"}
206
+ )
207
+ )
208
+ results_route = respx.post(
209
+ "https://charisma.test/api/v1/launches/launch-x/results"
210
+ ).mock(return_value=httpx.Response(200, json={"accepted": 1}))
211
+ close_route = respx.post(
212
+ "https://charisma.test/api/v1/launches/launch-x/close"
213
+ ).mock(return_value=httpx.Response(200, json={}))
214
+
215
+ monkeypatch.setenv("CHARISMA_ENDPOINT", "https://charisma.test")
216
+ monkeypatch.setenv("CHARISMA_TOKEN", "tok")
217
+
218
+ runner = CliRunner()
219
+ result = runner.invoke(cli, [
220
+ "watch",
221
+ "--project", "p",
222
+ "--results", str(results_dir),
223
+ "--", sys.executable, "-c", "import sys; sys.exit(0)",
224
+ ])
225
+
226
+ assert result.exit_code == 0
227
+
228
+ # The skipped result was sent (not dropped)
229
+ assert results_route.called
230
+ sent_results = json.loads(results_route.calls[0].request.content)["results"]
231
+ outcomes = {r["testId"]: r["outcome"] for r in sent_results}
232
+ assert outcomes.get("hist_skip") == "skipped"
233
+ # duration defaulted to 0 for the missing stop
234
+ skip_payload = next(r for r in sent_results if r["testId"] == "hist_skip")
235
+ assert skip_payload["duration_ms"] == 0
236
+
237
+ # environment.properties sent as variables on close
238
+ assert close_route.called
239
+ close_body = json.loads(close_route.calls[0].request.content)
240
+ assert close_body["variables"] == {
241
+ "environment_name": "dv1",
242
+ "aws_region": "us-west-2",
243
+ "workers_number": "8",
244
+ }
@@ -216,8 +216,12 @@ class TestParseResultFile:
216
216
 
217
217
  assert result is None
218
218
 
219
- def test_missing_start_stop_returns_none(self, tmp_path: Path) -> None:
220
- """Result without timing fields cannot compute duration → returns None."""
219
+ def test_missing_start_stop_parses_with_zero_duration(self, tmp_path: Path) -> None:
220
+ """Result without timing fields parses with duration 0 (not dropped).
221
+
222
+ Previously this returned None, which silently dropped skipped tests
223
+ (Allure omits 'stop' for tests that never executed).
224
+ """
221
225
  data = {
222
226
  "uuid": "no-time",
223
227
  "historyId": "hist",
@@ -227,7 +231,10 @@ class TestParseResultFile:
227
231
  filepath = self._write_json(tmp_path, data)
228
232
  result = parse_result_file(filepath)
229
233
 
230
- assert result is None
234
+ assert result is not None
235
+ assert result.duration_ms == 0
236
+ assert result.started_at is None
237
+ assert result.ended_at is None
231
238
 
232
239
  def test_nonexistent_file_returns_none(self, tmp_path: Path) -> None:
233
240
  """File that doesn't exist returns None gracefully."""
@@ -534,3 +541,111 @@ class TestExtractAttachmentRefs:
534
541
 
535
542
  assert len(refs) == 1
536
543
  assert refs[0].name == "abc-screenshot.png"
544
+
545
+
546
+ class TestSkippedResultParsing:
547
+ """Regression: skipped tests (no 'stop', sometimes no 'start') must parse.
548
+
549
+ Allure omits 'stop' for tests that never executed (skipped). The parser
550
+ previously required both start and stop and returned None, silently
551
+ dropping skipped results so they never reached Charisma.
552
+ """
553
+
554
+ def test_skipped_without_stop_parses(self, tmp_path: Path) -> None:
555
+ """A skipped result with 'start' but no 'stop' parses with duration 0."""
556
+ filepath = tmp_path / "skip-result.json"
557
+ filepath.write_text(json.dumps({
558
+ "uuid": "skip-uuid",
559
+ "historyId": "hist-skip",
560
+ "fullName": "tests.test_mod.test_skipped",
561
+ "name": "test_skipped",
562
+ "status": "skipped",
563
+ "statusDetails": {"message": "conditional skip"},
564
+ "start": 1000,
565
+ # no "stop"
566
+ "labels": [],
567
+ "parameters": [],
568
+ }))
569
+
570
+ result = parse_result_file(filepath)
571
+
572
+ assert result is not None
573
+ assert result.outcome == "skipped"
574
+ assert result.duration_ms == 0
575
+ assert result.testId == "hist-skip"
576
+ assert result.error_message == "conditional skip"
577
+
578
+ def test_skipped_without_start_or_stop_parses(self, tmp_path: Path) -> None:
579
+ """A skipped result with neither start nor stop still parses."""
580
+ filepath = tmp_path / "skip2-result.json"
581
+ filepath.write_text(json.dumps({
582
+ "uuid": "skip2-uuid",
583
+ "fullName": "tests.test_mod.test_skipped2",
584
+ "status": "skipped",
585
+ "labels": [],
586
+ "parameters": [],
587
+ }))
588
+
589
+ result = parse_result_file(filepath)
590
+
591
+ assert result is not None
592
+ assert result.outcome == "skipped"
593
+ assert result.duration_ms == 0
594
+ assert result.started_at is None
595
+ assert result.ended_at is None
596
+
597
+ def test_missing_uuid_still_returns_none(self, tmp_path: Path) -> None:
598
+ """uuid remains strictly required — its absence returns None."""
599
+ filepath = tmp_path / "nouuid-result.json"
600
+ filepath.write_text(json.dumps({
601
+ "fullName": "tests.test_mod.test_x",
602
+ "status": "passed",
603
+ "start": 1000,
604
+ "stop": 2000,
605
+ }))
606
+
607
+ assert parse_result_file(filepath) is None
608
+
609
+
610
+ class TestParseEnvironmentProperties:
611
+ """parse_environment_properties reads Allure environment.properties files."""
612
+
613
+ def test_parses_key_value_pairs(self, tmp_path: Path) -> None:
614
+ from charisma_cli.parser import parse_environment_properties
615
+
616
+ f = tmp_path / "environment.properties"
617
+ f.write_text(
618
+ "environment_name=dv1\n"
619
+ "aws_region=us-west-2\n"
620
+ "workers_number=8\n"
621
+ )
622
+ result = parse_environment_properties(f)
623
+ assert result == {
624
+ "environment_name": "dv1",
625
+ "aws_region": "us-west-2",
626
+ "workers_number": "8",
627
+ }
628
+
629
+ def test_ignores_comments_and_blank_lines(self, tmp_path: Path) -> None:
630
+ from charisma_cli.parser import parse_environment_properties
631
+
632
+ f = tmp_path / "environment.properties"
633
+ f.write_text(
634
+ "# a comment\n"
635
+ "! another comment\n"
636
+ "\n"
637
+ "key=value\n"
638
+ )
639
+ assert parse_environment_properties(f) == {"key": "value"}
640
+
641
+ def test_value_with_equals_sign_keeps_remainder(self, tmp_path: Path) -> None:
642
+ from charisma_cli.parser import parse_environment_properties
643
+
644
+ f = tmp_path / "environment.properties"
645
+ f.write_text("url=https://x.com/a?b=c\n")
646
+ assert parse_environment_properties(f) == {"url": "https://x.com/a?b=c"}
647
+
648
+ def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
649
+ from charisma_cli.parser import parse_environment_properties
650
+
651
+ assert parse_environment_properties(tmp_path / "nope.properties") == {}
@@ -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