oct
Loading...
Searching...
No Matches
oct_diag.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/diag/oct_diag.py
4
5"""
6Purpose
7-------
8Provide oc_diagnostics configuration management tools for the OCT CLI.
9
10Responsibilities
11----------------
12- Load, validate, and modify ``oc_diagnostics/debug_config.json``.
13- List configured domains with their levels and colors.
14- Update domain debug levels in-place.
15- Report validation warnings without importing oc_diagnostics at runtime
16 (preserving the dev-time/runtime architectural boundary).
17
18Diagnostics
19-----------
20Domain: OCT-DIAG
21Levels:
22 L2 - lifecycle
23 L3 - semantic details
24 L4 - deep tracing
25
26Contracts
27---------
28- Must not import ``oc_diagnostics`` - operates purely on the JSON config file.
29- Must preserve JSON formatting and key order when writing back.
30- Level values must be integers 0-9, in either form oc_diagnostics accepts:
31 compact (``"GIT": 3``) or rich (``"GIT": {"level": 3, "color": "cyan"}``).
32 Until 2026-09-28 this module allowed only 0-4 and the rich form, so a
33 valid config such as ``{"*": 9}`` (open every domain for a trace) failed
34 ``oct diag validate-config`` and crashed ``list-domains``.
35
36Dependencies
37------------
38- oct.core.option_c_dir (debug_config.json path resolution)
39- oct.core.diagnostics (_dbg structured logger)
40"""
41
42import json
43from pathlib import Path
44
45from oct.core.option_c_dir import resolve_debug_config
46from oct.core.diagnostics import _dbg
47
48
49#: oc_diagnostics' range (``_DOMAIN_SCHEMA`` and ``set_debug_level``: 0-9).
50VALID_LEVELS = set(range(10))
51
52#: The file formats ``global_settings.log_format`` accepts (oc_diagnostics 2.2.0).
53VALID_LOG_FORMATS = ("text", "jsonl")
54
55# OI-508 / FS-515: schema version written by migrations.
56CURRENT_SCHEMA_VERSION = "2.0"
57
58EXPECTED_GLOBAL_KEYS = {
59 "silent_mode", "enable_timestamps", "enable_colors", "include_filename",
60 "output_mode", "instrumentation_enabled", "max_log_files", "max_log_size_mb",
61 "instrumentation_allow_all", "mode", "http_redact_headers",
62 "ui_event_properties", "structural_id_prefixes", "structural_id_exact",
63}
64
65
66def _find_config(project_root: Path) -> Path:
67 """Locate debug_config.json (FS-539: `.option_c/`-first with fallbacks).
68
69 Delegates to :func:`oct.core.option_c_dir.resolve_debug_config`, which
70 tries ``.option_c/debug_config.json`` first, then
71 ``oc_diagnostics/debug_config.json`` (v0.17+), then
72 ``diagnostics/debug_config.json`` (pre-v0.17).
73 """
74 return resolve_debug_config(project_root)
75
76
77def load_config(project_root: Path) -> dict:
78 """Load and parse debug_config.json. Raises on missing/invalid file."""
79 func = "load_config"
80 _dbg("OCT-DIAG", func, "loading the debug config", 3)
81 config_path = _find_config(project_root)
82 if not config_path.is_file():
83 raise FileNotFoundError(f"Config not found: {config_path}")
84 text = config_path.read_text(encoding="utf-8")
85 return json.loads(text)
86
87
88def _get_debug_domain_map(config: dict) -> tuple[dict, bool]:
89 """OI-508 / FS-515: return ``(domain_map, is_legacy)`` from a config dict.
90
91 Accepts both the legacy v1 ``domains:`` layout and the current v2
92 ``DEBUG_LEVELS:`` layout. The returned map is always in the v2 shape
93 (``{name: {"level": int, "color": str}}``) regardless of what the file
94 contains - call sites can read the result uniformly.
95
96 ``is_legacy`` is True whenever the file used the ``domains:`` key
97 **and** the ``DEBUG_LEVELS:`` key was absent - that is the trigger
98 for the migration hint.
99 """
100 if "DEBUG_LEVELS" in config and isinstance(config["DEBUG_LEVELS"], dict):
101 # The compact form ("GIT": 3) is valid v2 too; normalise it so every
102 # reader sees {"level": ..., "color": ...}.
103 return {
104 name: dict(entry) if isinstance(entry, dict)
105 else {"level": entry, "color": "default"}
106 for name, entry in config["DEBUG_LEVELS"].items()
107 }, False
108 legacy = config.get("domains")
109 if not isinstance(legacy, dict):
110 return {}, False
111 normalised: dict = {}
112 for name, entry in legacy.items():
113 if isinstance(entry, dict):
114 normalised[name] = dict(entry)
115 elif isinstance(entry, int):
116 # Short form: domain → level. Fabricate default color.
117 normalised[name] = {"level": entry, "color": "default"}
118 else:
119 normalised[name] = {"level": entry, "color": "default"}
120 return normalised, True
121
122
123def migrate_config(config_path: Path, *, dry_run: bool = False) -> dict:
124 """OI-508 / FS-515: rewrite ``config_path`` in-place as a v2 config.
125
126 Behaviour:
127 * Idempotent - if the file already declares ``_schema_version == "2.0"``
128 and carries ``DEBUG_LEVELS``, nothing is written.
129 * Writes ``<config_path>.bk`` before overwriting the original, so a
130 botched migration is recoverable.
131 * When ``dry_run`` is True, the target file is never touched and no
132 backup is produced; the returned dict is the post-migration shape.
133
134 Returns the (would-be) post-migration config dict so callers can
135 inspect / pretty-print the result.
136 """
137 func = "migrate_config"
138 _dbg("OCT-DIAG", func, "migrating the debug config", 3)
139 if not config_path.is_file():
140 raise FileNotFoundError(f"Config not found: {config_path}")
141 original_text = config_path.read_text(encoding="utf-8")
142 config = json.loads(original_text)
143 if not isinstance(config, dict):
144 raise ValueError("Config root must be a JSON object")
145
146 domain_map, is_legacy = _get_debug_domain_map(config)
147 already_v2 = (
148 config.get("_schema_version") == CURRENT_SCHEMA_VERSION
149 and "DEBUG_LEVELS" in config
150 and not is_legacy
151 )
152 if already_v2:
153 return config
154
155 # Build the v2 shape: drop ``domains`` if present; insert ordered keys.
156 new_config: dict = {"_schema_version": CURRENT_SCHEMA_VERSION, "DEBUG_LEVELS": domain_map}
157 for key, value in config.items():
158 if key in ("_schema_version", "DEBUG_LEVELS", "domains"):
159 continue
160 new_config[key] = value
161
162 if dry_run:
163 return new_config
164
165 backup_path = config_path.with_suffix(config_path.suffix + ".bk")
166 backup_path.write_text(original_text, encoding="utf-8")
167 config_path.write_text(json.dumps(new_config, indent=2) + "\n", encoding="utf-8")
168 return new_config
169
170
171def validate_config(project_root: Path) -> list[str]:
172 """Validate debug_config.json schema. Returns list of warnings/errors."""
173 func = "validate_config"
174 _dbg("OCT-DIAG", func, "validating the debug config", 3)
175 issues: list[str] = []
176 config_path = _find_config(project_root)
177
178 if not config_path.is_file():
179 issues.append(f"ERROR: Config file not found: {config_path}")
180 return issues
181
182 try:
183 text = config_path.read_text(encoding="utf-8")
184 except OSError as e:
185 issues.append(f"ERROR: Cannot read {config_path}: {e}")
186 return issues
187
188 try:
189 config = json.loads(text)
190 except json.JSONDecodeError as e:
191 issues.append(f"ERROR: Invalid JSON in {config_path}: {e}")
192 return issues
193
194 if not isinstance(config, dict):
195 issues.append("ERROR: Config root must be a JSON object")
196 return issues
197
198 # Schema version
199 version = config.get("_schema_version")
200 if version is None:
201 issues.append("WARNING: Missing '_schema_version' key")
202
203 # OI-508 / FS-515: accept both schemas. Legacy ``domains:`` is a
204 # WARNING with a clear migration hint rather than a hard ERROR so a
205 # valid v1 file still passes non-strict validation.
206 domain_map, is_legacy = _get_debug_domain_map(config)
207 if is_legacy:
208 issues.append(
209 "WARNING: 'domains' key is legacy v1 schema; run 'oct diag migrate-config' "
210 "to upgrade to v2 ('DEBUG_LEVELS')."
211 )
212 elif not domain_map and "DEBUG_LEVELS" not in config and "domains" not in config:
213 issues.append("WARNING: Missing 'DEBUG_LEVELS' key")
214 elif "DEBUG_LEVELS" in config and not isinstance(config.get("DEBUG_LEVELS"), dict):
215 issues.append("ERROR: 'DEBUG_LEVELS' must be a JSON object")
216 for name, entry in domain_map.items():
217 if not isinstance(entry, dict):
218 issues.append(f"ERROR: Domain '{name}' must be a JSON object")
219 continue
220 level = entry.get("level")
221 if level is None:
222 issues.append(f"WARNING: Domain '{name}' missing 'level' key")
223 elif not isinstance(level, int) or isinstance(level, bool) or level not in VALID_LEVELS:
224 issues.append(f"ERROR: Domain '{name}' level must be 0-9, got: {level}")
225
226 # Global settings
227 settings = config.get("global_settings")
228 if settings is None:
229 issues.append("WARNING: Missing 'global_settings' key")
230 elif not isinstance(settings, dict):
231 issues.append("ERROR: 'global_settings' must be a JSON object")
232 else:
233 output_mode = settings.get("output_mode")
234 if output_mode is not None and output_mode not in ("terminal", "file", "both", "json"):
235 issues.append(f"WARNING: 'output_mode' should be terminal|file|both|json, got: {output_mode!r}")
236 log_format = settings.get("log_format")
237 if log_format is not None and log_format not in VALID_LOG_FORMATS:
238 issues.append(f"WARNING: 'log_format' should be text|jsonl, got: {log_format!r}")
239 mode = settings.get("mode")
240 if mode is not None and mode not in ("dev", "prod"):
241 issues.append(f"WARNING: 'mode' should be dev|prod, got: {mode!r}")
242
243 return issues
244
245
246def list_domains(project_root: Path) -> list[dict]:
247 """Return list of domain info dicts: [{name, level, color}, ...].
248
249 OI-508 / FS-515: reads via :func:`_get_debug_domain_map` so both v1
250 and v2 configs surface identical output.
251 """
252 config = load_config(project_root)
253 domain_map, _ = _get_debug_domain_map(config)
254 result = []
255 for name, entry in domain_map.items():
256 result.append({
257 "name": name,
258 "level": entry.get("level", "?"),
259 "color": entry.get("color", "default"),
260 })
261 return result
262
263
265 project_root: Path,
266 domain: str,
267 level: int,
268 *,
269 dry_run: bool = False,
270) -> dict:
271 """Update a domain's debug level in debug_config.json.
272
273 OI-508 / FS-515: writes to whichever key the file actually uses
274 (``DEBUG_LEVELS`` on v2, ``domains`` on legacy v1) so an operator
275 can tweak a level without a full migration first.
276
277 OI-528: safe-edit semantics. A ``<config_path>.bk`` backup is written
278 before overwriting the original so a bad edit is recoverable via
279 :func:`restore_config`. When ``dry_run`` is True, neither the target
280 nor the backup is touched; the returned dict is the *would-be*
281 post-edit config shape so callers can preview the result.
282 """
283 func = "set_level"
284 _dbg("OCT-DIAG", func, f"setting level for domain {domain}", 3)
285 if level not in VALID_LEVELS:
286 raise ValueError(f"Level must be 0-9, got: {level}")
287
288 config_path = _find_config(project_root)
289 if not config_path.is_file():
290 raise FileNotFoundError(f"Config not found: {config_path}")
291
292 original_text = config_path.read_text(encoding="utf-8")
293 config = json.loads(original_text)
294 if "DEBUG_LEVELS" in config and isinstance(config["DEBUG_LEVELS"], dict):
295 target_key = "DEBUG_LEVELS"
296 elif "domains" in config and isinstance(config["domains"], dict):
297 target_key = "domains"
298 else:
299 raise KeyError("Config has neither 'DEBUG_LEVELS' nor 'domains' keys")
300
301 domains = config[target_key]
302 if domain not in domains:
303 raise KeyError(f"Domain '{domain}' not found in config. Available: {', '.join(domains)}")
304
305 entry = domains[domain]
306 if isinstance(entry, dict):
307 entry["level"] = level
308 else:
309 domains[domain] = {"level": level, "color": "default"}
310
311 if dry_run:
312 return config
313
314 backup_path = config_path.with_suffix(config_path.suffix + ".bk")
315 backup_path.write_text(original_text, encoding="utf-8")
316 config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
317 return config
318
319
320def restore_config(project_root: Path) -> Path:
321 """OI-528: restore ``debug_config.json`` from its ``.bk`` backup.
322
323 Returns the path that was restored. Raises :class:`FileNotFoundError`
324 when no backup exists so the CLI can surface a clean error instead of
325 silently no-op-ing.
326 """
327 config_path = _find_config(project_root)
328 backup_path = config_path.with_suffix(config_path.suffix + ".bk")
329 if not backup_path.is_file():
330 raise FileNotFoundError(f"No backup found at {backup_path}")
331 config_path.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8")
332 return config_path
dict set_level(Path project_root, str domain, int level, *, bool dry_run=False)
Definition oct_diag.py:270
list[dict] list_domains(Path project_root)
Definition oct_diag.py:246
list[str] validate_config(Path project_root)
Definition oct_diag.py:171
Path restore_config(Path project_root)
Definition oct_diag.py:320
dict migrate_config(Path config_path, *, bool dry_run=False)
Definition oct_diag.py:123
Path _find_config(Path project_root)
Definition oct_diag.py:66
dict load_config(Path project_root)
Definition oct_diag.py:77
tuple[dict, bool] _get_debug_domain_map(dict config)
Definition oct_diag.py:88