dbtk 0.8.2__tar.gz → 0.8.3__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.3}/PKG-INFO +13 -5
- {dbtk-0.8.2 → dbtk-0.8.3}/README.md +6 -2
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/__init__.py +8 -2
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/cursors.py +89 -13
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/dbtk_sample.yml +0 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/defaults.py +0 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/__init__.py +2 -2
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/base.py +0 -70
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/csv.py +1 -2
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/data_frame.py +1 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/excel.py +1 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/json.py +1 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/utils.py +0 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/xml.py +27 -12
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/record.py +159 -32
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/utils.py +21 -8
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/base.py +49 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/csv.py +8 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/json.py +13 -2
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/PKG-INFO +13 -5
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/SOURCES.txt +1 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/requires.txt +4 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/pyproject.toml +8 -3
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_data_bending.py +88 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_readers.py +1 -1
- dbtk-0.8.3/tests/test_record.py +614 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_writers.py +116 -1
- {dbtk-0.8.2 → dbtk-0.8.3}/LICENSE.txt +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/cli.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/config.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/database.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/__init__.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/base_surge.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/bulk_surge.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/config_generators.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/data_surge.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/managers.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/table.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/__init__.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/address.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/core.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/database.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/datetime.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/email.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/phone.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/formats/__init__.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/formats/edi.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/logging_utils.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/fixed_width.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/__init__.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/database.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/excel.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/fixed_width.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/utils.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/xml.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/dependency_links.txt +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/entry_points.txt +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/top_level.txt +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/setup.cfg +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_bulk.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_config.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_edi_writer.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_excel_writer.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_fixed_width_record.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_logging_utils.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_table.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_table_new_features.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_transforms_core.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_transforms_datetime.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_transforms_phone.py +0 -0
- {dbtk-0.8.2 → dbtk-0.8.3}/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.3
|
|
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: oracledb>=1.0; python_version >= "3.7" and extra == "all"
|
|
65
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "all"
|
|
66
|
+
Requires-Dist: pymysql>=1.0; 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.3'
|
|
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 @@ 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."""
|
|
@@ -5,7 +5,6 @@ settings = {
|
|
|
5
5
|
'default_batch_size': 1000,
|
|
6
6
|
'default_country': 'US',
|
|
7
7
|
'default_db_type': 'postgres',
|
|
8
|
-
'default_header_clean': 2, # Clean.LOWER_NOSPACE as int
|
|
9
8
|
'data_dump_dir': '/tmp', # default directory for data dumps
|
|
10
9
|
'compressed_file_buffer_size': 1024 * 1024, # 1MB buffer for .gz/.bz2/.xz files
|
|
11
10
|
'null_string': '', # how null is represented in text outputs
|
|
@@ -8,7 +8,7 @@ consistent interface and automatic format detection.
|
|
|
8
8
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from .base import Reader
|
|
11
|
+
from .base import Reader
|
|
12
12
|
from .utils import get_reader, open_file
|
|
13
13
|
from .csv import CSVReader
|
|
14
14
|
from .data_frame import DataFrameReader
|
|
@@ -20,7 +20,7 @@ from .xml import XMLReader, XMLColumn
|
|
|
20
20
|
|
|
21
21
|
# Re-export everything
|
|
22
22
|
__all__ = [
|
|
23
|
-
'Reader', '
|
|
23
|
+
'Reader', 'get_reader', 'open_file',
|
|
24
24
|
'CSVReader', 'EDIReader', 'FixedColumn', 'FixedReader', 'JSONReader', 'NDJSONReader', 'XLSReader', 'XLSXReader',
|
|
25
25
|
'XMLColumn', 'XMLReader', 'DataFrameReader', 'open_workbook', 'get_sheet_by_index'
|
|
26
26
|
]
|
|
@@ -21,75 +21,6 @@ from ..defaults import settings
|
|
|
21
21
|
logger = logging.getLogger(__name__)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
class Clean:
|
|
25
|
-
"""Header cleaning levels for column names."""
|
|
26
|
-
NOOP = 0 # Leave unchanged
|
|
27
|
-
LOWER = 1 # Lower case header
|
|
28
|
-
LOWER_NOSPACE = 2 # Lower case and replace spaces with _
|
|
29
|
-
LOWER_ALPHANUM = 3 # Lower case, remove all non-alphanum characters
|
|
30
|
-
STANDARDIZE = 4 # Lower case, remove non-alphanum, strip "code" endings
|
|
31
|
-
DEFAULT = LOWER_NOSPACE
|
|
32
|
-
|
|
33
|
-
@classmethod
|
|
34
|
-
def from_string(cls, value):
|
|
35
|
-
"""Convert string to Clean constant, or pass through if already int."""
|
|
36
|
-
if isinstance(value, int):
|
|
37
|
-
return value
|
|
38
|
-
|
|
39
|
-
string_map = {
|
|
40
|
-
'noop': cls.NOOP,
|
|
41
|
-
'lower': cls.LOWER,
|
|
42
|
-
'lower_nospace': cls.LOWER_NOSPACE,
|
|
43
|
-
'lower_alphanum': cls.LOWER_ALPHANUM,
|
|
44
|
-
'standardize': cls.STANDARDIZE
|
|
45
|
-
}
|
|
46
|
-
return string_map.get(value.lower(), cls.NOOP)
|
|
47
|
-
|
|
48
|
-
@staticmethod
|
|
49
|
-
def normalize(val: Any, clean_level: Union[int, 'Clean', None] = None) -> str:
|
|
50
|
-
"""
|
|
51
|
-
Normalize column header names.
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
val: Column name (any type, will be converted to string)
|
|
55
|
-
clean_level: Cleaning level, either an integer (0-4), a Clean class constant,
|
|
56
|
-
or None to use Clean.DEFAULT
|
|
57
|
-
|
|
58
|
-
Returns:
|
|
59
|
-
Normalized column name string
|
|
60
|
-
|
|
61
|
-
Examples:
|
|
62
|
-
Clean.normalize("#Term Code", Clean.NOOP) # -> "#Term Code"
|
|
63
|
-
Clean.normalize("#Term Code", Clean.LOWER) # -> "#term code"
|
|
64
|
-
Clean.normalize("#Term Code", Clean.LOWER_NOSPACE) # -> "#term_code"
|
|
65
|
-
Clean.normalize("#Term Code", Clean.LOWER_ALPHANUM) # -> "termcode"
|
|
66
|
-
Clean.normalize("#Term Code", Clean.STANDARDIZE) # -> "term"
|
|
67
|
-
Clean.normalize("#Term Code") # -> "#term_code" (default to Clean.LOWER_NOSPACE)
|
|
68
|
-
"""
|
|
69
|
-
# Use Clean.DEFAULT if clean_level is None
|
|
70
|
-
if clean_level is None:
|
|
71
|
-
clean_level = Clean.LOWER_NOSPACE
|
|
72
|
-
# Convert clean_level to integer if it's a Clean constant
|
|
73
|
-
clean_level = clean_level if isinstance(clean_level, int) else clean_level.value
|
|
74
|
-
# Validate clean_level
|
|
75
|
-
if clean_level not in {Clean.NOOP, Clean.LOWER, Clean.LOWER_NOSPACE, Clean.LOWER_ALPHANUM, Clean.STANDARDIZE}:
|
|
76
|
-
raise ValueError(f"Invalid clean_level: {clean_level}. Must be 0-4 or a Clean constant.")
|
|
77
|
-
|
|
78
|
-
if val in (None, '') or clean_level == Clean.NOOP:
|
|
79
|
-
return str(val) if val is not None else ''
|
|
80
|
-
val = str(val).lower().strip()
|
|
81
|
-
if clean_level == Clean.LOWER:
|
|
82
|
-
return val
|
|
83
|
-
elif clean_level == Clean.LOWER_NOSPACE:
|
|
84
|
-
return val.replace(' ', '_')
|
|
85
|
-
elif clean_level == Clean.LOWER_ALPHANUM:
|
|
86
|
-
return re.sub(r'[^a-z0-9]', '', val)
|
|
87
|
-
elif clean_level == Clean.STANDARDIZE:
|
|
88
|
-
return re.sub(r'[^a-z0-9]+|code$', '', val)
|
|
89
|
-
else:
|
|
90
|
-
return val
|
|
91
|
-
|
|
92
|
-
|
|
93
24
|
class _Progress:
|
|
94
25
|
__slots__ = ('tell', 'byte_total', 'row_total')
|
|
95
26
|
|
|
@@ -215,7 +146,6 @@ class Reader(ABC):
|
|
|
215
146
|
XLSXReader : Read Excel .xlsx files
|
|
216
147
|
XMLReader : Read XML files
|
|
217
148
|
FixedReader : Read fixed-width text files
|
|
218
|
-
Clean : Header cleaning options
|
|
219
149
|
Record : Flexible row objects with multiple access patterns
|
|
220
150
|
|
|
221
151
|
Notes
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import csv
|
|
6
6
|
import os
|
|
7
7
|
from typing import TextIO, List, Any, Iterator, Optional
|
|
8
|
-
from .base import Reader,
|
|
8
|
+
from .base import Reader, logger
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class CSVReader(Reader):
|
|
@@ -84,7 +84,6 @@ class CSVReader(Reader):
|
|
|
84
84
|
--------
|
|
85
85
|
Reader : Base class with common reader features
|
|
86
86
|
readers.get_reader : Automatic reader selection based on file extension
|
|
87
|
-
Clean : Header cleaning options
|
|
88
87
|
|
|
89
88
|
Notes
|
|
90
89
|
-----
|
|
@@ -6,14 +6,32 @@ import logging
|
|
|
6
6
|
import os
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import List, Any, Dict, Optional, TextIO, Union, Iterator
|
|
9
|
-
from .base import Reader
|
|
9
|
+
from .base import Reader
|
|
10
10
|
|
|
11
11
|
try:
|
|
12
12
|
from lxml import etree
|
|
13
13
|
HAS_LXML = True
|
|
14
14
|
except ImportError:
|
|
15
|
+
from xml.etree import ElementTree as etree
|
|
15
16
|
HAS_LXML = False
|
|
16
17
|
|
|
18
|
+
# lxml and stdlib use different exception types
|
|
19
|
+
_XMLParseError = etree.XMLSyntaxError if HAS_LXML else etree.ParseError
|
|
20
|
+
_XPathError = etree.XPathEvalError if HAS_LXML else SyntaxError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _xpath(node, expr):
|
|
24
|
+
"""Run an XPath expression using lxml or stdlib ElementTree."""
|
|
25
|
+
if HAS_LXML:
|
|
26
|
+
return node.xpath(expr)
|
|
27
|
+
# stdlib findall supports a limited XPath subset; // must be relative
|
|
28
|
+
if expr.startswith('//'):
|
|
29
|
+
expr = '.' + expr
|
|
30
|
+
try:
|
|
31
|
+
return node.findall(expr)
|
|
32
|
+
except SyntaxError:
|
|
33
|
+
return []
|
|
34
|
+
|
|
17
35
|
logger = logging.getLogger(__name__)
|
|
18
36
|
|
|
19
37
|
|
|
@@ -102,13 +120,13 @@ class XMLReader(Reader):
|
|
|
102
120
|
"""Parse the XML file and prepare for reading."""
|
|
103
121
|
try:
|
|
104
122
|
self._tree = etree.parse(self.fp)
|
|
105
|
-
except
|
|
123
|
+
except _XMLParseError as e:
|
|
106
124
|
raise ValueError(f"Invalid XML: {e}")
|
|
107
125
|
|
|
108
126
|
# Find all record nodes
|
|
109
127
|
try:
|
|
110
|
-
self._record_nodes = self._tree
|
|
111
|
-
except
|
|
128
|
+
self._record_nodes = _xpath(self._tree, self.record_xpath)
|
|
129
|
+
except _XPathError as e:
|
|
112
130
|
raise ValueError(f"Invalid XPath expression '{self.record_xpath}': {e}")
|
|
113
131
|
|
|
114
132
|
if not self._record_nodes:
|
|
@@ -159,7 +177,7 @@ class XMLReader(Reader):
|
|
|
159
177
|
# Check if it has custom XPath
|
|
160
178
|
if xml_column.xpath:
|
|
161
179
|
try:
|
|
162
|
-
result = record_node
|
|
180
|
+
result = _xpath(record_node, xml_column.xpath)
|
|
163
181
|
if result:
|
|
164
182
|
# Handle different XPath result types
|
|
165
183
|
if isinstance(result[0], str):
|
|
@@ -175,7 +193,7 @@ class XMLReader(Reader):
|
|
|
175
193
|
return value.strip() if value else None
|
|
176
194
|
else:
|
|
177
195
|
return None
|
|
178
|
-
except
|
|
196
|
+
except _XPathError:
|
|
179
197
|
return None
|
|
180
198
|
|
|
181
199
|
# Look for simple child element by column name
|
|
@@ -249,12 +267,9 @@ def open_xml(filename: Union[str, Path], **kwargs) -> XMLReader:
|
|
|
249
267
|
for record in reader:
|
|
250
268
|
print(record.name)
|
|
251
269
|
"""
|
|
252
|
-
fp = open(filename, 'rb')
|
|
270
|
+
fp = open(filename, 'rb')
|
|
253
271
|
return XMLReader(fp, **kwargs)
|
|
254
272
|
|
|
255
273
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
logger.error('lxml is required for XML support. Install with "pip install lxml".')
|
|
259
|
-
|
|
260
|
-
check_dependencies()
|
|
274
|
+
if not HAS_LXML:
|
|
275
|
+
logger.debug('lxml not available; falling back to stdlib xml.etree.ElementTree (limited XPath support).')
|