collab-runtime 0.2.9__py3-none-any.whl
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.
- collab/__init__.py +77 -0
- collab/__main__.py +11 -0
- collab_runtime-0.2.9.dist-info/METADATA +218 -0
- collab_runtime-0.2.9.dist-info/RECORD +82 -0
- collab_runtime-0.2.9.dist-info/WHEEL +5 -0
- collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
- collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
- collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
- scripts/cleanup.py +395 -0
- scripts/collab_git_hook.py +190 -0
- scripts/format_code.py +594 -0
- scripts/generate_tests.py +560 -0
- scripts/validate_code.py +1397 -0
- src/__init__.py +4 -0
- src/dashboard/index.html +1131 -0
- src/live_locks_watcher.py +1982 -0
- src/lock_client.py +4268 -0
- src/logging_config.py +259 -0
- src/main.py +436 -0
- tests/backend/__init__.py +0 -0
- tests/backend/functional/__init__.py +0 -0
- tests/backend/functional/test_package_imports.py +43 -0
- tests/backend/integration/__init__.py +0 -0
- tests/backend/integration/test_cli_contract_parity.py +220 -0
- tests/backend/performance/__init__.py +0 -0
- tests/backend/reliability/__init__.py +0 -0
- tests/backend/security/__init__.py +0 -0
- tests/backend/unit/live_locks_watcher/__init__.py +5 -0
- tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
- tests/backend/unit/live_locks_watcher/conftest.py +18 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
- tests/backend/unit/lock_client/__init__.py +1 -0
- tests/backend/unit/lock_client/_helpers.py +132 -0
- tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
- tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
- tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
- tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
- tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
- tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
- tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
- tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
- tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
- tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
- tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
- tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
- tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
- tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
- tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
- tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
- tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
- tests/backend/unit/scripts/__init__.py +1 -0
- tests/backend/unit/scripts/_helpers.py +42 -0
- tests/backend/unit/scripts/test_cleanup.py +285 -0
- tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
- tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
- tests/backend/unit/scripts/test_format_code.py +368 -0
- tests/backend/unit/scripts/test_format_code_ported.py +177 -0
- tests/backend/unit/scripts/test_generate_tests.py +305 -0
- tests/backend/unit/scripts/test_hook_templates.py +357 -0
- tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
- tests/backend/unit/scripts/test_validate_code.py +867 -0
- tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
- tests/backend/unit/test_entrypoints_main_run.py +83 -0
- tests/backend/unit/test_logging_config.py +529 -0
- tests/backend/unit/test_main_watch_pid_file.py +278 -0
- tests/conftest.py +167 -0
- tests/frontend/__init__.py +0 -0
- tests/frontend/jest/__init__.py +0 -0
- tests/frontend/playwright/__init__.py +0 -0
- tests/packaging/test_smoke_install.py +76 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Tests for the collab logging configuration module.
|
|
2
|
+
|
|
3
|
+
Covers edge cases such as directory creation failure, fallback paths, test mode vs
|
|
4
|
+
production mode, stale handler cleanup, and file handler creation errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import types
|
|
13
|
+
from logging.handlers import RotatingFileHandler
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _find_logging_config_path() -> Path:
|
|
20
|
+
p = Path(__file__).resolve()
|
|
21
|
+
for parent in p.parents:
|
|
22
|
+
candidate = parent / "src" / "logging_config.py"
|
|
23
|
+
if candidate.exists():
|
|
24
|
+
return candidate
|
|
25
|
+
raise FileNotFoundError("logging_config.py not found in repo")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _import_fresh_logging_config():
|
|
29
|
+
"""Import logging_config module with a clean module object."""
|
|
30
|
+
path = _find_logging_config_path()
|
|
31
|
+
spec = importlib.util.spec_from_file_location(
|
|
32
|
+
"collab.logging_config_test", str(path)
|
|
33
|
+
)
|
|
34
|
+
mod = importlib.util.module_from_spec(spec)
|
|
35
|
+
assert spec and spec.loader
|
|
36
|
+
spec.loader.exec_module(mod) # type: ignore[arg-type]
|
|
37
|
+
return mod
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _reset_logging():
|
|
41
|
+
"""Reset the Python logging system to a pristine state."""
|
|
42
|
+
root = logging.getLogger()
|
|
43
|
+
for h in list(root.handlers):
|
|
44
|
+
root.removeHandler(h)
|
|
45
|
+
root.setLevel(logging.WARNING)
|
|
46
|
+
collab_logger = logging.getLogger("collab")
|
|
47
|
+
for h in list(collab_logger.handlers):
|
|
48
|
+
collab_logger.removeHandler(h)
|
|
49
|
+
collab_logger.propagate = True
|
|
50
|
+
collab_logger.setLevel(logging.NOTSET)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# =========================================================================
|
|
54
|
+
# _ensure_dir
|
|
55
|
+
# =========================================================================
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_ensure_dir_creates(tmp_path):
|
|
59
|
+
"""_ensure_dir creates the directory when possible."""
|
|
60
|
+
lc = _import_fresh_logging_config()
|
|
61
|
+
target = tmp_path / "logs"
|
|
62
|
+
lc._ensure_dir(str(target))
|
|
63
|
+
assert target.exists()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_ensure_dir_handles_exception(monkeypatch):
|
|
67
|
+
"""_ensure_dir does not raise when os.makedirs fails."""
|
|
68
|
+
lc = _import_fresh_logging_config()
|
|
69
|
+
|
|
70
|
+
def bad_makedirs(path, exist_ok=False):
|
|
71
|
+
raise PermissionError("Access denied")
|
|
72
|
+
|
|
73
|
+
monkeypatch.setattr(os, "makedirs", bad_makedirs)
|
|
74
|
+
# Should not raise
|
|
75
|
+
lc._ensure_dir("/nonexistent/path")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_is_test_mode_reflects_environment(monkeypatch):
|
|
79
|
+
"""_is_test_mode mirrors the COLLAB_TEST_MODE environment flag."""
|
|
80
|
+
lc = _import_fresh_logging_config()
|
|
81
|
+
|
|
82
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
83
|
+
assert lc._is_test_mode() is True
|
|
84
|
+
|
|
85
|
+
# Clear all test-mode signals to simulate production context
|
|
86
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
87
|
+
monkeypatch.delenv("TESTING", raising=False)
|
|
88
|
+
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
|
89
|
+
assert lc._is_test_mode() is False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# =========================================================================
|
|
93
|
+
# setup_collab_logging — fallback base path
|
|
94
|
+
# =========================================================================
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_setup_collab_logging_no_collab_dir(monkeypatch):
|
|
98
|
+
"""When collab_dir is None, uses dirname of __file__ as base."""
|
|
99
|
+
_reset_logging()
|
|
100
|
+
lc = _import_fresh_logging_config()
|
|
101
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
102
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", os.getcwd())
|
|
103
|
+
# Should not raise
|
|
104
|
+
logger = lc.setup_collab_logging(collab_dir=None)
|
|
105
|
+
assert logger is not None
|
|
106
|
+
assert logger is logging.getLogger()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# =========================================================================
|
|
110
|
+
# setup_collab_logging — test mode vs production mode file names
|
|
111
|
+
# =========================================================================
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_setup_collab_logging_test_mode_uses_test_filename(monkeypatch, tmp_path):
|
|
115
|
+
"""COLLAB_TEST_MODE=1 writes test_collab.log inside collab_dir/logs, not
|
|
116
|
+
COLLAB_STATE_DIR.
|
|
117
|
+
|
|
118
|
+
Logs must always live in <collab_dir>/logs/ so they are persistent and discoverable
|
|
119
|
+
after a test session ends. COLLAB_STATE_DIR isolates *process-state* artifacts (PID
|
|
120
|
+
files, lock files) but must NOT redirect log files.
|
|
121
|
+
"""
|
|
122
|
+
_reset_logging()
|
|
123
|
+
lc = _import_fresh_logging_config()
|
|
124
|
+
repo_collab_dir = tmp_path / "repo_collab"
|
|
125
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
126
|
+
# Set a COLLAB_STATE_DIR that differs from repo_collab_dir to prove logs
|
|
127
|
+
# do NOT follow it.
|
|
128
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path / "state"))
|
|
129
|
+
lc.setup_collab_logging(collab_dir=str(repo_collab_dir))
|
|
130
|
+
assert (repo_collab_dir / "logs" / "test_collab.log").exists()
|
|
131
|
+
assert not (tmp_path / "state" / "logs" / "test_collab.log").exists()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_setup_collab_logging_production_uses_collab_filename(monkeypatch, tmp_path):
|
|
135
|
+
"""Without COLLAB_TEST_MODE, uses 'collab.log'."""
|
|
136
|
+
_reset_logging()
|
|
137
|
+
lc = _import_fresh_logging_config()
|
|
138
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
139
|
+
monkeypatch.delenv("COLLAB_STATE_DIR", raising=False)
|
|
140
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path))
|
|
141
|
+
log_dir = os.path.join(str(tmp_path), "logs")
|
|
142
|
+
assert os.path.exists(os.path.join(log_dir, "collab.log"))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_setup_collab_logging_uses_rotating_file_handler(monkeypatch, tmp_path):
|
|
146
|
+
"""Collab logging uses bounded file rotation to avoid unbounded log growth."""
|
|
147
|
+
_reset_logging()
|
|
148
|
+
lc = _import_fresh_logging_config()
|
|
149
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
150
|
+
|
|
151
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path))
|
|
152
|
+
|
|
153
|
+
collab_logger = logging.getLogger("collab")
|
|
154
|
+
file_handler = next(
|
|
155
|
+
handler
|
|
156
|
+
for handler in collab_logger.handlers
|
|
157
|
+
if getattr(handler, "baseFilename", None)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
assert isinstance(file_handler, RotatingFileHandler)
|
|
161
|
+
assert file_handler.maxBytes == lc.DEFAULT_LOG_MAX_BYTES
|
|
162
|
+
assert file_handler.backupCount == lc.DEFAULT_LOG_BACKUP_COUNT
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_prune_old_log_files_removes_only_expired_rotated_logs(tmp_path):
|
|
166
|
+
"""Expired rotated logs are removed while fresh and active logs are preserved."""
|
|
167
|
+
lc = _import_fresh_logging_config()
|
|
168
|
+
log_dir = tmp_path / "logs"
|
|
169
|
+
log_dir.mkdir()
|
|
170
|
+
|
|
171
|
+
active = log_dir / "collab.log"
|
|
172
|
+
expired_rotated = log_dir / "collab.log.1"
|
|
173
|
+
fresh_rotated = log_dir / "collab.log.2"
|
|
174
|
+
other_log = log_dir / "test_collab.log.1"
|
|
175
|
+
for path in (active, expired_rotated, fresh_rotated, other_log):
|
|
176
|
+
path.write_text("x", encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
now = 1_700_000_000
|
|
179
|
+
expired_mtime = now - (6 * 24 * 60 * 60)
|
|
180
|
+
fresh_mtime = now - (2 * 24 * 60 * 60)
|
|
181
|
+
os.utime(active, (expired_mtime, expired_mtime))
|
|
182
|
+
os.utime(expired_rotated, (expired_mtime, expired_mtime))
|
|
183
|
+
os.utime(fresh_rotated, (fresh_mtime, fresh_mtime))
|
|
184
|
+
os.utime(other_log, (expired_mtime, expired_mtime))
|
|
185
|
+
|
|
186
|
+
lc._prune_old_log_files(str(log_dir), "collab.log", now=now)
|
|
187
|
+
|
|
188
|
+
assert active.exists()
|
|
189
|
+
assert not expired_rotated.exists()
|
|
190
|
+
assert fresh_rotated.exists()
|
|
191
|
+
assert other_log.exists()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_prune_old_log_files_ignores_os_errors(monkeypatch, tmp_path):
|
|
195
|
+
"""Best-effort pruning should not raise if directory scanning fails."""
|
|
196
|
+
lc = _import_fresh_logging_config()
|
|
197
|
+
|
|
198
|
+
def failing_scandir(path):
|
|
199
|
+
raise OSError("scan failed")
|
|
200
|
+
|
|
201
|
+
monkeypatch.setattr(os, "scandir", failing_scandir)
|
|
202
|
+
|
|
203
|
+
lc._prune_old_log_files(str(tmp_path), "collab.log")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_prune_old_log_files_skips_when_retention_disabled(monkeypatch, tmp_path):
|
|
207
|
+
"""Non-positive retention disables pruning and avoids directory scanning."""
|
|
208
|
+
lc = _import_fresh_logging_config()
|
|
209
|
+
called = []
|
|
210
|
+
|
|
211
|
+
def tracking_scandir(path):
|
|
212
|
+
called.append(path)
|
|
213
|
+
raise AssertionError("scandir should not run when retention is disabled")
|
|
214
|
+
|
|
215
|
+
monkeypatch.setattr(os, "scandir", tracking_scandir)
|
|
216
|
+
|
|
217
|
+
lc._prune_old_log_files(str(tmp_path), "collab.log", retention_days=0)
|
|
218
|
+
|
|
219
|
+
assert called == []
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_prune_old_log_files_skips_directories(tmp_path):
|
|
223
|
+
"""Directory entries in the logs folder are ignored during pruning."""
|
|
224
|
+
lc = _import_fresh_logging_config()
|
|
225
|
+
log_dir = tmp_path / "logs"
|
|
226
|
+
nested = log_dir / "collab.log.archive"
|
|
227
|
+
log_dir.mkdir()
|
|
228
|
+
nested.mkdir()
|
|
229
|
+
|
|
230
|
+
lc._prune_old_log_files(str(log_dir), "collab.log", now=1_700_000_000)
|
|
231
|
+
|
|
232
|
+
assert nested.exists()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_prune_old_log_files_ignores_remove_errors(monkeypatch, tmp_path):
|
|
236
|
+
"""Best-effort pruning continues if deleting an expired rotated log fails."""
|
|
237
|
+
lc = _import_fresh_logging_config()
|
|
238
|
+
log_dir = tmp_path / "logs"
|
|
239
|
+
log_dir.mkdir()
|
|
240
|
+
expired_rotated = log_dir / "collab.log.1"
|
|
241
|
+
expired_rotated.write_text("x", encoding="utf-8")
|
|
242
|
+
|
|
243
|
+
now = 1_700_000_000
|
|
244
|
+
expired_mtime = now - (6 * 24 * 60 * 60)
|
|
245
|
+
os.utime(expired_rotated, (expired_mtime, expired_mtime))
|
|
246
|
+
|
|
247
|
+
def failing_remove(path):
|
|
248
|
+
raise OSError("delete failed")
|
|
249
|
+
|
|
250
|
+
monkeypatch.setattr(os, "remove", failing_remove)
|
|
251
|
+
|
|
252
|
+
lc._prune_old_log_files(str(log_dir), "collab.log", now=now)
|
|
253
|
+
|
|
254
|
+
assert expired_rotated.exists()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# =========================================================================
|
|
258
|
+
# Stale handler removal (lines 95-108)
|
|
259
|
+
# =========================================================================
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_stale_handler_removal_does_not_raise(monkeypatch, tmp_path):
|
|
263
|
+
"""Calling setup_collab_logging multiple times does not raise."""
|
|
264
|
+
_reset_logging()
|
|
265
|
+
lc = _import_fresh_logging_config()
|
|
266
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
267
|
+
|
|
268
|
+
# First call creates handlers
|
|
269
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path))
|
|
270
|
+
|
|
271
|
+
# Second call - stale handler cleanup should not raise
|
|
272
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path))
|
|
273
|
+
collab_logger = logging.getLogger("collab")
|
|
274
|
+
assert len(collab_logger.handlers) > 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_stale_handler_removed_when_collab_name_changes(monkeypatch, tmp_path):
|
|
278
|
+
"""When switching between test and production mode, old file handler is stale."""
|
|
279
|
+
_reset_logging()
|
|
280
|
+
lc = _import_fresh_logging_config()
|
|
281
|
+
collab_dir = tmp_path / "collab"
|
|
282
|
+
# First call with COLLAB_TEST_MODE=1 -> creates handler for test_collab.log
|
|
283
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
284
|
+
lc.setup_collab_logging(collab_dir=str(collab_dir))
|
|
285
|
+
collab_logger = logging.getLogger("collab")
|
|
286
|
+
file_handlers = [h for h in collab_logger.handlers if hasattr(h, "baseFilename")]
|
|
287
|
+
assert len(file_handlers) >= 1
|
|
288
|
+
assert any(
|
|
289
|
+
"test_collab.log" in getattr(h, "baseFilename", "") for h in file_handlers
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Second call without COLLAB_TEST_MODE -> should create handler for collab.log
|
|
293
|
+
# and old test_collab.log handler should be detected as stale
|
|
294
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
295
|
+
lc.setup_collab_logging(collab_dir=str(collab_dir))
|
|
296
|
+
assert (collab_dir / "logs" / "test_collab.log").exists()
|
|
297
|
+
assert (collab_dir / "logs" / "collab.log").exists()
|
|
298
|
+
current_paths = {getattr(h, "baseFilename", None) for h in collab_logger.handlers}
|
|
299
|
+
assert str(collab_dir / "logs" / "collab.log") in current_paths
|
|
300
|
+
assert str(collab_dir / "logs" / "test_collab.log") not in current_paths
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# =========================================================================
|
|
304
|
+
# File handler creation failure (line 117)
|
|
305
|
+
# =========================================================================
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_setup_collab_logging_file_handler_failure(monkeypatch, tmp_path):
|
|
309
|
+
"""When the rotating log handler cannot be created, continue gracefully."""
|
|
310
|
+
_reset_logging()
|
|
311
|
+
lc = _import_fresh_logging_config()
|
|
312
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
313
|
+
|
|
314
|
+
def failing_rotating_handler(filename, *args, **kwargs):
|
|
315
|
+
if "collab.log" in str(filename):
|
|
316
|
+
raise OSError("Cannot open log file")
|
|
317
|
+
raise OSError("Unexpected")
|
|
318
|
+
|
|
319
|
+
monkeypatch.setattr(lc, "RotatingFileHandler", failing_rotating_handler)
|
|
320
|
+
# Should not raise
|
|
321
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# =========================================================================
|
|
325
|
+
# Console handler is added when missing
|
|
326
|
+
# =========================================================================
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_setup_collab_logging_adds_console_handler(monkeypatch, tmp_path):
|
|
330
|
+
"""When root logger has no StreamHandler, one is added."""
|
|
331
|
+
_reset_logging()
|
|
332
|
+
lc = _import_fresh_logging_config()
|
|
333
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
334
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path / "state"))
|
|
335
|
+
|
|
336
|
+
# Remove any existing StreamHandlers from root
|
|
337
|
+
root = logging.getLogger()
|
|
338
|
+
for h in list(root.handlers):
|
|
339
|
+
root.removeHandler(h)
|
|
340
|
+
|
|
341
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path))
|
|
342
|
+
has_stream = any(type(h).__name__ == "StreamHandler" for h in root.handlers)
|
|
343
|
+
assert has_stream
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_setup_collab_logging_console_already_present(monkeypatch, tmp_path):
|
|
347
|
+
"""When root logger already has a StreamHandler, no duplicate is added."""
|
|
348
|
+
_reset_logging()
|
|
349
|
+
lc = _import_fresh_logging_config()
|
|
350
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
351
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path / "state"))
|
|
352
|
+
|
|
353
|
+
root = logging.getLogger()
|
|
354
|
+
# Remove existing handlers then add exactly one StreamHandler
|
|
355
|
+
for h in list(root.handlers):
|
|
356
|
+
root.removeHandler(h)
|
|
357
|
+
root.addHandler(logging.StreamHandler())
|
|
358
|
+
|
|
359
|
+
stream_count_before = sum(
|
|
360
|
+
1 for h in root.handlers if type(h).__name__ == "StreamHandler"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path))
|
|
364
|
+
|
|
365
|
+
stream_count_after = sum(
|
|
366
|
+
1 for h in root.handlers if type(h).__name__ == "StreamHandler"
|
|
367
|
+
)
|
|
368
|
+
# Should not increase
|
|
369
|
+
assert stream_count_after == stream_count_before
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_close_collab_logging_releases_file_handlers(monkeypatch, tmp_path):
|
|
373
|
+
"""close_collab_logging removes collab file handlers from the current process."""
|
|
374
|
+
_reset_logging()
|
|
375
|
+
lc = _import_fresh_logging_config()
|
|
376
|
+
state_dir = tmp_path / "state"
|
|
377
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
378
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(state_dir))
|
|
379
|
+
|
|
380
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path / "repo_collab"))
|
|
381
|
+
collab_logger = logging.getLogger("collab")
|
|
382
|
+
assert any(getattr(h, "baseFilename", None) for h in collab_logger.handlers)
|
|
383
|
+
|
|
384
|
+
lc.close_collab_logging()
|
|
385
|
+
|
|
386
|
+
assert not any(getattr(h, "baseFilename", None) for h in collab_logger.handlers)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_setup_collab_logging_closes_stale_file_handler(monkeypatch, tmp_path):
|
|
390
|
+
"""When the effective log filename changes (test↔prod mode switch), the stale file
|
|
391
|
+
handler is explicitly closed, releasing Windows file locks."""
|
|
392
|
+
_reset_logging()
|
|
393
|
+
lc = _import_fresh_logging_config()
|
|
394
|
+
collab_dir = tmp_path / "collab"
|
|
395
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
396
|
+
lc.setup_collab_logging(collab_dir=str(collab_dir))
|
|
397
|
+
|
|
398
|
+
collab_logger = logging.getLogger("collab")
|
|
399
|
+
stale_handler = next(
|
|
400
|
+
handler
|
|
401
|
+
for handler in collab_logger.handlers
|
|
402
|
+
if getattr(handler, "baseFilename", None)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
closed = []
|
|
406
|
+
original_close = stale_handler.close
|
|
407
|
+
|
|
408
|
+
def tracked_close():
|
|
409
|
+
closed.append(stale_handler.baseFilename)
|
|
410
|
+
original_close()
|
|
411
|
+
|
|
412
|
+
stale_handler.close = tracked_close # type: ignore[assignment]
|
|
413
|
+
|
|
414
|
+
# Switch to production mode: filename changes test_collab.log → collab.log
|
|
415
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
416
|
+
lc.setup_collab_logging(collab_dir=str(collab_dir))
|
|
417
|
+
|
|
418
|
+
assert closed == [str(collab_dir / "logs" / "test_collab.log")]
|
|
419
|
+
current_paths = {
|
|
420
|
+
getattr(handler, "baseFilename", None) for handler in collab_logger.handlers
|
|
421
|
+
}
|
|
422
|
+
assert str(collab_dir / "logs" / "collab.log") in current_paths
|
|
423
|
+
assert str(collab_dir / "logs" / "test_collab.log") not in current_paths
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_setup_collab_logging_ignores_stale_handler_close_errors(monkeypatch, tmp_path):
|
|
427
|
+
"""Stale handler cleanup is best-effort if closing the old handler fails."""
|
|
428
|
+
_reset_logging()
|
|
429
|
+
lc = _import_fresh_logging_config()
|
|
430
|
+
collab_dir = tmp_path / "collab"
|
|
431
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
432
|
+
lc.setup_collab_logging(collab_dir=str(collab_dir))
|
|
433
|
+
|
|
434
|
+
collab_logger = logging.getLogger("collab")
|
|
435
|
+
stale_handler = next(
|
|
436
|
+
handler
|
|
437
|
+
for handler in collab_logger.handlers
|
|
438
|
+
if getattr(handler, "baseFilename", None)
|
|
439
|
+
)
|
|
440
|
+
monkeypatch.setattr(
|
|
441
|
+
stale_handler,
|
|
442
|
+
"close",
|
|
443
|
+
lambda: (_ for _ in ()).throw(OSError("close failed")),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
447
|
+
lc.setup_collab_logging(collab_dir=str(collab_dir))
|
|
448
|
+
|
|
449
|
+
current_paths = {
|
|
450
|
+
getattr(handler, "baseFilename", None) for handler in collab_logger.handlers
|
|
451
|
+
}
|
|
452
|
+
assert str(collab_dir / "logs" / "collab.log") in current_paths
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_close_collab_logging_preserves_console_handlers(monkeypatch, tmp_path):
|
|
456
|
+
"""close_collab_logging only removes file handlers and keeps console handlers."""
|
|
457
|
+
_reset_logging()
|
|
458
|
+
lc = _import_fresh_logging_config()
|
|
459
|
+
state_dir = tmp_path / "state"
|
|
460
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
461
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(state_dir))
|
|
462
|
+
|
|
463
|
+
root = logging.getLogger()
|
|
464
|
+
root.handlers.clear()
|
|
465
|
+
console_handler = logging.StreamHandler()
|
|
466
|
+
root.addHandler(console_handler)
|
|
467
|
+
|
|
468
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path / "repo_collab"))
|
|
469
|
+
lc.close_collab_logging()
|
|
470
|
+
|
|
471
|
+
assert console_handler in root.handlers
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_close_collab_logging_ignores_flush_and_close_errors(monkeypatch, tmp_path):
|
|
475
|
+
"""close_collab_logging is best-effort even if handler flush/close fail."""
|
|
476
|
+
_reset_logging()
|
|
477
|
+
lc = _import_fresh_logging_config()
|
|
478
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
479
|
+
|
|
480
|
+
lc.setup_collab_logging(collab_dir=str(tmp_path / "repo_collab"))
|
|
481
|
+
collab_logger = logging.getLogger("collab")
|
|
482
|
+
file_handler = next(
|
|
483
|
+
handler
|
|
484
|
+
for handler in collab_logger.handlers
|
|
485
|
+
if getattr(handler, "baseFilename", None)
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
monkeypatch.setattr(
|
|
489
|
+
file_handler,
|
|
490
|
+
"flush",
|
|
491
|
+
lambda: (_ for _ in ()).throw(OSError("flush failed")),
|
|
492
|
+
)
|
|
493
|
+
monkeypatch.setattr(
|
|
494
|
+
file_handler,
|
|
495
|
+
"close",
|
|
496
|
+
lambda: (_ for _ in ()).throw(OSError("close failed")),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
lc.close_collab_logging()
|
|
500
|
+
|
|
501
|
+
assert not any(getattr(h, "baseFilename", None) for h in collab_logger.handlers)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_collab_test_conftest_cleanup_fixture_calls_close_logging(monkeypatch):
|
|
505
|
+
"""The collab test autouse cleanup fixture calls close_collab_logging after
|
|
506
|
+
yield."""
|
|
507
|
+
conftest_path = Path(__file__).resolve().parents[2] / "conftest.py"
|
|
508
|
+
spec = importlib.util.spec_from_file_location(
|
|
509
|
+
"collab.tests.conftest_for_test", str(conftest_path)
|
|
510
|
+
)
|
|
511
|
+
module = importlib.util.module_from_spec(spec)
|
|
512
|
+
assert spec and spec.loader
|
|
513
|
+
spec.loader.exec_module(module) # type: ignore[arg-type]
|
|
514
|
+
|
|
515
|
+
close_calls = []
|
|
516
|
+
fake_logging_module = types.SimpleNamespace(
|
|
517
|
+
close_collab_logging=lambda: close_calls.append("closed")
|
|
518
|
+
)
|
|
519
|
+
monkeypatch.setattr(
|
|
520
|
+
module, "_load_logging_config_module", lambda: fake_logging_module
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
fixture_impl = module._close_collab_logging_after_each_test.__wrapped__
|
|
524
|
+
generator = fixture_impl()
|
|
525
|
+
next(generator)
|
|
526
|
+
with pytest.raises(StopIteration):
|
|
527
|
+
next(generator)
|
|
528
|
+
|
|
529
|
+
assert close_calls == ["closed"]
|