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.
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/.gitignore +1 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/PKG-INFO +1 -1
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/pyproject.toml +1 -1
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/__init__.py +1 -1
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/main.py +20 -5
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/uploader.py +20 -10
- charisma_cli-0.2.0/src/charisma_cli/watcher.py +348 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_integration.py +132 -2
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_watcher.py +257 -4
- charisma_cli-0.1.5/src/charisma_cli/watcher.py +0 -223
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/README.md +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/models.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/parser.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/__init__.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/conftest.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_cli.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_config.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_models.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_parser.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_retry.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.1.5 → charisma_cli-0.2.0}/tests/test_uploader.py +0 -0
|
@@ -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
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
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.
|
|
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
|
|
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":
|
|
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
|
-
"
|
|
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["
|
|
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
|
|
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
|
-
|
|
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["
|
|
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["
|
|
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
|
|
524
|
-
"""final_scan skips files
|
|
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
|
-
|
|
538
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|