kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,633 @@
1
+ """Logging module for kstlib with Rich console output and async helpers.
2
+
3
+ This module provides a flexible logging system with:
4
+ - Rich console output (colored, traceback with locals)
5
+ - File rotation (TimedRotatingFileHandler)
6
+ - Async-friendly wrappers (executed via thread pool)
7
+ - Structured logging (context key=value)
8
+ - Configurable presets (dev, prod, debug, + custom via config)
9
+ - Multiple instances support
10
+
11
+ Example:
12
+ Basic usage with preset::
13
+
14
+ from kstlib.logging import LogManager
15
+
16
+ logger = LogManager(preset="dev")
17
+ logger.info("Server started", host="localhost", port=8080)
18
+
19
+ Async logging::
20
+
21
+ async def main():
22
+ logger = LogManager(preset="prod")
23
+ await logger.ainfo("Order placed", symbol="BTCUSDT", qty=0.5)
24
+
25
+ Custom config::
26
+
27
+ config = {
28
+ "output": "both",
29
+ "console": {"level": "DEBUG"},
30
+ "file": {"log_name": "myapp.log"}
31
+ }
32
+ logger = LogManager(config=config)
33
+ """
34
+
35
+ import asyncio
36
+ import logging
37
+ import shutil
38
+ from functools import partial
39
+ from logging.handlers import TimedRotatingFileHandler
40
+ from pathlib import Path
41
+ from types import SimpleNamespace
42
+ from typing import Any
43
+
44
+ from box import Box
45
+ from rich.console import Console
46
+ from rich.logging import RichHandler
47
+ from rich.theme import Theme
48
+ from rich.traceback import Traceback
49
+
50
+ from kstlib.config import get_config
51
+
52
+ # =============================================================================
53
+ # HARDCODED LIMITS (Deep Defense)
54
+ # =============================================================================
55
+ # These limits are enforced regardless of user configuration to prevent abuse.
56
+
57
+ # Maximum log file path length (prevents filesystem issues)
58
+ HARD_MAX_FILE_PATH_LENGTH: int = 4096
59
+
60
+ # Maximum log file name length (prevents filesystem issues on some OS)
61
+ HARD_MAX_FILE_NAME_LENGTH: int = 255
62
+
63
+ # Forbidden path components (security: prevent path traversal)
64
+ FORBIDDEN_PATH_COMPONENTS: frozenset[str] = frozenset({"..", "~"})
65
+
66
+ # Allowed file extensions for log files
67
+ ALLOWED_LOG_EXTENSIONS: frozenset[str] = frozenset({".log", ".txt", ".json", ""})
68
+
69
+
70
+ # TODO: Add aiofiles for true async file I/O
71
+ # try:
72
+ # import aiofiles
73
+ # import aiofiles.os
74
+ # HAS_ASYNC = True
75
+ # except ImportError:
76
+ # HAS_ASYNC = False
77
+ HAS_ASYNC = False
78
+
79
+ # Custom log levels
80
+ TRACE_LEVEL = 5 # Below DEBUG (10) - for HTTP traces, detailed diagnostics
81
+ SUCCESS_LEVEL = 25 # Between INFO (20) and WARNING (30)
82
+
83
+ LOGGING_LEVEL = SimpleNamespace(
84
+ TRACE=TRACE_LEVEL,
85
+ DEBUG=logging.DEBUG,
86
+ INFO=logging.INFO,
87
+ SUCCESS=SUCCESS_LEVEL,
88
+ WARNING=logging.WARNING,
89
+ ERROR=logging.ERROR,
90
+ CRITICAL=logging.CRITICAL,
91
+ )
92
+
93
+ # Preset fallbacks used when configuration file does not define any
94
+ FALLBACK_PRESETS = {
95
+ "dev": {
96
+ "output": "console",
97
+ "console": {"level": "DEBUG", "show_path": True},
98
+ "icons": {"show": True},
99
+ "file": {"level": "DEBUG"},
100
+ },
101
+ "prod": {
102
+ "output": "file",
103
+ "console": {"level": "WARNING", "show_path": False},
104
+ "file": {"level": "INFO"},
105
+ "icons": {"show": False},
106
+ },
107
+ "debug": {
108
+ "output": "both",
109
+ "console": {"level": "DEBUG", "show_path": True, "tracebacks_show_locals": True},
110
+ "file": {"level": "DEBUG"},
111
+ "icons": {"show": True},
112
+ },
113
+ }
114
+
115
+
116
+ def _validate_log_file_path(file_path: Path) -> Path:
117
+ """Validate and sanitize log file path.
118
+
119
+ Applies hardcoded security limits regardless of user configuration.
120
+
121
+ Args:
122
+ file_path: The log file path to validate.
123
+
124
+ Returns:
125
+ The validated and resolved path.
126
+
127
+ Raises:
128
+ ValueError: If path violates security constraints.
129
+ """
130
+ # Convert to string for length checks
131
+ path_str = str(file_path)
132
+
133
+ # Check total path length
134
+ if len(path_str) > HARD_MAX_FILE_PATH_LENGTH:
135
+ raise ValueError(f"Log file path exceeds maximum length of {HARD_MAX_FILE_PATH_LENGTH} characters")
136
+
137
+ # Check file name length
138
+ if len(file_path.name) > HARD_MAX_FILE_NAME_LENGTH:
139
+ raise ValueError(f"Log file name exceeds maximum length of {HARD_MAX_FILE_NAME_LENGTH} characters")
140
+
141
+ # Check for forbidden path components (path traversal prevention)
142
+ for part in file_path.parts:
143
+ if part in FORBIDDEN_PATH_COMPONENTS:
144
+ raise ValueError(f"Log file path contains forbidden component: {part!r}")
145
+
146
+ # Check file extension
147
+ suffix = file_path.suffix.lower()
148
+ if suffix not in ALLOWED_LOG_EXTENSIONS:
149
+ raise ValueError(
150
+ f"Log file extension {suffix!r} not allowed. "
151
+ f"Allowed: {', '.join(sorted(ALLOWED_LOG_EXTENSIONS)) or '(no extension)'}"
152
+ )
153
+
154
+ return file_path.resolve()
155
+
156
+
157
+ # Default configuration fallback when config file is missing or incomplete
158
+ FALLBACK_DEFAULTS = {
159
+ "output": "both", # console | file | both
160
+ "theme": {
161
+ "trace": "medium_purple4 on dark_olive_green1",
162
+ "debug": "black on deep_sky_blue1",
163
+ "info": "sky_blue1",
164
+ "success": "black on sea_green3",
165
+ "warning": "bold white on salmon1",
166
+ "error": "bold white on deep_pink2",
167
+ "critical": "blink bold white on red3",
168
+ },
169
+ "icons": {
170
+ "show": True,
171
+ "trace": "🔬",
172
+ "debug": "🔎",
173
+ "info": "📄",
174
+ "success": "✅",
175
+ "warning": "🚨",
176
+ "error": "❌",
177
+ "critical": "💀",
178
+ },
179
+ "console": {
180
+ "level": "DEBUG",
181
+ "datefmt": "%Y-%m-%d %H:%M:%S",
182
+ "format": "::: PID %(process)d / TID %(thread)d ::: %(message)s",
183
+ "show_path": True,
184
+ "tracebacks_show_locals": True,
185
+ },
186
+ "file": {
187
+ "level": "DEBUG",
188
+ "datefmt": "%Y-%m-%d %H:%M:%S",
189
+ "format": "[%(asctime)s | %(levelname)-8s] ::: PID %(process)d / TID %(thread)d ::: %(message)s",
190
+ "log_path": "./",
191
+ "log_dir": "logs",
192
+ "log_name": "kstlib.log",
193
+ "log_dir_auto_create": True,
194
+ },
195
+ "rotation": {
196
+ "when": "midnight",
197
+ "interval": 1,
198
+ "backup_count": 7,
199
+ },
200
+ }
201
+
202
+
203
+ class LogManager(logging.Logger):
204
+ """Rich-based logger with async-friendly wrappers and flexible configuration.
205
+
206
+ Supports multiple configuration sources with priority order (lowest to highest):
207
+ 1. Built-in defaults (module fallback)
208
+ 2. Built-in presets
209
+ 3. ``logger.defaults`` from configuration file
210
+ 4. ``logger.presets[<name>]`` from configuration file
211
+ 5. Remaining ``logger`` keys from configuration file (global overrides)
212
+ 6. Explicit ``config`` parameter (constructor argument)
213
+
214
+ Args:
215
+ name: Logger name (default: "kstlib")
216
+ config: Explicit configuration dict/Box
217
+ preset: Preset name ("dev", "prod", "debug", or custom from config)
218
+
219
+ Example:
220
+ >>> logger = LogManager(preset="dev") # doctest: +SKIP
221
+ >>> logger.info("Server started", host="localhost", port=8080) # doctest: +SKIP
222
+ >>> logger.success("Connection established") # doctest: +SKIP
223
+ """
224
+
225
+ def __init__(
226
+ self,
227
+ name: str = "kstlib",
228
+ config: Box | dict[str, Any] | None = None,
229
+ preset: str | None = None,
230
+ ) -> None:
231
+ """Initialize LogManager with configuration priority chain."""
232
+ super().__init__(name)
233
+ logging.addLevelName(TRACE_LEVEL, "TRACE")
234
+ logging.addLevelName(SUCCESS_LEVEL, "SUCCESS")
235
+
236
+ # Load configuration with priority chain
237
+ self._config = self._load_config(config, preset)
238
+
239
+ # Setup console and theme
240
+ self.width = shutil.get_terminal_size(fallback=(120, 30)).columns
241
+ theme = self._create_theme()
242
+ self.console = Console(theme=theme, width=self.width)
243
+
244
+ # Setup handlers
245
+ self._setup_handlers()
246
+
247
+ def _load_config(self, config: Box | dict[str, Any] | None, preset: str | None) -> Box:
248
+ """Load configuration with priority chain.
249
+
250
+ Priority (lowest to highest):
251
+ 1. Built-in defaults (module fallback)
252
+ 2. Built-in presets
253
+ 3. ``logger.defaults`` from configuration file
254
+ 4. ``logger.presets[<name>]`` from configuration file
255
+ 5. Remaining ``logger`` keys from configuration file (global overrides)
256
+ 6. Explicit ``config`` parameter
257
+
258
+ Args:
259
+ config: Explicit configuration
260
+ preset: Preset name
261
+
262
+ Returns:
263
+ Merged configuration as Box
264
+ """
265
+ merged = Box(FALLBACK_DEFAULTS, default_box=True)
266
+
267
+ # Apply fallback preset if requested
268
+ preset_config = self._resolve_preset(preset, FALLBACK_PRESETS)
269
+ if preset_config is not None:
270
+ merged.merge_update(preset_config)
271
+
272
+ # Load from kstlib.conf.yml
273
+ try:
274
+ global_config = get_config()
275
+ except (FileNotFoundError, KeyError):
276
+ global_config = None
277
+
278
+ if global_config and "logger" in global_config:
279
+ logger_config = global_config.logger
280
+
281
+ defaults = logger_config.get("defaults")
282
+ config_presets = logger_config.get("presets")
283
+ overrides = {key: value for key, value in logger_config.items() if key not in {"defaults", "presets"}}
284
+
285
+ if defaults is not None:
286
+ merged.merge_update(Box(defaults, default_box=True))
287
+
288
+ preset_from_config = self._resolve_preset(preset, config_presets)
289
+ if preset_from_config is not None:
290
+ merged.merge_update(preset_from_config)
291
+
292
+ if overrides:
293
+ merged.merge_update(Box(overrides, default_box=True))
294
+
295
+ # Apply explicit config (highest priority)
296
+ if config:
297
+ if isinstance(config, dict):
298
+ config = Box(config, default_box=True)
299
+ merged.merge_update(config)
300
+
301
+ return merged
302
+
303
+ @staticmethod
304
+ def _resolve_preset(
305
+ preset: str | None,
306
+ presets: dict[str, Any] | Box | None,
307
+ ) -> Box | None:
308
+ """Return the preset configuration if available.
309
+
310
+ Args:
311
+ preset: Requested preset name.
312
+ presets: Mapping of preset names to configuration dictionaries.
313
+
314
+ Returns:
315
+ Box with preset configuration, or ``None`` if not found.
316
+ """
317
+ if not preset or not presets:
318
+ return None
319
+
320
+ candidate = presets.get(preset)
321
+ if candidate is None:
322
+ return None
323
+ return candidate if isinstance(candidate, Box) else Box(candidate, default_box=True)
324
+
325
+ def _create_theme(self) -> Theme:
326
+ """Create Rich theme from config.
327
+
328
+ Returns:
329
+ Rich Theme with logging level colors
330
+ """
331
+ theme_config = self._config.theme
332
+ return Theme(
333
+ {
334
+ "logging.level.trace": theme_config.trace,
335
+ "logging.level.debug": theme_config.debug,
336
+ "logging.level.info": theme_config.info,
337
+ "logging.level.success": theme_config.success,
338
+ "logging.level.warning": theme_config.warning,
339
+ "logging.level.error": theme_config.error,
340
+ "logging.level.critical": theme_config.critical,
341
+ }
342
+ )
343
+
344
+ def _setup_handlers(self) -> None:
345
+ """Setup console and/or file handlers based on config."""
346
+ # Clear existing handlers to prevent duplication on re-initialization
347
+ # (Python loggers are singletons by name, so handlers accumulate)
348
+ self.handlers.clear()
349
+
350
+ self.setLevel(TRACE_LEVEL) # Allow all levels, handlers filter
351
+ output = self._config.output.lower()
352
+
353
+ # Console handler
354
+ if output in ("console", "both"):
355
+ self._setup_console_handler()
356
+
357
+ # File handler
358
+ if output in ("file", "both"):
359
+ self._setup_file_handler()
360
+
361
+ def _setup_console_handler(self) -> None:
362
+ """Setup Rich console handler."""
363
+ console_config = self._config.console
364
+ rich_handler = RichHandler(
365
+ console=self.console,
366
+ show_path=console_config.get("show_path", True),
367
+ markup=True,
368
+ tracebacks_show_locals=console_config.get("tracebacks_show_locals", True),
369
+ )
370
+ rich_handler.setFormatter(logging.Formatter(console_config.format, datefmt=console_config.datefmt))
371
+ level = console_config.level.upper()
372
+ rich_handler.setLevel(getattr(LOGGING_LEVEL, level, logging.DEBUG))
373
+ self.addHandler(rich_handler)
374
+
375
+ def _setup_file_handler(self) -> None:
376
+ """Setup file handler with rotation.
377
+
378
+ Supports two configuration styles:
379
+ - New style: ``file.file_path`` (single path, recommended)
380
+ - Legacy style: ``file.log_path`` + ``file.log_dir`` + ``file.log_name``
381
+
382
+ The new style takes priority if ``file_path`` is defined.
383
+ """
384
+ file_config = self._config.file
385
+ rotation_config = self._config.rotation
386
+
387
+ # Build log file path (new style takes priority)
388
+ file_path_value = file_config.get("file_path")
389
+ if file_path_value:
390
+ # New style: single file_path
391
+ log_file = Path(file_path_value)
392
+ else:
393
+ # Legacy style: log_path / log_dir / log_name
394
+ log_path = Path(file_config.get("log_path", "./"))
395
+ log_dir = file_config.get("log_dir", "logs")
396
+ log_name = file_config.get("log_name", "kstlib.log")
397
+ log_file = log_path / log_dir / log_name
398
+
399
+ # Validate path (deep defense - hardcoded limits)
400
+ log_file = _validate_log_file_path(log_file)
401
+
402
+ # Determine auto_create setting (support both new and legacy names)
403
+ auto_create = file_config.get(
404
+ "auto_create_dir",
405
+ file_config.get("log_dir_auto_create", True),
406
+ )
407
+
408
+ # Create directory if needed with proper permissions
409
+ if auto_create:
410
+ log_file.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
411
+
412
+ # Create file handler with rotation
413
+ file_handler = TimedRotatingFileHandler(
414
+ log_file,
415
+ when=rotation_config.when,
416
+ interval=rotation_config.interval,
417
+ backupCount=rotation_config.backup_count,
418
+ encoding="utf-8",
419
+ delay=False, # Create file immediately for better debugging
420
+ )
421
+ file_handler.setFormatter(logging.Formatter(file_config.format, datefmt=file_config.datefmt))
422
+ level = file_config.level.upper()
423
+ file_handler.setLevel(getattr(LOGGING_LEVEL, level, logging.DEBUG))
424
+ self.addHandler(file_handler)
425
+
426
+ def _format_with_icon(self, level: str, msg: str) -> str:
427
+ """Add icon to message if enabled.
428
+
429
+ Args:
430
+ level: Log level name (debug, info, success, etc.)
431
+ msg: Log message
432
+
433
+ Returns:
434
+ Formatted message with icon
435
+ """
436
+ icons = self._config.icons
437
+ if not icons.show:
438
+ return msg
439
+ icon = icons.get(level, "")
440
+ return f"{icon} {msg}" if icon else msg
441
+
442
+ def _format_structured(self, msg: str, **context: Any) -> str:
443
+ """Format message with structured context.
444
+
445
+ Args:
446
+ msg: Base message
447
+ **context: Key-value context pairs
448
+
449
+ Returns:
450
+ Formatted message with context
451
+ """
452
+ if not context:
453
+ return msg
454
+ ctx_str = " | ".join(f"{k}={v}" for k, v in context.items())
455
+ return f"{msg} | {ctx_str}"
456
+
457
+ @staticmethod
458
+ def _split_log_kwargs(kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
459
+ """Separate structured context from logging kwargs.
460
+
461
+ Args:
462
+ kwargs: Keyword arguments received by the public logging method.
463
+
464
+ Returns:
465
+ A tuple containing the structured context dictionary and the kwargs
466
+ that should be forwarded to the underlying logging call.
467
+ """
468
+ reserved = {"exc_info", "stack_info", "stacklevel", "extra"}
469
+ context: dict[str, Any] = {}
470
+ log_kwargs: dict[str, Any] = {}
471
+ for key, value in kwargs.items():
472
+ if key in reserved:
473
+ log_kwargs[key] = value
474
+ else:
475
+ context[key] = value
476
+ return context, log_kwargs
477
+
478
+ def _prepare_message(self, level: str, msg: object, kwargs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
479
+ """Return formatted message and logging kwargs for emission."""
480
+ msg_str = str(msg)
481
+ context, log_kwargs = self._split_log_kwargs(kwargs)
482
+ formatted = self._format_structured(msg_str, **context)
483
+ formatted = self._format_with_icon(level, formatted)
484
+ return formatted, log_kwargs
485
+
486
+ # Synchronous logging methods
487
+
488
+ def trace(self, msg: object, *args: object, **kwargs: Any) -> None:
489
+ """Log trace message (custom level 5, below DEBUG).
490
+
491
+ Use for detailed HTTP traces, protocol dumps, and low-level diagnostics.
492
+
493
+ Args:
494
+ msg: Log message
495
+ *args: Format args
496
+ **kwargs: Context key=value pairs
497
+ """
498
+ if self.isEnabledFor(TRACE_LEVEL):
499
+ formatted, log_kwargs = self._prepare_message("trace", msg, kwargs)
500
+ log_kwargs.setdefault("stacklevel", 2)
501
+ self._log(TRACE_LEVEL, formatted, args, **log_kwargs)
502
+
503
+ def debug(self, msg: object, *args: object, **kwargs: Any) -> None:
504
+ """Log debug message.
505
+
506
+ Args:
507
+ msg: Log message
508
+ *args: Format args
509
+ **kwargs: Context key=value pairs
510
+ """
511
+ formatted, log_kwargs = self._prepare_message("debug", msg, kwargs)
512
+ log_kwargs.setdefault("stacklevel", 2)
513
+ super().debug(formatted, *args, **log_kwargs)
514
+
515
+ def info(self, msg: object, *args: object, **kwargs: Any) -> None:
516
+ """Log info message.
517
+
518
+ Args:
519
+ msg: Log message
520
+ *args: Format args
521
+ **kwargs: Context key=value pairs
522
+ """
523
+ formatted, log_kwargs = self._prepare_message("info", msg, kwargs)
524
+ log_kwargs.setdefault("stacklevel", 2)
525
+ super().info(formatted, *args, **log_kwargs)
526
+
527
+ def success(self, msg: object, *args: object, **kwargs: Any) -> None:
528
+ """Log success message (custom level 25).
529
+
530
+ Args:
531
+ msg: Log message
532
+ *args: Format args
533
+ **kwargs: Context key=value pairs
534
+ """
535
+ if self.isEnabledFor(SUCCESS_LEVEL):
536
+ formatted, log_kwargs = self._prepare_message("success", msg, kwargs)
537
+ log_kwargs.setdefault("stacklevel", 2)
538
+ self._log(SUCCESS_LEVEL, formatted, args, **log_kwargs)
539
+
540
+ def warning(self, msg: object, *args: object, **kwargs: Any) -> None:
541
+ """Log warning message.
542
+
543
+ Args:
544
+ msg: Log message
545
+ *args: Format args
546
+ **kwargs: Context key=value pairs
547
+ """
548
+ formatted, log_kwargs = self._prepare_message("warning", msg, kwargs)
549
+ log_kwargs.setdefault("stacklevel", 2)
550
+ super().warning(formatted, *args, **log_kwargs)
551
+
552
+ def error(self, msg: object, *args: object, **kwargs: Any) -> None:
553
+ """Log error message.
554
+
555
+ Args:
556
+ msg: Log message
557
+ *args: Format args
558
+ **kwargs: Context key=value pairs
559
+ """
560
+ formatted, log_kwargs = self._prepare_message("error", msg, kwargs)
561
+ log_kwargs.setdefault("stacklevel", 2)
562
+ super().error(formatted, *args, **log_kwargs)
563
+
564
+ def critical(self, msg: object, *args: object, **kwargs: Any) -> None:
565
+ """Log critical message.
566
+
567
+ Args:
568
+ msg: Log message
569
+ *args: Format args
570
+ **kwargs: Context key=value pairs
571
+ """
572
+ formatted, log_kwargs = self._prepare_message("critical", msg, kwargs)
573
+ log_kwargs.setdefault("stacklevel", 2)
574
+ super().critical(formatted, *args, **log_kwargs)
575
+
576
+ def traceback(self, exc: BaseException) -> None:
577
+ """Print Rich traceback with locals.
578
+
579
+ Args:
580
+ exc: Exception to display
581
+ """
582
+ self.console.print(
583
+ Traceback.from_exception(
584
+ type(exc),
585
+ exc,
586
+ exc.__traceback__,
587
+ show_locals=True,
588
+ width=self.width,
589
+ extra_lines=13,
590
+ )
591
+ )
592
+
593
+ @property
594
+ def has_native_async_support(self) -> bool:
595
+ """Return whether native async logs are available."""
596
+ return HAS_ASYNC
597
+
598
+ # Async logging methods (TODO: implement with aiofiles)
599
+
600
+ async def atrace(self, msg: str, **context: Any) -> None:
601
+ """Async trace wrapper executed via thread pool."""
602
+ loop = asyncio.get_running_loop()
603
+ await loop.run_in_executor(None, partial(self.trace, msg, **context))
604
+
605
+ async def adebug(self, msg: str, **context: Any) -> None:
606
+ """Async debug wrapper executed via thread pool."""
607
+ loop = asyncio.get_running_loop()
608
+ await loop.run_in_executor(None, partial(self.debug, msg, **context))
609
+
610
+ async def ainfo(self, msg: str, **context: Any) -> None:
611
+ """Async info wrapper executed via thread pool."""
612
+ loop = asyncio.get_running_loop()
613
+ await loop.run_in_executor(None, partial(self.info, msg, **context))
614
+
615
+ async def asuccess(self, msg: str, **context: Any) -> None:
616
+ """Async success wrapper executed via thread pool."""
617
+ loop = asyncio.get_running_loop()
618
+ await loop.run_in_executor(None, partial(self.success, msg, **context))
619
+
620
+ async def awarning(self, msg: str, **context: Any) -> None:
621
+ """Async warning wrapper executed via thread pool."""
622
+ loop = asyncio.get_running_loop()
623
+ await loop.run_in_executor(None, partial(self.warning, msg, **context))
624
+
625
+ async def aerror(self, msg: str, **context: Any) -> None:
626
+ """Async error wrapper executed via thread pool."""
627
+ loop = asyncio.get_running_loop()
628
+ await loop.run_in_executor(None, partial(self.error, msg, **context))
629
+
630
+ async def acritical(self, msg: str, **context: Any) -> None:
631
+ """Async critical wrapper executed via thread pool."""
632
+ loop = asyncio.get_running_loop()
633
+ await loop.run_in_executor(None, partial(self.critical, msg, **context))
@@ -0,0 +1,42 @@
1
+ """Mail composition and transport helpers.
2
+
3
+ Provides a fluent interface for building and sending emails with support
4
+ for both sync and async transports.
5
+
6
+ Examples:
7
+ Build and send via SMTP::
8
+
9
+ from kstlib.mail import MailBuilder
10
+ from kstlib.mail.transports import SMTPTransport
11
+
12
+ transport = SMTPTransport(host="smtp.example.com", port=587)
13
+ mail = MailBuilder(transport=transport)
14
+ mail.sender("me@example.com").to("you@example.com").subject("Hi").message("Hello!").send()
15
+
16
+ Async send via Resend::
17
+
18
+ from kstlib.mail import MailBuilder
19
+ from kstlib.mail.transports import ResendTransport
20
+
21
+ transport = ResendTransport(api_key="re_123")
22
+ # Use with async context
23
+ await transport.send(message)
24
+ """
25
+
26
+ from kstlib.mail.builder import MailBuilder, NotifyResult
27
+ from kstlib.mail.exceptions import MailConfigurationError, MailError, MailTransportError, MailValidationError
28
+ from kstlib.mail.filesystem import MailFilesystemGuards
29
+ from kstlib.mail.transport import AsyncMailTransport, AsyncTransportWrapper, MailTransport
30
+
31
+ __all__ = [
32
+ "AsyncMailTransport",
33
+ "AsyncTransportWrapper",
34
+ "MailBuilder",
35
+ "MailConfigurationError",
36
+ "MailError",
37
+ "MailFilesystemGuards",
38
+ "MailTransport",
39
+ "MailTransportError",
40
+ "MailValidationError",
41
+ "NotifyResult",
42
+ ]