dbtk 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. dbtk/__init__.py +54 -0
  2. dbtk/cli.py +169 -0
  3. dbtk/config.py +1194 -0
  4. dbtk/cursors.py +566 -0
  5. dbtk/database.py +959 -0
  6. dbtk/dbtk_sample.yml +90 -0
  7. dbtk/defaults.py +29 -0
  8. dbtk/etl/__init__.py +61 -0
  9. dbtk/etl/base_surge.py +257 -0
  10. dbtk/etl/bulk_surge.py +783 -0
  11. dbtk/etl/config_generators.py +458 -0
  12. dbtk/etl/data_surge.py +294 -0
  13. dbtk/etl/managers.py +734 -0
  14. dbtk/etl/table.py +1215 -0
  15. dbtk/etl/transforms/__init__.py +107 -0
  16. dbtk/etl/transforms/address.py +560 -0
  17. dbtk/etl/transforms/core.py +594 -0
  18. dbtk/etl/transforms/database.py +506 -0
  19. dbtk/etl/transforms/datetime.py +497 -0
  20. dbtk/etl/transforms/email.py +72 -0
  21. dbtk/etl/transforms/phone.py +670 -0
  22. dbtk/formats/__init__.py +18 -0
  23. dbtk/formats/edi.py +182 -0
  24. dbtk/logging_utils.py +310 -0
  25. dbtk/readers/__init__.py +26 -0
  26. dbtk/readers/base.py +644 -0
  27. dbtk/readers/csv.py +195 -0
  28. dbtk/readers/data_frame.py +119 -0
  29. dbtk/readers/excel.py +260 -0
  30. dbtk/readers/fixed_width.py +359 -0
  31. dbtk/readers/json.py +323 -0
  32. dbtk/readers/utils.py +394 -0
  33. dbtk/readers/xml.py +260 -0
  34. dbtk/record.py +710 -0
  35. dbtk/utils.py +537 -0
  36. dbtk/writers/__init__.py +46 -0
  37. dbtk/writers/base.py +718 -0
  38. dbtk/writers/csv.py +107 -0
  39. dbtk/writers/database.py +158 -0
  40. dbtk/writers/excel.py +1086 -0
  41. dbtk/writers/fixed_width.py +290 -0
  42. dbtk/writers/json.py +156 -0
  43. dbtk/writers/utils.py +41 -0
  44. dbtk/writers/xml.py +387 -0
  45. dbtk-0.8.0.dist-info/METADATA +305 -0
  46. dbtk-0.8.0.dist-info/RECORD +50 -0
  47. dbtk-0.8.0.dist-info/WHEEL +5 -0
  48. dbtk-0.8.0.dist-info/entry_points.txt +2 -0
  49. dbtk-0.8.0.dist-info/licenses/LICENSE.txt +7 -0
  50. dbtk-0.8.0.dist-info/top_level.txt +1 -0
dbtk/database.py ADDED
@@ -0,0 +1,959 @@
1
+ # dbtk/database.py
2
+ """
3
+ Database connection wrapper that provides a uniform interface
4
+ to different database adapters.
5
+ """
6
+
7
+ import importlib
8
+ import os
9
+ import logging
10
+ from typing import Dict, Any, Optional, Union, Type, List
11
+ from contextlib import contextmanager
12
+
13
+ from .cursors import Cursor, ParamStyle
14
+ from .defaults import settings
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # users can define their own drivers in the config file
19
+ _user_drivers = {}
20
+
21
+ def _hide_password(kwargs):
22
+ """Replace password with '********' to be printable"""
23
+ parms = kwargs.copy()
24
+ for key, val in parms.items():
25
+ if key in ('password', 'PWD', 'passwd'):
26
+ parms[key] = '********'
27
+ return parms
28
+
29
+ DRIVERS = {
30
+ # PostgreSQL Drivers
31
+ 'psycopg2': {
32
+ 'database_type': 'postgres',
33
+ 'priority': 11,
34
+ 'param_map': {'database': 'dbname'},
35
+ 'required_params': [{'host', 'database', 'user'}],
36
+ 'optional_params': {'port', 'password', 'sslmode', 'connect_timeout', 'application_name',
37
+ 'client_encoding', 'options', 'sslcert', 'sslkey', 'sslrootcert'},
38
+ 'connection_method': 'connection_string',
39
+ 'default_port': 5432,
40
+ },
41
+ 'psycopg': { # psycopg3
42
+ 'database_type': 'postgres',
43
+ 'priority': 12,
44
+ 'param_map': {'database': 'dbname'},
45
+ 'required_params': [{'host', 'database', 'user'}],
46
+ 'optional_params': {'port', 'password', 'sslmode', 'connect_timeout', 'application_name',
47
+ 'client_encoding', 'options', 'sslcert', 'sslkey', 'sslrootcert'},
48
+ 'connection_method': 'connection_string',
49
+ 'default_port': 5432,
50
+ 'note': 'pip install psycopg[binary]'
51
+ },
52
+ 'pgdb': {
53
+ 'database_type': 'postgres',
54
+ 'priority': 13,
55
+ 'param_map': {'database': 'database'},
56
+ 'required_params': [{'host', 'database', 'user'}],
57
+ 'optional_params': {'port', 'password'},
58
+ 'connection_method': 'kwargs',
59
+ 'default_port': 5432,
60
+ },
61
+
62
+ # Oracle Drivers
63
+ 'oracledb': {
64
+ 'database_type': 'oracle',
65
+ 'priority': 11,
66
+ 'param_map': {'database': 'service_name'},
67
+ 'required_params': [{'dsn', 'user'}, {'host', 'port', 'database', 'user'}],
68
+ 'optional_params': {'password', 'mode', 'events', 'purity', 'cclass', 'tag', 'matchanytag',
69
+ 'config_dir', 'wallet_location', 'wallet_password'},
70
+ 'connection_method': 'dsn',
71
+ 'default_port': 1521
72
+ },
73
+ 'cx_Oracle': {
74
+ 'database_type': 'oracle',
75
+ 'priority': 12,
76
+ 'param_map': {'database': 'service_name'},
77
+ 'required_params': [{'dsn'}, {'host', 'port', 'database', 'user'}],
78
+ 'optional_params': {'password', 'mode', 'events', 'purity', 'cclass', 'tag', 'matchanytag',
79
+ 'encoding', 'nencoding', 'edition', 'appcontext'},
80
+ 'connection_method': 'dsn',
81
+ 'default_port': 1521
82
+ },
83
+
84
+ # MySQL Drivers
85
+ 'MySQLdb': {
86
+ 'database_type': 'mysql',
87
+ 'priority': 11,
88
+ 'param_map': {'database': 'db', 'password': 'passwd'},
89
+ 'required_params': [{'host', 'database', 'user'}],
90
+ 'optional_params': {'port', 'password', 'charset', 'use_unicode', 'sql_mode', 'read_default_file',
91
+ 'conv', 'connect_timeout', 'compress', 'named_pipe', 'init_command',
92
+ 'read_default_group', 'unix_socket', 'port'},
93
+ 'connection_method': 'kwargs',
94
+ 'default_port': 3306,
95
+ 'note': 'pip install mysqlclient',
96
+ },
97
+ 'mariadb': {
98
+ 'database_type': 'mysql',
99
+ 'priority': 12,
100
+ 'param_map': {},
101
+ 'required_params': [{'host', 'database', 'user'}],
102
+ 'optional_params': {'port', 'password', 'unix_socket', 'ssl', 'tls_version',
103
+ 'autocommit', 'converter', 'connect_timeout'},
104
+ 'connection_method': 'kwargs',
105
+ 'default_port': 3306
106
+ },
107
+ 'mysql.connector': {
108
+ 'database_type': 'mysql',
109
+ 'priority': 13,
110
+ 'param_map': {},
111
+ 'required_params': [{'host', 'database', 'user'}],
112
+ 'optional_params': {'port', 'password', 'charset', 'collation', 'autocommit', 'time_zone',
113
+ 'sql_mode', 'use_unicode', 'get_warnings', 'raise_on_warnings',
114
+ 'connection_timeout', 'buffered', 'raw', 'consume_results'},
115
+ 'connection_method': 'kwargs',
116
+ 'default_port': 3306,
117
+ 'note': 'pip install mysql-connector-python'
118
+ },
119
+ 'pymysql': {
120
+ 'database_type': 'mysql',
121
+ 'priority': 14,
122
+ 'param_map': {'database': 'db', 'password': 'passwd'},
123
+ 'required_params': [{'host', 'database', 'user'}],
124
+ 'optional_params': {'port', 'password', 'charset', 'sql_mode', 'read_default_file',
125
+ 'conv', 'use_unicode', 'connect_timeout', 'read_timeout', 'write_timeout',
126
+ 'bind_address', 'unix_socket', 'autocommit'},
127
+ 'connection_method': 'kwargs',
128
+ 'default_port': 3306
129
+ },
130
+
131
+ # SQL Server Drivers
132
+ 'pyodbc_sqlserver': {
133
+ 'database_type': 'sqlserver',
134
+ 'module': 'pyodbc',
135
+ 'priority': 11,
136
+ 'param_map': {'host': 'SERVER', 'database': 'DATABASE', 'user': 'UID', 'password': 'PWD'},
137
+ 'required_params': [{'host', 'database', 'user'}, {'dsn'}],
138
+ 'optional_params': {'password', 'port', 'driver', 'trusted_connection', 'encrypt', 'trustservercertificate'},
139
+ 'connection_method': 'odbc_string',
140
+ 'odbc_driver_name': 'ODBC Driver 17 for SQL Server',
141
+ 'default_port': 1433
142
+ },
143
+ 'pymssql': {
144
+ 'database_type': 'sqlserver',
145
+ 'priority': 12,
146
+ 'param_map': {},
147
+ 'required_params': [{'host', 'database', 'user'}],
148
+ 'optional_params': {'password', 'port', 'timeout', 'login_timeout', 'charset', 'as_dict', 'appname'},
149
+ 'connection_method': 'kwargs',
150
+ 'default_port': 1433
151
+ },
152
+
153
+ # ODBC Drivers for other databases
154
+ 'pyodbc_postgres': {
155
+ 'database_type': 'postgres',
156
+ 'module': 'pyodbc',
157
+ 'priority': 14,
158
+ 'param_map': {'host': 'SERVER', 'database': 'DATABASE', 'user': 'UID', 'password': 'PWD'},
159
+ 'required_params': [{'host', 'database', 'user'}, {'dsn'}],
160
+ 'optional_params': {'password', 'port'},
161
+ 'connection_method': 'odbc_string',
162
+ 'odbc_driver_name': 'PostgreSQL Unicode',
163
+ 'default_port': 5432
164
+ },
165
+ 'pyodbc_mysql': {
166
+ 'database_type': 'mysql',
167
+ 'module': 'pyodbc',
168
+ 'priority': 16,
169
+ 'param_map': {'host': 'SERVER', 'database': 'DATABASE', 'user': 'UID', 'password': 'PWD'},
170
+ 'required_params': [{'host', 'database', 'user'}],
171
+ 'optional_params': {'password', 'port'},
172
+ 'connection_method': 'odbc_string',
173
+ 'odbc_driver_name': 'MySQL ODBC 8.0 Unicode Driver',
174
+ 'default_port': 3306
175
+ },
176
+ 'pyodbc_oracle': {
177
+ 'database_type': 'oracle',
178
+ 'module': 'pyodbc',
179
+ 'priority': 13,
180
+ 'param_map': {'host': 'SERVER', 'database': 'DATABASE', 'user': 'UID', 'password': 'PWD'},
181
+ 'required_params': [{'host', 'database', 'user'}],
182
+ 'optional_params': {'password', 'port'},
183
+ 'connection_method': 'odbc_string',
184
+ 'odbc_driver_name': 'Oracle in OraClient19Home1',
185
+ 'default_port': 1521
186
+ },
187
+
188
+ # SQLite Driver
189
+ 'sqlite3': {
190
+ 'database_type': 'sqlite',
191
+ 'priority': 1,
192
+ 'param_map': {},
193
+ 'required_params': [{'database'}],
194
+ 'optional_params': {'timeout', 'detect_types', 'isolation_level', 'check_same_thread',
195
+ 'factory', 'cached_statements', 'uri'},
196
+ 'connection_method': 'kwargs'
197
+ }
198
+ }
199
+
200
+
201
+ def register_user_drivers(drivers_config: dict) -> None:
202
+ """Register drivers from config file."""
203
+ global _user_drivers
204
+ _user_drivers.update(drivers_config)
205
+
206
+
207
+ def _get_all_drivers() -> dict:
208
+ """Get combined built-in and user drivers."""
209
+ return {**DRIVERS, **_user_drivers}
210
+
211
+
212
+ def _get_drivers_for_database(db_type: str, valid_only: bool = True) -> List[str]:
213
+ """
214
+ Gets a list of drivers available for the specified database type.
215
+
216
+ Iterates through DRIVERS to identify which ones match the specified database type.
217
+ Drivers can be filtered to include only those that are currently importable and available for use.
218
+ The result is a sorted list of driver names based on their priority.
219
+
220
+ Parameters:
221
+ db_type (str): The type of database for which to retrieve drivers.
222
+ valid_only (bool): Specifies whether to include only valid and importable
223
+ drivers (default is True).
224
+
225
+ Returns:
226
+ List[str]: A sorted list of driver names available for the given database type.
227
+ """
228
+ all_drivers = _get_all_drivers()
229
+ available_drivers = []
230
+
231
+ for driver_name, info in all_drivers.items():
232
+ if info['database_type'] == db_type:
233
+ if valid_only:
234
+ # only add if the driver is available (importable)
235
+ module_name = info.get('module', driver_name)
236
+ spec = importlib.util.find_spec(module_name)
237
+ if spec:
238
+ available_drivers.append(driver_name)
239
+ else:
240
+ available_drivers.append(driver_name)
241
+
242
+ def sort_key(driver_name):
243
+ priority = all_drivers[driver_name]['priority']
244
+ # User drivers get slight priority boost for tie-breaking
245
+ if driver_name in _user_drivers:
246
+ priority -= 0.5
247
+ return priority
248
+ available_drivers.sort(key=sort_key)
249
+ return available_drivers
250
+
251
+
252
+ def _get_db_type_for_driver(driver_name: str) -> str:
253
+ """Get database type for a driver."""
254
+ return _get_all_drivers().get(driver_name, dict()).get('database_type')
255
+
256
+
257
+ def _get_params_for_database(db_type: str, driver: str = None) -> set:
258
+ """Get all valid parameters for a database type from DRIVERS metadata."""
259
+ from .database import _get_all_drivers
260
+
261
+ all_drivers = _get_all_drivers()
262
+ valid_params = set()
263
+
264
+ # Collect parameters from all drivers for this database type
265
+ for driver_name, driver_info in all_drivers.items():
266
+ if driver_info['database_type'] == db_type:
267
+ if driver and driver_name != driver:
268
+ continue
269
+ # Add required params
270
+ for param_set in driver_info['required_params']:
271
+ valid_params.update(param_set)
272
+ # Add optional params
273
+ valid_params.update(driver_info.get('optional_params', set()))
274
+
275
+ return valid_params
276
+
277
+
278
+ def get_supported_db_types() -> set:
279
+ """Get all supported database types."""
280
+ valid_db_types = set()
281
+ all_drivers = _get_all_drivers()
282
+ for driver_name, driver_info in all_drivers.items():
283
+ valid_db_types.add(driver_info['database_type'])
284
+ return valid_db_types
285
+
286
+
287
+ def _validate_connection_params(driver_name: str, config_only: bool = False, **params) -> dict:
288
+ """
289
+ Validate connection parameters against driver requirements.
290
+
291
+ Args:
292
+ driver_name: Name of the database driver
293
+ config_only: If True, skip password validation for config storage
294
+ **params: Connection parameters
295
+
296
+ Returns:
297
+ Dict of validated parameters with extras removed
298
+
299
+ Raises:
300
+ ValueError: If required parameters are missing
301
+ """
302
+ if driver_name not in DRIVERS:
303
+ raise ValueError(f"Unknown driver: {driver_name}")
304
+
305
+ driver_info = DRIVERS[driver_name]
306
+ database_type = driver_info['database_type']
307
+
308
+ # Initialize with config-only parameters if needed
309
+ validated_params = {}
310
+ if config_only and 'encrypted_password' in params:
311
+ validated_params['encrypted_password'] = params['encrypted_password']
312
+
313
+ # get default port if not specified
314
+ if 'port' not in params:
315
+ default_port = driver_info.get('default_port')
316
+ if default_port:
317
+ params['port'] = default_port
318
+
319
+ # Check required parameters (any one set must be satisfied)
320
+ required_satisfied = False
321
+ for required_set in driver_info['required_params']:
322
+ if config_only:
323
+ # For config validation, ignore password requirement
324
+ check_set = required_set - {'password'}
325
+ if not check_set or check_set.issubset(params.keys()):
326
+ required_satisfied = True
327
+ break
328
+ else:
329
+ if required_set.issubset(params.keys()):
330
+ required_satisfied = True
331
+ break
332
+
333
+ if not required_satisfied:
334
+ msg = f"Missing required parameters. Need one of: {driver_info['required_params']}"
335
+ logger.error(msg)
336
+ logger.error(f"Current params: {params}")
337
+ raise ValueError(msg)
338
+
339
+ # Apply parameter mapping and filter valid params
340
+ param_map = driver_info.get('param_map', {})
341
+
342
+ all_valid_params = set()
343
+ for req_set in driver_info['required_params']:
344
+ all_valid_params.update(req_set)
345
+ all_valid_params.update(driver_info.get('optional_params', set()))
346
+
347
+ for key, value in params.items():
348
+ if key in all_valid_params or (config_only and key == 'encrypted_password'):
349
+ mapped_key = param_map.get(key, key)
350
+ validated_params[mapped_key] = value
351
+
352
+ return validated_params
353
+
354
+
355
+ def _get_connection_string(**kwargs) -> str:
356
+ """ Get connection string from keyword arguments."""
357
+ return " ".join([f"{key}={value}" for key, value in kwargs.items()])
358
+
359
+
360
+ def _get_odbc_string(**kwargs) -> str:
361
+ """Build ODBC connection string"""
362
+ port = kwargs.pop('port', None)
363
+ if port and 'SERVER' in kwargs and '\\' not in kwargs['SERVER']:
364
+ kwargs['SERVER'] += f',{port}'
365
+ printable = ';'.join([f"{key.upper()}={value}" for key, value in _hide_password(kwargs).items()])
366
+ logger.debug(f'ODBC connection string: {printable}')
367
+ return ';'.join([f"{key.upper()}={value}" for key, value in kwargs.items()])
368
+
369
+
370
+ def _get_odbc_connection_string(**kwargs) -> str:
371
+ """ Get connection string for ODBC from keyword arguments."""
372
+ # logger.debug(f'Generating ODBC connection string from: {_hide_password(kwargs)}')
373
+ if 'dsn' in kwargs and kwargs['dsn']:
374
+ # DSN only send DSN and password if present
375
+ conn_str = f"DSN={kwargs['dsn']}"
376
+ printable_conn_str = conn_str
377
+ if 'PWD' in kwargs:
378
+ conn_str += f";PWD={kwargs['PWD']}"
379
+ printable_conn_str += f";PWD=******"
380
+ else:
381
+ odbc_driver_name = kwargs.pop('odbc_driver_name', None)
382
+ if 'port' in kwargs:
383
+ kwargs['SERVER'] += f',{kwargs.pop("port")}'
384
+ params = {key.upper(): value for key, value in kwargs.items()}
385
+ conn_str = ";".join([f"{key}={value}" for key, value in params.items()])
386
+ printable_conn_str = ";".join([f"{key}={value}" for key, value in _hide_password(params).items()])
387
+ if odbc_driver_name:
388
+ conn_str = f"DRIVER={{{odbc_driver_name}}};" + conn_str
389
+ printable_conn_str = f"DRIVER={{{odbc_driver_name}}};" + printable_conn_str
390
+ logger.debug(f"ODBC connection string: {printable_conn_str}")
391
+ return conn_str
392
+
393
+
394
+ def _password_prompt(prompt: str = 'Enter password: ') -> str:
395
+ """
396
+ Prompts the user to enter a password securely without echoing it on the terminal.
397
+
398
+ This function uses the `getpass` module to securely capture user input for a password.
399
+ The prompt message can be customized by passing a specific string as the argument.
400
+
401
+ Args:
402
+ prompt (str): The message to display prompting the user for input. Defaults to 'Enter password: '.
403
+
404
+ Returns:
405
+ str: The password entered by the user.
406
+ """
407
+ import getpass
408
+ return getpass.getpass(prompt)
409
+
410
+
411
+ class Database:
412
+ """
413
+ Database connection wrapper providing a uniform interface across database adapters.
414
+
415
+ The Database class wraps database-specific connection objects and provides a consistent
416
+ API regardless of which database driver is being used (psycopg2, oracledb, mysqlclient,
417
+ etc.). It handles parameter style conversions, manages cursors, and delegates attribute
418
+ access to the underlying connection for driver-specific functionality.
419
+
420
+ Key features:
421
+
422
+ * **Unified interface** - Same API for PostgreSQL, Oracle, MySQL, SQL Server, SQLite
423
+ * **Cursor factory** - Create different cursor types (Record, tuple, dict, list)
424
+ * **Transaction management** - Context managers for safe transactions
425
+ * **Attribute delegation** - Access underlying driver features when needed
426
+ * **Parameter style abstraction** - Automatic handling of different bind parameter formats
427
+
428
+ Attributes
429
+ ----------
430
+ driver
431
+ The database adapter module (e.g., psycopg2, oracledb)
432
+ database_type : str
433
+ Database type: 'postgres', 'oracle', 'mysql', 'sqlserver', or 'sqlite'
434
+ database_name : str
435
+ Name of the connected database
436
+ placeholder : str
437
+ Parameter placeholder for this database's parameter style
438
+
439
+ Example
440
+ -------
441
+ ::
442
+
443
+ import dbtk
444
+
445
+ # Create connection
446
+ db = dbtk.database.postgres(user='admin', password='secret', database='mydb')
447
+
448
+ # Or from configuration
449
+ db = dbtk.connect('production_db')
450
+
451
+ # Use as context manager
452
+ with db:
453
+ cursor = db.cursor()
454
+ cursor.execute("SELECT * FROM users WHERE status = :status", {'status': 'active'})
455
+ users = cursor.fetchall()
456
+
457
+ # Manual connection management
458
+ db = dbtk.connect('production_db')
459
+ cursor = db.cursor('dict') # Dictionary cursor
460
+ cursor.execute("SELECT * FROM orders")
461
+ db.commit()
462
+ db.close()
463
+
464
+ See Also
465
+ --------
466
+ dbtk.connect : Connect to database from configuration
467
+ Database.cursor : Create a cursor for executing queries
468
+ Database.transaction : Context manager for transactions
469
+ """
470
+
471
+ # Attributes stored locally, others delegated to _connection
472
+ _local_attrs = [
473
+ '_connection', 'database_type', 'database_name', 'driver',
474
+ 'connection_name', 'placeholder', '_cursor_settings'
475
+ ]
476
+
477
+ def __init__(self, connection, driver,
478
+ database_name: Optional[str] = None,
479
+ connection_name: Optional[str] = None,
480
+ cursor_settings: Optional[dict] = None):
481
+ """
482
+ Initialize Database wrapper around an existing connection.
483
+
484
+ This is typically called by connection factory functions rather than directly.
485
+ Use ``dbtk.database.postgres()``, ``dbtk.connect()``, etc. instead.
486
+
487
+ Parameters
488
+ ----------
489
+ connection
490
+ Underlying database connection object from the adapter
491
+ driver
492
+ Database adapter module (psycopg2, oracledb, mysqlclient, etc.)
493
+ database_name : str, optional
494
+ Name of the database. If None, attempts to extract from connection.
495
+ connection_name : str, optional
496
+ Name/alias from config file (e.g., 'imdb', 'prod_db'). None if not from config.
497
+ cursor_settings : dict, optional
498
+ Values passed to the cursor constructor. e.g. {'batch_size': 2000}
499
+
500
+ Example
501
+ -------
502
+ ::
503
+
504
+ import psycopg2
505
+ from dbtk.database import Database
506
+
507
+ # Direct instantiation (not typical)
508
+ conn = psycopg2.connect(dbname='mydb', user='admin', password='secret')
509
+ db = Database(conn, psycopg2, 'mydb')
510
+
511
+ # Typical usage via factory functions
512
+ db = dbtk.database.postgres(user='admin', password='secret', database='mydb')
513
+ """
514
+ self._connection = connection
515
+ self.driver = driver
516
+ self.connection_name = connection_name
517
+
518
+ if database_name is None:
519
+ database_name = (connection.get('database') or
520
+ connection.get('service_name') or
521
+ connection.get('dbname') or
522
+ connection.get('db'))
523
+
524
+ self.database_name = database_name
525
+
526
+ # Set parameter placeholder based on adapter style
527
+ paramstyle = getattr(driver, 'paramstyle', ParamStyle.DEFAULT)
528
+ self.placeholder = ParamStyle.get_placeholder(paramstyle)
529
+
530
+ # Determine server type from driver name
531
+ if driver.__name__ in DRIVERS:
532
+ self.database_type = DRIVERS[driver.__name__]['database_type']
533
+ else:
534
+ self.database_type = 'unknown'
535
+
536
+ logger.debug(f"Cursor Database.__init__ cursor_settings: {cursor_settings}")
537
+ if cursor_settings:
538
+ self._cursor_settings = {key: val for key, val in cursor_settings.items()
539
+ if key in Cursor.WRAPPER_SETTINGS}
540
+ else:
541
+ self._cursor_settings = dict()
542
+
543
+ def __getattr__(self, key: str) -> Any:
544
+ """Delegate attribute access to underlying connection."""
545
+ if key == '__name__':
546
+ return self.connection_name or self.database_name or 'unknown'
547
+ else:
548
+ return getattr(self._connection, key)
549
+
550
+ def __setattr__(self, key: str, value: Any) -> None:
551
+ """Set attributes locally or delegate to connection."""
552
+ if key in self._local_attrs:
553
+ self.__dict__[key] = value
554
+ else:
555
+ setattr(self._connection, key, value)
556
+
557
+ def __dir__(self) -> list:
558
+ """Return available attributes."""
559
+ return list(set(
560
+ dir(self._connection) +
561
+ dir(self.__class__) +
562
+ self._local_attrs
563
+ ))
564
+
565
+ def __str__(self) -> str:
566
+ """String representation of the database connection."""
567
+ if self.connection_name and self.database_name:
568
+ return f"Database('{self.connection_name}' -> {self.database_name}:{self.database_type})"
569
+ elif self.database_name:
570
+ return f'Database({self.database_name}:{self.database_type})'
571
+ else:
572
+ return f'Database({self.database_type})'
573
+
574
+ def __repr__(self) -> str:
575
+ if self.database_name:
576
+ return f"Database('{self.database_name}', database_type='{self.database_type}')"
577
+ else:
578
+ return f"Database(database_type='{self.database_type}')"
579
+
580
+ def __enter__(self):
581
+ """Context manager entry."""
582
+ return self
583
+
584
+ def __exit__(self, exc_type, exc_val, exc_tb):
585
+ """Context manager exit - close connection."""
586
+ self.close()
587
+
588
+ def cursor(self, **kwargs) -> Cursor:
589
+ """
590
+ Create a cursor for executing database queries.
591
+
592
+ Returns a cursor that yields Record objects, providing flexible access to query results
593
+ through attribute, dictionary, and index notation.
594
+
595
+ Parameters
596
+ ----------
597
+ **kwargs
598
+ Optional cursor configuration:
599
+
600
+ * ``batch_size`` (int) - Rows to process at once in bulk operations
601
+ * ``debug`` (bool) - Enable debug output showing queries and bind variables
602
+ * ``return_cursor`` (bool) - If True, execute() returns cursor for method chaining
603
+
604
+ Returns
605
+ -------
606
+ Cursor
607
+ Cursor instance that returns Record objects
608
+
609
+ Example
610
+ -------
611
+ ::
612
+
613
+ # Create cursor - returns Records
614
+ cursor = db.cursor()
615
+ cursor.execute("SELECT id, name, email FROM users WHERE status = :status",
616
+ {'status': 'active'})
617
+
618
+ # Record supports multiple access patterns
619
+ for row in cursor:
620
+ print(row['name']) # Dictionary access
621
+ print(row.name) # Attribute access
622
+ print(row[1]) # Index access
623
+ print(row.get('phone', 'N/A')) # Safe access with default
624
+
625
+ # With configuration options
626
+ cursor = db.cursor(debug=True)
627
+
628
+ See Also
629
+ --------
630
+ Record : Flexible row object with dict, attribute, and index access
631
+ Database.transaction : Context manager for safe transactions
632
+ """
633
+ # Only pass through allowed kwargs
634
+ filtered_kwargs = dict()
635
+ for key in Cursor.WRAPPER_SETTINGS:
636
+ if key in kwargs:
637
+ filtered_kwargs[key] = kwargs.pop(key)
638
+ elif key in self._cursor_settings:
639
+ filtered_kwargs[key] = self._cursor_settings[key]
640
+ return Cursor(self, **filtered_kwargs)
641
+
642
+ @contextmanager
643
+ def transaction(self):
644
+ """
645
+ Context manager for safe database transactions.
646
+
647
+ Automatically commits the transaction on successful completion or rolls back
648
+ if an exception occurs. This ensures transactions are properly cleaned up
649
+ even if errors occur.
650
+
651
+ Yields
652
+ ------
653
+ Database
654
+ This database connection instance
655
+
656
+ Raises
657
+ ------
658
+ Exception
659
+ Re-raises any exception that occurs within the transaction block
660
+ after rolling back the transaction
661
+
662
+ Example
663
+ -------
664
+ ::
665
+
666
+ with db.transaction():
667
+ cursor = db.cursor()
668
+ cursor.execute("INSERT INTO orders (customer, amount) VALUES (:c, :a)",
669
+ {'c': 'Aang', 'a': 100})
670
+ cursor.execute("UPDATE inventory SET stock = stock - 1 WHERE item_id = :id",
671
+ {'id': 42})
672
+ # Automatically commits on success
673
+
674
+ # If exception occurs, transaction is automatically rolled back
675
+ try:
676
+ with db.transaction():
677
+ cursor = db.cursor()
678
+ cursor.execute("INSERT INTO invalid_table ...") # Raises error
679
+ # Rollback happens automatically
680
+ except Exception as e:
681
+ logger.error(f"Transaction failed: {e}")
682
+
683
+ See Also
684
+ --------
685
+ Database.commit : Manually commit a transaction
686
+ Database.rollback : Manually roll back a transaction
687
+ """
688
+ try:
689
+ yield self
690
+ self.commit()
691
+ except Exception:
692
+ self.rollback()
693
+ raise
694
+
695
+ def param_help(self) -> None:
696
+ """Print help on this driver's parameter style."""
697
+ print(f"{self.driver.__name__}'s parameter style is \"{self.driver.paramstyle}\"")
698
+ print(f'"SELECT * FROM people WHERE name = {self.placeholder} AND age > {self.placeholder}", ("Smith", 30)')
699
+
700
+ if self.driver.paramstyle == ParamStyle.NAMED:
701
+ print(r'"SELECT * FROM people WHERE name = :name AND age > :age", {"name": "Smith", "age": 30}')
702
+ elif self.driver.paramstyle == ParamStyle.PYFORMAT:
703
+ print(r'"SELECT * FROM people WHERE name = %(name)s AND age > %(age)s", {"name": "Smith", "age": 30}')
704
+
705
+ @classmethod
706
+ def create(cls, db_type: str, driver: Optional[str] = None,
707
+ connection_name: Optional[str] = None,
708
+ cursor_settings: Optional[dict] = None, **kwargs) -> 'Database':
709
+ """
710
+ Factory method to create database connections.
711
+
712
+ Args:
713
+ db_type: Database type ('postgres', 'oracle', 'mysql', etc.)
714
+ driver: Specific driver to use (optional)
715
+ connection_name: Config file connection name/alias (optional)
716
+ cursor_settings: Defaults to use when creating cursors.
717
+ **kwargs: Connection parameters
718
+
719
+ Returns:
720
+ Database instance
721
+ """
722
+ db_driver = None
723
+ all_drivers = _get_all_drivers()
724
+ if driver:
725
+ if driver not in all_drivers:
726
+ raise ValueError(f"Unknown driver: {driver}")
727
+ if all_drivers[driver]['database_type'] != db_type:
728
+ raise ValueError(f"Driver '{driver}' is not compatible with database type '{db_type}'")
729
+ try:
730
+ module_name = all_drivers[driver].get('module', driver)
731
+ db_driver = importlib.import_module(module_name)
732
+ driver_name = driver
733
+ except ImportError:
734
+ logger.warning(f"Driver '{driver}' not available, falling back to default")
735
+
736
+ if db_driver is None:
737
+ drivers_for_db = _get_drivers_for_database(db_type)
738
+ for driver_name in drivers_for_db:
739
+ try:
740
+ module_name = all_drivers[driver_name].get('module', driver_name)
741
+ db_driver = importlib.import_module(module_name)
742
+ break
743
+ except ImportError:
744
+ pass
745
+
746
+ if db_driver is None:
747
+ raise ImportError(f"No database driver found for database type '{db_type}'")
748
+
749
+ logger.debug(f'parms before _validate: {_hide_password(kwargs)}')
750
+ params = _validate_connection_params(driver_name, **kwargs)
751
+ logger.debug(f'parms after _validate: {_hide_password(params)}')
752
+ if not params:
753
+ raise ValueError("The connection parameters were not valid.")
754
+
755
+ driver_conf = all_drivers[driver_name]
756
+ if driver_conf['connection_method'] == 'kwargs':
757
+ connection = db_driver.connect(**params)
758
+ elif driver_conf['connection_method'] == 'connection_string':
759
+ connection = db_driver.connect(_get_connection_string(**params))
760
+ elif driver_conf['connection_method'] == 'dsn':
761
+ if hasattr(db_driver, 'makedsn') and 'dsn' not in params:
762
+ host = params.pop('host', 'localhost')
763
+ port = params.pop('port', None)
764
+ service_name = params.pop('service_name', None)
765
+ params['dsn'] = db_driver.makedsn(host, port, service_name=service_name)
766
+ connection = db_driver.connect(**params)
767
+ elif driver_conf['connection_method'] == 'odbc_string':
768
+ cx_string = _get_odbc_string(DRIVER=driver_conf.get('odbc_driver_name', None), **params)
769
+ connection = db_driver.connect(cx_string)
770
+ else:
771
+ raise ValueError(f"Unknown connection method ({driver_conf['connection_method']}) for driver '{driver_name}'")
772
+
773
+ if connection:
774
+ db = cls(connection, db_driver, kwargs.get('database'),
775
+ connection_name=connection_name, cursor_settings=cursor_settings)
776
+ if db.database_type == 'unknown':
777
+ db.database_type = db_type
778
+ return db
779
+
780
+
781
+ def postgres(user: str, password: Optional[str] = None, database: str = 'postgres',
782
+ host: str = 'localhost', port: int = 5432, driver: str = None, **kwargs) -> Database:
783
+ """
784
+ Create a PostgreSQL database connection.
785
+
786
+ Automatically selects the best available PostgreSQL driver (psycopg2, psycopg3, or pgdb).
787
+ You can specify a specific driver if needed.
788
+
789
+ Args:
790
+ user: Database username
791
+ password: Database password (prompts if None)
792
+ database: Database name (default: 'postgres')
793
+ host: Server hostname or IP (default: 'localhost')
794
+ port: Server port (default: 5432)
795
+ driver: Specific driver to use ('psycopg2', 'psycopg', 'pgdb')
796
+ **kwargs: Additional driver-specific connection parameters
797
+
798
+ Returns:
799
+ Database connection object with context manager support
800
+
801
+ Example
802
+ -------
803
+ ::
804
+ >>> from dbtk.database import postgres
805
+ >>> with postgres(user='user', password='pass', database='mydb') as db:
806
+ ... cursor = db.cursor()
807
+ ... cursor.execute("SELECT * FROM users")
808
+
809
+ See Also:
810
+ Database.create() for more connection options
811
+ """
812
+ return Database.create('postgres', user=user, password=password, database=database,
813
+ host=host, port=port, driver=driver, **kwargs)
814
+
815
+
816
+ def oracle(user: str, password: Optional[str] = None, database: str = None,
817
+ host: Optional[str] = None, port: int = 1521, driver: str = None, **kwargs) -> Database:
818
+ """
819
+ Create an Oracle database connection.
820
+
821
+ Supports both DSN and connection string formats. Automatically selects
822
+ the best available Oracle driver (oracledb or cx_Oracle).
823
+
824
+ Args:
825
+ user: Database username
826
+ password: Database password (prompts if None)
827
+ database: Service name or SID
828
+ host: Server hostname or IP (required if not using dsn)
829
+ port: Server port (default: 1521)
830
+ driver: Specific driver to use ('oracledb', 'cx_Oracle')
831
+ **kwargs: Additional driver-specific parameters (dsn, mode, etc.)
832
+
833
+ Returns:
834
+ Database connection object with context manager support
835
+
836
+ Example
837
+ -------
838
+ ::
839
+ >>> from dbtk.database import oracle
840
+ >>> # Using service name
841
+ >>> db = oracle(user='scott', password='tiger',
842
+ ... host='oracle.example.com', database='ORCL')
843
+ >>>
844
+ >>> # Using DSN directly
845
+ >>> db = oracle(user='scott', password='tiger',
846
+ ... dsn='oracle.example.com:1521/ORCL')
847
+
848
+ See Also:
849
+ Database.create() for more connection options
850
+ """
851
+ return Database.create('oracle', user=user, password=password, database=database,
852
+ host=host, port=port, driver=driver, **kwargs)
853
+
854
+
855
+ def mysql(user: str, password: Optional[str] = None, database: str = 'mysql',
856
+ host: str = 'localhost', port: int = 3306, driver: str = None, **kwargs) -> Database:
857
+ """
858
+ Create a MySQL/MariaDB database connection.
859
+
860
+ Automatically selects the best available MySQL driver (mysqlclient, mysql.connector,
861
+ pymysql, or MySQLdb).
862
+
863
+ Args:
864
+ user: Database username
865
+ password: Database password (prompts if None)
866
+ database: Database name (default: 'mysql')
867
+ host: Server hostname or IP (default: 'localhost')
868
+ port: Server port (default: 3306)
869
+ driver: Specific driver to use ('mysqlclient', 'mysql.connector', 'pymysql', 'MySQLdb')
870
+ **kwargs: Additional driver-specific parameters (charset, ssl, etc.)
871
+
872
+ Returns:
873
+ Database connection object with context manager support
874
+
875
+ Example
876
+ -------
877
+ ::
878
+ >>> from dbtk.database import mysql
879
+ >>> with mysql(user='root', password='pass', database='myapp') as db:
880
+ ... cursor = db.cursor()
881
+ ... cursor.execute("SELECT * FROM users")
882
+
883
+ See Also:
884
+ Database.create() for more connection options
885
+ """
886
+ return Database.create('mysql', user=user, password=password, database=database,
887
+ host=host, port=port, driver=driver, **kwargs)
888
+
889
+
890
+ def sqlserver(user: str, password: Optional[str] = None, database: str = None,
891
+ host: str = 'localhost', port: int = 1433, **kwargs) -> Database:
892
+ """
893
+ Create a Microsoft SQL Server database connection.
894
+
895
+ Automatically selects the best available SQL Server driver (pyodbc or pymssql).
896
+
897
+ Args:
898
+ user: Database username
899
+ password: Database password (prompts if None)
900
+ database: Database name
901
+ host: Server hostname or IP (default: 'localhost')
902
+ port: Server port (default: 1433)
903
+ **kwargs: Additional driver-specific parameters (driver, encrypt, etc.)
904
+
905
+ Returns:
906
+ Database connection object with context manager support
907
+
908
+ Example
909
+ -------
910
+ ::
911
+ >>> from dbtk.database import sqlserver
912
+ >>> db = sqlserver(user='sa', password='pass',
913
+ ... database='AdventureWorks', host='sqlserver.local')
914
+ >>> cursor = db.cursor()
915
+
916
+ Note:
917
+ When using pyodbc, you may need to specify the ODBC driver:
918
+ sqlserver(..., driver='ODBC Driver 17 for SQL Server')
919
+
920
+ See Also:
921
+ Database.create() for more connection options
922
+ """
923
+ return Database.create('sqlserver', user=user, password=password, database=database,
924
+ host=host, port=port, **kwargs)
925
+
926
+
927
+ def sqlite(database: str, **kwargs) -> Database:
928
+ """
929
+ Create a SQLite database connection.
930
+
931
+ SQLite is a serverless, file-based database. Use ':memory:' for an in-memory database.
932
+
933
+ Args:
934
+ database: Path to database file or ':memory:' for in-memory database
935
+ **kwargs: Additional sqlite3.connect() parameters (timeout, isolation_level, etc.)
936
+
937
+ Returns:
938
+ Database connection object with context manager support
939
+
940
+ Example
941
+ -------
942
+ ::
943
+ >>> from dbtk.database import sqlite
944
+ >>> # File-based database
945
+ >>> with sqlite('app.db') as db:
946
+ ... cursor = db.cursor()
947
+ ... cursor.execute("CREATE TABLE users (id INTEGER, name TEXT)")
948
+ >>>
949
+ >>> # In-memory database (useful for testing)
950
+ >>> db = sqlite(':memory:')
951
+
952
+ See Also:
953
+ Database.create() for more connection options
954
+ sqlite3 module documentation for additional parameters
955
+ """
956
+ import sqlite3
957
+
958
+ connection = sqlite3.connect(database, **kwargs)
959
+ return Database(connection, sqlite3, os.path.basename(database))