cli.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. # -*- coding: utf-8 -*-
  2. """
  3. CLI-Argument-Merging für Konfigurationen.
  4. Ermöglicht das Überschreiben von Konfigurationswerten
  5. durch Kommandozeilenargumente.
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import json
  10. import re
  11. from dataclasses import dataclass, field
  12. from typing import Any, Callable, Sequence, Type
  13. @dataclass
  14. class CLIOption:
  15. """
  16. Definition einer CLI-Option für Konfigurationsoverride.
  17. """
  18. name: str
  19. """Langname der Option (z.B. 'host')."""
  20. short: str | None = None
  21. """Kurzname (z.B. 'h')."""
  22. config_path: str = ""
  23. """Pfad in der Konfiguration (z.B. 'network.host')."""
  24. help: str = ""
  25. """Hilfetext."""
  26. arg_type: Type = str
  27. """Argumenttyp (str, int, float, bool)."""
  28. default: Any = None
  29. """Standardwert."""
  30. choices: Sequence[Any] | None = None
  31. """Erlaubte Werte."""
  32. required: bool = False
  33. """Ob erforderlich."""
  34. action: str | None = None
  35. """argparse action (z.B. 'store_true')."""
  36. nargs: str | int | None = None
  37. """argparse nargs."""
  38. class CLIConfigMerger:
  39. """
  40. Merged Konfiguration mit CLI-Argumenten.
  41. Ermöglicht das Überschreiben von Konfigurationswerten
  42. durch Kommandozeilenargumente mit konsistenter Syntax.
  43. Example:
  44. merger = CLIConfigMerger()
  45. merger.option("host", config_path="network.host")
  46. merger.option("port", arg_type=int, config_path="network.port")
  47. merger.option("debug", action="store_true")
  48. # Parsen und Mergen
  49. config = {"network": {"host": "localhost", "port": 80}}
  50. merged = merger.merge(config, sys.argv[1:])
  51. """
  52. def __init__(
  53. self,
  54. prefix: str = "--",
  55. config_prefix: str = "config.",
  56. parser: argparse.ArgumentParser | None = None,
  57. ) -> None:
  58. """
  59. Initialisiert den CLI-Config-Merger.
  60. Args:
  61. prefix: Präfix für Langoptionen (default: '--').
  62. config_prefix: Präfix für direkte Konfigurationspfade.
  63. parser: Optionaler existierender ArgumentParser.
  64. """
  65. self._prefix = prefix
  66. self._config_prefix = config_prefix
  67. self._options: list[CLIOption] = []
  68. self._parser = parser or argparse.ArgumentParser()
  69. self._generic_pattern = re.compile(
  70. rf"^{re.escape(config_prefix)}([a-zA-Z0-9_.]+)=(.*)$"
  71. )
  72. @property
  73. def parser(self) -> argparse.ArgumentParser:
  74. """Gibt den ArgumentParser zurück."""
  75. return self._parser
  76. def option(
  77. self,
  78. name: str,
  79. short: str | None = None,
  80. config_path: str | None = None,
  81. help: str = "",
  82. arg_type: Type = str,
  83. default: Any = None,
  84. choices: Sequence[Any] | None = None,
  85. required: bool = False,
  86. action: str | None = None,
  87. nargs: str | int | None = None,
  88. ) -> "CLIConfigMerger":
  89. """
  90. Fügt eine CLI-Option hinzu.
  91. Args:
  92. name: Optionsname.
  93. short: Kurzname.
  94. config_path: Konfigurationspfad (Default: name).
  95. help: Hilfetext.
  96. arg_type: Argumenttyp.
  97. default: Standardwert.
  98. choices: Erlaubte Werte.
  99. required: Ob erforderlich.
  100. action: argparse action.
  101. nargs: argparse nargs.
  102. Returns:
  103. Self für Method-Chaining.
  104. """
  105. opt = CLIOption(
  106. name=name,
  107. short=short,
  108. config_path=config_path or name,
  109. help=help,
  110. arg_type=arg_type,
  111. default=default,
  112. choices=choices,
  113. required=required,
  114. action=action,
  115. nargs=nargs,
  116. )
  117. self._options.append(opt)
  118. self._add_to_parser(opt)
  119. return self
  120. def _add_to_parser(self, opt: CLIOption) -> None:
  121. """Fügt eine Option zum ArgumentParser hinzu."""
  122. args = []
  123. if opt.short:
  124. args.append(f"-{opt.short}")
  125. args.append(f"--{opt.name}")
  126. kwargs: dict[str, Any] = {"help": opt.help}
  127. if opt.action:
  128. kwargs["action"] = opt.action
  129. else:
  130. kwargs["type"] = opt.arg_type
  131. kwargs["default"] = None # Kein Default, sonst wird immer überschrieben
  132. if opt.choices:
  133. kwargs["choices"] = opt.choices
  134. if opt.required:
  135. kwargs["required"] = opt.required
  136. if opt.nargs:
  137. kwargs["nargs"] = opt.nargs
  138. self._parser.add_argument(*args, **kwargs)
  139. def _set_nested(
  140. self,
  141. data: dict,
  142. path: str,
  143. value: Any,
  144. ) -> None:
  145. """Setzt einen verschachtelten Wert."""
  146. keys = path.split(".")
  147. current = data
  148. for key in keys[:-1]:
  149. if key not in current:
  150. current[key] = {}
  151. current = current[key]
  152. current[keys[-1]] = value
  153. def _get_nested(
  154. self,
  155. data: dict,
  156. path: str,
  157. default: Any = None,
  158. ) -> Any:
  159. """Holt einen verschachtelten Wert."""
  160. keys = path.split(".")
  161. current = data
  162. for key in keys:
  163. if isinstance(current, dict) and key in current:
  164. current = current[key]
  165. else:
  166. return default
  167. return current
  168. def _parse_generic_args(
  169. self,
  170. args: Sequence[str],
  171. ) -> tuple[list[str], dict[str, Any]]:
  172. """
  173. Parst generische config.path=value Argumente.
  174. Returns:
  175. Tupel (verbleibende Args, Konfigurationsoverrides).
  176. """
  177. remaining: list[str] = []
  178. overrides: dict[str, Any] = {}
  179. for arg in args:
  180. match = self._generic_pattern.match(arg)
  181. if match:
  182. path = match.group(1)
  183. value_str = match.group(2)
  184. # Wert parsen
  185. value = self._parse_value(value_str)
  186. overrides[path] = value
  187. else:
  188. remaining.append(arg)
  189. return remaining, overrides
  190. def _parse_value(self, value_str: str) -> Any:
  191. """Parst einen Wert-String in den entsprechenden Typ."""
  192. # Boolean
  193. if value_str.lower() in ("true", "yes", "1"):
  194. return True
  195. if value_str.lower() in ("false", "no", "0"):
  196. return False
  197. # Null
  198. if value_str.lower() in ("null", "none"):
  199. return None
  200. # Zahl
  201. try:
  202. if "." in value_str:
  203. return float(value_str)
  204. return int(value_str)
  205. except ValueError:
  206. pass
  207. # JSON für komplexe Werte
  208. if value_str.startswith(("{", "[")):
  209. try:
  210. return json.loads(value_str)
  211. except json.JSONDecodeError:
  212. pass
  213. # String
  214. return value_str
  215. def parse(
  216. self,
  217. args: Sequence[str] | None = None,
  218. ) -> argparse.Namespace:
  219. """
  220. Parst CLI-Argumente.
  221. Args:
  222. args: Argumente (Default: sys.argv[1:]).
  223. Returns:
  224. argparse.Namespace mit geparsten Werten.
  225. """
  226. # Generische Argumente vorverarbeiten
  227. if args is not None:
  228. remaining, _ = self._parse_generic_args(args)
  229. return self._parser.parse_args(remaining)
  230. return self._parser.parse_args()
  231. def merge(
  232. self,
  233. config: dict[str, Any],
  234. args: Sequence[str] | None = None,
  235. parsed: argparse.Namespace | None = None,
  236. ) -> dict[str, Any]:
  237. """
  238. Merged Konfiguration mit CLI-Argumenten.
  239. CLI-Argumente überschreiben Konfigurationswerte.
  240. Args:
  241. config: Basis-Konfiguration.
  242. args: CLI-Argumente (oder parsed Namespace).
  243. parsed: Vorab geparste Argumente.
  244. Returns:
  245. Gemergte Konfiguration.
  246. """
  247. result = dict(config)
  248. # Generische Argumente verarbeiten
  249. generic_overrides: dict[str, Any] = {}
  250. remaining_args = args
  251. if args is not None:
  252. remaining_args, generic_overrides = self._parse_generic_args(args)
  253. # Definierte Optionen parsen
  254. if parsed is None:
  255. if remaining_args is not None:
  256. parsed = self._parser.parse_args(remaining_args)
  257. else:
  258. parsed = self._parser.parse_args()
  259. # Definierte Optionen anwenden
  260. for opt in self._options:
  261. value = getattr(parsed, opt.name.replace("-", "_"), None)
  262. if value is not None:
  263. self._set_nested(result, opt.config_path, value)
  264. # Generische Overrides anwenden
  265. for path, value in generic_overrides.items():
  266. self._set_nested(result, path, value)
  267. return result
  268. def add_common_options(self) -> "CLIConfigMerger":
  269. """
  270. Fügt häufig verwendete Optionen hinzu.
  271. Returns:
  272. Self für Method-Chaining.
  273. """
  274. self.option(
  275. "config",
  276. short="c",
  277. config_path="_config_file",
  278. help="Pfad zur Konfigurationsdatei",
  279. )
  280. self.option(
  281. "debug",
  282. short="d",
  283. config_path="debug",
  284. action="store_true",
  285. help="Debug-Modus aktivieren",
  286. )
  287. self.option(
  288. "verbose",
  289. short="v",
  290. config_path="verbose",
  291. action="store_true",
  292. help="Ausführliche Ausgabe",
  293. )
  294. return self
  295. def merge_cli_config(
  296. config: dict[str, Any],
  297. args: Sequence[str] | None = None,
  298. options: list[CLIOption] | None = None,
  299. ) -> dict[str, Any]:
  300. """
  301. Convenience-Funktion zum Mergen von Config und CLI.
  302. Args:
  303. config: Basis-Konfiguration.
  304. args: CLI-Argumente.
  305. options: CLI-Optionen.
  306. Returns:
  307. Gemergte Konfiguration.
  308. """
  309. merger = CLIConfigMerger()
  310. if options:
  311. for opt in options:
  312. merger._options.append(opt)
  313. merger._add_to_parser(opt)
  314. return merger.merge(config, args)