mssql-python 1.16.0__cp315-cp315-win_arm64.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.
@@ -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