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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {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))
|
kstlib/mail/__init__.py
ADDED
|
@@ -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
|
+
]
|