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.
Files changed (80) hide show
  1. {dbtk-0.8.2 → dbtk-0.8.4}/PKG-INFO +13 -5
  2. {dbtk-0.8.2 → dbtk-0.8.4}/README.md +6 -2
  3. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/__init__.py +8 -2
  4. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/cli.py +16 -13
  5. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/config.py +17 -16
  6. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/cursors.py +89 -13
  7. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/database.py +52 -31
  8. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/dbtk_sample.yml +14 -10
  9. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/defaults.py +0 -1
  10. dbtk-0.8.4/dbtk/dialects/__init__.py +19 -0
  11. dbtk-0.8.4/dbtk/dialects/base.py +126 -0
  12. dbtk-0.8.4/dbtk/dialects/mysql.py +97 -0
  13. dbtk-0.8.4/dbtk/dialects/oracle.py +134 -0
  14. dbtk-0.8.4/dbtk/dialects/postgres.py +94 -0
  15. dbtk-0.8.4/dbtk/dialects/sqlserver.py +148 -0
  16. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/__init__.py +2 -2
  17. dbtk-0.8.4/dbtk/etl/config_generators.py +89 -0
  18. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/data_surge.py +8 -30
  19. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/table.py +12 -210
  20. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/core.py +25 -4
  21. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/database.py +71 -8
  22. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/__init__.py +4 -4
  23. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/base.py +2 -72
  24. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/csv.py +1 -2
  25. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/data_frame.py +1 -1
  26. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/excel.py +5 -5
  27. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/fixed_width.py +16 -38
  28. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/json.py +1 -1
  29. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/utils.py +4 -5
  30. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/readers/xml.py +27 -12
  31. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/record.py +228 -34
  32. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/utils.py +21 -8
  33. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/__init__.py +2 -2
  34. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/base.py +49 -1
  35. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/csv.py +8 -1
  36. dbtk-0.8.4/dbtk/writers/excel.py +1702 -0
  37. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/json.py +13 -2
  38. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/PKG-INFO +13 -5
  39. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/SOURCES.txt +8 -0
  40. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/requires.txt +5 -1
  41. {dbtk-0.8.2 → dbtk-0.8.4}/pyproject.toml +10 -4
  42. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_data_bending.py +88 -0
  43. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_fixed_width_record.py +145 -0
  44. dbtk-0.8.4/tests/test_query_lookup.py +287 -0
  45. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_readers.py +7 -6
  46. dbtk-0.8.4/tests/test_record.py +614 -0
  47. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_writers.py +118 -3
  48. dbtk-0.8.2/dbtk/etl/config_generators.py +0 -458
  49. dbtk-0.8.2/dbtk/writers/excel.py +0 -1086
  50. {dbtk-0.8.2 → dbtk-0.8.4}/LICENSE.txt +0 -0
  51. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/base_surge.py +0 -0
  52. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/bulk_surge.py +0 -0
  53. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/managers.py +0 -0
  54. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/__init__.py +0 -0
  55. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/address.py +0 -0
  56. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/datetime.py +0 -0
  57. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/email.py +0 -0
  58. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/etl/transforms/phone.py +0 -0
  59. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/formats/__init__.py +0 -0
  60. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/formats/edi.py +0 -0
  61. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/logging_utils.py +0 -0
  62. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/database.py +0 -0
  63. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/fixed_width.py +0 -0
  64. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/utils.py +0 -0
  65. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk/writers/xml.py +0 -0
  66. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/dependency_links.txt +0 -0
  67. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/entry_points.txt +0 -0
  68. {dbtk-0.8.2 → dbtk-0.8.4}/dbtk.egg-info/top_level.txt +0 -0
  69. {dbtk-0.8.2 → dbtk-0.8.4}/setup.cfg +0 -0
  70. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_bulk.py +0 -0
  71. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_config.py +0 -0
  72. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_edi_writer.py +0 -0
  73. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_excel_writer.py +0 -0
  74. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_logging_utils.py +0 -0
  75. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_table.py +0 -0
  76. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_table_new_features.py +0 -0
  77. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_transforms_core.py +0 -0
  78. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_transforms_datetime.py +0 -0
  79. {dbtk-0.8.2 → dbtk-0.8.4}/tests/test_transforms_phone.py +0 -0
  80. {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.2
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
- readers/writers → consistent API across every file format and compression type
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 a polars and doing direct loads to PostgreSQL will
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
- readers/writers → consistent API across every file format and compression type
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 a polars and doing direct loads to PostgreSQL will
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.0'
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
- 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
 
@@ -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
- Basic cursor that returns query results as lists.
113
+ Cursor that returns query results as Records.
98
114
 
99
- This is the base class for all DBTK cursor types. It wraps database-specific cursor
100
- objects and provides a consistent interface plus additional functionality like SQL
101
- file execution, parameter conversion, and prepared statements.
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
- # Usually created via Database.cursor()
127
- cursor = db.cursor('list')
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, bind_vars: tuple = ()) -> None:
408
- """Execute a database query."""
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 (relative to CWD)
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(filename, encoding=encoding) as f:
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: {filename}\n"
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 (relative to CWD)
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
- return PreparedStatement(self, filename=filename, encoding=encoding)
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': '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}'")