from __future__ import annotations import json from pathlib import Path from typing import Any DEFAULT_CONFIG: dict[str, Any] = { "seed": None, "layers": 3, "stage2_layers": 3, "rename": True, "encrypt_strings": True, "obfuscate_numbers": True, "opaque_predicates": True, "mba": True, "junk_branches": True, "lazy_funcs": True, "anti_debug": True, "abyss": False, "abyss_functions": [], "lower_fstrings": True, "lower_match": True, "keep": [], "max_output_bytes": None, "max_ratio": None, "max_review_indicators": None, } BOOLEAN_KEYS = { "rename", "encrypt_strings", "obfuscate_numbers", "opaque_predicates", "mba", "junk_branches", "lazy_funcs", "anti_debug", "abyss", "lower_fstrings", "lower_match", } INTEGER_KEYS = {"seed", "layers", "stage2_layers", "max_output_bytes", "max_review_indicators"} FLOAT_KEYS = {"max_ratio"} LIST_KEYS = {"keep", "abyss_functions"} class ConfigError(ValueError): pass def load_config(path: str | Path | None) -> dict[str, Any]: if path is None: return {} config_path = Path(path) try: raw = json.loads(config_path.read_text(encoding="utf-8")) except FileNotFoundError as exc: raise ConfigError(f"config file not found: {config_path}") from exc except json.JSONDecodeError as exc: raise ConfigError(f"invalid config JSON: {exc}") from exc if not isinstance(raw, dict): raise ConfigError("config file must contain a JSON object") unknown = sorted(set(raw) - set(DEFAULT_CONFIG)) if unknown: raise ConfigError(f"unknown config option(s): {', '.join(unknown)}") return dict(raw) def read_keep_file(path: str | Path | None) -> list[str]: if path is None: return [] keep_path = Path(path) try: lines = keep_path.read_text(encoding="utf-8").splitlines() except FileNotFoundError as exc: raise ConfigError(f"keep file not found: {keep_path}") from exc names: list[str] = [] for line in lines: value = line.split("#", 1)[0].strip() if not value: continue names.extend(part.strip() for part in value.split(",") if part.strip()) return names def normalize_config(config: dict[str, Any]) -> dict[str, Any]: normalized = dict(DEFAULT_CONFIG) normalized.update(config) for key in BOOLEAN_KEYS: if not isinstance(normalized[key], bool): raise ConfigError(f"{key} must be a boolean") for key in INTEGER_KEYS: value = normalized[key] if value is None: continue if not isinstance(value, int) or isinstance(value, bool): raise ConfigError(f"{key} must be an integer") for key in FLOAT_KEYS: value = normalized[key] if value is None: continue if not isinstance(value, (int, float)) or isinstance(value, bool): raise ConfigError(f"{key} must be a number") normalized[key] = float(value) for key in LIST_KEYS: value = normalized[key] if not isinstance(value, list) or not all(isinstance(item, str) for item in value): raise ConfigError(f"{key} must be a list of strings") normalized[key] = sorted(set(item for item in value if item)) if normalized["layers"] < 1: raise ConfigError("layers must be at least 1") if normalized["stage2_layers"] < 1: raise ConfigError("stage2_layers must be at least 1") if normalized["max_output_bytes"] is not None and normalized["max_output_bytes"] < 1: raise ConfigError("max_output_bytes must be at least 1") if normalized["max_ratio"] is not None and normalized["max_ratio"] <= 0: raise ConfigError("max_ratio must be positive") if normalized["max_review_indicators"] is not None and normalized["max_review_indicators"] < 0: raise ConfigError("max_review_indicators cannot be negative") return normalized def write_config(path: str | Path, config: dict[str, Any]) -> Path: output_path = Path(path) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(json.dumps(normalize_config(config), indent=2, sort_keys=True) + "\n", encoding="utf-8") return output_path