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.
Files changed (71) hide show
  1. {dbtk-0.8.2 → dbtk-0.8.3}/PKG-INFO +13 -5
  2. {dbtk-0.8.2 → dbtk-0.8.3}/README.md +6 -2
  3. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/__init__.py +8 -2
  4. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/cursors.py +89 -13
  5. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/dbtk_sample.yml +0 -1
  6. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/defaults.py +0 -1
  7. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/__init__.py +2 -2
  8. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/base.py +0 -70
  9. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/csv.py +1 -2
  10. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/data_frame.py +1 -1
  11. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/excel.py +1 -1
  12. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/json.py +1 -1
  13. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/utils.py +0 -1
  14. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/xml.py +27 -12
  15. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/record.py +159 -32
  16. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/utils.py +21 -8
  17. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/base.py +49 -1
  18. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/csv.py +8 -1
  19. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/json.py +13 -2
  20. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/PKG-INFO +13 -5
  21. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/SOURCES.txt +1 -0
  22. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/requires.txt +4 -1
  23. {dbtk-0.8.2 → dbtk-0.8.3}/pyproject.toml +8 -3
  24. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_data_bending.py +88 -0
  25. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_readers.py +1 -1
  26. dbtk-0.8.3/tests/test_record.py +614 -0
  27. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_writers.py +116 -1
  28. {dbtk-0.8.2 → dbtk-0.8.3}/LICENSE.txt +0 -0
  29. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/cli.py +0 -0
  30. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/config.py +0 -0
  31. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/database.py +0 -0
  32. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/__init__.py +0 -0
  33. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/base_surge.py +0 -0
  34. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/bulk_surge.py +0 -0
  35. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/config_generators.py +0 -0
  36. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/data_surge.py +0 -0
  37. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/managers.py +0 -0
  38. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/table.py +0 -0
  39. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/__init__.py +0 -0
  40. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/address.py +0 -0
  41. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/core.py +0 -0
  42. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/database.py +0 -0
  43. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/datetime.py +0 -0
  44. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/email.py +0 -0
  45. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/etl/transforms/phone.py +0 -0
  46. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/formats/__init__.py +0 -0
  47. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/formats/edi.py +0 -0
  48. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/logging_utils.py +0 -0
  49. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/readers/fixed_width.py +0 -0
  50. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/__init__.py +0 -0
  51. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/database.py +0 -0
  52. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/excel.py +0 -0
  53. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/fixed_width.py +0 -0
  54. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/utils.py +0 -0
  55. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk/writers/xml.py +0 -0
  56. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/dependency_links.txt +0 -0
  57. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/entry_points.txt +0 -0
  58. {dbtk-0.8.2 → dbtk-0.8.3}/dbtk.egg-info/top_level.txt +0 -0
  59. {dbtk-0.8.2 → dbtk-0.8.3}/setup.cfg +0 -0
  60. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_bulk.py +0 -0
  61. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_config.py +0 -0
  62. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_edi_writer.py +0 -0
  63. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_excel_writer.py +0 -0
  64. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_fixed_width_record.py +0 -0
  65. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_logging_utils.py +0 -0
  66. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_table.py +0 -0
  67. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_table_new_features.py +0 -0
  68. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_transforms_core.py +0 -0
  69. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_transforms_datetime.py +0 -0
  70. {dbtk-0.8.2 → dbtk-0.8.3}/tests/test_transforms_phone.py +0 -0
  71. {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.2
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
- 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.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
- 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."""
@@ -4,7 +4,6 @@
4
4
  settings:
5
5
  default_batch_size: 1000
6
6
  default_country: US
7
- default_header_clean: 2 # Clean.LOWER_NOSPACE
8
7
  default_timezone: UTC
9
8
 
10
9
  # Logging configuration for integration scripts
@@ -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, Clean
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', 'Clean', 'get_reader', 'open_file',
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, Clean, logger
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
  -----
@@ -1,7 +1,7 @@
1
1
  # dbtk/readers/data_frame.py
2
2
  import itertools
3
3
  from typing import Iterable, Optional
4
- from .base import Reader, Record, Clean, logger
4
+ from .base import Reader, Record, logger
5
5
 
6
6
 
7
7
  class DataFrameReader(Reader):
@@ -6,7 +6,7 @@ import datetime as dt
6
6
  import logging
7
7
  from pathlib import Path
8
8
  from typing import List, Any, Iterator, Optional, Union
9
- from .base import Reader, Clean
9
+ from .base import Reader
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -5,7 +5,7 @@
5
5
  import json
6
6
  import os
7
7
  from typing import List, Any, Dict, Optional, TextIO, Iterator
8
- from .base import Reader, Clean
8
+ from .base import Reader
9
9
 
10
10
 
11
11
  class JSONReader(Reader):
@@ -252,7 +252,6 @@ def get_reader(filename: Union[str, Path],
252
252
  for record in reader:
253
253
  print(record)
254
254
 
255
- # Fixed width file (uses Clean.NOOP by default)
256
255
  config = [
257
256
  FixedColumn('name', 1, 20),
258
257
  FixedColumn('age', 21, 23, 'int')
@@ -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, Clean
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 etree.XMLSyntaxError as e:
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.xpath(self.record_xpath)
111
- except etree.XPathEvalError as e:
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.xpath(xml_column.xpath)
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 etree.XPathEvalError:
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') # lxml prefers binary mode
270
+ fp = open(filename, 'rb')
253
271
  return XMLReader(fp, **kwargs)
254
272
 
255
273
 
256
- def check_dependencies():
257
- if not HAS_LXML:
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).')