dbtk 0.8.2__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.2 → dbtk-0.8.4}/PKG-INFO +13 -5
- {dbtk-0.8.2 → dbtk-0.8.4}/README.md +6 -2
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/__init__.py +8 -2
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/cli.py +16 -13
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/config.py +17 -16
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/cursors.py +89 -13
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/database.py +52 -31
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/dbtk_sample.yml +14 -10
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/defaults.py +0 -1
- 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.2 → dbtk-0.8.4}/dbtk/etl/__init__.py +2 -2
- dbtk-0.8.4/dbtk/etl/config_generators.py +89 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/data_surge.py +8 -30
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/table.py +12 -210
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/core.py +25 -4
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/database.py +71 -8
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/__init__.py +4 -4
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/base.py +2 -72
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/csv.py +1 -2
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/data_frame.py +1 -1
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/excel.py +5 -5
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/fixed_width.py +16 -38
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/json.py +1 -1
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/utils.py +4 -5
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/xml.py +27 -12
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/record.py +228 -34
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/utils.py +21 -8
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/__init__.py +2 -2
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/base.py +49 -1
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/csv.py +8 -1
- dbtk-0.8.4/dbtk/writers/excel.py +1702 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/json.py +13 -2
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/PKG-INFO +13 -5
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/SOURCES.txt +8 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/requires.txt +5 -1
- {dbtk-0.8.2 → dbtk-0.8.4}/pyproject.toml +10 -4
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_data_bending.py +88 -0
- {dbtk-0.8.2 → 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.2 → dbtk-0.8.4}/tests/test_readers.py +7 -6
- dbtk-0.8.4/tests/test_record.py +614 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_writers.py +118 -3
- dbtk-0.8.2/dbtk/etl/config_generators.py +0 -458
- dbtk-0.8.2/dbtk/writers/excel.py +0 -1086
- {dbtk-0.8.2 → dbtk-0.8.4}/LICENSE.txt +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/base_surge.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/bulk_surge.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/managers.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/__init__.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/address.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/datetime.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/email.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/phone.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/formats/__init__.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/formats/edi.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/logging_utils.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/database.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/fixed_width.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/utils.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/xml.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/dependency_links.txt +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/entry_points.txt +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/top_level.txt +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/setup.cfg +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_bulk.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_config.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_edi_writer.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_excel_writer.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_logging_utils.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_table.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_table_new_features.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_transforms_core.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_transforms_datetime.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_transforms_phone.py +0 -0
- {dbtk-0.8.2 → 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
|
|
@@ -43,7 +43,6 @@ Requires-Dist: usaddress==0.5.10; python_version < "3.7" and extra == "recommend
|
|
|
43
43
|
Requires-Dist: usaddress>=0.5.11; python_version >= "3.7" and extra == "recommended"
|
|
44
44
|
Provides-Extra: postgresql
|
|
45
45
|
Requires-Dist: psycopg2-binary>=2.9; extra == "postgresql"
|
|
46
|
-
Requires-Dist: psycopg[binary]>=3.1; extra == "postgresql"
|
|
47
46
|
Provides-Extra: oracle
|
|
48
47
|
Requires-Dist: oracledb>=1.0; python_version >= "3.7" and extra == "oracle"
|
|
49
48
|
Requires-Dist: cx_Oracle>=8.0; python_version < "3.7" and extra == "oracle"
|
|
@@ -58,8 +57,13 @@ Requires-Dist: openpyxl>=3.0.0; extra == "all"
|
|
|
58
57
|
Requires-Dist: xlrd>=1.2.0; extra == "all"
|
|
59
58
|
Requires-Dist: phonenumbers>=8.12.0; extra == "all"
|
|
60
59
|
Requires-Dist: python-dateutil>=2.8.0; extra == "all"
|
|
61
|
-
Requires-Dist: usaddress==0.5.10; python_version < "3.7" and extra == "all"
|
|
62
60
|
Requires-Dist: usaddress>=0.5.11; python_version >= "3.7" and extra == "all"
|
|
61
|
+
Requires-Dist: usaddress==0.5.10; python_version < "3.7" and extra == "all"
|
|
62
|
+
Requires-Dist: oracledb>=1.0; python_version >= "3.7" and extra == "all"
|
|
63
|
+
Requires-Dist: cx_Oracle>=8.0; python_version < "3.7" and extra == "all"
|
|
64
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "all"
|
|
65
|
+
Requires-Dist: pymysql>=1.0; extra == "all"
|
|
66
|
+
Requires-Dist: pyodbc<5.0,>=4.0.21; extra == "all"
|
|
63
67
|
Provides-Extra: dev
|
|
64
68
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
65
69
|
Requires-Dist: pytest-cov>=2.10; extra == "dev"
|
|
@@ -110,13 +114,17 @@ that you stay in control. When something breaks, you know exactly where to look.
|
|
|
110
114
|
The architecture is intentionally layered — use what you need, skip what you don't:
|
|
111
115
|
|
|
112
116
|
```
|
|
117
|
+
Configuration → encrypted YAML, env vars, named connections, driver overrides, smart logging
|
|
118
|
+
Connection → consistent connection and parameter handling, clean reference hierarchy
|
|
113
119
|
Record → ergonomic row handling, memory-efficient at scale
|
|
114
120
|
Table → field mapping, transforms, validation, upserts
|
|
115
121
|
DataSurge → batched inserts with progress tracking and stats
|
|
116
122
|
BulkSurge → direct bulk loads (SQL*Loader, BCP, COPY) for maximum throughput
|
|
117
|
-
|
|
123
|
+
Readers/Writers → consistent API across every file format and compression type
|
|
118
124
|
```
|
|
119
125
|
|
|
126
|
+
<img src="https://raw.githubusercontent.com/scottrbailey/dbtk/main/docs/assets/dbtk_flowchart.png" width="800" align="center" />
|
|
127
|
+
|
|
120
128
|
When developers convert existing jobs to DBTK, the result can be **half to a quarter the
|
|
121
129
|
original code**. That reduction comes from specific things DBTK just handles:
|
|
122
130
|
|
|
@@ -137,7 +145,7 @@ finish the job and think *"that was satisfyingly elegant"* — not because corne
|
|
|
137
145
|
because the tool was collaborating with you instead of making you fight it.
|
|
138
146
|
|
|
139
147
|
**Speed and Memory** The primary objective of DBTK is to give data integrators an elegant toolkit to speed up your development.
|
|
140
|
-
But DBTK's throughput and memory usage are very good. BulkSurge streaming from
|
|
148
|
+
But DBTK's throughput and memory usage are very good. BulkSurge streaming from polars and doing direct loads to PostgreSQL will
|
|
141
149
|
process 1M rows in 3-4 seconds. But even with a standard Python csv reader and numerous column transforms, DataSurge is able to
|
|
142
150
|
write 1M rows to every supported database in 5-10 seconds.
|
|
143
151
|
|
|
@@ -39,13 +39,17 @@ that you stay in control. When something breaks, you know exactly where to look.
|
|
|
39
39
|
The architecture is intentionally layered — use what you need, skip what you don't:
|
|
40
40
|
|
|
41
41
|
```
|
|
42
|
+
Configuration → encrypted YAML, env vars, named connections, driver overrides, smart logging
|
|
43
|
+
Connection → consistent connection and parameter handling, clean reference hierarchy
|
|
42
44
|
Record → ergonomic row handling, memory-efficient at scale
|
|
43
45
|
Table → field mapping, transforms, validation, upserts
|
|
44
46
|
DataSurge → batched inserts with progress tracking and stats
|
|
45
47
|
BulkSurge → direct bulk loads (SQL*Loader, BCP, COPY) for maximum throughput
|
|
46
|
-
|
|
48
|
+
Readers/Writers → consistent API across every file format and compression type
|
|
47
49
|
```
|
|
48
50
|
|
|
51
|
+
<img src="https://raw.githubusercontent.com/scottrbailey/dbtk/main/docs/assets/dbtk_flowchart.png" width="800" align="center" />
|
|
52
|
+
|
|
49
53
|
When developers convert existing jobs to DBTK, the result can be **half to a quarter the
|
|
50
54
|
original code**. That reduction comes from specific things DBTK just handles:
|
|
51
55
|
|
|
@@ -66,7 +70,7 @@ finish the job and think *"that was satisfyingly elegant"* — not because corne
|
|
|
66
70
|
because the tool was collaborating with you instead of making you fight it.
|
|
67
71
|
|
|
68
72
|
**Speed and Memory** The primary objective of DBTK is to give data integrators an elegant toolkit to speed up your development.
|
|
69
|
-
But DBTK's throughput and memory usage are very good. BulkSurge streaming from
|
|
73
|
+
But DBTK's throughput and memory usage are very good. BulkSurge streaming from polars and doing direct loads to PostgreSQL will
|
|
70
74
|
process 1M rows in 3-4 seconds. But even with a standard Python csv reader and numerous column transforms, DataSurge is able to
|
|
71
75
|
write 1M rows to every supported database in 5-10 seconds.
|
|
72
76
|
|
|
@@ -29,13 +29,15 @@ Direct connections:
|
|
|
29
29
|
cursor = db.cursor() # Returns Record objects
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
__version__ = '0.8.
|
|
32
|
+
__version__ = '0.8.4'
|
|
33
33
|
__author__ = 'Scott Bailey <scottrbailey@gmail.com>'
|
|
34
34
|
|
|
35
35
|
from .database import Database
|
|
36
36
|
from .config import connect, set_config_file
|
|
37
|
-
from .cursors import Cursor
|
|
37
|
+
from .cursors import Cursor, PreparedStatement
|
|
38
38
|
from .logging_utils import setup_logging, cleanup_old_logs, errors_logged
|
|
39
|
+
from .record import fixed_record_factory, FixedWidthRecord
|
|
40
|
+
from .utils import FixedColumn
|
|
39
41
|
from . import readers
|
|
40
42
|
from . import writers
|
|
41
43
|
from . import etl
|
|
@@ -45,6 +47,10 @@ __all__ = [
|
|
|
45
47
|
'connect',
|
|
46
48
|
'Database',
|
|
47
49
|
'Cursor',
|
|
50
|
+
'PreparedStatement',
|
|
51
|
+
'FixedColumn',
|
|
52
|
+
'FixedWidthRecord',
|
|
53
|
+
'fixed_record_factory',
|
|
48
54
|
'etl',
|
|
49
55
|
'readers',
|
|
50
56
|
'writers',
|
|
@@ -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
|
|
|
@@ -4,6 +4,7 @@ Cursor classes that wrap database cursors and provide different return types.
|
|
|
4
4
|
All cursors delegate to the underlying database cursor stored in _cursor.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import inspect
|
|
7
8
|
import logging
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import List, Any, Optional, Iterator, Callable, Union
|
|
@@ -16,6 +17,21 @@ logger = logging.getLogger(__name__)
|
|
|
16
17
|
__all__ = ['Cursor', 'PreparedStatement']
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
def _resolve_sql_path(filename: Union[str, Path], caller_file: str) -> Path:
|
|
21
|
+
"""Resolve a SQL filename to an existing path.
|
|
22
|
+
|
|
23
|
+
Checks the path as given first (absolute or relative to CWD), then falls
|
|
24
|
+
back to the directory containing *caller_file* so that scripts can refer to
|
|
25
|
+
sibling SQL files by bare name.
|
|
26
|
+
"""
|
|
27
|
+
path = Path(filename)
|
|
28
|
+
if not path.is_absolute() and not path.exists():
|
|
29
|
+
candidate = Path(caller_file).parent / path
|
|
30
|
+
if candidate.exists():
|
|
31
|
+
return candidate
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
19
35
|
class PreparedStatement:
|
|
20
36
|
"""
|
|
21
37
|
A prepared SQL statement loaded from a file with cached parameter mapping.
|
|
@@ -94,11 +110,12 @@ class PreparedStatement:
|
|
|
94
110
|
|
|
95
111
|
class Cursor:
|
|
96
112
|
"""
|
|
97
|
-
|
|
113
|
+
Cursor that returns query results as Records.
|
|
98
114
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
It wraps database-specific cursor objects and provides a consistent interface plus additional
|
|
116
|
+
functionality like SQL file execution, parameter conversion, and prepared statements.
|
|
117
|
+
It also maintains a clean reference hierarchy to the connection (cursor.connection) and
|
|
118
|
+
to the driver (cursor.connection.driver)
|
|
102
119
|
|
|
103
120
|
Cursor returns Record objects, which provide flexible access via dictionary keys,
|
|
104
121
|
attributes, or integer indices.
|
|
@@ -123,8 +140,8 @@ class Cursor:
|
|
|
123
140
|
-------
|
|
124
141
|
::
|
|
125
142
|
|
|
126
|
-
|
|
127
|
-
cursor = db.cursor(
|
|
143
|
+
db = dbtk.connect('prod_ods')
|
|
144
|
+
cursor = db.cursor()
|
|
128
145
|
cursor.execute("SELECT id, name, email FROM users WHERE status = :status",
|
|
129
146
|
{'status': 'active'})
|
|
130
147
|
|
|
@@ -404,9 +421,40 @@ class Cursor:
|
|
|
404
421
|
self._create_record_factory()
|
|
405
422
|
return True
|
|
406
423
|
|
|
407
|
-
def execute(self, query: str,
|
|
408
|
-
|
|
424
|
+
def execute(self, query: str,
|
|
425
|
+
bind_vars: Union[tuple, dict] = (),
|
|
426
|
+
convert_params: bool = False) -> None:
|
|
427
|
+
"""
|
|
428
|
+
Execute a database query.
|
|
429
|
+
|
|
430
|
+
Pass convert_params=True to have the query rewritten to the cursor's paramstyle
|
|
431
|
+
and parameters handled automatically (same as PreparedStatement and execute_file).
|
|
432
|
+
|
|
433
|
+
Parameters
|
|
434
|
+
----------
|
|
435
|
+
bind_vars : tuple or dict, default ()
|
|
436
|
+
Bind parameters to pass to the database.
|
|
437
|
+
|
|
438
|
+
When convert_params is False (the default), passed directly to the
|
|
439
|
+
underlying cursor and must already be in the format required by the
|
|
440
|
+
cursor's paramstyle.
|
|
441
|
+
|
|
442
|
+
When convert_params is True, must be a dict (or Record).
|
|
443
|
+
|
|
444
|
+
convert_params : bool, default False
|
|
445
|
+
If True, parameter order will be extracted from the query and the query
|
|
446
|
+
will be rewritten to match the cursor's paramstyle. Missing parameters
|
|
447
|
+
will be defaulted to None and extra parameters will be ignored.
|
|
448
|
+
"""
|
|
449
|
+
|
|
409
450
|
self._row_factory_invalid = True
|
|
451
|
+
if convert_params:
|
|
452
|
+
if not hasattr(bind_vars, 'items'):
|
|
453
|
+
if bind_vars:
|
|
454
|
+
raise ValueError(f'bind_vars must be a dict when convert_params=True')
|
|
455
|
+
bind_vars = {}
|
|
456
|
+
query, param_names = process_sql_parameters(query, self.paramstyle)
|
|
457
|
+
bind_vars = self.prepare_params(param_names, bind_vars)
|
|
410
458
|
|
|
411
459
|
if self.debug:
|
|
412
460
|
logger.debug(f'Query:\n{query}')
|
|
@@ -433,7 +481,8 @@ class Cursor:
|
|
|
433
481
|
executed multiple times, use prepare_file() instead for better performance.
|
|
434
482
|
|
|
435
483
|
Args:
|
|
436
|
-
filename: Path to SQL file
|
|
484
|
+
filename: Path to SQL file. Resolved relative to CWD first; if not
|
|
485
|
+
found there, falls back to the directory of the calling script.
|
|
437
486
|
bind_vars: Dictionary of named parameters
|
|
438
487
|
**kwargs:
|
|
439
488
|
encoding: File encoding (default: utf-8-sig)
|
|
@@ -445,10 +494,11 @@ class Cursor:
|
|
|
445
494
|
cursor.execute_file('queries/get_user.sql', {'user_id': 123})
|
|
446
495
|
"""
|
|
447
496
|
encoding = kwargs.get('encoding', 'utf-8-sig')
|
|
497
|
+
path = _resolve_sql_path(filename, inspect.stack()[1].filename)
|
|
448
498
|
|
|
449
499
|
try:
|
|
450
500
|
# Read SQL from file
|
|
451
|
-
with open(
|
|
501
|
+
with open(path, encoding=encoding) as f:
|
|
452
502
|
sql = f.read()
|
|
453
503
|
|
|
454
504
|
# Transform SQL for this cursor's paramstyle
|
|
@@ -466,7 +516,7 @@ class Cursor:
|
|
|
466
516
|
except Exception as e:
|
|
467
517
|
statement = locals().get('transformed_sql', 'N/A')
|
|
468
518
|
logger.error(
|
|
469
|
-
f"Error executing SQL file: {
|
|
519
|
+
f"Error executing SQL file: {path}\n"
|
|
470
520
|
f"Transformed SQL: {statement}\n"
|
|
471
521
|
f"Parameters: {bind_vars}"
|
|
472
522
|
)
|
|
@@ -480,7 +530,8 @@ class Cursor:
|
|
|
480
530
|
The returned PreparedStatement can be executed multiple times efficiently.
|
|
481
531
|
|
|
482
532
|
Args:
|
|
483
|
-
filename: Path to SQL file
|
|
533
|
+
filename: Path to SQL file. Resolved relative to CWD first; if not
|
|
534
|
+
found there, falls back to the directory of the calling script.
|
|
484
535
|
encoding: File encoding (default: utf-8-sig)
|
|
485
536
|
|
|
486
537
|
Returns:
|
|
@@ -494,7 +545,32 @@ class Cursor:
|
|
|
494
545
|
for user in users:
|
|
495
546
|
stmt.execute({'user_id': user.id, 'name': user.name})
|
|
496
547
|
"""
|
|
497
|
-
|
|
548
|
+
path = _resolve_sql_path(filename, inspect.stack()[1].filename)
|
|
549
|
+
return PreparedStatement(self, filename=path, encoding=encoding)
|
|
550
|
+
|
|
551
|
+
def prepare_query(self, query: str) -> PreparedStatement:
|
|
552
|
+
"""
|
|
553
|
+
Prepare a SQL statement from a query string for repeated execution.
|
|
554
|
+
|
|
555
|
+
The parameter conversion is performed once.
|
|
556
|
+
The returned PreparedStatement can be executed multiple times efficiently.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
query:
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
PreparedStatement object
|
|
563
|
+
|
|
564
|
+
Example
|
|
565
|
+
-------
|
|
566
|
+
::
|
|
567
|
+
|
|
568
|
+
stmt = cursor.prepare_query('SELECT * FROM users WHERE user_id = :user_id')
|
|
569
|
+
for user in users:
|
|
570
|
+
stmt.execute({'user_id': user.id})
|
|
571
|
+
"""
|
|
572
|
+
return PreparedStatement(self, query=query)
|
|
573
|
+
|
|
498
574
|
|
|
499
575
|
def executemany(self, query: str, bind_vars: List[tuple]) -> None:
|
|
500
576
|
"""Execute a query against multiple parameter sets."""
|
|
@@ -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}'")
|