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