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