db-sync-tool-kmi 2.11.6__py3-none-any.whl → 3.0.2__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.
- db_sync_tool/__main__.py +7 -252
- db_sync_tool/cli.py +733 -0
- db_sync_tool/database/process.py +94 -111
- db_sync_tool/database/utility.py +339 -121
- db_sync_tool/info.py +1 -1
- db_sync_tool/recipes/drupal.py +87 -12
- db_sync_tool/recipes/laravel.py +7 -6
- db_sync_tool/recipes/parsing.py +102 -0
- db_sync_tool/recipes/symfony.py +17 -28
- db_sync_tool/recipes/typo3.py +33 -54
- db_sync_tool/recipes/wordpress.py +13 -12
- db_sync_tool/remote/client.py +206 -71
- db_sync_tool/remote/file_transfer.py +303 -0
- db_sync_tool/remote/rsync.py +18 -15
- db_sync_tool/remote/system.py +2 -3
- db_sync_tool/remote/transfer.py +51 -47
- db_sync_tool/remote/utility.py +29 -30
- db_sync_tool/sync.py +52 -28
- db_sync_tool/utility/config.py +367 -0
- db_sync_tool/utility/config_resolver.py +573 -0
- db_sync_tool/utility/console.py +779 -0
- db_sync_tool/utility/exceptions.py +32 -0
- db_sync_tool/utility/helper.py +155 -148
- db_sync_tool/utility/info.py +53 -20
- db_sync_tool/utility/log.py +55 -31
- db_sync_tool/utility/logging_config.py +410 -0
- db_sync_tool/utility/mode.py +85 -150
- db_sync_tool/utility/output.py +122 -51
- db_sync_tool/utility/parser.py +33 -53
- db_sync_tool/utility/pure.py +93 -0
- db_sync_tool/utility/security.py +79 -0
- db_sync_tool/utility/system.py +277 -194
- db_sync_tool/utility/validation.py +2 -9
- db_sync_tool_kmi-3.0.2.dist-info/METADATA +99 -0
- db_sync_tool_kmi-3.0.2.dist-info/RECORD +44 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/WHEEL +1 -1
- db_sync_tool_kmi-2.11.6.dist-info/METADATA +0 -276
- db_sync_tool_kmi-2.11.6.dist-info/RECORD +0 -34
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/entry_points.txt +0 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info/licenses}/LICENSE +0 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/top_level.txt +0 -0
db_sync_tool/utility/log.py
CHANGED
|
@@ -1,49 +1,73 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: future_fstrings -*-
|
|
3
2
|
|
|
4
3
|
"""
|
|
5
|
-
|
|
6
|
-
"""
|
|
4
|
+
Logging module.
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
This module provides backward-compatible logging functions while delegating
|
|
7
|
+
to the new structured logging infrastructure in logging_config.py.
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
For new code, prefer using:
|
|
10
|
+
from db_sync_tool.utility.logging_config import get_sync_logger, init_logging
|
|
11
|
+
"""
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
from __future__ import annotations
|
|
16
14
|
|
|
15
|
+
import logging
|
|
17
16
|
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
# Global logger instance (lazy initialization)
|
|
18
|
+
_logger: logging.Logger | None = None
|
|
19
|
+
_initialized: bool = False
|
|
21
20
|
|
|
22
21
|
|
|
23
|
-
def init_logger():
|
|
22
|
+
def init_logger() -> None:
|
|
24
23
|
"""
|
|
25
|
-
Initialize the logger instance
|
|
26
|
-
|
|
24
|
+
Initialize the logger instance.
|
|
25
|
+
|
|
26
|
+
This function integrates with the new logging_config module for
|
|
27
|
+
structured logging support while maintaining backward compatibility.
|
|
27
28
|
"""
|
|
28
|
-
global
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
global _logger, _initialized
|
|
30
|
+
|
|
31
|
+
if _initialized:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Import here to avoid circular imports
|
|
35
|
+
from db_sync_tool.utility import system
|
|
36
|
+
from db_sync_tool.utility.logging_config import init_logging
|
|
37
|
+
|
|
38
|
+
cfg = system.get_typed_config()
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
# Initialize the new logging infrastructure
|
|
41
|
+
_logger = init_logging(
|
|
42
|
+
verbose=1 if cfg.verbose else 0,
|
|
43
|
+
mute=cfg.mute,
|
|
44
|
+
log_file=cfg.log_file,
|
|
45
|
+
json_logging=cfg.json_log,
|
|
46
|
+
)
|
|
47
|
+
_initialized = True
|
|
40
48
|
|
|
41
49
|
|
|
42
|
-
def get_logger():
|
|
50
|
+
def get_logger() -> logging.Logger:
|
|
43
51
|
"""
|
|
44
|
-
Return the logger instance
|
|
45
|
-
|
|
52
|
+
Return the logger instance.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Configured logger instance
|
|
46
56
|
"""
|
|
47
|
-
|
|
57
|
+
global _logger, _initialized
|
|
58
|
+
|
|
59
|
+
if _logger is None or not _initialized:
|
|
48
60
|
init_logger()
|
|
49
|
-
|
|
61
|
+
|
|
62
|
+
return _logger # type: ignore[return-value]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reset_logger() -> None:
|
|
66
|
+
"""Reset the logger (for testing)."""
|
|
67
|
+
global _logger, _initialized
|
|
68
|
+
|
|
69
|
+
from db_sync_tool.utility.logging_config import reset_logging
|
|
70
|
+
|
|
71
|
+
reset_logging()
|
|
72
|
+
_logger = None
|
|
73
|
+
_initialized = False
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Structured Logging Configuration.
|
|
5
|
+
|
|
6
|
+
This module provides a unified logging infrastructure with:
|
|
7
|
+
- Subject-aware logging (ORIGIN, TARGET, LOCAL)
|
|
8
|
+
- Rich console output (interactive mode)
|
|
9
|
+
- Structured JSON logging (machine-readable)
|
|
10
|
+
- File logging with configurable formats
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from db_sync_tool.utility.logging_config import get_sync_logger, init_logging
|
|
14
|
+
|
|
15
|
+
# Initialize logging (once at startup)
|
|
16
|
+
init_logging(verbose=1, log_file="/path/to/log", json_logging=True)
|
|
17
|
+
|
|
18
|
+
# Get a subject-specific logger
|
|
19
|
+
logger = get_sync_logger("origin")
|
|
20
|
+
logger.info("Creating database dump", extra={"remote": True})
|
|
21
|
+
|
|
22
|
+
# Or use the default logger
|
|
23
|
+
from db_sync_tool.utility.logging_config import logger
|
|
24
|
+
logger.info("General message")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from collections.abc import MutableMapping
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from enum import Enum
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Subject(str, Enum):
|
|
40
|
+
"""Log message subjects indicating the source/context of the operation."""
|
|
41
|
+
ORIGIN = "ORIGIN"
|
|
42
|
+
TARGET = "TARGET"
|
|
43
|
+
LOCAL = "LOCAL"
|
|
44
|
+
INFO = "INFO"
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
"""Return the value for string conversion (StrEnum behavior)."""
|
|
48
|
+
return self.value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SyncLogRecord(logging.LogRecord):
|
|
52
|
+
"""Extended LogRecord with sync-specific fields."""
|
|
53
|
+
|
|
54
|
+
subject: str
|
|
55
|
+
remote: bool
|
|
56
|
+
|
|
57
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
58
|
+
super().__init__(*args, **kwargs)
|
|
59
|
+
# Set defaults for custom fields
|
|
60
|
+
if not hasattr(self, 'subject'):
|
|
61
|
+
self.subject = Subject.INFO.value
|
|
62
|
+
if not hasattr(self, 'remote'):
|
|
63
|
+
self.remote = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Note: We don't modify the LogRecord factory globally anymore
|
|
67
|
+
# because Python 3.13+ is stricter about overwriting attributes.
|
|
68
|
+
# Instead, we rely on the SyncLoggerAdapter to add the extra fields
|
|
69
|
+
# and formatters use getattr() with defaults.
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class LoggingConfig:
|
|
74
|
+
"""Logging configuration settings."""
|
|
75
|
+
verbose: int = 0 # 0=normal, 1=verbose (-v), 2=debug (-vv)
|
|
76
|
+
mute: bool = False
|
|
77
|
+
log_file: str | None = None
|
|
78
|
+
json_logging: bool = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SyncFormatter(logging.Formatter):
|
|
82
|
+
"""Custom formatter for sync tool logs with subject prefix."""
|
|
83
|
+
|
|
84
|
+
LEVEL_COLORS = {
|
|
85
|
+
logging.DEBUG: "\033[90m", # Gray
|
|
86
|
+
logging.INFO: "\033[92m", # Green
|
|
87
|
+
logging.WARNING: "\033[93m", # Yellow
|
|
88
|
+
logging.ERROR: "\033[91m", # Red
|
|
89
|
+
logging.CRITICAL: "\033[91m", # Red bold
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
SUBJECT_COLORS = {
|
|
93
|
+
"ORIGIN": "\033[95m", # Magenta
|
|
94
|
+
"TARGET": "\033[94m", # Blue
|
|
95
|
+
"LOCAL": "\033[96m", # Cyan
|
|
96
|
+
"INFO": "\033[92m", # Green
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
RESET = "\033[0m"
|
|
100
|
+
|
|
101
|
+
def __init__(self, use_colors: bool = True, show_timestamp: bool = False):
|
|
102
|
+
super().__init__()
|
|
103
|
+
self.use_colors = use_colors
|
|
104
|
+
self.show_timestamp = show_timestamp
|
|
105
|
+
|
|
106
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
107
|
+
"""Format log record with subject prefix and optional colors."""
|
|
108
|
+
subject = getattr(record, 'subject', 'INFO')
|
|
109
|
+
remote = getattr(record, 'remote', False)
|
|
110
|
+
|
|
111
|
+
# Build prefix
|
|
112
|
+
if subject in ("ORIGIN", "TARGET"):
|
|
113
|
+
location = "REMOTE" if remote else "LOCAL"
|
|
114
|
+
prefix = f"[{subject}][{location}]"
|
|
115
|
+
else:
|
|
116
|
+
prefix = f"[{subject}]"
|
|
117
|
+
|
|
118
|
+
# Build message
|
|
119
|
+
if self.use_colors and sys.stdout.isatty():
|
|
120
|
+
subject_color = self.SUBJECT_COLORS.get(subject, self.RESET)
|
|
121
|
+
level_color = self.LEVEL_COLORS.get(record.levelno, self.RESET)
|
|
122
|
+
|
|
123
|
+
if record.levelno >= logging.WARNING:
|
|
124
|
+
prefix_str = f"{level_color}{prefix}{self.RESET}"
|
|
125
|
+
else:
|
|
126
|
+
prefix_str = f"{subject_color}{prefix}{self.RESET}"
|
|
127
|
+
|
|
128
|
+
message = f"{prefix_str} {record.getMessage()}"
|
|
129
|
+
else:
|
|
130
|
+
message = f"{prefix} {record.getMessage()}"
|
|
131
|
+
|
|
132
|
+
# Add timestamp if requested
|
|
133
|
+
if self.show_timestamp:
|
|
134
|
+
timestamp = self.formatTime(record, "%Y-%m-%d %H:%M:%S")
|
|
135
|
+
message = f"{timestamp} - {message}"
|
|
136
|
+
|
|
137
|
+
return message
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class JSONFormatter(logging.Formatter):
|
|
141
|
+
"""JSON formatter for structured logging."""
|
|
142
|
+
|
|
143
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
144
|
+
"""Format log record as JSON."""
|
|
145
|
+
log_data = {
|
|
146
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(record.created)),
|
|
147
|
+
"level": record.levelname,
|
|
148
|
+
"subject": getattr(record, 'subject', 'INFO'),
|
|
149
|
+
"remote": getattr(record, 'remote', False),
|
|
150
|
+
"message": record.getMessage(),
|
|
151
|
+
"module": record.module,
|
|
152
|
+
"function": record.funcName,
|
|
153
|
+
"line": record.lineno,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Add exception info if present
|
|
157
|
+
if record.exc_info:
|
|
158
|
+
log_data["exception"] = self.formatException(record.exc_info)
|
|
159
|
+
|
|
160
|
+
return json.dumps(log_data)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class RichHandler(logging.Handler):
|
|
164
|
+
"""
|
|
165
|
+
Handler that integrates with Rich console for beautiful output.
|
|
166
|
+
|
|
167
|
+
Falls back to plain text if Rich is not available.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, level: int = logging.NOTSET, mute: bool = False):
|
|
171
|
+
super().__init__(level)
|
|
172
|
+
self.mute = mute
|
|
173
|
+
self._console: Any = None
|
|
174
|
+
self._escape: Any = None
|
|
175
|
+
self._init_rich()
|
|
176
|
+
|
|
177
|
+
def _init_rich(self) -> None:
|
|
178
|
+
"""Initialize Rich console if available."""
|
|
179
|
+
try:
|
|
180
|
+
from rich.console import Console
|
|
181
|
+
from rich.markup import escape
|
|
182
|
+
from rich.theme import Theme
|
|
183
|
+
|
|
184
|
+
theme = Theme({
|
|
185
|
+
"info": "cyan",
|
|
186
|
+
"success": "green",
|
|
187
|
+
"warning": "yellow",
|
|
188
|
+
"error": "red bold",
|
|
189
|
+
"origin": "magenta",
|
|
190
|
+
"target": "blue",
|
|
191
|
+
"local": "cyan",
|
|
192
|
+
"debug": "dim",
|
|
193
|
+
})
|
|
194
|
+
self._console = Console(theme=theme)
|
|
195
|
+
self._escape = escape
|
|
196
|
+
except ImportError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def _get_style(self, subject: str, level: int) -> str:
|
|
200
|
+
"""Get Rich style based on subject and level."""
|
|
201
|
+
if level >= logging.ERROR:
|
|
202
|
+
return "error"
|
|
203
|
+
if level >= logging.WARNING:
|
|
204
|
+
return "warning"
|
|
205
|
+
return subject.lower() if subject.lower() in ("origin", "target", "local") else "info"
|
|
206
|
+
|
|
207
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
208
|
+
"""Emit a log record."""
|
|
209
|
+
if self.mute and record.levelno < logging.ERROR:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
subject = getattr(record, 'subject', 'INFO')
|
|
214
|
+
remote = getattr(record, 'remote', False)
|
|
215
|
+
message = record.getMessage()
|
|
216
|
+
|
|
217
|
+
# Build prefix
|
|
218
|
+
if subject in ("ORIGIN", "TARGET"):
|
|
219
|
+
location = "REMOTE" if remote else "LOCAL"
|
|
220
|
+
prefix = f"[{subject}][{location}]"
|
|
221
|
+
else:
|
|
222
|
+
prefix = f"[{subject}]"
|
|
223
|
+
|
|
224
|
+
if self._console and self._escape:
|
|
225
|
+
style = self._get_style(subject, record.levelno)
|
|
226
|
+
esc = self._escape
|
|
227
|
+
|
|
228
|
+
# Format with Rich
|
|
229
|
+
if record.levelno >= logging.ERROR:
|
|
230
|
+
self._console.print(f"[{style}]{esc(prefix)} {esc(message)}[/{style}]")
|
|
231
|
+
elif record.levelno >= logging.WARNING:
|
|
232
|
+
self._console.print(f"[{style}]{esc(prefix)} {esc(message)}[/{style}]")
|
|
233
|
+
else:
|
|
234
|
+
self._console.print(f"[{style}]{esc(prefix)}[/{style}] {esc(message)}")
|
|
235
|
+
else:
|
|
236
|
+
# Fallback to plain formatter
|
|
237
|
+
formatter = SyncFormatter(use_colors=True)
|
|
238
|
+
print(formatter.format(record))
|
|
239
|
+
|
|
240
|
+
except Exception:
|
|
241
|
+
self.handleError(record)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class SyncLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg]
|
|
245
|
+
"""
|
|
246
|
+
Logger adapter that automatically adds subject context.
|
|
247
|
+
|
|
248
|
+
Usage:
|
|
249
|
+
logger = SyncLoggerAdapter(logging.getLogger("db_sync_tool"), subject="ORIGIN")
|
|
250
|
+
logger.info("Creating dump", extra={"remote": True})
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
extra: dict[str, Any] # Override type to be mutable dict
|
|
254
|
+
|
|
255
|
+
def __init__(self, logger: logging.Logger, subject: str = "INFO", remote: bool = False):
|
|
256
|
+
super().__init__(logger, {"subject": subject, "remote": remote})
|
|
257
|
+
self.subject = subject
|
|
258
|
+
self.default_remote = remote
|
|
259
|
+
|
|
260
|
+
def process(
|
|
261
|
+
self, msg: Any, kwargs: MutableMapping[str, Any]
|
|
262
|
+
) -> tuple[Any, MutableMapping[str, Any]]:
|
|
263
|
+
"""Process log message and add subject context."""
|
|
264
|
+
extra = kwargs.get("extra", {})
|
|
265
|
+
if isinstance(extra, dict):
|
|
266
|
+
extra.setdefault("subject", self.subject)
|
|
267
|
+
extra.setdefault("remote", self.default_remote)
|
|
268
|
+
kwargs["extra"] = extra
|
|
269
|
+
return msg, kwargs
|
|
270
|
+
|
|
271
|
+
# Global logger instances
|
|
272
|
+
_root_logger: logging.Logger | None = None
|
|
273
|
+
_logging_config: LoggingConfig = LoggingConfig()
|
|
274
|
+
_subject_loggers: dict[str, SyncLoggerAdapter] = {}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def init_logging(
|
|
278
|
+
verbose: int = 0,
|
|
279
|
+
mute: bool = False,
|
|
280
|
+
log_file: str | None = None,
|
|
281
|
+
json_logging: bool = False,
|
|
282
|
+
console_output: bool = False,
|
|
283
|
+
) -> logging.Logger:
|
|
284
|
+
"""
|
|
285
|
+
Initialize the logging system.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
verbose: Verbosity level (0=normal, 1=verbose, 2=debug)
|
|
289
|
+
mute: Suppress non-error output
|
|
290
|
+
log_file: Path to log file (optional)
|
|
291
|
+
json_logging: Use JSON format for file logging
|
|
292
|
+
console_output: Add console handler (False when OutputManager handles console)
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Configured root logger
|
|
296
|
+
"""
|
|
297
|
+
global _root_logger, _logging_config, _subject_loggers
|
|
298
|
+
|
|
299
|
+
_logging_config = LoggingConfig(
|
|
300
|
+
verbose=verbose,
|
|
301
|
+
mute=mute,
|
|
302
|
+
log_file=log_file,
|
|
303
|
+
json_logging=json_logging,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Create or get root logger
|
|
307
|
+
_root_logger = logging.getLogger("db_sync_tool")
|
|
308
|
+
_root_logger.handlers.clear()
|
|
309
|
+
|
|
310
|
+
# Set level based on verbosity
|
|
311
|
+
if verbose >= 2:
|
|
312
|
+
_root_logger.setLevel(logging.DEBUG)
|
|
313
|
+
elif verbose >= 1:
|
|
314
|
+
_root_logger.setLevel(logging.INFO)
|
|
315
|
+
else:
|
|
316
|
+
_root_logger.setLevel(logging.INFO)
|
|
317
|
+
|
|
318
|
+
# Add console handler only if explicitly requested
|
|
319
|
+
# (When OutputManager handles console output, we skip this)
|
|
320
|
+
if console_output:
|
|
321
|
+
console_handler = RichHandler(mute=mute)
|
|
322
|
+
if verbose >= 2:
|
|
323
|
+
console_handler.setLevel(logging.DEBUG)
|
|
324
|
+
else:
|
|
325
|
+
console_handler.setLevel(logging.INFO)
|
|
326
|
+
_root_logger.addHandler(console_handler)
|
|
327
|
+
|
|
328
|
+
# Add file handler if log file specified
|
|
329
|
+
if log_file:
|
|
330
|
+
file_handler = logging.FileHandler(log_file)
|
|
331
|
+
file_handler.setLevel(logging.DEBUG)
|
|
332
|
+
|
|
333
|
+
if json_logging:
|
|
334
|
+
file_handler.setFormatter(JSONFormatter())
|
|
335
|
+
else:
|
|
336
|
+
file_handler.setFormatter(SyncFormatter(use_colors=False, show_timestamp=True))
|
|
337
|
+
|
|
338
|
+
_root_logger.addHandler(file_handler)
|
|
339
|
+
|
|
340
|
+
# Add NullHandler if no handlers were added (prevents "No handlers" warning)
|
|
341
|
+
if not _root_logger.handlers:
|
|
342
|
+
_root_logger.addHandler(logging.NullHandler())
|
|
343
|
+
|
|
344
|
+
# Clear cached subject loggers
|
|
345
|
+
_subject_loggers.clear()
|
|
346
|
+
|
|
347
|
+
return _root_logger
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def get_sync_logger(
|
|
351
|
+
subject: str | Subject = Subject.INFO,
|
|
352
|
+
remote: bool = False,
|
|
353
|
+
) -> SyncLoggerAdapter:
|
|
354
|
+
"""
|
|
355
|
+
Get a logger adapter with subject context.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
subject: Subject (ORIGIN, TARGET, LOCAL, INFO)
|
|
359
|
+
remote: Whether the operation is remote
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
SyncLoggerAdapter with subject context
|
|
363
|
+
"""
|
|
364
|
+
global _root_logger, _subject_loggers
|
|
365
|
+
|
|
366
|
+
if _root_logger is None:
|
|
367
|
+
init_logging()
|
|
368
|
+
|
|
369
|
+
# Normalize subject
|
|
370
|
+
if isinstance(subject, Subject):
|
|
371
|
+
subject_str = subject.value
|
|
372
|
+
else:
|
|
373
|
+
subject_str = subject.upper()
|
|
374
|
+
|
|
375
|
+
# Cache key includes remote status
|
|
376
|
+
cache_key = f"{subject_str}:{remote}"
|
|
377
|
+
|
|
378
|
+
if cache_key not in _subject_loggers:
|
|
379
|
+
_subject_loggers[cache_key] = SyncLoggerAdapter(
|
|
380
|
+
_root_logger, # type: ignore[arg-type]
|
|
381
|
+
subject=subject_str,
|
|
382
|
+
remote=remote,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return _subject_loggers[cache_key]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def reset_logging() -> None:
|
|
389
|
+
"""Reset logging configuration (for testing)."""
|
|
390
|
+
global _root_logger, _subject_loggers, _logging_config
|
|
391
|
+
|
|
392
|
+
if _root_logger:
|
|
393
|
+
_root_logger.handlers.clear()
|
|
394
|
+
|
|
395
|
+
_root_logger = None
|
|
396
|
+
_subject_loggers.clear()
|
|
397
|
+
_logging_config = LoggingConfig()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# Convenience exports
|
|
401
|
+
def get_logger() -> logging.Logger:
|
|
402
|
+
"""Get the root sync logger."""
|
|
403
|
+
global _root_logger
|
|
404
|
+
if _root_logger is None:
|
|
405
|
+
init_logging()
|
|
406
|
+
return _root_logger # type: ignore[return-value]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# Default logger for direct import
|
|
410
|
+
logger = get_sync_logger()
|