dbtk 0.8.3__tar.gz → 0.8.4__tar.gz

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 (80) hide show
  1. {dbtk-0.8.3 → dbtk-0.8.4}/PKG-INFO +2 -2
  2. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/__init__.py +1 -1
  3. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/cli.py +16 -13
  4. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/config.py +17 -16
  5. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/database.py +52 -31
  6. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/dbtk_sample.yml +14 -9
  7. dbtk-0.8.4/dbtk/dialects/__init__.py +19 -0
  8. dbtk-0.8.4/dbtk/dialects/base.py +126 -0
  9. dbtk-0.8.4/dbtk/dialects/mysql.py +97 -0
  10. dbtk-0.8.4/dbtk/dialects/oracle.py +134 -0
  11. dbtk-0.8.4/dbtk/dialects/postgres.py +94 -0
  12. dbtk-0.8.4/dbtk/dialects/sqlserver.py +148 -0
  13. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/__init__.py +2 -2
  14. dbtk-0.8.4/dbtk/etl/config_generators.py +89 -0
  15. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/data_surge.py +8 -30
  16. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/table.py +12 -210
  17. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/core.py +25 -4
  18. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/database.py +71 -8
  19. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/__init__.py +2 -2
  20. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/base.py +2 -2
  21. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/excel.py +4 -4
  22. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/fixed_width.py +16 -38
  23. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/utils.py +4 -4
  24. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/record.py +70 -3
  25. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/__init__.py +2 -2
  26. dbtk-0.8.4/dbtk/writers/excel.py +1702 -0
  27. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/PKG-INFO +2 -2
  28. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/SOURCES.txt +7 -0
  29. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/requires.txt +1 -0
  30. {dbtk-0.8.3 → dbtk-0.8.4}/pyproject.toml +5 -4
  31. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_fixed_width_record.py +145 -0
  32. dbtk-0.8.4/tests/test_query_lookup.py +287 -0
  33. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_readers.py +6 -5
  34. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_writers.py +2 -2
  35. dbtk-0.8.3/dbtk/etl/config_generators.py +0 -458
  36. dbtk-0.8.3/dbtk/writers/excel.py +0 -1086
  37. {dbtk-0.8.3 → dbtk-0.8.4}/LICENSE.txt +0 -0
  38. {dbtk-0.8.3 → dbtk-0.8.4}/README.md +0 -0
  39. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/cursors.py +0 -0
  40. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/defaults.py +0 -0
  41. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/base_surge.py +0 -0
  42. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/bulk_surge.py +0 -0
  43. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/managers.py +0 -0
  44. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/__init__.py +0 -0
  45. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/address.py +0 -0
  46. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/datetime.py +0 -0
  47. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/email.py +0 -0
  48. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/phone.py +0 -0
  49. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/formats/__init__.py +0 -0
  50. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/formats/edi.py +0 -0
  51. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/logging_utils.py +0 -0
  52. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/csv.py +0 -0
  53. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/data_frame.py +0 -0
  54. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/json.py +0 -0
  55. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/xml.py +0 -0
  56. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/utils.py +0 -0
  57. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/base.py +0 -0
  58. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/csv.py +0 -0
  59. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/database.py +0 -0
  60. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/fixed_width.py +0 -0
  61. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/json.py +0 -0
  62. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/utils.py +0 -0
  63. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/xml.py +0 -0
  64. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/dependency_links.txt +0 -0
  65. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/entry_points.txt +0 -0
  66. {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/top_level.txt +0 -0
  67. {dbtk-0.8.3 → dbtk-0.8.4}/setup.cfg +0 -0
  68. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_bulk.py +0 -0
  69. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_config.py +0 -0
  70. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_data_bending.py +0 -0
  71. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_edi_writer.py +0 -0
  72. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_excel_writer.py +0 -0
  73. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_logging_utils.py +0 -0
  74. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_record.py +0 -0
  75. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_table.py +0 -0
  76. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_table_new_features.py +0 -0
  77. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_transforms_core.py +0 -0
  78. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_transforms_datetime.py +0 -0
  79. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_transforms_phone.py +0 -0
  80. {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_validation_collector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbtk
3
- Version: 0.8.3
3
+ Version: 0.8.4
4
4
  Summary: Data Benders Toolkit - A lightweight toolkit for data integration, ELT and ETL
5
5
  Author: Scott Bailey <scottrbailey@gmail.com>
6
6
  License: MIT
@@ -61,9 +61,9 @@ Requires-Dist: usaddress>=0.5.11; python_version >= "3.7" and extra == "all"
61
61
  Requires-Dist: usaddress==0.5.10; python_version < "3.7" and extra == "all"
62
62
  Requires-Dist: oracledb>=1.0; python_version >= "3.7" and extra == "all"
63
63
  Requires-Dist: cx_Oracle>=8.0; python_version < "3.7" and extra == "all"
64
- Requires-Dist: oracledb>=1.0; python_version >= "3.7" and extra == "all"
65
64
  Requires-Dist: psycopg2-binary>=2.9; extra == "all"
66
65
  Requires-Dist: pymysql>=1.0; extra == "all"
66
+ Requires-Dist: pyodbc<5.0,>=4.0.21; extra == "all"
67
67
  Provides-Extra: dev
68
68
  Requires-Dist: pytest>=6.0; extra == "dev"
69
69
  Requires-Dist: pytest-cov>=2.10; extra == "dev"
@@ -29,7 +29,7 @@ Direct connections:
29
29
  cursor = db.cursor() # Returns Record objects
30
30
  """
31
31
 
32
- __version__ = '0.8.3'
32
+ __version__ = '0.8.4'
33
33
  __author__ = 'Scott Bailey <scottrbailey@gmail.com>'
34
34
 
35
35
  from .database import Database
@@ -4,6 +4,7 @@ import argparse
4
4
  import importlib.util
5
5
  import sys
6
6
  from .database import _get_all_drivers
7
+ from .record import fixed_record_factory
7
8
  from . import config
8
9
 
9
10
  try:
@@ -51,18 +52,23 @@ def checkup():
51
52
  deps.append(dep.split('>=')[0].split('==')[0].split('<')[0].strip())
52
53
 
53
54
  installed = {_name_cleanup(d.name): d.version for d in distributions()}
54
-
55
- print(f"{'Package':<20} {'Status':<8} {'Version'}")
56
- print("-" * 40)
55
+ package_rec = fixed_record_factory([('Package', 22), ('Status', 8), ('Version', 15)])
56
+ driver_rec = fixed_record_factory([('DB Driver', 22), ('Priority*', 10),
57
+ ('Status', 9), ('Version', 8), ('Notes', 41)])
58
+ row = package_rec(*package_rec._fields)
59
+ print(row.to_line(True))
60
+ print("-" * row._line_len)
57
61
 
58
62
  for dep in deps:
59
63
  clean = dep.replace('-', '_')
60
64
  status = "✓" if _is_installed(clean) else "✗"
61
65
  version = installed.get(clean.lower(), '-')
62
- print(f"{dep:<20} {status:<8} {version}")
63
-
64
- print("\nDB Drivers Priority* Status Version")
65
- print("-" * 56)
66
+ row = package_rec(dep, status, version)
67
+ print(row.to_line(True))
68
+ print("")
69
+ row = driver_rec(*driver_rec._fields)
70
+ print(row.to_line(True))
71
+ print("-" * 80)
66
72
  all_drivers = _get_all_drivers()
67
73
  by_type = {}
68
74
 
@@ -80,9 +86,6 @@ def checkup():
80
86
  drivers = sorted(by_type[db_type], key=lambda x: x[0])
81
87
  print(f"{db_type}") # ← bold header
82
88
  for pri, name, info in drivers:
83
- # ── 2-space indent for hierarchy
84
- display_name = f" {name}" # ← indented
85
-
86
89
  try:
87
90
  # see if driver has 'module' attribute to use instead of name
88
91
  module_name = info.get('module', name)
@@ -90,7 +93,7 @@ def checkup():
90
93
  version = installed.get(_name_cleanup(module_name), '-- ')
91
94
  status = "✓" if spec else "✗"
92
95
  except ModuleNotFoundError:
93
- version = '-- '
96
+ version = '--'
94
97
  status = "✗"
95
98
  odbc_driver_name = info.get("odbc_driver_name")
96
99
  if odbc_driver_name:
@@ -101,8 +104,8 @@ def checkup():
101
104
  note = f'({info.get("note")})'
102
105
  else:
103
106
  note = ''
104
-
105
- print(f"{display_name:<20} {pri:<9} {status:<8} {version} {note}")
107
+ row = driver_rec(f' {name}', pri, status, version, note)
108
+ print(row.to_line(True))
106
109
 
107
110
  print("\n* Lower priority = preferred")
108
111
 
@@ -208,7 +208,7 @@ class ConfigManager:
208
208
  4. ``~/.config/dbtk.yml`` (user config directory)
209
209
  5. ``~/.config/dbtk.yaml`` (user config directory)
210
210
 
211
- If no config is found, creates a sample config at ``~/.config/dbtk.yml``.
211
+ If no config is found, creates a sample config at ``~/.config/dbtk_sample.yml``.
212
212
 
213
213
  Parameters
214
214
  ----------
@@ -252,7 +252,7 @@ class ConfigManager:
252
252
  * Connections require 'type' field (postgres, oracle, mysql, etc.)
253
253
  * Encrypted passwords require DBTK_ENCRYPTION_KEY environment variable
254
254
  * Environment variables can be used with ${VAR_NAME} syntax
255
- * Sample config is created at ~/.config/dbtk.yml on first run if no config exists
255
+ * Sample config is created at ~/.config/dbtk_sample.yml on first run if no config exists
256
256
  """
257
257
 
258
258
  def __init__(self, config_file: Optional[Union[str, Path]] = None):
@@ -337,6 +337,11 @@ class ConfigManager:
337
337
  if not isinstance(config, dict):
338
338
  raise ValueError(f"Invalid config file {self.config_file}.")
339
339
 
340
+ # Normalize empty/null sections to empty dicts
341
+ for section in ('connections', 'passwords', 'drivers'):
342
+ if config.get(section) is None:
343
+ config[section] = {}
344
+
340
345
  # Validate connections section if it exists
341
346
  if 'connections' in config:
342
347
  for name, conn in config.get('connections', {}).items():
@@ -968,15 +973,22 @@ def setup_config() -> None:
968
973
  print("Cancelled.")
969
974
  return
970
975
 
971
- # Copy dbtk_sample.yml to target location
976
+ # Write user config from sample, minus the example connections/passwords
972
977
  sample_path = Path(__file__).parent / 'dbtk_sample.yml'
973
978
  if not sample_path.exists():
974
979
  print(f"⚠ Sample config not found at {sample_path}")
975
980
  print("Cannot continue without sample file.")
976
981
  return
977
982
 
978
- shutil.copy(sample_path, config_path)
979
- print(f"\n✓ Created config from sample at {config_path}")
983
+ with open(sample_path) as f:
984
+ config_data = yaml.safe_load(f) or {}
985
+ # keep settings but clear out example connections, passwords and drivers sections
986
+ config_data['connections'] = {}
987
+ config_data['passwords'] = {}
988
+ config_data['drivers'] = {}
989
+ with open(config_path, 'w') as f:
990
+ yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False)
991
+ print(f"\n✓ Created config at {config_path}")
980
992
 
981
993
  # Also copy sample to ~/.config for reference
982
994
  user_sample_path = Path.home() / '.config' / 'dbtk_sample.yml'
@@ -1065,19 +1077,9 @@ def setup_config() -> None:
1065
1077
  print("Database Connections")
1066
1078
  print("-"*60)
1067
1079
 
1068
- # Load the config we just created
1069
- with open(config_path) as f:
1070
- config_data = yaml.safe_load(f)
1071
-
1072
1080
  if 'connections' not in config_data:
1073
1081
  config_data['connections'] = {}
1074
1082
 
1075
- print(dedent("""\
1076
- Warning: The config file created has lots of comments that will be lost if you continue.
1077
- The YAML format was designed to be readable and it is recommended to just edit in the
1078
- text editor of your choice. If you do continue and overwrite the comments, a fully commented
1079
- sample is also available at ~/.config/dbtk_sample.yml.
1080
- """))
1081
1083
  add_connection = input("\nAdd a database connection now? [y/N]: ").strip().lower()
1082
1084
  edits = 0
1083
1085
  while add_connection in ('y', 'yes'):
@@ -1170,7 +1172,6 @@ def setup_config() -> None:
1170
1172
  add_connection = input("\nAdd another connection? [y/N]: ").strip().lower()
1171
1173
 
1172
1174
  if edits:
1173
- # Write updated config file
1174
1175
  with open(config_path, 'w') as f:
1175
1176
  yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False)
1176
1177
 
@@ -22,7 +22,7 @@ def _hide_password(kwargs):
22
22
  """Replace password with '********' to be printable"""
23
23
  parms = kwargs.copy()
24
24
  for key, val in parms.items():
25
- if key in ('password', 'PWD', 'passwd'):
25
+ if key in ('password', 'pwd', 'PWD', 'passwd'):
26
26
  parms[key] = '********'
27
27
  return parms
28
28
 
@@ -133,16 +133,27 @@ DRIVERS = {
133
133
  'database_type': 'sqlserver',
134
134
  'module': 'pyodbc',
135
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'},
136
+ 'param_map': {'host': 'server', 'user': 'uid', 'password': 'pwd'},
137
+ 'required_params': [{'host', 'database', 'user'}, {'host', 'database', 'trusted_connection'}, {'dsn'}],
138
+ 'optional_params': {'password', 'port', 'driver', 'encrypt', 'trustservercertificate'},
139
+ 'connection_method': 'odbc_string',
140
+ 'odbc_driver_name': 'ODBC Driver 18 for SQL Server',
141
+ 'default_port': 1433
142
+ },
143
+ 'pyodbc_sqlserver_17': {
144
+ 'database_type': 'sqlserver',
145
+ 'module': 'pyodbc',
146
+ 'priority': 12,
147
+ 'param_map': {'host': 'server', 'user': 'uid', 'password': 'pwd'},
148
+ 'required_params': [{'host', 'database', 'user'}, {'host', 'database', 'trusted_connection'}, {'dsn'}],
149
+ 'optional_params': {'password', 'port', 'driver', 'encrypt', 'trustservercertificate'},
139
150
  'connection_method': 'odbc_string',
140
151
  'odbc_driver_name': 'ODBC Driver 17 for SQL Server',
141
152
  'default_port': 1433
142
153
  },
143
154
  'pymssql': {
144
155
  'database_type': 'sqlserver',
145
- 'priority': 12,
156
+ 'priority': 13,
146
157
  'param_map': {},
147
158
  'required_params': [{'host', 'database', 'user'}],
148
159
  'optional_params': {'password', 'port', 'timeout', 'login_timeout', 'charset', 'as_dict', 'appname'},
@@ -155,7 +166,7 @@ DRIVERS = {
155
166
  'database_type': 'postgres',
156
167
  'module': 'pyodbc',
157
168
  'priority': 14,
158
- 'param_map': {'host': 'SERVER', 'database': 'DATABASE', 'user': 'UID', 'password': 'PWD'},
169
+ 'param_map': {'host': 'server', 'user': 'uid', 'password': 'pwd'},
159
170
  'required_params': [{'host', 'database', 'user'}, {'dsn'}],
160
171
  'optional_params': {'password', 'port'},
161
172
  'connection_method': 'odbc_string',
@@ -166,7 +177,7 @@ DRIVERS = {
166
177
  'database_type': 'mysql',
167
178
  'module': 'pyodbc',
168
179
  'priority': 16,
169
- 'param_map': {'host': 'SERVER', 'database': 'DATABASE', 'user': 'UID', 'password': 'PWD'},
180
+ 'param_map': {'host': 'server', 'user': 'uid', 'password': 'pwd'},
170
181
  'required_params': [{'host', 'database', 'user'}],
171
182
  'optional_params': {'password', 'port'},
172
183
  'connection_method': 'odbc_string',
@@ -177,7 +188,7 @@ DRIVERS = {
177
188
  'database_type': 'oracle',
178
189
  'module': 'pyodbc',
179
190
  'priority': 13,
180
- 'param_map': {'host': 'SERVER', 'database': 'DATABASE', 'user': 'UID', 'password': 'PWD'},
191
+ 'param_map': {'host': 'server', 'user': 'uid', 'password': 'pwd'},
181
192
  'required_params': [{'host', 'database', 'user'}],
182
193
  'optional_params': {'password', 'port'},
183
194
  'connection_method': 'odbc_string',
@@ -201,6 +212,12 @@ DRIVERS = {
201
212
  def register_user_drivers(drivers_config: dict) -> None:
202
213
  """Register drivers from config file."""
203
214
  global _user_drivers
215
+ for info in drivers_config.values():
216
+ # lowercase keys for optional_params and param_map
217
+ if 'optional_params' in info:
218
+ info['optional_params'] = {p.lower() for p in info['optional_params']}
219
+ if 'param_map' in info:
220
+ info['param_map'] = {k.lower(): v.lower() for k,v in info['param_map'].items()}
204
221
  _user_drivers.update(drivers_config)
205
222
 
206
223
 
@@ -305,6 +322,10 @@ def _validate_connection_params(driver_name: str, config_only: bool = False, **p
305
322
  driver_info = DRIVERS[driver_name]
306
323
  database_type = driver_info['database_type']
307
324
 
325
+ # Normalize param keys to lowercase so callers can pass TrustServerCertificate,
326
+ # Port, etc. without being silently ignored.
327
+ params = {k.lower(): v for k, v in params.items()}
328
+
308
329
  # Initialize with config-only parameters if needed
309
330
  validated_params = {}
310
331
  if config_only and 'encrypted_password' in params:
@@ -348,6 +369,8 @@ def _validate_connection_params(driver_name: str, config_only: bool = False, **p
348
369
  if key in all_valid_params or (config_only and key == 'encrypted_password'):
349
370
  mapped_key = param_map.get(key, key)
350
371
  validated_params[mapped_key] = value
372
+ else:
373
+ logger.warning(f"Unknown parameter '{key}' for driver '{driver_name}' — ignoring")
351
374
 
352
375
  return validated_params
353
376
 
@@ -358,30 +381,20 @@ def _get_connection_string(**kwargs) -> str:
358
381
 
359
382
 
360
383
  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
384
  """ Get connection string for ODBC from keyword arguments."""
372
- # logger.debug(f'Generating ODBC connection string from: {_hide_password(kwargs)}')
373
385
  if 'dsn' in kwargs and kwargs['dsn']:
374
386
  # DSN only send DSN and password if present
375
387
  conn_str = f"DSN={kwargs['dsn']}"
376
388
  printable_conn_str = conn_str
377
- if 'PWD' in kwargs:
378
- conn_str += f";PWD={kwargs['PWD']}"
379
- printable_conn_str += f";PWD=******"
389
+ if 'pwd' in kwargs:
390
+ conn_str += f";PWD={kwargs['pwd']}"
391
+ printable_conn_str += ";PWD=******"
380
392
  else:
381
393
  odbc_driver_name = kwargs.pop('odbc_driver_name', None)
382
394
  if 'port' in kwargs:
383
- kwargs['SERVER'] += f',{kwargs.pop("port")}'
384
- params = {key.upper(): value for key, value in kwargs.items()}
395
+ kwargs['server'] += f',{kwargs.pop("port")}'
396
+ params = {key.upper(): ('yes' if value is True else 'no' if value is False else value)
397
+ for key, value in kwargs.items()}
385
398
  conn_str = ";".join([f"{key}={value}" for key, value in params.items()])
386
399
  printable_conn_str = ";".join([f"{key}={value}" for key, value in _hide_password(params).items()])
387
400
  if odbc_driver_name:
@@ -471,7 +484,7 @@ class Database:
471
484
  # Attributes stored locally, others delegated to _connection
472
485
  _local_attrs = [
473
486
  '_connection', 'database_type', 'database_name', 'driver',
474
- 'connection_name', 'placeholder', '_cursor_settings'
487
+ 'connection_name', 'placeholder', '_cursor_settings', '_dialect'
475
488
  ]
476
489
 
477
490
  def __init__(self, connection, driver,
@@ -516,11 +529,11 @@ class Database:
516
529
  self.connection_name = connection_name
517
530
 
518
531
  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
-
532
+ if hasattr(connection, 'database'):
533
+ database_name = connection.database
534
+ elif hasattr(driver, 'SQL_DATABASE_NAME'):
535
+ # pyodbc probably from DSN
536
+ database_name = connection.getinfo(driver.SQL_DATABASE_NAME)
524
537
  self.database_name = database_name
525
538
 
526
539
  # Set parameter placeholder based on adapter style
@@ -533,6 +546,9 @@ class Database:
533
546
  else:
534
547
  self.database_type = 'unknown'
535
548
 
549
+ from .dialects import get_dialect
550
+ self._dialect = get_dialect(self.database_type)
551
+
536
552
  logger.debug(f"Cursor Database.__init__ cursor_settings: {cursor_settings}")
537
553
  if cursor_settings:
538
554
  self._cursor_settings = {key: val for key, val in cursor_settings.items()
@@ -540,6 +556,11 @@ class Database:
540
556
  else:
541
557
  self._cursor_settings = dict()
542
558
 
559
+ @property
560
+ def dialect(self):
561
+ """Database dialect instance providing SQL generation and schema introspection."""
562
+ return self._dialect
563
+
543
564
  def __getattr__(self, key: str) -> Any:
544
565
  """Delegate attribute access to underlying connection."""
545
566
  if key == '__name__':
@@ -765,7 +786,7 @@ class Database:
765
786
  params['dsn'] = db_driver.makedsn(host, port, service_name=service_name)
766
787
  connection = db_driver.connect(**params)
767
788
  elif driver_conf['connection_method'] == 'odbc_string':
768
- cx_string = _get_odbc_string(DRIVER=driver_conf.get('odbc_driver_name', None), **params)
789
+ cx_string = _get_odbc_string(odbc_driver_name=driver_conf.get('odbc_driver_name', None), **params)
769
790
  connection = db_driver.connect(cx_string)
770
791
  else:
771
792
  raise ValueError(f"Unknown connection method ({driver_conf['connection_method']}) for driver '{driver_name}'")
@@ -21,7 +21,7 @@ settings:
21
21
 
22
22
  connections:
23
23
  # Example connection configurations
24
- dev_postgres:
24
+ sample_postgres:
25
25
  driver: psycopg # Optional, psycopg2 has the highest priority, this will ensure psycopg(3) is used if both are installed
26
26
  type: postgres # type is optional IF you specify a driver
27
27
  host: localhost
@@ -35,7 +35,7 @@ connections:
35
35
  # password: prompt # Will prompt for password
36
36
  # encrypted_password: gAAAAABh... # Or use encrypted
37
37
 
38
- prod_oracle:
38
+ sample_oracle:
39
39
  type: oracle
40
40
  host: oracle.company.com
41
41
  port: 1521
@@ -43,12 +43,12 @@ connections:
43
43
  user: app_user
44
44
  encrypted_password: gAAAAABh...
45
45
 
46
- imdb_odbc_dsn:
46
+ sample_odbc_dsn:
47
47
  driver: pyodbc_postgres
48
48
  dsn: IMDB_PG # user or system DSN
49
49
  password: '${IMDB_USER_PASS}' # Pull from environment variable
50
50
 
51
- imdb_odbc:
51
+ sample_odbc:
52
52
  driver: pyodbc_postgres
53
53
  host: localhost
54
54
  port: 5432
@@ -56,7 +56,7 @@ connections:
56
56
  user: imdb_user
57
57
  encrypted_password: gAAAAABh...
58
58
 
59
- sqlserver_dev:
59
+ sample_sqlserver:
60
60
  driver: pyodbc_sqlserver
61
61
  type: sqlserver
62
62
  host: localhost\SQLEXPRESS # Use instance name; omit port when using instance names
@@ -66,13 +66,13 @@ connections:
66
66
  cursor:
67
67
  fast_executemany: true # Recommended for bulk operations; disable if using TEXT/NVARCHAR(MAX)/JSON columns
68
68
 
69
- states_db:
69
+ sample_sqlite:
70
70
  type: sqlite
71
71
  database: /data/test_states.db
72
72
 
73
73
  # Encrypted passwords (optional)
74
74
  passwords:
75
- openai_key:
75
+ sample_openai_key:
76
76
  description: "OpenAI API key for data processing"
77
77
  encrypted_password: gAAAAABh...
78
78
 
@@ -80,10 +80,15 @@ passwords:
80
80
  drivers:
81
81
  firbird:
82
82
  database_type: firebird
83
- module: firebird.driver # only required if name (firbird) does not mactch module (import) name
83
+ # only required if name (firebird) does not match module (import) name
84
+ module: firebird.driver
85
+ # lowest number with installed library will be used first
84
86
  priority: 1
85
- param_map: { } # if driver does not use standard names (user, password, host, port, database) map them here
87
+ # if driver does not use standard names (user, password, host, port, database) map them here
88
+ param_map: { } # {'database': 'db', 'password': 'passwd'}
89
+ # must have either: host, database and user OR dsn
86
90
  required_params: [ { 'host', 'database', 'user' }, { 'dsn' } ]
87
91
  optional_params: { 'port', 'password' }
92
+ # kwargs (most common), odbc_string (all odbc), connection_string (psycopg*), dsn (oracle)
88
93
  connection_method: kwargs
89
94
  default_port: 3050
@@ -0,0 +1,19 @@
1
+ # dbtk/dialects/__init__.py
2
+ from .base import DatabaseDialect
3
+ from .postgres import PostgresDialect
4
+ from .oracle import OracleDialect
5
+ from .mysql import MySQLDialect
6
+ from .sqlserver import SQLServerDialect
7
+
8
+ _DIALECT_MAP = {
9
+ 'postgres': PostgresDialect,
10
+ 'oracle': OracleDialect,
11
+ 'mysql': MySQLDialect,
12
+ 'sqlserver': SQLServerDialect,
13
+ 'sqlite': DatabaseDialect, # base class IS the SQLite dialect
14
+ }
15
+
16
+
17
+ def get_dialect(database_type: str) -> DatabaseDialect:
18
+ cls = _DIALECT_MAP.get(database_type, DatabaseDialect)
19
+ return cls()
@@ -0,0 +1,126 @@
1
+ # dbtk/dialects/base.py
2
+ from textwrap import dedent
3
+ from typing import List, Tuple, Any, Optional
4
+
5
+ from ..utils import wrap_at_comma
6
+
7
+
8
+ class DatabaseDialect:
9
+ """
10
+ Base database dialect. Default implementations match PostgreSQL/SQLite behavior.
11
+
12
+ Subclass and override to support a different database. Adding support for a new
13
+ engine means writing one class — no grep-and-branch across the ETL layer.
14
+
15
+ For unregistered databases (custom drivers without a matching dialect subclass),
16
+ dbtk falls back to this base class. The following features work without a dialect:
17
+
18
+ - All reads and writes (CSV, Excel, JSON, etc.)
19
+ - Plain ``SELECT``, ``INSERT``, ``UPDATE``, and ``DELETE`` via ``DataSurge``
20
+ - ``BulkSurge`` is database-specific and will not work
21
+
22
+ Features that require a dialect subclass:
23
+
24
+ - ``Table.upsert`` / ``DataSurge.upsert`` — base class generates
25
+ ``INSERT … ON CONFLICT`` (PostgreSQL/SQLite syntax); will fail on other engines
26
+ - ``Table.merge`` / ``DataSurge.merge`` — base class generates a SQL Server-style
27
+ ``MERGE``; will fail on engines that don't support that syntax
28
+ - ``column_defs_from_db`` — raises ``NotImplementedError``
29
+ """
30
+
31
+ use_upsert = True # False for MERGE-based dialects (Oracle, SQL Server)
32
+ temp_table_all_cols = False # True when temp table must mirror all DB columns (Oracle)
33
+ temp_table_cleanup_commit = False # True when cleanup DDL needs an explicit commit (Oracle)
34
+
35
+ # ------------------------------------------------------------------
36
+ # Upsert (INSERT … ON CONFLICT / ON DUPLICATE KEY)
37
+ # ------------------------------------------------------------------
38
+
39
+ def upsert_sql(self, table_name: str, cols_str: str, placeholders_str: str,
40
+ key_cols: List[str], update_cols: List[Tuple]) -> str:
41
+ """INSERT … ON CONFLICT DO UPDATE SET — Postgres/SQLite default."""
42
+ conflict_cols = ', '.join(key_cols)
43
+ update_assignments = []
44
+ for col, ident, bind_name, db_expr in update_cols:
45
+ if db_expr and '#' in db_expr:
46
+ assignment = f"{ident} = {db_expr.replace('#', f'EXCLUDED.{ident}')}"
47
+ elif db_expr:
48
+ assignment = f"{ident} = {db_expr}"
49
+ else:
50
+ assignment = f"{ident} = EXCLUDED.{ident}"
51
+ update_assignments.append(assignment)
52
+ update_clause = ', '.join(update_assignments)
53
+ if len(update_assignments) > 4:
54
+ update_clause = wrap_at_comma(update_clause)
55
+ return dedent(f"""\
56
+ INSERT INTO {table_name} ({cols_str})
57
+ VALUES ({placeholders_str})
58
+ ON CONFLICT ({conflict_cols}) DO UPDATE SET {update_clause}""")
59
+
60
+ # ------------------------------------------------------------------
61
+ # Merge (MERGE … USING … ON … WHEN MATCHED / NOT MATCHED)
62
+ # ------------------------------------------------------------------
63
+
64
+ def _merge_source_clause(self, source_cols: str) -> str:
65
+ """Return the USING subquery string. Override to add a FROM clause (e.g. Oracle's FROM dual)."""
66
+ return f"SELECT {source_cols}"
67
+
68
+ def merge_sql(self, table_name: str, all_cols: List[Tuple],
69
+ key_conditions: List[str], update_cols: List[Tuple]) -> str:
70
+ """Full MERGE statement. SQL Server-style by default (no FROM in USING subquery)."""
71
+ source_items = [f"{placeholder} AS {ident}" for _, ident, placeholder in all_cols]
72
+ source_cols = ', '.join(source_items)
73
+ if len(all_cols) > 4:
74
+ source_cols = wrap_at_comma(source_cols)
75
+
76
+ source_clause = self._merge_source_clause(source_cols)
77
+
78
+ update_assignments = [f"t.{ident} = s.{ident}" for _, ident in update_cols]
79
+ update_set = ', '.join(update_assignments)
80
+ if len(update_assignments) > 4:
81
+ update_set = wrap_at_comma(update_set)
82
+
83
+ insert_cols = ', '.join(ident for _, ident, _ in all_cols)
84
+ insert_values = ', '.join(f"s.{ident}" for _, ident, _ in all_cols)
85
+ if len(all_cols) > 4:
86
+ insert_cols = wrap_at_comma(insert_cols)
87
+ insert_values = wrap_at_comma(insert_values)
88
+
89
+ return dedent(f"""\
90
+ MERGE INTO {table_name} t
91
+ USING ({source_clause}) s
92
+ ON ({' AND '.join(key_conditions)})
93
+ WHEN MATCHED THEN
94
+ UPDATE SET {update_set}
95
+ WHEN NOT MATCHED THEN
96
+ INSERT ({insert_cols})
97
+ VALUES ({insert_values})""")
98
+
99
+ # ------------------------------------------------------------------
100
+ # SQL type mapping (used when generating temp table DDL)
101
+ # ------------------------------------------------------------------
102
+
103
+ def sql_type(self, type_obj: Any, internal_size: Optional[int],
104
+ precision: Optional[int], scale: Optional[int]) -> str:
105
+ """Map a driver type descriptor to a SQL type string. Generic fallback."""
106
+ return "VARCHAR(255)"
107
+
108
+ # ------------------------------------------------------------------
109
+ # Schema introspection
110
+ # ------------------------------------------------------------------
111
+
112
+ def table_metadata(self, cursor, table_name: str, add_comments: bool) -> dict:
113
+ """Extract column definitions from the database catalog."""
114
+ raise NotImplementedError(f"{type(self).__name__} does not support table_metadata")
115
+
116
+ # ------------------------------------------------------------------
117
+ # Temp table hooks (MERGE-based dialects only: Oracle, SQL Server)
118
+ # ------------------------------------------------------------------
119
+
120
+ def create_temp_table_ddl(self, table_name: str, col_info: list) -> Tuple[str, str]:
121
+ """Return (temp_table_name, CREATE TABLE sql) for the given column info list."""
122
+ raise NotImplementedError(f"{type(self).__name__} does not use temp-table MERGE")
123
+
124
+ def cleanup_temp_table_sql(self, temp_name: str) -> str:
125
+ """Return SQL to clean up the temp table after a MERGE (TRUNCATE or DROP)."""
126
+ raise NotImplementedError(f"{type(self).__name__} does not use temp-table MERGE")