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.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. 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"]