# -*- coding: utf-8 -*- """ CLI-Argument-Merging für Konfigurationen. Ermöglicht das Überschreiben von Konfigurationswerten durch Kommandozeilenargumente. """ from __future__ import annotations import argparse import json import re from dataclasses import dataclass, field from typing import Any, Callable, Sequence, Type @dataclass class CLIOption: """ Definition einer CLI-Option für Konfigurationsoverride. """ name: str """Langname der Option (z.B. 'host').""" short: str | None = None """Kurzname (z.B. 'h').""" config_path: str = "" """Pfad in der Konfiguration (z.B. 'network.host').""" help: str = "" """Hilfetext.""" arg_type: Type = str """Argumenttyp (str, int, float, bool).""" default: Any = None """Standardwert.""" choices: Sequence[Any] | None = None """Erlaubte Werte.""" required: bool = False """Ob erforderlich.""" action: str | None = None """argparse action (z.B. 'store_true').""" nargs: str | int | None = None """argparse nargs.""" class CLIConfigMerger: """ Merged Konfiguration mit CLI-Argumenten. Ermöglicht das Überschreiben von Konfigurationswerten durch Kommandozeilenargumente mit konsistenter Syntax. Example: merger = CLIConfigMerger() merger.option("host", config_path="network.host") merger.option("port", arg_type=int, config_path="network.port") merger.option("debug", action="store_true") # Parsen und Mergen config = {"network": {"host": "localhost", "port": 80}} merged = merger.merge(config, sys.argv[1:]) """ def __init__( self, prefix: str = "--", config_prefix: str = "config.", parser: argparse.ArgumentParser | None = None, ) -> None: """ Initialisiert den CLI-Config-Merger. Args: prefix: Präfix für Langoptionen (default: '--'). config_prefix: Präfix für direkte Konfigurationspfade. parser: Optionaler existierender ArgumentParser. """ self._prefix = prefix self._config_prefix = config_prefix self._options: list[CLIOption] = [] self._parser = parser or argparse.ArgumentParser() self._generic_pattern = re.compile( rf"^{re.escape(config_prefix)}([a-zA-Z0-9_.]+)=(.*)$" ) @property def parser(self) -> argparse.ArgumentParser: """Gibt den ArgumentParser zurück.""" return self._parser def option( self, name: str, short: str | None = None, config_path: str | None = None, help: str = "", arg_type: Type = str, default: Any = None, choices: Sequence[Any] | None = None, required: bool = False, action: str | None = None, nargs: str | int | None = None, ) -> "CLIConfigMerger": """ Fügt eine CLI-Option hinzu. Args: name: Optionsname. short: Kurzname. config_path: Konfigurationspfad (Default: name). help: Hilfetext. arg_type: Argumenttyp. default: Standardwert. choices: Erlaubte Werte. required: Ob erforderlich. action: argparse action. nargs: argparse nargs. Returns: Self für Method-Chaining. """ opt = CLIOption( name=name, short=short, config_path=config_path or name, help=help, arg_type=arg_type, default=default, choices=choices, required=required, action=action, nargs=nargs, ) self._options.append(opt) self._add_to_parser(opt) return self def _add_to_parser(self, opt: CLIOption) -> None: """Fügt eine Option zum ArgumentParser hinzu.""" args = [] if opt.short: args.append(f"-{opt.short}") args.append(f"--{opt.name}") kwargs: dict[str, Any] = {"help": opt.help} if opt.action: kwargs["action"] = opt.action else: kwargs["type"] = opt.arg_type kwargs["default"] = None # Kein Default, sonst wird immer überschrieben if opt.choices: kwargs["choices"] = opt.choices if opt.required: kwargs["required"] = opt.required if opt.nargs: kwargs["nargs"] = opt.nargs self._parser.add_argument(*args, **kwargs) def _set_nested( self, data: dict, path: str, value: Any, ) -> None: """Setzt einen verschachtelten Wert.""" keys = path.split(".") current = data for key in keys[:-1]: if key not in current: current[key] = {} current = current[key] current[keys[-1]] = value def _get_nested( self, data: dict, path: str, default: Any = None, ) -> Any: """Holt einen verschachtelten Wert.""" keys = path.split(".") current = data for key in keys: if isinstance(current, dict) and key in current: current = current[key] else: return default return current def _parse_generic_args( self, args: Sequence[str], ) -> tuple[list[str], dict[str, Any]]: """ Parst generische config.path=value Argumente. Returns: Tupel (verbleibende Args, Konfigurationsoverrides). """ remaining: list[str] = [] overrides: dict[str, Any] = {} for arg in args: match = self._generic_pattern.match(arg) if match: path = match.group(1) value_str = match.group(2) # Wert parsen value = self._parse_value(value_str) overrides[path] = value else: remaining.append(arg) return remaining, overrides def _parse_value(self, value_str: str) -> Any: """Parst einen Wert-String in den entsprechenden Typ.""" # Boolean if value_str.lower() in ("true", "yes", "1"): return True if value_str.lower() in ("false", "no", "0"): return False # Null if value_str.lower() in ("null", "none"): return None # Zahl try: if "." in value_str: return float(value_str) return int(value_str) except ValueError: pass # JSON für komplexe Werte if value_str.startswith(("{", "[")): try: return json.loads(value_str) except json.JSONDecodeError: pass # String return value_str def parse( self, args: Sequence[str] | None = None, ) -> argparse.Namespace: """ Parst CLI-Argumente. Args: args: Argumente (Default: sys.argv[1:]). Returns: argparse.Namespace mit geparsten Werten. """ # Generische Argumente vorverarbeiten if args is not None: remaining, _ = self._parse_generic_args(args) return self._parser.parse_args(remaining) return self._parser.parse_args() def merge( self, config: dict[str, Any], args: Sequence[str] | None = None, parsed: argparse.Namespace | None = None, ) -> dict[str, Any]: """ Merged Konfiguration mit CLI-Argumenten. CLI-Argumente überschreiben Konfigurationswerte. Args: config: Basis-Konfiguration. args: CLI-Argumente (oder parsed Namespace). parsed: Vorab geparste Argumente. Returns: Gemergte Konfiguration. """ result = dict(config) # Generische Argumente verarbeiten generic_overrides: dict[str, Any] = {} remaining_args = args if args is not None: remaining_args, generic_overrides = self._parse_generic_args(args) # Definierte Optionen parsen if parsed is None: if remaining_args is not None: parsed = self._parser.parse_args(remaining_args) else: parsed = self._parser.parse_args() # Definierte Optionen anwenden for opt in self._options: value = getattr(parsed, opt.name.replace("-", "_"), None) if value is not None: self._set_nested(result, opt.config_path, value) # Generische Overrides anwenden for path, value in generic_overrides.items(): self._set_nested(result, path, value) return result def add_common_options(self) -> "CLIConfigMerger": """ Fügt häufig verwendete Optionen hinzu. Returns: Self für Method-Chaining. """ self.option( "config", short="c", config_path="_config_file", help="Pfad zur Konfigurationsdatei", ) self.option( "debug", short="d", config_path="debug", action="store_true", help="Debug-Modus aktivieren", ) self.option( "verbose", short="v", config_path="verbose", action="store_true", help="Ausführliche Ausgabe", ) return self def merge_cli_config( config: dict[str, Any], args: Sequence[str] | None = None, options: list[CLIOption] | None = None, ) -> dict[str, Any]: """ Convenience-Funktion zum Mergen von Config und CLI. Args: config: Basis-Konfiguration. args: CLI-Argumente. options: CLI-Optionen. Returns: Gemergte Konfiguration. """ merger = CLIConfigMerger() if options: for opt in options: merger._options.append(opt) merger._add_to_parser(opt) return merger.merge(config, args)