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.
- {dbtk-0.8.3 → dbtk-0.8.4}/PKG-INFO +2 -2
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/__init__.py +1 -1
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/cli.py +16 -13
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/config.py +17 -16
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/database.py +52 -31
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/dbtk_sample.yml +14 -9
- dbtk-0.8.4/dbtk/dialects/__init__.py +19 -0
- dbtk-0.8.4/dbtk/dialects/base.py +126 -0
- dbtk-0.8.4/dbtk/dialects/mysql.py +97 -0
- dbtk-0.8.4/dbtk/dialects/oracle.py +134 -0
- dbtk-0.8.4/dbtk/dialects/postgres.py +94 -0
- dbtk-0.8.4/dbtk/dialects/sqlserver.py +148 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/__init__.py +2 -2
- dbtk-0.8.4/dbtk/etl/config_generators.py +89 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/data_surge.py +8 -30
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/table.py +12 -210
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/core.py +25 -4
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/database.py +71 -8
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/__init__.py +2 -2
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/base.py +2 -2
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/excel.py +4 -4
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/fixed_width.py +16 -38
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/utils.py +4 -4
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/record.py +70 -3
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/__init__.py +2 -2
- dbtk-0.8.4/dbtk/writers/excel.py +1702 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/PKG-INFO +2 -2
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/SOURCES.txt +7 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/requires.txt +1 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/pyproject.toml +5 -4
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_fixed_width_record.py +145 -0
- dbtk-0.8.4/tests/test_query_lookup.py +287 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_readers.py +6 -5
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_writers.py +2 -2
- dbtk-0.8.3/dbtk/etl/config_generators.py +0 -458
- dbtk-0.8.3/dbtk/writers/excel.py +0 -1086
- {dbtk-0.8.3 → dbtk-0.8.4}/LICENSE.txt +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/README.md +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/cursors.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/defaults.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/base_surge.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/bulk_surge.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/managers.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/__init__.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/address.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/datetime.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/email.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/etl/transforms/phone.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/formats/__init__.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/formats/edi.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/logging_utils.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/csv.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/data_frame.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/json.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/readers/xml.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/utils.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/base.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/csv.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/database.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/fixed_width.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/json.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/utils.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk/writers/xml.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/dependency_links.txt +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/entry_points.txt +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/dbtk.egg-info/top_level.txt +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/setup.cfg +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_bulk.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_config.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_data_bending.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_edi_writer.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_excel_writer.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_logging_utils.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_record.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_table.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_table_new_features.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_transforms_core.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_transforms_datetime.py +0 -0
- {dbtk-0.8.3 → dbtk-0.8.4}/tests/test_transforms_phone.py +0 -0
- {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
|
+
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"
|
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
print("
|
|
65
|
-
|
|
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(
|
|
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/
|
|
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/
|
|
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
|
-
#
|
|
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
|
-
|
|
979
|
-
|
|
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': '
|
|
137
|
-
'required_params': [{'host', 'database', 'user'}, {'dsn'}],
|
|
138
|
-
'optional_params': {'password', 'port', 'driver', '
|
|
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':
|
|
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': '
|
|
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': '
|
|
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': '
|
|
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 '
|
|
378
|
-
conn_str += f";PWD={kwargs['
|
|
379
|
-
printable_conn_str +=
|
|
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['
|
|
384
|
-
params = {key.upper(): value
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|