cmdscope 0.1.0__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.
cmdscope/config.py ADDED
@@ -0,0 +1,461 @@
1
+ """Configuration loading, validation, and path resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cmdscope.core.ignore import DEFAULT_IGNORE_PATTERNS
13
+ from cmdscope.errors import ConfigurationError
14
+ from cmdscope.platforms import current_platform_info
15
+
16
+ _ALLOWED_TOP_LEVEL = {"filesystem", "processes", "network", "privacy", "storage", "report"}
17
+ _ALLOWED_KEYS = {
18
+ "filesystem": {"enabled", "watch", "ignore", "max_files", "poll_interval", "native_events"},
19
+ "processes": {"enabled", "poll_interval"},
20
+ "network": {"enabled", "poll_interval"},
21
+ "privacy": {"capture_environment", "show_environment"},
22
+ "storage": {"database", "max_sessions", "max_events"},
23
+ "report": {"max_events", "format"},
24
+ }
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class FilesystemConfig:
29
+ enabled: bool = True
30
+ watch: tuple[str, ...] = ()
31
+ ignore: tuple[str, ...] = DEFAULT_IGNORE_PATTERNS
32
+ max_files: int = 100_000
33
+ poll_interval: float = 1.0
34
+ native_events: bool = True
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ProcessesConfig:
39
+ enabled: bool = True
40
+ poll_interval: float = 0.5
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class NetworkConfig:
45
+ enabled: bool = True
46
+ poll_interval: float = 0.25
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class PrivacyConfig:
51
+ capture_environment: bool = True
52
+ show_environment: bool = False
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class StorageConfig:
57
+ database: Path | None = None
58
+ max_sessions: int = 0
59
+ max_events: int = 100_000
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ReportConfig:
64
+ max_events: int = 500
65
+ default_format: str = "terminal"
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Config:
70
+ filesystem: FilesystemConfig = field(default_factory=FilesystemConfig)
71
+ processes: ProcessesConfig = field(default_factory=ProcessesConfig)
72
+ network: NetworkConfig = field(default_factory=NetworkConfig)
73
+ privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
74
+ storage: StorageConfig = field(default_factory=StorageConfig)
75
+ report: ReportConfig = field(default_factory=ReportConfig)
76
+ source_path: Path | None = None
77
+ base_dir: Path = field(default_factory=Path.cwd)
78
+ warnings: tuple[str, ...] = ()
79
+
80
+ def database_path(self) -> Path:
81
+ """Return the configured database path or the platform default."""
82
+
83
+ if self.storage.database is not None:
84
+ return self.storage.database
85
+ return current_platform_info().default_database_path
86
+
87
+
88
+ def user_config_path(environment: Mapping[str, str] | None = None) -> Path:
89
+ env = os.environ if environment is None else environment
90
+ info = current_platform_info(env)
91
+ return info.default_config_dir / "config.toml"
92
+
93
+
94
+ def find_config_path(
95
+ explicit: Path | None,
96
+ *,
97
+ cwd: Path,
98
+ environment: Mapping[str, str] | None = None,
99
+ ) -> Path | None:
100
+ """Resolve configuration precedence: explicit, env, local, user."""
101
+
102
+ env = os.environ if environment is None else environment
103
+ if explicit is not None:
104
+ candidate = explicit.expanduser()
105
+ if not candidate.is_absolute():
106
+ candidate = cwd / candidate
107
+ if not candidate.is_file():
108
+ raise ConfigurationError(f"configuration file not found: {candidate}")
109
+ return candidate.resolve()
110
+
111
+ env_path = env.get("CMDSCOPE_CONFIG")
112
+ if env_path:
113
+ candidate = Path(env_path).expanduser()
114
+ if not candidate.is_absolute():
115
+ candidate = cwd / candidate
116
+ if not candidate.is_file():
117
+ raise ConfigurationError(f"CMDSCOPE_CONFIG points to a missing file: {candidate}")
118
+ return candidate.resolve()
119
+
120
+ local = cwd / "cmdscope.toml"
121
+ if local.is_file():
122
+ return local.resolve()
123
+
124
+ user = user_config_path(env)
125
+ if user.is_file():
126
+ return user.resolve()
127
+ return None
128
+
129
+
130
+ def _require_table(value: Any, name: str) -> Mapping[str, Any]:
131
+ if not isinstance(value, dict):
132
+ raise ConfigurationError(f"configuration section [{name}] must be a table")
133
+ return value
134
+
135
+
136
+ def _unknown_keys(section: str, table: Mapping[str, Any], warnings: list[str]) -> None:
137
+ for key in sorted(set(table) - _ALLOWED_KEYS[section]):
138
+ warnings.append(f"unknown configuration key ignored: {section}.{key}")
139
+
140
+
141
+ def _bool(table: Mapping[str, Any], key: str, default: bool, section: str) -> bool:
142
+ value = table.get(key, default)
143
+ if not isinstance(value, bool):
144
+ raise ConfigurationError(f"{section}.{key} must be true or false")
145
+ return value
146
+
147
+
148
+ def _int(table: Mapping[str, Any], key: str, default: int, section: str) -> int:
149
+ value = table.get(key, default)
150
+ if isinstance(value, bool) or not isinstance(value, int):
151
+ raise ConfigurationError(f"{section}.{key} must be an integer")
152
+ return value
153
+
154
+
155
+ def _float(table: Mapping[str, Any], key: str, default: float, section: str) -> float:
156
+ value = table.get(key, default)
157
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
158
+ raise ConfigurationError(f"{section}.{key} must be a number")
159
+ return float(value)
160
+
161
+
162
+ def _str_list(
163
+ table: Mapping[str, Any], key: str, default: tuple[str, ...], section: str
164
+ ) -> tuple[str, ...]:
165
+ value = table.get(key, list(default))
166
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
167
+ raise ConfigurationError(f"{section}.{key} must be a list of strings")
168
+ return tuple(value)
169
+
170
+
171
+ def _optional_str(
172
+ table: Mapping[str, Any], key: str, default: str | None, section: str
173
+ ) -> str | None:
174
+ value = table.get(key, default)
175
+ if value is not None and not isinstance(value, str):
176
+ raise ConfigurationError(f"{section}.{key} must be a string")
177
+ return value
178
+
179
+
180
+ def _parse_filesystem(table: Mapping[str, Any], warnings: list[str]) -> FilesystemConfig:
181
+ _unknown_keys("filesystem", table, warnings)
182
+ config = FilesystemConfig(
183
+ enabled=_bool(table, "enabled", True, "filesystem"),
184
+ watch=_str_list(table, "watch", (), "filesystem"),
185
+ ignore=_str_list(table, "ignore", DEFAULT_IGNORE_PATTERNS, "filesystem"),
186
+ max_files=_int(table, "max_files", 100_000, "filesystem"),
187
+ poll_interval=_float(table, "poll_interval", 1.0, "filesystem"),
188
+ native_events=_bool(table, "native_events", True, "filesystem"),
189
+ )
190
+ if config.max_files < 1:
191
+ raise ConfigurationError("filesystem.max_files must be at least 1")
192
+ if config.poll_interval <= 0:
193
+ raise ConfigurationError("filesystem.poll_interval must be greater than zero")
194
+ return config
195
+
196
+
197
+ def _parse_processes(table: Mapping[str, Any], warnings: list[str]) -> ProcessesConfig:
198
+ _unknown_keys("processes", table, warnings)
199
+ config = ProcessesConfig(
200
+ enabled=_bool(table, "enabled", True, "processes"),
201
+ poll_interval=_float(table, "poll_interval", 0.5, "processes"),
202
+ )
203
+ if config.poll_interval <= 0:
204
+ raise ConfigurationError("processes.poll_interval must be greater than zero")
205
+ return config
206
+
207
+
208
+ def _parse_network(table: Mapping[str, Any], warnings: list[str]) -> NetworkConfig:
209
+ _unknown_keys("network", table, warnings)
210
+ config = NetworkConfig(
211
+ enabled=_bool(table, "enabled", True, "network"),
212
+ poll_interval=_float(table, "poll_interval", 0.25, "network"),
213
+ )
214
+ if config.poll_interval <= 0:
215
+ raise ConfigurationError("network.poll_interval must be greater than zero")
216
+ return config
217
+
218
+
219
+ def _parse_privacy(table: Mapping[str, Any], warnings: list[str]) -> PrivacyConfig:
220
+ _unknown_keys("privacy", table, warnings)
221
+ return PrivacyConfig(
222
+ capture_environment=_bool(table, "capture_environment", True, "privacy"),
223
+ show_environment=_bool(table, "show_environment", False, "privacy"),
224
+ )
225
+
226
+
227
+ def _parse_storage(
228
+ table: Mapping[str, Any],
229
+ warnings: list[str],
230
+ base_dir: Path,
231
+ ) -> StorageConfig:
232
+ _unknown_keys("storage", table, warnings)
233
+ database_value = _optional_str(table, "database", None, "storage")
234
+ database: Path | None = None
235
+ if database_value:
236
+ database = Path(database_value).expanduser()
237
+ if not database.is_absolute():
238
+ database = base_dir / database
239
+ database = database.resolve()
240
+ config = StorageConfig(
241
+ database=database,
242
+ max_sessions=_int(table, "max_sessions", 0, "storage"),
243
+ max_events=_int(table, "max_events", 100_000, "storage"),
244
+ )
245
+ if config.max_sessions < 0:
246
+ raise ConfigurationError("storage.max_sessions must be zero or greater")
247
+ if config.max_events < 1:
248
+ raise ConfigurationError("storage.max_events must be at least 1")
249
+ return config
250
+
251
+
252
+ def _parse_report(table: Mapping[str, Any], warnings: list[str]) -> ReportConfig:
253
+ _unknown_keys("report", table, warnings)
254
+ format_value = _optional_str(table, "format", "terminal", "report") or "terminal"
255
+ if format_value not in {"terminal", "json", "html"}:
256
+ raise ConfigurationError("report.format must be terminal, json, or html")
257
+ config = ReportConfig(
258
+ max_events=_int(table, "max_events", 500, "report"),
259
+ default_format=format_value,
260
+ )
261
+ if config.max_events < 1:
262
+ raise ConfigurationError("report.max_events must be at least 1")
263
+ return config
264
+
265
+
266
+ def _parse_config(
267
+ data: Mapping[str, Any],
268
+ *,
269
+ source_path: Path | None,
270
+ base_dir: Path,
271
+ ) -> Config:
272
+ warnings: list[str] = []
273
+ for key in sorted(set(data) - _ALLOWED_TOP_LEVEL):
274
+ warnings.append(f"unknown configuration section ignored: {key}")
275
+
276
+ filesystem = _parse_filesystem(
277
+ _require_table(data.get("filesystem", {}), "filesystem"), warnings
278
+ )
279
+ processes = _parse_processes(_require_table(data.get("processes", {}), "processes"), warnings)
280
+ network = _parse_network(_require_table(data.get("network", {}), "network"), warnings)
281
+ privacy = _parse_privacy(_require_table(data.get("privacy", {}), "privacy"), warnings)
282
+ storage = _parse_storage(_require_table(data.get("storage", {}), "storage"), warnings, base_dir)
283
+ report = _parse_report(_require_table(data.get("report", {}), "report"), warnings)
284
+
285
+ return Config(
286
+ filesystem=filesystem,
287
+ processes=processes,
288
+ network=network,
289
+ privacy=privacy,
290
+ storage=storage,
291
+ report=report,
292
+ source_path=source_path,
293
+ base_dir=base_dir,
294
+ warnings=tuple(warnings),
295
+ )
296
+
297
+
298
+ def load_config(
299
+ explicit: Path | None = None,
300
+ *,
301
+ cwd: Path | None = None,
302
+ environment: Mapping[str, str] | None = None,
303
+ ) -> Config:
304
+ """Load configuration from the first available location.
305
+
306
+ Relative paths inside a configuration file resolve against that file's
307
+ directory. A missing explicit file is an error; no implicit file is fine.
308
+ """
309
+
310
+ working_dir = (cwd or Path.cwd()).resolve()
311
+ path = find_config_path(explicit, cwd=working_dir, environment=environment)
312
+ if path is None:
313
+ return Config(base_dir=working_dir)
314
+
315
+ try:
316
+ raw = path.read_text(encoding="utf-8")
317
+ data = tomllib.loads(raw)
318
+ except tomllib.TOMLDecodeError as exc:
319
+ raise ConfigurationError(f"invalid TOML in {path}: {exc}") from exc
320
+ except OSError as exc:
321
+ raise ConfigurationError(f"cannot read configuration file {path}: {exc}") from exc
322
+
323
+ return _parse_config(data, source_path=path, base_dir=path.parent)
324
+
325
+
326
+ def resolve_watch_roots(
327
+ config: Config,
328
+ command_line_roots: tuple[str, ...],
329
+ *,
330
+ cwd: Path,
331
+ ) -> tuple[Path, ...]:
332
+ """Resolve, validate, deduplicate, and collapse watch roots.
333
+
334
+ CLI paths are relative to the invocation directory. Paths from a config file
335
+ are relative to the configuration file. Overlapping roots are collapsed to
336
+ the parent so the same event is not observed twice.
337
+ """
338
+
339
+ if command_line_roots:
340
+ raw_roots = command_line_roots
341
+ base = cwd
342
+ elif config.filesystem.watch:
343
+ raw_roots = config.filesystem.watch
344
+ base = config.base_dir
345
+ else:
346
+ raw_roots = (".",)
347
+ base = cwd
348
+
349
+ resolved: list[Path] = []
350
+ for raw in raw_roots:
351
+ candidate = Path(raw).expanduser()
352
+ if not candidate.is_absolute():
353
+ candidate = base / candidate
354
+ candidate = candidate.resolve()
355
+ if not candidate.exists():
356
+ raise ConfigurationError(f"watch root does not exist: {candidate}")
357
+ if not candidate.is_dir():
358
+ raise ConfigurationError(f"watch root is not a directory: {candidate}")
359
+ resolved.append(candidate)
360
+
361
+ unique: list[Path] = []
362
+ seen: set[str] = set()
363
+ for root in resolved:
364
+ key = os.path.normcase(str(root))
365
+ if key not in seen:
366
+ seen.add(key)
367
+ unique.append(root)
368
+
369
+ collapsed: list[Path] = []
370
+ for root in unique:
371
+ if any(root != other and root.is_relative_to(other) for other in unique):
372
+ continue
373
+ collapsed.append(root)
374
+ return tuple(collapsed)
375
+
376
+
377
+ def default_config_text() -> str:
378
+ """Return a commented TOML template for ``cmdscope config --init``."""
379
+
380
+ ignored = ", ".join(f'"{pattern}"' for pattern in DEFAULT_IGNORE_PATTERNS)
381
+ return f"""# CmdScope configuration
382
+ # All settings are optional. Relative paths resolve against this file.
383
+
384
+ [filesystem]
385
+ enabled = true
386
+ # Empty means the current working directory.
387
+ watch = []
388
+ ignore = [{ignored}]
389
+ max_files = 100000
390
+ poll_interval = 1.0
391
+ # Use native OS events (ReadDirectoryChangesW/inotify/FSEvents) when possible.
392
+ native_events = true
393
+
394
+ [processes]
395
+ enabled = true
396
+ poll_interval = 0.5
397
+
398
+ [network]
399
+ enabled = true
400
+ poll_interval = 0.25
401
+
402
+ [privacy]
403
+ # Store only a sanitized diff of the initial child environment.
404
+ capture_environment = true
405
+ # Do not include environment keys in reports by default.
406
+ show_environment = false
407
+
408
+ [storage]
409
+ # Defaults to the per-user data directory.
410
+ # database = "cmdscope.db"
411
+ # 0 keeps all sessions.
412
+ max_sessions = 0
413
+ max_events = 100000
414
+
415
+ [report]
416
+ max_events = 500
417
+ format = "terminal"
418
+ """
419
+
420
+
421
+ def config_as_dict(config: Config) -> dict[str, Any]:
422
+ """Return a JSON-friendly representation of the effective configuration."""
423
+
424
+ def database(value: Path | None) -> str | None:
425
+ return str(value) if value is not None else None
426
+
427
+ return {
428
+ "source_path": str(config.source_path) if config.source_path else None,
429
+ "base_dir": str(config.base_dir),
430
+ "filesystem": {
431
+ "enabled": config.filesystem.enabled,
432
+ "watch": list(config.filesystem.watch),
433
+ "ignore": list(config.filesystem.ignore),
434
+ "max_files": config.filesystem.max_files,
435
+ "poll_interval": config.filesystem.poll_interval,
436
+ "native_events": config.filesystem.native_events,
437
+ },
438
+ "processes": {
439
+ "enabled": config.processes.enabled,
440
+ "poll_interval": config.processes.poll_interval,
441
+ },
442
+ "network": {
443
+ "enabled": config.network.enabled,
444
+ "poll_interval": config.network.poll_interval,
445
+ },
446
+ "privacy": {
447
+ "capture_environment": config.privacy.capture_environment,
448
+ "show_environment": config.privacy.show_environment,
449
+ },
450
+ "storage": {
451
+ "database": database(config.storage.database),
452
+ "effective_database": str(config.database_path()),
453
+ "max_sessions": config.storage.max_sessions,
454
+ "max_events": config.storage.max_events,
455
+ },
456
+ "report": {
457
+ "max_events": config.report.max_events,
458
+ "format": config.report.default_format,
459
+ },
460
+ "warnings": list(config.warnings),
461
+ }
@@ -0,0 +1 @@
1
+ """Core models and interfaces."""
@@ -0,0 +1,48 @@
1
+ """Collector interface and the immutable context passed to collectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ from cmdscope.core.events import CollectorStatus, Event
12
+ from cmdscope.core.ignore import IgnoreRules
13
+ from cmdscope.core.registry import ProcessRegistry
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class CollectorContext:
18
+ """Everything a collector needs to observe one session.
19
+
20
+ Collectors must use ``emit`` for observations and must never write directly
21
+ to storage, reports, or the terminal.
22
+ """
23
+
24
+ session_id: int
25
+ watch_roots: tuple[Path, ...]
26
+ ignore: IgnoreRules
27
+ root_pid: int
28
+ command_name: str
29
+ registry: ProcessRegistry
30
+ emit: Callable[[Event], None]
31
+ filesystem_poll_interval: float = 1.0
32
+ process_poll_interval: float = 0.5
33
+ network_poll_interval: float = 0.25
34
+ max_files: int = 100_000
35
+ use_native_events: bool = True
36
+ started_at: datetime | None = None
37
+
38
+
39
+ class Collector(Protocol):
40
+ """Minimal lifecycle implemented by every collector."""
41
+
42
+ name: str
43
+
44
+ def start(self, context: CollectorContext) -> None:
45
+ """Begin observing. Must perform the baseline before the command starts."""
46
+
47
+ def stop(self) -> CollectorStatus:
48
+ """Stop observing, reconcile, and return an honest status."""