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.
- dbtk/__init__.py +54 -0
- dbtk/cli.py +169 -0
- dbtk/config.py +1194 -0
- dbtk/cursors.py +566 -0
- dbtk/database.py +959 -0
- dbtk/dbtk_sample.yml +90 -0
- dbtk/defaults.py +29 -0
- dbtk/etl/__init__.py +61 -0
- dbtk/etl/base_surge.py +257 -0
- dbtk/etl/bulk_surge.py +783 -0
- dbtk/etl/config_generators.py +458 -0
- dbtk/etl/data_surge.py +294 -0
- dbtk/etl/managers.py +734 -0
- dbtk/etl/table.py +1215 -0
- dbtk/etl/transforms/__init__.py +107 -0
- dbtk/etl/transforms/address.py +560 -0
- dbtk/etl/transforms/core.py +594 -0
- dbtk/etl/transforms/database.py +506 -0
- dbtk/etl/transforms/datetime.py +497 -0
- dbtk/etl/transforms/email.py +72 -0
- dbtk/etl/transforms/phone.py +670 -0
- dbtk/formats/__init__.py +18 -0
- dbtk/formats/edi.py +182 -0
- dbtk/logging_utils.py +310 -0
- dbtk/readers/__init__.py +26 -0
- dbtk/readers/base.py +644 -0
- dbtk/readers/csv.py +195 -0
- dbtk/readers/data_frame.py +119 -0
- dbtk/readers/excel.py +260 -0
- dbtk/readers/fixed_width.py +359 -0
- dbtk/readers/json.py +323 -0
- dbtk/readers/utils.py +394 -0
- dbtk/readers/xml.py +260 -0
- dbtk/record.py +710 -0
- dbtk/utils.py +537 -0
- dbtk/writers/__init__.py +46 -0
- dbtk/writers/base.py +718 -0
- dbtk/writers/csv.py +107 -0
- dbtk/writers/database.py +158 -0
- dbtk/writers/excel.py +1086 -0
- dbtk/writers/fixed_width.py +290 -0
- dbtk/writers/json.py +156 -0
- dbtk/writers/utils.py +41 -0
- dbtk/writers/xml.py +387 -0
- dbtk-0.8.0.dist-info/METADATA +305 -0
- dbtk-0.8.0.dist-info/RECORD +50 -0
- dbtk-0.8.0.dist-info/WHEEL +5 -0
- dbtk-0.8.0.dist-info/entry_points.txt +2 -0
- dbtk-0.8.0.dist-info/licenses/LICENSE.txt +7 -0
- 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
|