template_formatter.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. # -*- coding: utf-8 -*-
  2. """
  3. Template-Formatter mit Platzhalter-System und Ausdruecken.
  4. Verarbeitungs-Pipeline:
  5. 1. {placeholder}-Variablen aufloesen (date, sys, env, ctx, app)
  6. 2. (:math)-Ausdruecke auswerten
  7. 3. (condition ? true : false) Logik-Ausdruecke auswerten
  8. 4. <tag>-basierte String-/Zahlenformatierung
  9. Unterstuetzte Namespaces fuer {placeholder}:
  10. - {date.now.*} Aktuelles Datum/Uhrzeit
  11. - {date.tomorrow.*} Morgen
  12. - {date.yesterday.*} Gestern
  13. - {sys.*} Systeminformationen
  14. - {env.*} Umgebungsvariablen
  15. - {ctx.*} Werte aus dem uebergebenen Kontext-Dict
  16. - {app.*} Werte aus der Server-Applikation (magische Konstanten)
  17. Mathematische Ausdruecke:
  18. (:10 + 5) → 15
  19. (:2 ^ 10) → 1024
  20. <calc>3 * 4</calc> → 12
  21. Logische Ausdruecke:
  22. (5 > 3 ? ja : nein) → ja
  23. (x == 1 ? eins :: x == 2 ? zwei : andere)
  24. String-Operationen:
  25. <upper>text</upper> → TEXT
  26. <lower>TEXT</lower> → text
  27. <trim> text </trim> → text
  28. <len>hello</len> → 5
  29. <repeat=3>ab</repeat> → ababab
  30. <substr=1,3>hello</substr> → ell
  31. <strpos=ll>hello</strpos> → 2
  32. <replace=alt,neu>alt</replace> → neu
  33. Zahlenformatierung:
  34. <number>1234567</number> → 1.234.567
  35. <int>3.7</int> → 4
  36. <decimal=2>3.1</decimal> → 3.10
  37. <size>1073741824</size> → 1 GB
  38. """
  39. from __future__ import annotations
  40. import locale
  41. import os
  42. import platform
  43. import re
  44. import socket
  45. from datetime import datetime, timedelta
  46. from typing import Any
  47. # Wochentage und Monate auf Deutsch
  48. _WEEKDAY_NAMES = [
  49. "Montag", "Dienstag", "Mittwoch", "Donnerstag",
  50. "Freitag", "Samstag", "Sonntag",
  51. ]
  52. _MONTH_NAMES = [
  53. "", "Januar", "Februar", "Maerz", "April", "Mai", "Juni",
  54. "Juli", "August", "September", "Oktober", "November", "Dezember",
  55. ]
  56. # Pattern fuer {placeholder} (keine verschachtelten Klammern)
  57. _PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}")
  58. # Pattern fuer (:math-expression)
  59. _MATH_INLINE_RE = re.compile(r"\(:([^)]+)\)")
  60. # Pattern fuer <calc>...</calc> und <math>...</math>
  61. _MATH_TAG_RE = re.compile(r"<(calc|math)>(.*?)</\1>", re.DOTALL | re.IGNORECASE)
  62. # Pattern fuer (condition ? true : false) — muss ? enthalten
  63. _LOGIC_RE = re.compile(r"\(([^()]*?\?[^()]*?)\)")
  64. # String-Tags
  65. _UPPER_RE = re.compile(r"<upper>(.*?)</upper>", re.DOTALL | re.IGNORECASE)
  66. _LOWER_RE = re.compile(r"<lower>(.*?)</lower>", re.DOTALL | re.IGNORECASE)
  67. _TRIM_RE = re.compile(r"<trim>(.*?)</trim>", re.DOTALL | re.IGNORECASE)
  68. _LEN_RE = re.compile(r"<len>(.*?)</len>", re.DOTALL | re.IGNORECASE)
  69. _REPEAT_RE = re.compile(r"<repeat=(\d+)>(.*?)</repeat>", re.DOTALL | re.IGNORECASE)
  70. _SUBSTR_RE = re.compile(
  71. r"<substr=(-?\d+)(?:,(-?\d+))?>(.*?)</substr>", re.DOTALL | re.IGNORECASE
  72. )
  73. _STRPOS_RE = re.compile(r"<strpos=(.+?)>(.*?)</strpos>", re.DOTALL | re.IGNORECASE)
  74. _REPLACE_RE = re.compile(r"<replace=(.+?)>(.*?)</replace>", re.DOTALL | re.IGNORECASE)
  75. # Zahlen-Tags
  76. _NUMBER_RE = re.compile(r"<number(?:=([^>]*))?>(.*?)</number>", re.DOTALL | re.IGNORECASE)
  77. _INT_RE = re.compile(r"<int(?:=([^>]*))?>(.*?)</int>", re.DOTALL | re.IGNORECASE)
  78. _DECIMAL_RE = re.compile(
  79. r"<decimal(?:=([^>]*))?>(.*?)</decimal>", re.DOTALL | re.IGNORECASE
  80. )
  81. _SIZE_RE = re.compile(r"<size>(.*?)</size>", re.DOTALL | re.IGNORECASE)
  82. # =====================================================================
  83. # Haupt-API
  84. # =====================================================================
  85. def format_template(
  86. template: str,
  87. context: dict[str, Any] | None = None,
  88. now: datetime | None = None,
  89. application: Any | None = None,
  90. ) -> str:
  91. """
  92. Verarbeitet ein Template mit der vollstaendigen Pipeline.
  93. Args:
  94. template: Text mit Platzhaltern und Ausdruecken.
  95. context: Optionaler Kontext fuer {ctx.*}-Variablen.
  96. now: Optionaler Zeitpunkt (default: datetime.now()).
  97. application: Optionale Referenz zur Applikation fuer {app.*}.
  98. Returns:
  99. Der formatierte Text.
  100. """
  101. if not template:
  102. return template
  103. dt_now = now or datetime.now()
  104. ctx = context or {}
  105. text = template
  106. # Phase 1: {placeholder}-Variablen aufloesen
  107. text = _resolve_placeholders(text, dt_now, ctx, application)
  108. # Phase 2+3: Math und Logik iterativ aufloesen (max 32 Durchlaeufe)
  109. for _ in range(32):
  110. prev = text
  111. text = _process_math(text)
  112. text = _process_logic(text)
  113. if text == prev:
  114. break
  115. # Phase 4: String-Operationen
  116. text = _process_string_ops(text)
  117. # Phase 5: Zahlenformatierung
  118. text = _process_number_format(text)
  119. return text
  120. # =====================================================================
  121. # Phase 1: Platzhalter-Auflosung
  122. # =====================================================================
  123. def _resolve_placeholders(
  124. text: str, now: datetime, ctx: dict[str, Any], app: Any | None
  125. ) -> str:
  126. """Ersetzt alle {placeholder}-Variablen."""
  127. if "{" not in text:
  128. return text
  129. def _replace(match: re.Match) -> str:
  130. key = match.group(1).strip()
  131. try:
  132. value = _resolve(key, now, ctx, app)
  133. if value is not None:
  134. return str(value)
  135. except Exception:
  136. pass
  137. return match.group(0)
  138. return _PLACEHOLDER_RE.sub(_replace, text)
  139. def _resolve(key: str, now: datetime, ctx: dict[str, Any], app: Any | None) -> Any:
  140. """Loest einen einzelnen Platzhalter-Schluessel auf."""
  141. parts = key.split(".")
  142. if len(parts) < 2:
  143. return None
  144. namespace = parts[0].lower()
  145. if namespace == "date":
  146. return _resolve_date(parts[1:], now)
  147. if namespace == "sys":
  148. return _resolve_sys(parts[1:])
  149. if namespace == "env":
  150. return _resolve_env(parts[1:])
  151. if namespace == "ctx":
  152. return _resolve_ctx(parts[1:], ctx)
  153. if namespace == "app" and app is not None:
  154. return _resolve_app(parts[1:], app)
  155. return None
  156. # --- Datum/Uhrzeit ---
  157. def _resolve_date(parts: list[str], now: datetime) -> Any:
  158. """Loest date.*-Platzhalter auf."""
  159. if not parts:
  160. return None
  161. base_key = parts[0].lower()
  162. if base_key == "now":
  163. dt = now
  164. elif base_key == "tomorrow":
  165. dt = now + timedelta(days=1)
  166. elif base_key == "yesterday":
  167. dt = now - timedelta(days=1)
  168. else:
  169. return None
  170. if len(parts) < 2:
  171. return dt.strftime("%Y-%m-%d %H:%M:%S")
  172. field = parts[1].lower()
  173. return _date_field(dt, field)
  174. def _date_field(dt: datetime, field: str) -> Any:
  175. """Gibt ein einzelnes Datumsfeld zurueck."""
  176. mapping = {
  177. "year": dt.year,
  178. "month": dt.month,
  179. "month_name": _MONTH_NAMES[dt.month],
  180. "day": dt.day,
  181. "hour": dt.hour,
  182. "minute": dt.minute,
  183. "second": dt.second,
  184. "weekday": _WEEKDAY_NAMES[dt.weekday()],
  185. "weekday_num": dt.isoweekday(),
  186. "date": dt.strftime("%Y-%m-%d"),
  187. "time": dt.strftime("%H:%M:%S"),
  188. "iso": dt.isoformat(),
  189. "timestamp": int(dt.timestamp()),
  190. "week": dt.isocalendar()[1],
  191. }
  192. return mapping.get(field)
  193. # --- System ---
  194. def _resolve_sys(parts: list[str]) -> Any:
  195. """Loest sys.*-Platzhalter auf."""
  196. if not parts:
  197. return None
  198. key = ".".join(parts).lower()
  199. mapping = {
  200. "hostname": socket.gethostname,
  201. "os.name": lambda: platform.system(),
  202. "os.version": lambda: platform.version(),
  203. "os.release": lambda: platform.release(),
  204. "os.platform": lambda: platform.platform(),
  205. "os.arch": lambda: platform.machine(),
  206. "python.version": lambda: platform.python_version(),
  207. "user": lambda: os.getenv("USER") or os.getenv("USERNAME") or "unknown",
  208. "pid": lambda: os.getpid(),
  209. "cpu_count": lambda: os.cpu_count(),
  210. }
  211. resolver = mapping.get(key)
  212. if resolver:
  213. return resolver()
  214. return None
  215. # --- Umgebungsvariablen ---
  216. def _resolve_env(parts: list[str]) -> Any:
  217. """Loest env.*-Platzhalter auf."""
  218. if not parts:
  219. return None
  220. var_name = ".".join(parts)
  221. return os.getenv(var_name, "")
  222. # --- Kontext ---
  223. def _resolve_ctx(parts: list[str], ctx: dict[str, Any]) -> Any:
  224. """Loest ctx.*-Platzhalter auf (verschachtelte Dict-Keys)."""
  225. if not parts or not ctx:
  226. return None
  227. current: Any = ctx
  228. for part in parts:
  229. if isinstance(current, dict):
  230. current = current.get(part)
  231. else:
  232. return None
  233. if current is None:
  234. return None
  235. return current
  236. # --- Applikation (Magische Klassenkonstanten) ---
  237. def _resolve_app(parts: list[str], app: Any) -> Any:
  238. """
  239. Loest app.*-Platzhalter gegen die Applikation auf.
  240. Navigiert ueber Properties und Attribute des Applikationsobjekts.
  241. Beispiele:
  242. {app.satellites.count} → Anzahl registrierter Satellites
  243. {app.satellites.connected} → Anzahl verbundener Satellites
  244. {app.scheduler.job_count} → Anzahl Scheduler-Jobs
  245. {app.scheduler.running} → Ob Scheduler laeuft
  246. {app.plugins.count} → Anzahl geladener Plugins
  247. {app.config.network.command_port} → Config-Wert
  248. {app.uptime} → Laufzeit in Sekunden
  249. """
  250. if not parts:
  251. return None
  252. current: Any = app
  253. for part in parts:
  254. if current is None:
  255. return None
  256. # Spezielle Zugriffe fuer bekannte Trixy-Typen
  257. result = _resolve_app_special(current, part)
  258. if result is not _UNRESOLVED:
  259. current = result
  260. continue
  261. # Generischer Attribut-Zugriff
  262. if hasattr(current, part):
  263. current = getattr(current, part)
  264. elif hasattr(current, f"_{part}"):
  265. current = getattr(current, f"_{part}")
  266. elif isinstance(current, dict):
  267. current = current.get(part)
  268. else:
  269. return None
  270. # Callables nicht zurueckgeben (Properties sind ok, Methoden nicht)
  271. if callable(current) and not isinstance(current, (int, float, str, bool, list, dict)):
  272. try:
  273. current = current()
  274. except Exception:
  275. return None
  276. return current
  277. # Sentinel fuer nicht-aufgeloeste Spezialzugriffe
  278. _UNRESOLVED = object()
  279. def _resolve_app_special(obj: Any, key: str) -> Any:
  280. """
  281. Behandelt spezielle Zugriffe auf bekannte Trixy-Objekte.
  282. Gibt _UNRESOLVED zurueck wenn nicht anwendbar.
  283. """
  284. cls_name = type(obj).__name__
  285. # SatelliteManager
  286. if cls_name == "SatelliteManager":
  287. if key == "count":
  288. return len(obj) if hasattr(obj, "__len__") else 0
  289. if key == "connected":
  290. return len(obj.get_connected()) if hasattr(obj, "get_connected") else 0
  291. if key == "disconnected":
  292. return len(obj.get_disconnected()) if hasattr(obj, "get_disconnected") else 0
  293. # Zugriff auf einzelnen Satellite per ID
  294. if hasattr(obj, "get"):
  295. sat = obj.get(key)
  296. if sat is not None:
  297. return sat
  298. # Satellite
  299. if cls_name == "Satellite":
  300. if key == "connected":
  301. return obj.is_connected if hasattr(obj, "is_connected") else False
  302. if key == "room":
  303. return obj.room_id if hasattr(obj, "room_id") else ""
  304. if key == "alias":
  305. return obj.alias if hasattr(obj, "alias") else ""
  306. # SchedulerService / Scheduler
  307. if cls_name in ("SchedulerService", "Scheduler"):
  308. if key == "running":
  309. return obj.is_running if hasattr(obj, "is_running") else False
  310. # ServiceContainer
  311. if cls_name == "ServiceContainer":
  312. if key == "count":
  313. return len(obj.services) if hasattr(obj, "services") else 0
  314. # Zugriff auf einzelnen Service
  315. if hasattr(obj, "get_service"):
  316. svc = obj.get_service(key)
  317. if svc is not None:
  318. return svc
  319. # PluginManager
  320. if cls_name == "PluginManager":
  321. if key == "count":
  322. return obj.plugin_count if hasattr(obj, "plugin_count") else 0
  323. if key == "loaded":
  324. return obj.loaded_plugins if hasattr(obj, "loaded_plugins") else []
  325. # ConfigManager — config-Werte auslesen
  326. if cls_name == "ConfigManager":
  327. return _UNRESOLVED # Standard-Attributzugriff nutzen
  328. return _UNRESOLVED
  329. # =====================================================================
  330. # Phase 2: Mathematische Ausdruecke
  331. # =====================================================================
  332. def _process_math(text: str) -> str:
  333. """Wertet alle (:expr) und <calc>/<math> Ausdruecke aus."""
  334. from trixy_core.utils.math_eval import eval_math
  335. # (:expression)
  336. def _inline_replace(match: re.Match) -> str:
  337. result = eval_math(match.group(1))
  338. return result if result is not None else match.group(0)
  339. text = _MATH_INLINE_RE.sub(_inline_replace, text)
  340. # <calc>expression</calc> / <math>expression</math>
  341. def _tag_replace(match: re.Match) -> str:
  342. result = eval_math(match.group(2))
  343. return result if result is not None else match.group(0)
  344. text = _MATH_TAG_RE.sub(_tag_replace, text)
  345. return text
  346. # =====================================================================
  347. # Phase 3: Logische Ausdruecke
  348. # =====================================================================
  349. def _process_logic(text: str) -> str:
  350. """Wertet alle (cond ? true : false) Ausdruecke aus."""
  351. from trixy_core.utils.logic_eval import eval_logic
  352. def _replace(match: re.Match) -> str:
  353. result = eval_logic(match.group(1))
  354. return result if result is not None else match.group(0)
  355. return _LOGIC_RE.sub(_replace, text)
  356. # =====================================================================
  357. # Phase 4: String-Operationen
  358. # =====================================================================
  359. def _process_string_ops(text: str) -> str:
  360. """Verarbeitet alle String-Tags."""
  361. # Reihenfolge wie in PrintText: substr/len/strpos zuerst, dann extra
  362. # <substr=START,LENGTH>text</substr>
  363. def _substr(match: re.Match) -> str:
  364. start = int(match.group(1))
  365. length = int(match.group(2)) if match.group(2) else None
  366. content = match.group(3)
  367. if length is not None:
  368. return content[start:start + length]
  369. return content[start:]
  370. text = _SUBSTR_RE.sub(_substr, text)
  371. # <len>text</len>
  372. text = _LEN_RE.sub(lambda m: str(len(m.group(1))), text)
  373. # <strpos=search>text</strpos>
  374. def _strpos(match: re.Match) -> str:
  375. needle = match.group(1)
  376. haystack = match.group(2)
  377. return str(haystack.find(needle))
  378. text = _STRPOS_RE.sub(_strpos, text)
  379. # <upper>text</upper>
  380. text = _UPPER_RE.sub(lambda m: m.group(1).upper(), text)
  381. # <lower>text</lower>
  382. text = _LOWER_RE.sub(lambda m: m.group(1).lower(), text)
  383. # <trim>text</trim>
  384. text = _TRIM_RE.sub(lambda m: m.group(1).strip(), text)
  385. # <repeat=N>text</repeat>
  386. def _repeat(match: re.Match) -> str:
  387. n = min(int(match.group(1)), 1000)
  388. return match.group(2) * n
  389. text = _REPEAT_RE.sub(_repeat, text)
  390. # <replace=old,new>text</replace>
  391. def _replace_tag(match: re.Match) -> str:
  392. params = match.group(1)
  393. content = match.group(2)
  394. # Komma splitten, aber \, escapen
  395. parts = re.split(r"(?<!\\),", params, maxsplit=1)
  396. if len(parts) < 2:
  397. return content
  398. old = parts[0].replace("\\,", ",")
  399. new = parts[1].replace("\\,", ",")
  400. return content.replace(old, new)
  401. text = _REPLACE_RE.sub(_replace_tag, text)
  402. return text
  403. # =====================================================================
  404. # Phase 5: Zahlenformatierung
  405. # =====================================================================
  406. def _process_number_format(text: str) -> str:
  407. """Verarbeitet Zahlen-Tags."""
  408. # <number=FORMAT>value</number>
  409. def _fmt_number(match: re.Match) -> str:
  410. fmt = match.group(1) or ""
  411. raw = match.group(2).strip()
  412. try:
  413. num = float(raw)
  414. except ValueError:
  415. return raw
  416. if not fmt:
  417. # Standard: Tausender-Trennung mit Punkt
  418. if num == int(num):
  419. return f"{int(num):,}".replace(",", ".")
  420. return f"{num:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
  421. # N0-N9: Anzahl Dezimalstellen mit Tausender-Trennung
  422. m = re.match(r"[Nn](\d)", fmt)
  423. if m:
  424. decimals = int(m.group(1))
  425. formatted = f"{num:,.{decimals}f}"
  426. return formatted.replace(",", "X").replace(".", ",").replace("X", ".")
  427. return raw
  428. text = _NUMBER_RE.sub(_fmt_number, text)
  429. # <int>value</int>
  430. def _fmt_int(match: re.Match) -> str:
  431. raw = match.group(2).strip()
  432. try:
  433. return str(round(float(raw)))
  434. except ValueError:
  435. return raw
  436. text = _INT_RE.sub(_fmt_int, text)
  437. # <decimal=N>value</decimal>
  438. def _fmt_decimal(match: re.Match) -> str:
  439. fmt = match.group(1) or "2"
  440. raw = match.group(2).strip()
  441. try:
  442. num = float(raw)
  443. decimals = int(fmt) if fmt.isdigit() else 2
  444. return f"{num:.{decimals}f}"
  445. except ValueError:
  446. return raw
  447. text = _DECIMAL_RE.sub(_fmt_decimal, text)
  448. # <size>bytes</size> → menschenlesbare Groesse
  449. def _fmt_size(match: re.Match) -> str:
  450. raw = match.group(1).strip()
  451. try:
  452. size = float(raw)
  453. except ValueError:
  454. return raw
  455. return _format_byte_size(size)
  456. text = _SIZE_RE.sub(_fmt_size, text)
  457. return text
  458. def _format_byte_size(size: float) -> str:
  459. """Formatiert Bytes als menschenlesbare Groesse."""
  460. units = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB"]
  461. unit_index = 0
  462. value = abs(size)
  463. while value >= 1000 and unit_index < len(units) - 1:
  464. value /= 1024
  465. unit_index += 1
  466. sign = "-" if size < 0 else ""
  467. if unit_index == 0:
  468. return f"{sign}{int(value)} {units[0]}"
  469. if value < 10:
  470. return f"{sign}{value:.2f} {units[unit_index]}"
  471. if value < 100:
  472. return f"{sign}{value:.1f} {units[unit_index]}"
  473. return f"{sign}{value:.0f} {units[unit_index]}"