dbtk 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. dbtk/__init__.py +54 -0
  2. dbtk/cli.py +169 -0
  3. dbtk/config.py +1194 -0
  4. dbtk/cursors.py +566 -0
  5. dbtk/database.py +959 -0
  6. dbtk/dbtk_sample.yml +90 -0
  7. dbtk/defaults.py +29 -0
  8. dbtk/etl/__init__.py +61 -0
  9. dbtk/etl/base_surge.py +257 -0
  10. dbtk/etl/bulk_surge.py +783 -0
  11. dbtk/etl/config_generators.py +458 -0
  12. dbtk/etl/data_surge.py +294 -0
  13. dbtk/etl/managers.py +734 -0
  14. dbtk/etl/table.py +1215 -0
  15. dbtk/etl/transforms/__init__.py +107 -0
  16. dbtk/etl/transforms/address.py +560 -0
  17. dbtk/etl/transforms/core.py +594 -0
  18. dbtk/etl/transforms/database.py +506 -0
  19. dbtk/etl/transforms/datetime.py +497 -0
  20. dbtk/etl/transforms/email.py +72 -0
  21. dbtk/etl/transforms/phone.py +670 -0
  22. dbtk/formats/__init__.py +18 -0
  23. dbtk/formats/edi.py +182 -0
  24. dbtk/logging_utils.py +310 -0
  25. dbtk/readers/__init__.py +26 -0
  26. dbtk/readers/base.py +644 -0
  27. dbtk/readers/csv.py +195 -0
  28. dbtk/readers/data_frame.py +119 -0
  29. dbtk/readers/excel.py +260 -0
  30. dbtk/readers/fixed_width.py +359 -0
  31. dbtk/readers/json.py +323 -0
  32. dbtk/readers/utils.py +394 -0
  33. dbtk/readers/xml.py +260 -0
  34. dbtk/record.py +710 -0
  35. dbtk/utils.py +537 -0
  36. dbtk/writers/__init__.py +46 -0
  37. dbtk/writers/base.py +718 -0
  38. dbtk/writers/csv.py +107 -0
  39. dbtk/writers/database.py +158 -0
  40. dbtk/writers/excel.py +1086 -0
  41. dbtk/writers/fixed_width.py +290 -0
  42. dbtk/writers/json.py +156 -0
  43. dbtk/writers/utils.py +41 -0
  44. dbtk/writers/xml.py +387 -0
  45. dbtk-0.8.0.dist-info/METADATA +305 -0
  46. dbtk-0.8.0.dist-info/RECORD +50 -0
  47. dbtk-0.8.0.dist-info/WHEEL +5 -0
  48. dbtk-0.8.0.dist-info/entry_points.txt +2 -0
  49. dbtk-0.8.0.dist-info/licenses/LICENSE.txt +7 -0
  50. dbtk-0.8.0.dist-info/top_level.txt +1 -0
dbtk/dbtk_sample.yml ADDED
@@ -0,0 +1,90 @@
1
+ # dbtk sample configuration file
2
+ # Copy to ~/.config/dbtk.yml and customize
3
+
4
+ settings:
5
+ default_batch_size: 1000
6
+ default_country: US
7
+ default_header_clean: 2 # Clean.LOWER_NOSPACE
8
+ default_timezone: UTC
9
+
10
+ # Logging configuration for integration scripts
11
+ logging:
12
+ directory: ./logs # Directory for log files
13
+ level: INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
14
+ format: '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
15
+ timestamp_format: '%Y-%m-%d %H:%M:%S' # Format for timestamps in log messages
16
+ filename_format: '%Y%m%d_%H%M%S' # Timestamp format for log filenames
17
+ # Set to '%Y%m%d' for one log per day
18
+ # Set to '' for single rolling log file (no timestamp)
19
+ split_errors: true # Create separate _error.log file for errors
20
+ console: true # Also output to console
21
+ retention_days: 30 # Days to keep old log files (for cleanup_old_logs)
22
+
23
+ connections:
24
+ # Example connection configurations
25
+ dev_postgres:
26
+ driver: psycopg # Optional, psycopg2 has the highest priority, this will ensure psycopg(3) is used if both are installed
27
+ type: postgres # type is optional IF you specify a driver
28
+ host: localhost
29
+ port: 5432
30
+ database: myapp_dev
31
+ user: developer
32
+ cursor: # These settings will be passed down when creating a cursor
33
+ batch_size: 4000
34
+ debug: False
35
+ return_cursor: True
36
+ # password: prompt # Will prompt for password
37
+ # encrypted_password: gAAAAABh... # Or use encrypted
38
+
39
+ prod_oracle:
40
+ type: oracle
41
+ host: oracle.company.com
42
+ port: 1521
43
+ database: prod.company.com
44
+ user: app_user
45
+ encrypted_password: gAAAAABh...
46
+
47
+ imdb_odbc_dsn:
48
+ driver: pyodbc_postgres
49
+ dsn: IMDB_PG # user or system DSN
50
+ password: '${IMDB_USER_PASS}' # Pull from environment variable
51
+
52
+ imdb_odbc:
53
+ driver: pyodbc_postgres
54
+ host: localhost
55
+ port: 5432
56
+ database: imdb
57
+ user: imdb_user
58
+ encrypted_password: gAAAAABh...
59
+
60
+ sqlserver_dev:
61
+ driver: pyodbc_sqlserver
62
+ type: sqlserver
63
+ host: localhost\SQLEXPRESS # Use instance name; omit port when using instance names
64
+ database: myapp_dev
65
+ user: dev_user
66
+ password: prompt
67
+ cursor:
68
+ fast_executemany: true # Recommended for bulk operations; disable if using TEXT/NVARCHAR(MAX)/JSON columns
69
+
70
+ states_db:
71
+ type: sqlite
72
+ database: /data/test_states.db
73
+
74
+ # Encrypted passwords (optional)
75
+ passwords:
76
+ openai_key:
77
+ description: "OpenAI API key for data processing"
78
+ encrypted_password: gAAAAABh...
79
+
80
+ # Custom driver definitions (optional)
81
+ drivers:
82
+ firbird:
83
+ database_type: firebird
84
+ module: firebird.driver # only required if name (firbird) does not mactch module (import) name
85
+ priority: 1
86
+ param_map: { } # if driver does not use standard names (user, password, host, port, database) map them here
87
+ required_params: [ { 'host', 'database', 'user' }, { 'dsn' } ]
88
+ optional_params: { 'port', 'password' }
89
+ connection_method: kwargs
90
+ default_port: 3050
dbtk/defaults.py ADDED
@@ -0,0 +1,29 @@
1
+ # dbtk/defaults.py
2
+ """Default settings - no imports to avoid circular dependencies."""
3
+
4
+ settings = {
5
+ 'default_batch_size': 1000,
6
+ 'default_country': 'US',
7
+ 'default_db_type': 'postgres',
8
+ 'default_header_clean': 2, # Clean.LOWER_NOSPACE as int
9
+ 'data_dump_dir': '/tmp', # default directory for data dumps
10
+ 'compressed_file_buffer_size': 1024 * 1024, # 1MB buffer for .gz/.bz2/.xz files
11
+ 'null_string': '', # how null is represented in text outputs
12
+ 'null_string_csv': '', # how null is represented in CSV outputs
13
+ 'default_timezone': 'UTC',
14
+ 'date_format': '%Y-%m-%d',
15
+ 'time_format': '%H:%M:%S',
16
+ 'datetime_format': '%Y-%m-%d %H:%M:%S',
17
+ 'timestamp_format': '%Y-%m-%d %H:%M:%S.%f', # with microseconds
18
+ 'tz_suffix': ' %z',
19
+ 'logging': {
20
+ 'directory': './logs',
21
+ 'level': 'INFO',
22
+ 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s',
23
+ 'timestamp_format': '%Y-%m-%d %H:%M:%S',
24
+ 'filename_format': '%Y%m%d_%H%M%S', # Set to '' for single log file (no timestamp)
25
+ 'split_errors': True,
26
+ 'console': True,
27
+ 'retention_days': 30,
28
+ }
29
+ }
dbtk/etl/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ # dbtk/etl/__init__.py
2
+ """
3
+ ETL (Extract, Transform, Load) operations and utilities.
4
+
5
+ This module provides tools for managing database tables, resolving identities,
6
+ and performing bulk operations:
7
+
8
+ - :class:`Table`: Schema-aware table operations with automatic SQL generation,
9
+ field mapping, transformations, and per-operation error tracking via ``last_error``.
10
+ - :class:`DataSurge`: Row-oriented bulk INSERT, UPDATE, DELETE, and MERGE using
11
+ ``executemany``. Works with all drivers; supports ``db_expr`` columns.
12
+ - :class:`BulkSurge`: High-throughput bulk loading via native database mechanisms
13
+ (PostgreSQL ``COPY``, Oracle direct-path, SQL Server ``bcp``, etc.).
14
+ Memory-efficient streaming; does not support ``db_expr`` columns.
15
+ - :class:`IdentityManager`: Resumable source-to-target identity resolution with
16
+ per-entity status, error, and message tracking. State can be saved/loaded as JSON.
17
+ - :class:`ValidationCollector`: Callable collector for fn-pipelines that enriches
18
+ and accumulates unique codes from source data.
19
+ - :class:`TableLookup`: Cached SQL-backed lookup transform for use in fn-pipelines
20
+ and as a resolver for :class:`IdentityManager`.
21
+ - :func:`column_defs_from_db`: Generate ``Table`` column definitions by introspecting
22
+ a live database table.
23
+
24
+ Example
25
+ -------
26
+ ::
27
+
28
+ from dbtk.etl import Table, DataSurge, BulkSurge, IdentityManager
29
+
30
+ # Define table structure
31
+ table = Table('users', columns={
32
+ 'id': {'field': 'id', 'key': True},
33
+ 'name': {'field': 'full_name', 'nullable': False},
34
+ 'email': {'field': 'email', 'fn': 'email'}
35
+ }, cursor=cursor)
36
+
37
+ # Row-oriented operations (INSERT + UPDATE/MERGE in same run, or db_expr columns)
38
+ surge = DataSurge(table)
39
+ surge.upsert(new_records) # INSERT new, UPDATE existing
40
+ surge.delete(stale_records)
41
+
42
+ # High-throughput load (no db_expr columns, INSERT only)
43
+ bulk = BulkSurge(table)
44
+ bulk.insert(records) # Streams via COPY / direct-path / bcp
45
+
46
+ # Identity resolution
47
+ stmt = cursor.prepare_file('sql/resolve_user.sql')
48
+ im = IdentityManager('source_id', 'user_id', resolver=stmt)
49
+ for row in reader:
50
+ entity = im.resolve(row)
51
+ """
52
+
53
+ from .table import Table
54
+ from .data_surge import DataSurge
55
+ from .bulk_surge import BulkSurge
56
+ from .managers import IdentityManager, ValidationCollector, EntityStatus
57
+ from .transforms.database import TableLookup
58
+ from .config_generators import column_defs_from_db
59
+
60
+ __all__ = ['Table', 'DataSurge', 'BulkSurge', 'IdentityManager', 'ValidationCollector',
61
+ 'EntityStatus', 'TableLookup', 'column_defs_from_db']
dbtk/etl/base_surge.py ADDED
@@ -0,0 +1,257 @@
1
+ # dbtk/etl/base_surge.py
2
+ import logging
3
+ import time
4
+ from abc import ABC, abstractmethod
5
+ from typing import Iterable, Generator, Optional
6
+ import datetime as dt
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from . import Table
11
+ from ..config import get_setting
12
+ from ..utils import RecordLike, batch_iterable, sanitize_identifier
13
+ from ..record import Record
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class BaseSurge(ABC):
19
+ """
20
+ Base class for all Surge loaders.
21
+
22
+ Provides common iteration, transformation, validation, and skip tracking
23
+ for loading data into database tables.
24
+
25
+ Parameters
26
+ ----------
27
+ table : Table
28
+ Table instance with column definitions and cursor
29
+ batch_size : int, optional
30
+ Number of records per batch (default: cursor.batch_size or 1000)
31
+ pass_through : bool, optional
32
+ Skip transformation and validation (default: False)
33
+
34
+ Attributes
35
+ ----------
36
+ total_read : int
37
+ Total rows read from source. 1-based (first row = 1). Includes
38
+ both loaded and skipped rows.
39
+ total_loaded : int
40
+ Total rows successfully transformed, validated and loaded.
41
+ skipped : int
42
+ Total rows skipped due to missing required fields.
43
+ skip_details : dict
44
+ Skip tracking grouped by reason. Key is a frozenset of missing
45
+ required field names. Value is a dict with:
46
+
47
+ - ``count``: total rows skipped for this reason
48
+ - ``sample``: list of up to 20 1-based row numbers (for debugging)
49
+
50
+ Example::
51
+
52
+ {frozenset({'primary_name'}): {'count': 5, 'sample': [937887, 957847, ...]}}
53
+ """
54
+ def __init__(
55
+ self,
56
+ table: Table,
57
+ batch_size: Optional[int] = None,
58
+ pass_through: bool = False
59
+ ):
60
+ self.table = table
61
+ self.cursor = table.cursor
62
+ self.batch_size = batch_size or getattr(self.cursor, "batch_size", 1000)
63
+ self.pass_through = pass_through
64
+ self.operation = 'insert'
65
+ # force positional parameter style
66
+ self.table.force_positional()
67
+ # stats
68
+ self.start_time = 0.0
69
+ self.total_read = 0
70
+ self.total_loaded = 0
71
+ self.skipped = 0
72
+ self.start_time = None
73
+ self.log_dir = get_setting('logging.directory')
74
+ self.skip_details = {} # key: frozenset of missing fields, value: {'count': int, 'sample': [row_nums]}
75
+
76
+ self._RecordClass = None # Built on first use
77
+
78
+ def _log_summary(self):
79
+ """Log uniform load statistics after each operation."""
80
+ elapsed = time.monotonic() - self.start_time
81
+ rate = int(self.total_loaded / elapsed) if elapsed > 0 else 0
82
+ skipped = f', {self.skipped:,} skipped' if self.skipped else ''
83
+ logger.info(f"Loaded {self.total_loaded:,} records into {self.table.name} in {elapsed:.2f}s ({rate:,} rec/s){skipped}")
84
+
85
+ def _get_record_class(self, operation: Optional[str] = None):
86
+ """Build or return the Record subclass for this operation's columns."""
87
+ if self._RecordClass is None:
88
+ if operation is not None:
89
+ # Force SQL generation to populate _param_config[operation]
90
+ _ = self.table.get_sql(operation)
91
+ cols = list(self.table.param_config[operation])
92
+ else:
93
+ cols = list(self.table.columns.keys())
94
+
95
+ self._RecordClass = type("Record", (Record,), {})
96
+ self._RecordClass.set_columns(cols)
97
+ return self._RecordClass
98
+
99
+ def _get_columns(self, operation: Optional[str] = None):
100
+ """ Get column names in the correct order for this operation. """
101
+ if operation is None:
102
+ operation = self.operation
103
+ bind_params = self.table.param_config[operation]
104
+ return [self.table.bind_name_column(name) for name in bind_params]
105
+
106
+ def _transform_row(self, record, mode=None):
107
+ """
108
+ Transform and validate a single row.
109
+
110
+ Applies column transforms and checks required fields. On failure,
111
+ records the 1-based row number in skip_details for debugging (up to
112
+ 20 samples per unique set of missing fields).
113
+
114
+ Returns None if validation fails (caller should skip the row).
115
+ """
116
+ self.table.set_values(record)
117
+ if not self.table.is_ready(self.operation):
118
+ missing = self.table.reqs_missing(self.operation)
119
+ if missing:
120
+ missing_set = frozenset(missing)
121
+ # Initialize skip tracking for this reason if needed
122
+ if missing_set not in self.skip_details:
123
+ self.skip_details[missing_set] = {'count': 0, 'sample': []}
124
+
125
+ self.skip_details[missing_set]['count'] += 1
126
+
127
+ # Keep first 20 row numbers as samples for debugging
128
+ if self.skip_details[missing_set]['count'] <= 20:
129
+ row_num = record.get('_row_num', self.total_read)
130
+ self.skip_details[missing_set]['sample'].append(row_num)
131
+ return None
132
+ return self.table.get_bind_params(self.operation, mode=mode)
133
+
134
+ def records(self, source: Iterable[RecordLike]) -> Generator[tuple, None, None]:
135
+ """Yield individual transformed and validated records, updating stats."""
136
+ for raw in source:
137
+ self.total_read += 1
138
+ if self.pass_through:
139
+ params = raw
140
+ else:
141
+ params = self._transform_row(raw)
142
+ if params is not None:
143
+ self.total_loaded += 1
144
+ yield params
145
+ else:
146
+ self.skipped += 1
147
+
148
+ def batched(self, source: Iterable[RecordLike]) -> Generator[list, None, None]:
149
+ """
150
+ Primary batch interface.
151
+
152
+ Returns an iterator that yields batches of fully transformed and validated
153
+ Record objects. This is the canonical way to consume data from a Surge.
154
+
155
+ Used by:
156
+ - DataSurge.load() → executemany
157
+ - BulkSurge.dump() → CSVWriter
158
+ - Any custom streaming pipeline
159
+
160
+ Example
161
+ -------
162
+ >>> for batch in surge.batched(reader):
163
+ >>> writer.write_batch(batch)
164
+ """
165
+
166
+ batch = []
167
+
168
+ for raw in source:
169
+ self.total_read += 1
170
+ if self.pass_through:
171
+ params = raw
172
+ else:
173
+ params = self._transform_row(raw)
174
+ if params is not None:
175
+ batch.append(params)
176
+ self.total_loaded += 1
177
+ else:
178
+ self.skipped += 1
179
+ if len(batch) >= self.batch_size:
180
+ yield batch
181
+ batch = []
182
+
183
+ if batch:
184
+ yield batch
185
+
186
+ def _resolve_file_path(self, path_input: Optional[str | Path] = None, extension: str = '.csv') -> Path:
187
+ """
188
+ Resolve an output file path from user input.
189
+
190
+ Handles both file paths and directory paths, generating timestamped
191
+ filenames when a directory is provided. Sanitizes table names for
192
+ safe filesystem use.
193
+
194
+ Resolution Priority
195
+ -------------------
196
+ 1. User-provided path_input
197
+ - If existing file or valid file path: use exactly
198
+ - If existing directory: generate timestamped file inside it
199
+ 2. Configured settings['data_dump_dir']
200
+ - If directory exists: generate timestamped file inside it
201
+ 3. System temp directory (fallback)
202
+ """
203
+ if extension and not extension.startswith('.'):
204
+ extension = '.' + extension
205
+
206
+ if path_input:
207
+ p = Path(path_input)
208
+ if p.is_file() or (p.suffix == extension and p.parent.exists()):
209
+ return p
210
+ elif p.is_dir() and p.exists():
211
+ timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
212
+ safe_name = sanitize_identifier(self.table.name)
213
+ return p / f"{safe_name}_{timestamp}{extension}"
214
+
215
+ configured = get_setting('data_dump_dir')
216
+ if configured:
217
+ p = Path(configured)
218
+ if p.is_dir() and p.exists():
219
+ timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
220
+ safe_name = sanitize_identifier(self.table.name)
221
+ return p / f"{safe_name}_{timestamp}{extension}"
222
+ else:
223
+ logger.warning(f"Configured data_dump_dir '{configured}' invalid. Using temp dir.")
224
+
225
+ temp_dir = Path(tempfile.gettempdir())
226
+ timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
227
+ safe_name = sanitize_identifier(self.table.name)
228
+ return temp_dir / f"{safe_name}_{timestamp}{extension}"
229
+
230
+ @abstractmethod
231
+ def load(self,
232
+ records: Iterable[RecordLike],
233
+ operation: Optional[str] = 'insert',
234
+ raise_error: bool = True) -> int:
235
+ """ Load records into database. """
236
+ pass
237
+
238
+ def __enter__(self):
239
+ return self
240
+
241
+ def __exit__(self, exc_type, exc_val, exc_tb):
242
+ if exc_type is None:
243
+ logger.info(
244
+ f"{self.__class__.__name__} [{self.operation.upper()}]: "
245
+ f"{self.total_loaded:,} loaded, {self.skipped:,} skipped → {self.table.name}"
246
+ )
247
+ if self.skipped:
248
+ logger.info(f"Skipped {self.skipped:,} rows total.")
249
+ # Sort by count (descending) for consistent reporting
250
+ sorted_skips = sorted(self.skip_details.items(), key=lambda x: x[1]['count'], reverse=True)
251
+ for reason_set, details in sorted_skips:
252
+ fields_str = ', '.join(sorted(reason_set)) or "<unknown reason>"
253
+ logger.info(f" - {details['count']:,} rows skipped due to missing: {fields_str}")
254
+ # Log first few samples if debug mode
255
+ for row_idx in details['sample'][:3]: # first 3 per reason
256
+ logger.debug(f" Sample skip at row #{row_idx}: missing {reason_set}")
257
+ return None