mssql-python 1.16.0__cp315-cp315-win_amd64.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.
- mssql_python/__init__.py +662 -0
- mssql_python/async_query/__init__.py +40 -0
- mssql_python/async_query/_connection_context.py +39 -0
- mssql_python/async_query/_native.py +23 -0
- mssql_python/async_query/async_connection.py +160 -0
- mssql_python/async_query/async_cursor.py +230 -0
- mssql_python/async_query/async_execute.py +115 -0
- mssql_python/async_query/async_fetch.py +134 -0
- mssql_python/async_query/exception_translator.py +114 -0
- mssql_python/auth.py +913 -0
- mssql_python/connection.py +2357 -0
- mssql_python/connection_string_builder.py +114 -0
- mssql_python/connection_string_parser.py +424 -0
- mssql_python/constants.py +701 -0
- mssql_python/cursor.py +4188 -0
- mssql_python/db_connection.py +98 -0
- mssql_python/ddbc_bindings.cp315-amd64.pyd +0 -0
- mssql_python/ddbc_bindings.py +215 -0
- mssql_python/decimal_config.py +114 -0
- mssql_python/exceptions.py +588 -0
- mssql_python/helpers.py +416 -0
- mssql_python/logging.py +714 -0
- mssql_python/mssql_python.pyi +481 -0
- mssql_python/msvcp140.dll +0 -0
- mssql_python/odbc_provider.py +251 -0
- mssql_python/parameter_helper.py +488 -0
- mssql_python/perf_timer.py +286 -0
- mssql_python/pooling.py +151 -0
- mssql_python/py.typed +1 -0
- mssql_python/row.py +398 -0
- mssql_python/type.py +160 -0
- mssql_python-1.16.0.dist-info/METADATA +102 -0
- mssql_python-1.16.0.dist-info/RECORD +36 -0
- mssql_python-1.16.0.dist-info/WHEEL +5 -0
- mssql_python-1.16.0.dist-info/licenses/LICENSE +47 -0
- mssql_python-1.16.0.dist-info/top_level.txt +1 -0
mssql_python/logging.py
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation.
|
|
3
|
+
Licensed under the MIT license.
|
|
4
|
+
|
|
5
|
+
Enhanced logging module for mssql_python with JDBC-style logging levels.
|
|
6
|
+
This module provides fine-grained logging control with zero overhead when disabled.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from logging.handlers import RotatingFileHandler
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import datetime
|
|
15
|
+
import re
|
|
16
|
+
import platform
|
|
17
|
+
import atexit
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
# Single DEBUG level - all or nothing philosophy
|
|
21
|
+
# If you need logging, you need to see everything
|
|
22
|
+
DEBUG = logging.DEBUG # 10
|
|
23
|
+
|
|
24
|
+
# Output destination constants
|
|
25
|
+
STDOUT = "stdout" # Log to stdout only
|
|
26
|
+
FILE = "file" # Log to file only (default)
|
|
27
|
+
BOTH = "both" # Log to both file and stdout
|
|
28
|
+
|
|
29
|
+
# Allowed log file extensions
|
|
30
|
+
ALLOWED_LOG_EXTENSIONS = {".txt", ".log", ".csv"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ThreadIDFilter(logging.Filter):
|
|
34
|
+
"""Filter that adds thread_id to all log records."""
|
|
35
|
+
|
|
36
|
+
def filter(self, record):
|
|
37
|
+
"""Add thread_id (OS native) attribute to log record."""
|
|
38
|
+
# Use OS native thread ID for debugging compatibility
|
|
39
|
+
try:
|
|
40
|
+
thread_id = threading.get_native_id()
|
|
41
|
+
except AttributeError:
|
|
42
|
+
# Fallback for Python < 3.8
|
|
43
|
+
thread_id = threading.current_thread().ident
|
|
44
|
+
record.thread_id = thread_id
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MSSQLLogger:
|
|
49
|
+
"""
|
|
50
|
+
Singleton logger for mssql_python with single DEBUG level.
|
|
51
|
+
|
|
52
|
+
Philosophy: All or nothing - if you enable logging, you see EVERYTHING.
|
|
53
|
+
Logging is a troubleshooting tool, not a production feature.
|
|
54
|
+
|
|
55
|
+
Features:
|
|
56
|
+
- Single DEBUG level (no categorization)
|
|
57
|
+
- Automatic file rotation (512MB, 5 backups)
|
|
58
|
+
- Password sanitization
|
|
59
|
+
- Trace ID support with contextvars (automatic propagation)
|
|
60
|
+
- Thread-safe operation
|
|
61
|
+
- Zero overhead when disabled (level check only)
|
|
62
|
+
|
|
63
|
+
⚠️ Performance Warning: Logging adds ~2-5% overhead. Only enable when troubleshooting.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_instance: Optional["MSSQLLogger"] = None
|
|
67
|
+
_lock = threading.Lock()
|
|
68
|
+
_init_lock = threading.Lock() # Separate lock for initialization
|
|
69
|
+
|
|
70
|
+
def __new__(cls) -> "MSSQLLogger":
|
|
71
|
+
"""Ensure singleton pattern"""
|
|
72
|
+
if cls._instance is None:
|
|
73
|
+
with cls._lock:
|
|
74
|
+
if cls._instance is None:
|
|
75
|
+
cls._instance = super(MSSQLLogger, cls).__new__(cls)
|
|
76
|
+
return cls._instance
|
|
77
|
+
|
|
78
|
+
def __init__(self):
|
|
79
|
+
"""Initialize the logger (only once) - thread-safe"""
|
|
80
|
+
# Use separate lock for initialization check to prevent race condition
|
|
81
|
+
# This ensures hasattr check and assignment are atomic
|
|
82
|
+
with self._init_lock:
|
|
83
|
+
# Skip if already initialized
|
|
84
|
+
if hasattr(self, "_initialized"):
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
self._initialized = True
|
|
88
|
+
|
|
89
|
+
# Create the underlying Python logger
|
|
90
|
+
self._logger = logging.getLogger("mssql_python")
|
|
91
|
+
self._logger.setLevel(logging.CRITICAL) # Disabled by default
|
|
92
|
+
self._logger.propagate = False # Don't propagate to root logger
|
|
93
|
+
|
|
94
|
+
# Add trace ID filter (injects thread_id into every log record)
|
|
95
|
+
self._logger.addFilter(ThreadIDFilter())
|
|
96
|
+
|
|
97
|
+
# Output mode and handlers
|
|
98
|
+
self._output_mode = FILE # Default to file only
|
|
99
|
+
self._file_handler = None
|
|
100
|
+
self._stdout_handler = None
|
|
101
|
+
self._log_file = None
|
|
102
|
+
self._custom_log_path = None # Custom log file path (if specified)
|
|
103
|
+
self._handlers_initialized = False
|
|
104
|
+
self._handler_lock = threading.RLock() # Reentrant lock for handler operations
|
|
105
|
+
self._cleanup_registered = False # Track if atexit cleanup is registered
|
|
106
|
+
|
|
107
|
+
# Cached level for fast checks (avoid repeated isEnabledFor calls)
|
|
108
|
+
self._cached_level = logging.WARNING
|
|
109
|
+
self._is_debug_enabled = False
|
|
110
|
+
|
|
111
|
+
# Set up default stderr handler for WARNING and ERROR messages
|
|
112
|
+
# This ensures warnings are always visible even when logging is not enabled
|
|
113
|
+
import sys
|
|
114
|
+
|
|
115
|
+
default_handler = logging.StreamHandler(sys.stderr)
|
|
116
|
+
default_handler.setLevel(logging.WARNING)
|
|
117
|
+
# Simple format for warnings - no CSV formatting needed
|
|
118
|
+
default_handler.setFormatter(logging.Formatter("[%(name)s] %(levelname)s: %(message)s"))
|
|
119
|
+
self._logger.addHandler(default_handler)
|
|
120
|
+
self._default_handler = default_handler # Keep reference for later removal
|
|
121
|
+
|
|
122
|
+
# Don't setup full handlers yet - do it lazily when setLevel is called
|
|
123
|
+
# This prevents creating log files when user changes output mode before enabling logging
|
|
124
|
+
|
|
125
|
+
def _setup_handlers(self):
|
|
126
|
+
"""
|
|
127
|
+
Setup handlers based on output mode.
|
|
128
|
+
Creates file handler and/or stdout handler as needed.
|
|
129
|
+
Thread-safe: Protects against concurrent handler removal during logging.
|
|
130
|
+
"""
|
|
131
|
+
# Lock prevents race condition where one thread logs while another removes handlers
|
|
132
|
+
with self._handler_lock:
|
|
133
|
+
# Acquire locks on all existing handlers before closing
|
|
134
|
+
# This ensures no thread is mid-write when we close
|
|
135
|
+
old_handlers = self._logger.handlers[:]
|
|
136
|
+
for handler in old_handlers:
|
|
137
|
+
handler.acquire()
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
# Flush and close each handler while holding its lock
|
|
141
|
+
for handler in old_handlers:
|
|
142
|
+
try:
|
|
143
|
+
handler.flush() # Flush BEFORE close
|
|
144
|
+
except:
|
|
145
|
+
pass # Ignore flush errors
|
|
146
|
+
handler.close()
|
|
147
|
+
self._logger.removeHandler(handler)
|
|
148
|
+
finally:
|
|
149
|
+
# Release locks on old handlers
|
|
150
|
+
for handler in old_handlers:
|
|
151
|
+
try:
|
|
152
|
+
handler.release()
|
|
153
|
+
except:
|
|
154
|
+
pass # Handler might already be closed
|
|
155
|
+
|
|
156
|
+
self._file_handler = None
|
|
157
|
+
self._stdout_handler = None
|
|
158
|
+
|
|
159
|
+
# Create CSV formatter
|
|
160
|
+
# Custom formatter to extract source from message and format as CSV
|
|
161
|
+
class CSVFormatter(logging.Formatter):
|
|
162
|
+
def format(self, record):
|
|
163
|
+
# Check if this is from py-core (via py_core_log method)
|
|
164
|
+
if hasattr(record, "funcName") and record.funcName == "py-core":
|
|
165
|
+
source = "py-core"
|
|
166
|
+
message = record.getMessage()
|
|
167
|
+
else:
|
|
168
|
+
# Extract source from message (e.g., [Python] or [DDBC])
|
|
169
|
+
msg = record.getMessage()
|
|
170
|
+
if msg.startswith("[") and "]" in msg:
|
|
171
|
+
end_bracket = msg.index("]")
|
|
172
|
+
source = msg[1:end_bracket]
|
|
173
|
+
message = msg[end_bracket + 2 :].strip() # Skip '] '
|
|
174
|
+
else:
|
|
175
|
+
source = "Unknown"
|
|
176
|
+
message = msg
|
|
177
|
+
|
|
178
|
+
# Format timestamp with milliseconds using period separator
|
|
179
|
+
timestamp = self.formatTime(record, "%Y-%m-%d %H:%M:%S")
|
|
180
|
+
timestamp_with_ms = f"{timestamp}.{int(record.msecs):03d}"
|
|
181
|
+
|
|
182
|
+
# Get thread ID
|
|
183
|
+
thread_id = getattr(record, "thread_id", 0)
|
|
184
|
+
|
|
185
|
+
# Build CSV row
|
|
186
|
+
location = f"{record.filename}:{record.lineno}"
|
|
187
|
+
csv_row = f"{timestamp_with_ms}, {thread_id}, {record.levelname}, {location}, {source}, {message}"
|
|
188
|
+
|
|
189
|
+
return csv_row
|
|
190
|
+
|
|
191
|
+
formatter = CSVFormatter()
|
|
192
|
+
|
|
193
|
+
# Override format to use milliseconds with period separator
|
|
194
|
+
formatter.default_msec_format = "%s.%03d"
|
|
195
|
+
|
|
196
|
+
# Setup file handler if needed
|
|
197
|
+
if self._output_mode in (FILE, BOTH):
|
|
198
|
+
# Use custom path or auto-generate
|
|
199
|
+
if self._custom_log_path:
|
|
200
|
+
self._log_file = self._custom_log_path
|
|
201
|
+
# Ensure directory exists for custom path
|
|
202
|
+
log_dir = os.path.dirname(self._custom_log_path)
|
|
203
|
+
if log_dir and not os.path.exists(log_dir):
|
|
204
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
205
|
+
else:
|
|
206
|
+
# Create log file in mssql_python_logs folder
|
|
207
|
+
log_dir = os.path.join(os.getcwd(), "mssql_python_logs")
|
|
208
|
+
if not os.path.exists(log_dir):
|
|
209
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
210
|
+
|
|
211
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
|
212
|
+
pid = os.getpid()
|
|
213
|
+
self._log_file = os.path.join(log_dir, f"mssql_python_trace_{timestamp}_{pid}.log")
|
|
214
|
+
|
|
215
|
+
# Create rotating file handler (512MB, 5 backups)
|
|
216
|
+
# Use UTF-8 encoding for unicode support on all platforms
|
|
217
|
+
self._file_handler = RotatingFileHandler(
|
|
218
|
+
self._log_file, maxBytes=512 * 1024 * 1024, backupCount=5, encoding="utf-8" # 512MB
|
|
219
|
+
)
|
|
220
|
+
self._file_handler.setFormatter(formatter)
|
|
221
|
+
self._logger.addHandler(self._file_handler)
|
|
222
|
+
|
|
223
|
+
# Write CSV header to new log file
|
|
224
|
+
self._write_log_header()
|
|
225
|
+
else:
|
|
226
|
+
# No file logging - clear the log file path
|
|
227
|
+
self._log_file = None
|
|
228
|
+
|
|
229
|
+
# Setup stdout handler if needed
|
|
230
|
+
if self._output_mode in (STDOUT, BOTH):
|
|
231
|
+
import sys
|
|
232
|
+
|
|
233
|
+
self._stdout_handler = logging.StreamHandler(sys.stdout)
|
|
234
|
+
self._stdout_handler.setFormatter(formatter)
|
|
235
|
+
self._logger.addHandler(self._stdout_handler)
|
|
236
|
+
|
|
237
|
+
def _reconfigure_handlers(self):
|
|
238
|
+
"""
|
|
239
|
+
Reconfigure handlers when output mode changes.
|
|
240
|
+
Closes existing handlers and creates new ones based on current output mode.
|
|
241
|
+
"""
|
|
242
|
+
self._setup_handlers()
|
|
243
|
+
|
|
244
|
+
def _cleanup_handlers(self):
|
|
245
|
+
"""
|
|
246
|
+
Cleanup all handlers on process exit.
|
|
247
|
+
Registered with atexit to ensure proper file handle cleanup.
|
|
248
|
+
|
|
249
|
+
Thread-safe: Protects against concurrent logging during cleanup.
|
|
250
|
+
|
|
251
|
+
Note on RotatingFileHandler:
|
|
252
|
+
- File rotation (at 512MB) is already thread-safe
|
|
253
|
+
- doRollover() is called within emit() which holds handler.lock
|
|
254
|
+
- No additional synchronization needed for rotation
|
|
255
|
+
"""
|
|
256
|
+
with self._handler_lock:
|
|
257
|
+
handlers = self._logger.handlers[:]
|
|
258
|
+
for handler in handlers:
|
|
259
|
+
handler.acquire()
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
for handler in handlers:
|
|
263
|
+
try:
|
|
264
|
+
handler.flush()
|
|
265
|
+
handler.close()
|
|
266
|
+
except:
|
|
267
|
+
pass # Ignore errors during cleanup
|
|
268
|
+
self._logger.removeHandler(handler)
|
|
269
|
+
finally:
|
|
270
|
+
for handler in handlers:
|
|
271
|
+
try:
|
|
272
|
+
handler.release()
|
|
273
|
+
except:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
def _validate_log_file_path(self, file_path: str) -> str:
|
|
277
|
+
"""
|
|
278
|
+
Validate and sanitize the log file path.
|
|
279
|
+
|
|
280
|
+
Resolves the path to its canonical form, checks for path traversal
|
|
281
|
+
outside the current working directory (for relative paths), and
|
|
282
|
+
validates the file extension.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
file_path: Path to the log file
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
The resolved canonical path.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
ValueError: If the path contains traversal outside the allowed
|
|
292
|
+
base directory, or if the file extension is not allowed
|
|
293
|
+
"""
|
|
294
|
+
resolved = os.path.realpath(os.path.abspath(file_path))
|
|
295
|
+
|
|
296
|
+
# For relative paths, ensure the resolved path stays under cwd
|
|
297
|
+
if not os.path.isabs(file_path):
|
|
298
|
+
base = os.path.realpath(os.path.abspath(os.getcwd()))
|
|
299
|
+
# os.path.commonpath raises ValueError if paths are on different drives
|
|
300
|
+
try:
|
|
301
|
+
common = os.path.commonpath([base, resolved])
|
|
302
|
+
except ValueError:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
"log_file_path resolves outside the current working directory. "
|
|
305
|
+
"Path traversal is not permitted for relative paths."
|
|
306
|
+
)
|
|
307
|
+
if common != base:
|
|
308
|
+
raise ValueError(
|
|
309
|
+
"log_file_path resolves outside the current working directory. "
|
|
310
|
+
"Path traversal is not permitted for relative paths."
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
_, ext = os.path.splitext(resolved)
|
|
314
|
+
ext_lower = ext.lower()
|
|
315
|
+
|
|
316
|
+
if ext_lower not in ALLOWED_LOG_EXTENSIONS:
|
|
317
|
+
allowed = ", ".join(sorted(ALLOWED_LOG_EXTENSIONS))
|
|
318
|
+
raise ValueError(
|
|
319
|
+
f"Invalid log file extension '{ext}'. " f"Allowed extensions: {allowed}"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return resolved
|
|
323
|
+
|
|
324
|
+
def _write_log_header(self):
|
|
325
|
+
"""
|
|
326
|
+
Write CSV header and metadata to the log file.
|
|
327
|
+
Called once when log file is created.
|
|
328
|
+
"""
|
|
329
|
+
if not self._log_file or not self._file_handler:
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
# Get script name from sys.argv or __main__
|
|
334
|
+
script_name = os.path.basename(sys.argv[0]) if sys.argv else "<interactive>"
|
|
335
|
+
|
|
336
|
+
# Get Python version
|
|
337
|
+
python_version = platform.python_version()
|
|
338
|
+
|
|
339
|
+
# Get driver version (try to import from package)
|
|
340
|
+
try:
|
|
341
|
+
from mssql_python import __version__
|
|
342
|
+
|
|
343
|
+
driver_version = __version__
|
|
344
|
+
except:
|
|
345
|
+
driver_version = "unknown"
|
|
346
|
+
|
|
347
|
+
# Get current time
|
|
348
|
+
start_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
349
|
+
|
|
350
|
+
# Get PID
|
|
351
|
+
pid = os.getpid()
|
|
352
|
+
|
|
353
|
+
# Get OS info
|
|
354
|
+
os_info = platform.platform()
|
|
355
|
+
|
|
356
|
+
# Build header comment line
|
|
357
|
+
header_line = f"# MSSQL-Python Driver Log | Script: {script_name} | PID: {pid} | Log Level: DEBUG | Python: {python_version} | Driver: {driver_version} | Start: {start_time} | OS: {os_info}\n"
|
|
358
|
+
|
|
359
|
+
# CSV column headers
|
|
360
|
+
csv_header = "Timestamp, ThreadID, Level, Location, Source, Message\n"
|
|
361
|
+
|
|
362
|
+
# Write directly to file (bypass formatter)
|
|
363
|
+
with open(self._log_file, "a") as f:
|
|
364
|
+
f.write(header_line)
|
|
365
|
+
f.write(csv_header)
|
|
366
|
+
|
|
367
|
+
except Exception as e:
|
|
368
|
+
# Notify on stderr so user knows why header is missing
|
|
369
|
+
try:
|
|
370
|
+
sys.stderr.write(
|
|
371
|
+
f"[MSSQL-Python] Warning: Failed to write log header to {self._log_file}: {type(e).__name__}\n"
|
|
372
|
+
)
|
|
373
|
+
sys.stderr.flush()
|
|
374
|
+
except:
|
|
375
|
+
pass # Even stderr notification failed
|
|
376
|
+
# Don't crash - logging continues without header
|
|
377
|
+
|
|
378
|
+
def py_core_log(self, level: int, msg: str, filename: str = "cursor.rs", lineno: int = 0):
|
|
379
|
+
"""
|
|
380
|
+
Logging method for py-core (Rust/TDS) code with custom source location.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
|
384
|
+
msg: Message string (already formatted)
|
|
385
|
+
filename: Source filename (e.g., 'cursor.rs')
|
|
386
|
+
lineno: Line number in source file
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
# Fast level check using cached level (same optimization as _log method)
|
|
390
|
+
# Exception: Always allow WARNING and ERROR messages through
|
|
391
|
+
if level < self._cached_level and level < logging.WARNING:
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
# Create a custom LogRecord with Rust source location
|
|
395
|
+
import logging as log_module
|
|
396
|
+
|
|
397
|
+
record = log_module.LogRecord(
|
|
398
|
+
name=self._logger.name,
|
|
399
|
+
level=level,
|
|
400
|
+
pathname=filename,
|
|
401
|
+
lineno=lineno,
|
|
402
|
+
msg=msg,
|
|
403
|
+
args=(),
|
|
404
|
+
exc_info=None,
|
|
405
|
+
func="py-core",
|
|
406
|
+
sinfo=None,
|
|
407
|
+
)
|
|
408
|
+
self._logger.handle(record)
|
|
409
|
+
except Exception:
|
|
410
|
+
# Fallback - use regular logging
|
|
411
|
+
try:
|
|
412
|
+
self._logger.log(level, msg)
|
|
413
|
+
except:
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
def _log(self, level: int, msg: str, add_prefix: bool = True, *args, **kwargs):
|
|
417
|
+
"""
|
|
418
|
+
Internal logging method with exception safety.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
|
422
|
+
msg: Message format string
|
|
423
|
+
add_prefix: Whether to add [Python] prefix (default True)
|
|
424
|
+
*args: Arguments for message formatting
|
|
425
|
+
**kwargs: Additional keyword arguments
|
|
426
|
+
|
|
427
|
+
Note:
|
|
428
|
+
Callers are responsible for sanitizing sensitive data (passwords,
|
|
429
|
+
tokens, etc.) before logging. Use helpers.sanitize_connection_string()
|
|
430
|
+
for connection strings.
|
|
431
|
+
|
|
432
|
+
Exception Safety:
|
|
433
|
+
NEVER crashes the application. Catches all exceptions:
|
|
434
|
+
- TypeError/ValueError: Bad format string or args
|
|
435
|
+
- IOError/OSError: Disk full, permission denied
|
|
436
|
+
- UnicodeEncodeError: Encoding issues
|
|
437
|
+
|
|
438
|
+
On critical failures (ERROR level), attempts stderr fallback.
|
|
439
|
+
All other failures are silently ignored to prevent app crashes.
|
|
440
|
+
"""
|
|
441
|
+
try:
|
|
442
|
+
# Fast level check using cached level (zero overhead if disabled)
|
|
443
|
+
# This avoids the overhead of isEnabledFor() method call
|
|
444
|
+
# Exception: Always allow WARNING and ERROR messages through
|
|
445
|
+
if level < self._cached_level and level < logging.WARNING:
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
# Add prefix if requested (only after level check)
|
|
449
|
+
if add_prefix:
|
|
450
|
+
msg = f"[Python] {msg}"
|
|
451
|
+
|
|
452
|
+
# Format message with args if provided
|
|
453
|
+
if args:
|
|
454
|
+
msg = msg % args
|
|
455
|
+
|
|
456
|
+
# Log the message with proper stack level to capture caller's location
|
|
457
|
+
# stacklevel=3 skips: _log -> debug/info/warning/error -> actual caller
|
|
458
|
+
self._logger.log(level, msg, stacklevel=3, **kwargs)
|
|
459
|
+
except Exception:
|
|
460
|
+
# Last resort: Try stderr fallback for any logging failure
|
|
461
|
+
# This helps diagnose critical issues (disk full, permission denied, etc.)
|
|
462
|
+
try:
|
|
463
|
+
import sys
|
|
464
|
+
|
|
465
|
+
level_name = logging.getLevelName(level)
|
|
466
|
+
sys.stderr.write(
|
|
467
|
+
f"[MSSQL-Python Logging Failed - {level_name}] {msg if 'msg' in locals() else 'Unable to format message'}\n"
|
|
468
|
+
)
|
|
469
|
+
sys.stderr.flush()
|
|
470
|
+
except:
|
|
471
|
+
pass # Even stderr failed - give up silently
|
|
472
|
+
|
|
473
|
+
# Convenience methods for logging
|
|
474
|
+
|
|
475
|
+
def debug(self, msg: str, *args, **kwargs):
|
|
476
|
+
"""Log at DEBUG level (all diagnostic messages)"""
|
|
477
|
+
self._log(logging.DEBUG, msg, True, *args, **kwargs)
|
|
478
|
+
|
|
479
|
+
def info(self, msg: str, *args, **kwargs):
|
|
480
|
+
"""Log at INFO level"""
|
|
481
|
+
self._log(logging.INFO, msg, True, *args, **kwargs)
|
|
482
|
+
|
|
483
|
+
def warning(self, msg: str, *args, **kwargs):
|
|
484
|
+
"""Log at WARNING level"""
|
|
485
|
+
self._log(logging.WARNING, msg, True, *args, **kwargs)
|
|
486
|
+
|
|
487
|
+
def error(self, msg: str, *args, **kwargs):
|
|
488
|
+
"""Log at ERROR level"""
|
|
489
|
+
self._log(logging.ERROR, msg, True, *args, **kwargs)
|
|
490
|
+
|
|
491
|
+
# Level control
|
|
492
|
+
|
|
493
|
+
def _setLevel(
|
|
494
|
+
self, level: int, output: Optional[str] = None, log_file_path: Optional[str] = None
|
|
495
|
+
):
|
|
496
|
+
"""
|
|
497
|
+
Internal method to set logging level (use setup_logging() instead).
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
level: Logging level (typically DEBUG)
|
|
501
|
+
output: Optional output mode (FILE, STDOUT, BOTH)
|
|
502
|
+
log_file_path: Optional custom path for log file
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
ValueError: If output mode is invalid
|
|
506
|
+
"""
|
|
507
|
+
# Validate and set output mode if specified
|
|
508
|
+
if output is not None:
|
|
509
|
+
if output not in (FILE, STDOUT, BOTH):
|
|
510
|
+
raise ValueError(
|
|
511
|
+
f"Invalid output mode: {output}. " f"Must be one of: {FILE}, {STDOUT}, {BOTH}"
|
|
512
|
+
)
|
|
513
|
+
self._output_mode = output
|
|
514
|
+
|
|
515
|
+
# Store custom log file path if provided (sanitized)
|
|
516
|
+
if log_file_path is not None:
|
|
517
|
+
self._custom_log_path = self._validate_log_file_path(log_file_path)
|
|
518
|
+
|
|
519
|
+
# Setup handlers if not yet initialized or if output mode/path changed
|
|
520
|
+
# Handler setup is protected by _handler_lock inside _setup_handlers()
|
|
521
|
+
if not self._handlers_initialized or output is not None or log_file_path is not None:
|
|
522
|
+
self._setup_handlers()
|
|
523
|
+
self._handlers_initialized = True
|
|
524
|
+
|
|
525
|
+
# Register atexit cleanup on first handler setup
|
|
526
|
+
if not self._cleanup_registered:
|
|
527
|
+
atexit.register(self._cleanup_handlers)
|
|
528
|
+
self._cleanup_registered = True
|
|
529
|
+
|
|
530
|
+
# Set level (atomic operation, no lock needed)
|
|
531
|
+
self._logger.setLevel(level)
|
|
532
|
+
|
|
533
|
+
# Cache level for fast checks (avoid repeated isEnabledFor calls)
|
|
534
|
+
# Note: These updates are not atomic across both variables, creating a brief
|
|
535
|
+
# window where reads might see inconsistent state (e.g., updated _cached_level
|
|
536
|
+
# but old _is_debug_enabled). This is an acceptable benign race condition:
|
|
537
|
+
# - Worst case: one log statement might be incorrectly allowed/blocked
|
|
538
|
+
# - Duration: nanoseconds (single Python bytecode instruction gap)
|
|
539
|
+
# - Impact: negligible - next check will see consistent state
|
|
540
|
+
# - Alternative (locking) would add overhead to every log call
|
|
541
|
+
self._cached_level = level
|
|
542
|
+
self._is_debug_enabled = level <= logging.DEBUG
|
|
543
|
+
|
|
544
|
+
# Notify C++ bridge of level change
|
|
545
|
+
self._notify_cpp_level_change(level)
|
|
546
|
+
|
|
547
|
+
def getLevel(self) -> int:
|
|
548
|
+
"""
|
|
549
|
+
Get the current logging level.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
int: Current log level
|
|
553
|
+
"""
|
|
554
|
+
return self._logger.level
|
|
555
|
+
|
|
556
|
+
def isEnabledFor(self, level: int) -> bool:
|
|
557
|
+
"""
|
|
558
|
+
Check if a given log level is enabled.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
level: Log level to check
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
bool: True if the level is enabled
|
|
565
|
+
"""
|
|
566
|
+
return self._logger.isEnabledFor(level)
|
|
567
|
+
|
|
568
|
+
# Handler management
|
|
569
|
+
|
|
570
|
+
def addHandler(self, handler: logging.Handler):
|
|
571
|
+
"""Add a handler to the logger (thread-safe)"""
|
|
572
|
+
with self._handler_lock:
|
|
573
|
+
self._logger.addHandler(handler)
|
|
574
|
+
|
|
575
|
+
def removeHandler(self, handler: logging.Handler):
|
|
576
|
+
"""Remove a handler from the logger (thread-safe)"""
|
|
577
|
+
with self._handler_lock:
|
|
578
|
+
self._logger.removeHandler(handler)
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def handlers(self) -> list:
|
|
582
|
+
"""Get list of handlers attached to the logger (thread-safe)"""
|
|
583
|
+
with self._handler_lock:
|
|
584
|
+
return self._logger.handlers[:] # Return copy to prevent external modification
|
|
585
|
+
|
|
586
|
+
def reset_handlers(self):
|
|
587
|
+
"""
|
|
588
|
+
Reset/recreate handlers.
|
|
589
|
+
Useful when log file has been deleted or needs to be recreated.
|
|
590
|
+
"""
|
|
591
|
+
self._setup_handlers()
|
|
592
|
+
|
|
593
|
+
def _notify_cpp_level_change(self, level: int):
|
|
594
|
+
"""
|
|
595
|
+
Notify C++ bridge that log level has changed.
|
|
596
|
+
This updates the cached level in C++ for fast checks.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
level: New log level
|
|
600
|
+
"""
|
|
601
|
+
try:
|
|
602
|
+
# Import here to avoid circular dependency
|
|
603
|
+
from . import ddbc_bindings
|
|
604
|
+
|
|
605
|
+
if hasattr(ddbc_bindings, "update_log_level"):
|
|
606
|
+
ddbc_bindings.update_log_level(level)
|
|
607
|
+
except (ImportError, AttributeError):
|
|
608
|
+
# C++ bindings not available or not yet initialized
|
|
609
|
+
pass
|
|
610
|
+
|
|
611
|
+
# Properties
|
|
612
|
+
|
|
613
|
+
@property
|
|
614
|
+
def output(self) -> str:
|
|
615
|
+
"""Get the current output mode"""
|
|
616
|
+
return self._output_mode
|
|
617
|
+
|
|
618
|
+
@output.setter
|
|
619
|
+
def output(self, mode: str):
|
|
620
|
+
"""
|
|
621
|
+
Set the output mode.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
mode: Output mode (FILE, STDOUT, or BOTH)
|
|
625
|
+
|
|
626
|
+
Raises:
|
|
627
|
+
ValueError: If mode is not a valid OutputMode value
|
|
628
|
+
"""
|
|
629
|
+
if mode not in (FILE, STDOUT, BOTH):
|
|
630
|
+
raise ValueError(
|
|
631
|
+
f"Invalid output mode: {mode}. " f"Must be one of: {FILE}, {STDOUT}, {BOTH}"
|
|
632
|
+
)
|
|
633
|
+
self._output_mode = mode
|
|
634
|
+
|
|
635
|
+
# Only reconfigure if handlers were already initialized
|
|
636
|
+
if self._handlers_initialized:
|
|
637
|
+
self._reconfigure_handlers()
|
|
638
|
+
|
|
639
|
+
@property
|
|
640
|
+
def log_file(self) -> Optional[str]:
|
|
641
|
+
"""Get the current log file path (None if file output is disabled)"""
|
|
642
|
+
return self._log_file
|
|
643
|
+
|
|
644
|
+
@property
|
|
645
|
+
def level(self) -> int:
|
|
646
|
+
"""Get the current logging level"""
|
|
647
|
+
return self._logger.level
|
|
648
|
+
|
|
649
|
+
@property
|
|
650
|
+
def is_debug_enabled(self) -> bool:
|
|
651
|
+
"""Fast check if debug logging is enabled (cached for performance)"""
|
|
652
|
+
return self._is_debug_enabled
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# ============================================================================
|
|
656
|
+
# Module-level exports (Primary API)
|
|
657
|
+
# ============================================================================
|
|
658
|
+
|
|
659
|
+
# Singleton logger instance
|
|
660
|
+
logger = MSSQLLogger()
|
|
661
|
+
|
|
662
|
+
# Expose the underlying Python logger for use in application code
|
|
663
|
+
# This allows applications to access the same logger used by the driver
|
|
664
|
+
# Usage: from mssql_python.logging import driver_logger
|
|
665
|
+
driver_logger = logger._logger
|
|
666
|
+
|
|
667
|
+
# ============================================================================
|
|
668
|
+
# Primary API - setup_logging()
|
|
669
|
+
# ============================================================================
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def setup_logging(output: str = "file", log_file_path: Optional[str] = None):
|
|
673
|
+
"""
|
|
674
|
+
Enable DEBUG logging for troubleshooting.
|
|
675
|
+
|
|
676
|
+
⚠️ PERFORMANCE WARNING: Logging adds ~2-5% overhead.
|
|
677
|
+
Only enable when investigating issues. Do NOT enable in production without reason.
|
|
678
|
+
|
|
679
|
+
Philosophy: All or nothing - if you need logging, you need to see EVERYTHING.
|
|
680
|
+
Logging is a troubleshooting tool, not a production monitoring solution.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
output: Where to send logs (default: 'file')
|
|
684
|
+
Options: 'file', 'stdout', 'both'
|
|
685
|
+
log_file_path: Optional custom path for log file
|
|
686
|
+
Must have extension: .txt, .log, or .csv
|
|
687
|
+
If not specified, auto-generates in ./mssql_python_logs/
|
|
688
|
+
|
|
689
|
+
Examples:
|
|
690
|
+
import mssql_python
|
|
691
|
+
|
|
692
|
+
# File only (default, in mssql_python_logs folder)
|
|
693
|
+
mssql_python.setup_logging()
|
|
694
|
+
|
|
695
|
+
# Stdout only (for CI/CD)
|
|
696
|
+
mssql_python.setup_logging(output='stdout')
|
|
697
|
+
|
|
698
|
+
# Both file and stdout (for development)
|
|
699
|
+
mssql_python.setup_logging(output='both')
|
|
700
|
+
|
|
701
|
+
# Custom log file path (must use .txt, .log, or .csv extension)
|
|
702
|
+
mssql_python.setup_logging(log_file_path="/var/log/myapp.log")
|
|
703
|
+
mssql_python.setup_logging(log_file_path="/tmp/debug.txt")
|
|
704
|
+
mssql_python.setup_logging(log_file_path="/tmp/data.csv")
|
|
705
|
+
|
|
706
|
+
# Custom path with both outputs
|
|
707
|
+
mssql_python.setup_logging(output='both', log_file_path="/tmp/debug.log")
|
|
708
|
+
|
|
709
|
+
Future Enhancement:
|
|
710
|
+
For performance analysis, use the universal profiler (coming soon)
|
|
711
|
+
instead of logging. Logging is not designed for performance measurement.
|
|
712
|
+
"""
|
|
713
|
+
logger._setLevel(logging.DEBUG, output, log_file_path)
|
|
714
|
+
return logger
|