@@ -2955,6 +2955,55 @@ def _looks_like_arrow_source(self, data) -> bool:
29552955 return False
29562956 return isinstance (data , (pa .Table , pa .RecordBatch , pa .RecordBatchReader ))
29572957
2958+ @staticmethod
2959+ def _bulkcopy_core_and_validate (table_name , batch_size , timeout ):
2960+ """Import the native core and validate the args shared by ``bulkcopy``
2961+ and ``bulkcopy_arrow``. Returns the imported ``mssql_py_core`` module."""
2962+ try :
2963+ import mssql_py_core
2964+ except ImportError as exc :
2965+ logger .error ("bulkcopy: Failed to import mssql_py_core module" )
2966+ raise ImportError (
2967+ "Bulk copy requires the mssql_py_core library which is not available. "
2968+ "This is an unexpected error. "
2969+ ) from exc
2970+
2971+ if not table_name or not isinstance (table_name , str ):
2972+ logger .error ("bulkcopy: Invalid table_name parameter" )
2973+ raise ValueError ("table_name must be a non-empty string" )
2974+
2975+ if not isinstance (batch_size , int ):
2976+ raise TypeError (
2977+ f"batch_size must be a non-negative integer, got { type (batch_size ).__name__ } "
2978+ )
2979+ if batch_size < 0 :
2980+ raise ValueError (f"batch_size must be non-negative, got { batch_size } " )
2981+
2982+ if not isinstance (timeout , int ):
2983+ raise TypeError (f"timeout must be a positive integer, got { type (timeout ).__name__ } " )
2984+ if timeout <= 0 :
2985+ raise ValueError (f"timeout must be positive, got { timeout } " )
2986+
2987+ return mssql_py_core
2988+
2989+ @staticmethod
2990+ def _bulkcopy_teardown (pycore_context , pycore_cursor , pycore_connection ):
2991+ """Scrub credential material from the context and close native
2992+ bulk-copy resources. Safe to call with partially-initialized state."""
2993+ if pycore_context :
2994+ for key in ("password" , "user_name" , "access_token" , "entra_id_token_factory" ):
2995+ pycore_context .pop (key , None )
2996+ for resource in (pycore_cursor , pycore_connection ):
2997+ if resource and hasattr (resource , "close" ):
2998+ try :
2999+ resource .close ()
3000+ except Exception as cleanup_error :
3001+ logger .debug (
3002+ "Failed to close bulk copy resource %s: %s" ,
3003+ type (resource ).__name__ ,
3004+ cleanup_error ,
3005+ )
3006+
29583007 def bulkcopy (
29593008 self ,
29603009 table_name : str ,
@@ -3047,19 +3096,7 @@ def bulkcopy(
30473096 "cursor.bulkcopy_arrow() instead."
30483097 )
30493098
3050- try :
3051- import mssql_py_core
3052- except ImportError as exc :
3053- logger .error ("_bulkcopy: Failed to import mssql_py_core module" )
3054- raise ImportError (
3055- "Bulk copy requires the mssql_py_core library which is not available. "
3056- "This is an unexpected error. "
3057- ) from exc
3058-
3059- # Validate inputs
3060- if not table_name or not isinstance (table_name , str ):
3061- logger .error ("_bulkcopy: Invalid table_name parameter" )
3062- raise ValueError ("table_name must be a non-empty string" )
3099+ mssql_py_core = self ._bulkcopy_core_and_validate (table_name , batch_size , timeout )
30633100
30643101 # Validate that data is iterable (but not a string or bytes, which are technically iterable)
30653102 if data is None :
@@ -3074,20 +3111,6 @@ def bulkcopy(
30743111 f"data must be an iterable of tuples or lists, got non-iterable { type (data ).__name__ } "
30753112 )
30763113
3077- # Validate batch_size type and value (0 means server optimal)
3078- if not isinstance (batch_size , int ):
3079- raise TypeError (
3080- f"batch_size must be a non-negative integer, got { type (batch_size ).__name__ } "
3081- )
3082- if batch_size < 0 :
3083- raise ValueError (f"batch_size must be non-negative, got { batch_size } " )
3084-
3085- # Validate timeout type and value
3086- if not isinstance (timeout , int ):
3087- raise TypeError (f"timeout must be a positive integer, got { type (timeout ).__name__ } " )
3088- if timeout <= 0 :
3089- raise ValueError (f"timeout must be positive, got { timeout } " )
3090-
30913114 pycore_context = self ._build_pycore_context ()
30923115
30933116 pycore_connection = None
@@ -3157,35 +3180,13 @@ def _prepare_row_iterator(iterable):
31573180 type (e ).__name__ ,
31583181 str (e ),
31593182 )
3160- # Re-raise without exposing connection context in the error chain
3161- # to prevent credential leakage in stack traces
3162- raise type (e )(str (e )) from None
3183+ # Re-raise the original exception, preserving its type, args, and
3184+ # traceback. The finally block scrubs credentials, and Python
3185+ # tracebacks don't expose local values, so no reconstruction is needed.
3186+ raise
31633187
31643188 finally :
3165- # Clear sensitive data to minimize memory exposure. The
3166- # entra_id_token_factory closure captures client_secret, so drop
3167- # our dict reference to it (Rust still holds an Arc until the
3168- # connection is dropped, but at least we don't keep an extra ref).
3169- if pycore_context :
3170- for key in (
3171- "password" ,
3172- "user_name" ,
3173- "access_token" ,
3174- "entra_id_token_factory" ,
3175- ):
3176- pycore_context .pop (key , None )
3177- # Clean up bulk copy resources
3178- for resource in (pycore_cursor , pycore_connection ):
3179- if resource and hasattr (resource , "close" ):
3180- try :
3181- resource .close ()
3182- except Exception as cleanup_error :
3183- # Log cleanup errors only - aids troubleshooting without masking original exception
3184- logger .debug (
3185- "Failed to close bulk copy resource %s: %s" ,
3186- type (resource ).__name__ ,
3187- cleanup_error ,
3188- )
3189+ self ._bulkcopy_teardown (pycore_context , pycore_cursor , pycore_connection )
31893190
31903191 def bulkcopy_arrow (
31913192 self ,
@@ -3241,7 +3242,7 @@ def bulkcopy_arrow(
32413242
32423243 Raises:
32433244 ImportError: If the mssql_py_core library is not installed.
3244- TypeError: If ``source`` is None.
3245+ TypeError: If ``source`` is None, a str, or bytes .
32453246 ValueError: If ``table_name`` is empty or parameters are invalid.
32463247 RuntimeError: If the connection string is not available.
32473248
@@ -3254,38 +3255,16 @@ def bulkcopy_arrow(
32543255 """
32553256 is_logging_enabled = logger .is_debug_enabled
32563257
3257- try :
3258- import mssql_py_core
3259- except ImportError as exc :
3260- logger .error ("bulkcopy_arrow: Failed to import mssql_py_core module" )
3261- raise ImportError (
3262- "Bulk copy requires the mssql_py_core library which is not available. "
3263- "This is an unexpected error. "
3264- ) from exc
3265-
3266- if not table_name or not isinstance (table_name , str ):
3267- logger .error ("bulkcopy_arrow: Invalid table_name parameter" )
3268- raise ValueError ("table_name must be a non-empty string" )
3258+ mssql_py_core = self ._bulkcopy_core_and_validate (table_name , batch_size , timeout )
32693259
3270- if source is None :
3260+ if source is None or isinstance (source , (str , bytes )):
3261+ got = "None" if source is None else type (source ).__name__
32713262 raise TypeError (
32723263 "source must be a pyarrow Table/RecordBatch/RecordBatchReader, "
32733264 "an iterable of RecordBatch, or an object implementing "
3274- "__arrow_c_stream__/__arrow_c_array__, got None "
3265+ f "__arrow_c_stream__/__arrow_c_array__, got { got } "
32753266 )
32763267
3277- if not isinstance (batch_size , int ):
3278- raise TypeError (
3279- f"batch_size must be a non-negative integer, got { type (batch_size ).__name__ } "
3280- )
3281- if batch_size < 0 :
3282- raise ValueError (f"batch_size must be non-negative, got { batch_size } " )
3283-
3284- if not isinstance (timeout , int ):
3285- raise TypeError (f"timeout must be a positive integer, got { type (timeout ).__name__ } " )
3286- if timeout <= 0 :
3287- raise ValueError (f"timeout must be positive, got { timeout } " )
3288-
32893268 pycore_context = self ._build_pycore_context ()
32903269
32913270 pycore_connection = None
@@ -3329,27 +3308,7 @@ def bulkcopy_arrow(
33293308 raise
33303309
33313310 finally :
3332- # Clear sensitive data to minimize memory exposure. The
3333- # entra_id_token_factory closure captures client_secret, so drop
3334- # our dict reference to it as well.
3335- if pycore_context :
3336- for key in (
3337- "password" ,
3338- "user_name" ,
3339- "access_token" ,
3340- "entra_id_token_factory" ,
3341- ):
3342- pycore_context .pop (key , None )
3343- for resource in (pycore_cursor , pycore_connection ):
3344- if resource and hasattr (resource , "close" ):
3345- try :
3346- resource .close ()
3347- except Exception as cleanup_error :
3348- logger .debug (
3349- "Failed to close bulk copy resource %s: %s" ,
3350- type (resource ).__name__ ,
3351- cleanup_error ,
3352- )
3311+ self ._bulkcopy_teardown (pycore_context , pycore_cursor , pycore_connection )
33533312
33543313 def __enter__ (self ):
33553314 """
0 commit comments