ping-dataexport 0.1.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 (42) hide show
  1. ping_dataexport/__init__.py +11 -0
  2. ping_dataexport/__main__.py +54 -0
  3. ping_dataexport/api.py +164 -0
  4. ping_dataexport/applogger.py +104 -0
  5. ping_dataexport/cli.py +101 -0
  6. ping_dataexport/config.py +233 -0
  7. ping_dataexport/db/__init__.py +0 -0
  8. ping_dataexport/db/connector.py +99 -0
  9. ping_dataexport/db/oracle_connector.py +86 -0
  10. ping_dataexport/db/pyodbc_connector.py +82 -0
  11. ping_dataexport/db/sqlite_connector.py +61 -0
  12. ping_dataexport/db/type_map.py +118 -0
  13. ping_dataexport/errors.py +19 -0
  14. ping_dataexport/export/__init__.py +0 -0
  15. ping_dataexport/export/engine.py +170 -0
  16. ping_dataexport/export/options.py +48 -0
  17. ping_dataexport/export/runner.py +50 -0
  18. ping_dataexport/export/util.py +65 -0
  19. ping_dataexport/job/__init__.py +0 -0
  20. ping_dataexport/job/executor.py +107 -0
  21. ping_dataexport/job/shared_context.py +22 -0
  22. ping_dataexport/job/spec.py +251 -0
  23. ping_dataexport/job/validator.py +87 -0
  24. ping_dataexport/paths.py +71 -0
  25. ping_dataexport/planner/__init__.py +0 -0
  26. ping_dataexport/planner/base.py +71 -0
  27. ping_dataexport/planner/datelist.py +35 -0
  28. ping_dataexport/planner/groupby.py +71 -0
  29. ping_dataexport/planner/monthbydate.py +104 -0
  30. ping_dataexport/planner/single.py +18 -0
  31. ping_dataexport/planner/watermark.py +202 -0
  32. ping_dataexport/writer/__init__.py +0 -0
  33. ping_dataexport/writer/arrow_map.py +174 -0
  34. ping_dataexport/writer/base.py +37 -0
  35. ping_dataexport/writer/delimited.py +108 -0
  36. ping_dataexport/writer/parquet.py +84 -0
  37. ping_dataexport-0.1.0.dist-info/METADATA +228 -0
  38. ping_dataexport-0.1.0.dist-info/RECORD +42 -0
  39. ping_dataexport-0.1.0.dist-info/WHEEL +5 -0
  40. ping_dataexport-0.1.0.dist-info/entry_points.txt +2 -0
  41. ping_dataexport-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. ping_dataexport-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,99 @@
1
+ """Connector interface + ColumnInfo + factory.
2
+
3
+ Driver packages (pyodbc, oracledb) are imported lazily inside their connector
4
+ modules, so the package imports cleanly with only the stdlib + pyarrow installed
5
+ (the sqlite connector needs nothing extra).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import Any, Iterator, List, Optional, Sequence
13
+
14
+ from ..config import DBConfig
15
+ from ..errors import ConfigError
16
+
17
+ # Canonical column kinds — feed the Parquet schema and watermark type detection.
18
+ INT = "INT"
19
+ BIGINT = "BIGINT"
20
+ FLOAT = "FLOAT"
21
+ DOUBLE = "DOUBLE"
22
+ DECIMAL = "DECIMAL"
23
+ BOOL = "BOOL"
24
+ DATE = "DATE"
25
+ TIME = "TIME"
26
+ TIMESTAMP = "TIMESTAMP"
27
+ STRING = "STRING"
28
+ BINARY = "BINARY"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ColumnInfo:
33
+ name: str
34
+ kind: str = STRING
35
+ precision: Optional[int] = None # DECIMAL only
36
+ scale: Optional[int] = None # DECIMAL only
37
+
38
+
39
+ class Connector(ABC):
40
+ """One instance per DB Type; stateless — connections are created per job."""
41
+
42
+ paramstyle: str = "qmark"
43
+
44
+ #: False when describe() infers types from sample values (sqlite) rather than
45
+ #: driver type codes — such kinds are best-effort, not authoritative.
46
+ authoritative_types: bool = True
47
+
48
+ @abstractmethod
49
+ def connect(self, db: DBConfig) -> Any:
50
+ """Open a DB-API connection (read-only usage; autocommit where applicable)."""
51
+
52
+ @abstractmethod
53
+ def describe(self, cursor: Any, sample_rows: Sequence[Sequence[Any]] = ()) -> List[ColumnInfo]:
54
+ """Column names + canonical kinds from cursor.description (+ type codes).
55
+
56
+ `sample_rows` is the already-fetched first batch — used only by drivers
57
+ whose description carries no type codes (sqlite).
58
+ """
59
+
60
+ def rewrite_params(self, sql: str) -> str:
61
+ """Planners emit qmark '?' placeholders; numeric-style drivers rewrite them."""
62
+ return sql
63
+
64
+ def adapt_params(self, params: Sequence[Any]) -> Sequence[Any]:
65
+ """Convert bind values to what the driver accepts (planners bind native types)."""
66
+ return params
67
+
68
+ def prepare_cursor(self, conn: Any, fetch_size: int) -> Any:
69
+ cursor = conn.cursor()
70
+ if hasattr(cursor, "arraysize"):
71
+ cursor.arraysize = fetch_size
72
+ return cursor
73
+
74
+ @staticmethod
75
+ def fetch_batches(cursor: Any, fetch_size: int) -> Iterator[List[Sequence[Any]]]:
76
+ """fetchmany loop — never fetchall()."""
77
+ while True:
78
+ rows = cursor.fetchmany(fetch_size)
79
+ if not rows:
80
+ return
81
+ yield rows
82
+
83
+
84
+ def create_connector(db_type: str) -> Connector:
85
+ if db_type == "sqlite":
86
+ from .sqlite_connector import SqliteConnector
87
+
88
+ return SqliteConnector()
89
+ if db_type in ("mssql-odbc", "freetds", "oracle-odbc", "odbc"):
90
+ from .pyodbc_connector import PyodbcConnector
91
+
92
+ return PyodbcConnector(db_type)
93
+ if db_type == "oracle":
94
+ from .oracle_connector import OracleConnector
95
+
96
+ return OracleConnector()
97
+ if db_type == "sqlalchemy":
98
+ raise ConfigError("Type=sqlalchemy is a phase-2 connector (not implemented yet)")
99
+ raise ConfigError(f"unsupported DB Type: {db_type!r}")
@@ -0,0 +1,86 @@
1
+ """Oracle native connector via oracledb (cx_Oracle's maintained successor).
2
+
3
+ - paramstyle is numeric: planners emit qmark '?', rewrite_params converts to :1, :2, …
4
+ - An output type handler keeps NUMBER columns as int/Decimal — never float — so
5
+ precision survives all the way to the writers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import decimal
11
+ from typing import Any, List, Sequence
12
+
13
+ from ..config import DBConfig
14
+ from ..errors import ConfigError, ExportError
15
+ from .connector import ColumnInfo, Connector
16
+ from .type_map import _MAX_INT64_PRECISION, from_oracledb_description
17
+
18
+
19
+ def rewrite_qmark_to_numeric(sql: str) -> str:
20
+ """'?' -> ':1', ':2', … outside single-quoted literals (pure, testable)."""
21
+ out: List[str] = []
22
+ n = 0
23
+ in_string = False
24
+ for ch in sql:
25
+ if ch == "'":
26
+ in_string = not in_string
27
+ out.append(ch)
28
+ elif ch == "?" and not in_string:
29
+ n += 1
30
+ out.append(f":{n}")
31
+ else:
32
+ out.append(ch)
33
+ return "".join(out)
34
+
35
+
36
+ class OracleConnector(Connector):
37
+ paramstyle = "numeric"
38
+
39
+ def rewrite_params(self, sql: str) -> str:
40
+ return rewrite_qmark_to_numeric(sql)
41
+
42
+ def connect(self, db: DBConfig) -> Any:
43
+ try:
44
+ import oracledb
45
+ except ImportError:
46
+ raise ExportError(
47
+ "Type=oracle requires the oracledb package (pip install oracledb)"
48
+ ) from None
49
+ if not db.host or not (db.service or db.sid):
50
+ raise ConfigError(f"[{db.section}] Type=oracle needs Host and Service (or SID)")
51
+ port = db.port or 1521
52
+ if db.service:
53
+ dsn = f"{db.host}:{port}/{db.service}"
54
+ else:
55
+ dsn = oracledb.makedsn(db.host, port, sid=db.sid)
56
+ try:
57
+ conn = oracledb.connect(user=db.username, password=db.password, dsn=dsn)
58
+ except oracledb.Error as exc:
59
+ raise ExportError(f"cannot connect to [{db.section}]: {exc}") from exc
60
+ conn.outputtypehandler = _number_fidelity_handler(oracledb)
61
+ return conn
62
+
63
+ def prepare_cursor(self, conn: Any, fetch_size: int) -> Any:
64
+ cursor = conn.cursor()
65
+ cursor.arraysize = fetch_size
66
+ return cursor
67
+
68
+ def describe(self, cursor: Any, sample_rows: Sequence[Sequence[Any]] = ()) -> List[ColumnInfo]:
69
+ if cursor.description is None:
70
+ return []
71
+ return [from_oracledb_description(d) for d in cursor.description]
72
+
73
+
74
+ def _number_fidelity_handler(oracledb: Any):
75
+ """NUMBER(scale=0, p<=18) -> int; any other NUMBER -> Decimal (never float)."""
76
+
77
+ def handler(cursor: Any, metadata: Any):
78
+ if metadata.type_code is oracledb.DB_TYPE_NUMBER:
79
+ precision = metadata.precision or 0
80
+ scale = metadata.scale if metadata.scale is not None else 0
81
+ if scale == 0 and 0 < precision <= _MAX_INT64_PRECISION:
82
+ return cursor.var(int, arraysize=cursor.arraysize)
83
+ return cursor.var(decimal.Decimal, arraysize=cursor.arraysize)
84
+ return None
85
+
86
+ return handler
@@ -0,0 +1,82 @@
1
+ """pyodbc connector — Types mssql-odbc / freetds / oracle-odbc / generic odbc.
2
+
3
+ pyodbc itself is imported lazily in connect(), so this module (and the
4
+ connection-string builder) works without the driver installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, List, Sequence
10
+
11
+ from ..config import DBConfig
12
+ from ..errors import ConfigError, ExportError
13
+ from .connector import ColumnInfo, Connector
14
+ from .type_map import from_pyodbc_description
15
+
16
+ _DEFAULT_MSSQL_DRIVER = "ODBC Driver 18 for SQL Server"
17
+ _DEFAULT_FREETDS_DRIVER = "FreeTDS"
18
+ _DEFAULT_MSSQL_PORT = 1433
19
+
20
+
21
+ def build_connection_string(db: DBConfig, db_type: str) -> str:
22
+ """Build/complete the ODBC connection string from discrete keys (pure, testable)."""
23
+ if db.connection_string:
24
+ cs = db.connection_string
25
+ elif db.dsn:
26
+ cs = f"DSN={db.dsn}"
27
+ elif db_type == "mssql-odbc":
28
+ if not (db.host and db.database):
29
+ raise ConfigError(f"[{db.section}] mssql-odbc needs Host+Database (or ConnectionString)")
30
+ driver = db.driver or _DEFAULT_MSSQL_DRIVER
31
+ server = f"{db.host},{db.port}" if db.port else f"{db.host},{_DEFAULT_MSSQL_PORT}"
32
+ cs = f"Driver={{{driver}}};Server={server};Database={db.database}"
33
+ elif db_type == "freetds":
34
+ if not (db.host and db.database):
35
+ raise ConfigError(f"[{db.section}] freetds needs Host+Database (or ConnectionString/DSN)")
36
+ driver = db.driver or _DEFAULT_FREETDS_DRIVER
37
+ cs = f"Driver={{{driver}}};Server={db.host};Port={db.port or _DEFAULT_MSSQL_PORT};Database={db.database}"
38
+ elif db_type == "oracle-odbc":
39
+ if not db.driver or not db.host:
40
+ raise ConfigError(
41
+ f"[{db.section}] oracle-odbc needs Driver+Host (+Port/Service) or ConnectionString/DSN"
42
+ )
43
+ target = f"{db.host}:{db.port or 1521}/{db.service or db.sid or ''}".rstrip("/")
44
+ cs = f"Driver={{{db.driver}}};DBQ={target}"
45
+ else:
46
+ raise ConfigError(f"[{db.section}] Type=odbc requires ConnectionString or DSN")
47
+
48
+ upper = cs.upper()
49
+ if db.username and "UID=" not in upper:
50
+ cs += f";UID={db.username}"
51
+ if db.password and "PWD=" not in upper:
52
+ cs += f";PWD={db.password}"
53
+ return cs
54
+
55
+
56
+ class PyodbcConnector(Connector):
57
+ paramstyle = "qmark"
58
+
59
+ def __init__(self, db_type: str):
60
+ self.db_type = db_type
61
+
62
+ def connect(self, db: DBConfig) -> Any:
63
+ try:
64
+ import pyodbc
65
+ except ImportError:
66
+ raise ExportError(
67
+ f"Type={self.db_type} requires the pyodbc package (pip install pyodbc)"
68
+ ) from None
69
+ cs = build_connection_string(db, self.db_type)
70
+ try:
71
+ conn = pyodbc.connect(cs, autocommit=True) # export is read-only
72
+ except pyodbc.Error as exc:
73
+ raise ExportError(f"cannot connect to [{db.section}]: {exc}") from exc
74
+ encoding = db.encoding or "utf-8"
75
+ conn.setdecoding(pyodbc.SQL_CHAR, encoding=encoding)
76
+ conn.setdecoding(pyodbc.SQL_WCHAR, encoding="utf-16le" if not db.encoding else encoding)
77
+ return conn
78
+
79
+ def describe(self, cursor: Any, sample_rows: Sequence[Sequence[Any]] = ()) -> List[ColumnInfo]:
80
+ if cursor.description is None:
81
+ return []
82
+ return [from_pyodbc_description(d) for d in cursor.description]
@@ -0,0 +1,61 @@
1
+ """Native SQLite connector (stdlib sqlite3) — Type=sqlite.
2
+
3
+ Zero-dependency; powers the integration test suite. Opens the database file
4
+ read-only via URI mode so an export can never modify the source.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sqlite3
10
+ from pathlib import Path
11
+ from typing import Any, List, Sequence
12
+
13
+ from ..config import DBConfig
14
+ from ..errors import ConfigError, ExportError
15
+ from .connector import ColumnInfo, Connector
16
+ from .type_map import from_sqlite_sample
17
+
18
+
19
+ class SqliteConnector(Connector):
20
+ paramstyle = "qmark"
21
+ authoritative_types = False # kinds inferred from sample values
22
+
23
+ def connect(self, db: DBConfig) -> sqlite3.Connection:
24
+ if db.connection_string and db.connection_string.startswith("file:"):
25
+ uri = db.connection_string
26
+ else:
27
+ raw = db.database or db.connection_string
28
+ if not raw:
29
+ raise ConfigError(f"[{db.section}] Type=sqlite requires Database=<file path>")
30
+ path = Path(raw).expanduser()
31
+ if not path.is_file():
32
+ raise ExportError(f"sqlite database file not found: {path}")
33
+ uri = f"file:{path.resolve().as_posix()}?mode=ro"
34
+ try:
35
+ return sqlite3.connect(uri, uri=True)
36
+ except sqlite3.Error as exc:
37
+ raise ExportError(f"cannot open sqlite database {uri}: {exc}") from exc
38
+
39
+ def adapt_params(self, params: Sequence[Any]) -> Sequence[Any]:
40
+ """sqlite3 stores dates as TEXT and (3.12+) has no default date adapters:
41
+ bind date/datetime as ISO strings so comparisons match the stored form."""
42
+ import datetime
43
+
44
+ adapted = []
45
+ for p in params:
46
+ if isinstance(p, datetime.datetime):
47
+ adapted.append(p.isoformat(sep=" "))
48
+ elif isinstance(p, datetime.date):
49
+ adapted.append(p.isoformat())
50
+ else:
51
+ adapted.append(p)
52
+ return adapted
53
+
54
+ def describe(self, cursor: Any, sample_rows: Sequence[Sequence[Any]] = ()) -> List[ColumnInfo]:
55
+ if cursor.description is None:
56
+ return []
57
+ columns: List[ColumnInfo] = []
58
+ for idx, desc in enumerate(cursor.description):
59
+ values = [row[idx] for row in sample_rows]
60
+ columns.append(from_sqlite_sample(desc[0], values))
61
+ return columns
@@ -0,0 +1,118 @@
1
+ """Driver type codes -> canonical ColumnInfo kinds.
2
+
3
+ Pure functions, no driver imports: pyodbc's type codes are plain Python types and
4
+ oracledb's are matched by their DB_TYPE_* names, so everything here is unit-testable
5
+ without any driver installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import decimal
12
+ import re
13
+ from typing import Any, Optional, Sequence
14
+
15
+ from .connector import (
16
+ BIGINT,
17
+ BINARY,
18
+ BOOL,
19
+ DATE,
20
+ DECIMAL,
21
+ DOUBLE,
22
+ FLOAT,
23
+ STRING,
24
+ TIME,
25
+ TIMESTAMP,
26
+ ColumnInfo,
27
+ )
28
+
29
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
30
+ _TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$")
31
+
32
+ #: Oracle NUMBER with scale 0 up to this precision stays an integer (int64-safe).
33
+ _MAX_INT64_PRECISION = 18
34
+
35
+
36
+ def from_pyodbc_description(desc_row: Sequence[Any]) -> ColumnInfo:
37
+ """pyodbc description entry: (name, type_code, display, internal, precision, scale, nullok).
38
+
39
+ pyodbc type codes are the Python types the driver will produce.
40
+ """
41
+ name = desc_row[0]
42
+ code = desc_row[1]
43
+ precision = desc_row[4] if len(desc_row) > 4 else None
44
+ scale = desc_row[5] if len(desc_row) > 5 else None
45
+ if code is bool:
46
+ return ColumnInfo(name, BOOL)
47
+ if code is int:
48
+ return ColumnInfo(name, BIGINT)
49
+ if code is float:
50
+ return ColumnInfo(name, DOUBLE)
51
+ if code is decimal.Decimal:
52
+ return ColumnInfo(name, DECIMAL, precision=precision or 38, scale=scale or 0)
53
+ if code is datetime.date:
54
+ return ColumnInfo(name, DATE)
55
+ if code is datetime.time:
56
+ return ColumnInfo(name, TIME)
57
+ if code is datetime.datetime:
58
+ return ColumnInfo(name, TIMESTAMP)
59
+ if code in (bytes, bytearray, memoryview):
60
+ return ColumnInfo(name, BINARY)
61
+ return ColumnInfo(name, STRING)
62
+
63
+
64
+ def from_oracledb_description(desc_row: Sequence[Any]) -> ColumnInfo:
65
+ """oracledb FetchInfo/tuple entry; type matched by DB_TYPE_* name (no import needed)."""
66
+ name = desc_row[0]
67
+ type_name = getattr(desc_row[1], "name", str(desc_row[1])).upper()
68
+ precision: Optional[int] = desc_row[4] if len(desc_row) > 4 else None
69
+ scale: Optional[int] = desc_row[5] if len(desc_row) > 5 else None
70
+
71
+ if "NUMBER" in type_name:
72
+ p = precision or 0
73
+ s = scale if scale is not None else 0
74
+ if p == 0 and s in (0, -127):
75
+ # Unconstrained NUMBER — p/s unknown, downcast to DOUBLE (documented).
76
+ return ColumnInfo(name, DOUBLE)
77
+ if s == 0 and p <= _MAX_INT64_PRECISION:
78
+ return ColumnInfo(name, BIGINT)
79
+ return ColumnInfo(name, DECIMAL, precision=p, scale=max(s, 0))
80
+ if "BINARY_DOUBLE" in type_name:
81
+ return ColumnInfo(name, DOUBLE)
82
+ if "BINARY_FLOAT" in type_name:
83
+ return ColumnInfo(name, FLOAT)
84
+ if "TIMESTAMP" in type_name or type_name.endswith("DB_TYPE_DATE"):
85
+ # Oracle DATE carries a time component -> TIMESTAMP
86
+ return ColumnInfo(name, TIMESTAMP)
87
+ if any(k in type_name for k in ("RAW", "BLOB")):
88
+ return ColumnInfo(name, BINARY)
89
+ if "BOOLEAN" in type_name:
90
+ return ColumnInfo(name, BOOL)
91
+ return ColumnInfo(name, STRING)
92
+
93
+
94
+ def from_sqlite_sample(name: str, sample_values: Sequence[Any]) -> ColumnInfo:
95
+ """sqlite3 has no type codes: infer from the first non-NULL value of the first batch.
96
+
97
+ ISO-formatted strings are promoted to DATE/TIMESTAMP (the values themselves stay
98
+ strings from the driver; the writers convert). All-NULL columns fall back to STRING.
99
+ """
100
+ for value in sample_values:
101
+ if value is None:
102
+ continue
103
+ if isinstance(value, bool):
104
+ return ColumnInfo(name, BOOL)
105
+ if isinstance(value, int):
106
+ return ColumnInfo(name, BIGINT)
107
+ if isinstance(value, float):
108
+ return ColumnInfo(name, DOUBLE)
109
+ if isinstance(value, (bytes, bytearray, memoryview)):
110
+ return ColumnInfo(name, BINARY)
111
+ if isinstance(value, str):
112
+ if _DATE_RE.match(value):
113
+ return ColumnInfo(name, DATE)
114
+ if _TIMESTAMP_RE.match(value):
115
+ return ColumnInfo(name, TIMESTAMP)
116
+ return ColumnInfo(name, STRING)
117
+ return ColumnInfo(name, STRING)
118
+ return ColumnInfo(name, STRING)
@@ -0,0 +1,19 @@
1
+ """Exception hierarchy. Library code raises these; only __main__ maps them to exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PingExportError(Exception):
7
+ """Base for all errors raised by ping_dataexport."""
8
+
9
+
10
+ class ConfigError(PingExportError):
11
+ """config.ini / job.ini is missing, unreadable, or has invalid values."""
12
+
13
+
14
+ class JobValidationError(PingExportError):
15
+ """A job spec violates the argument/mode rules (the table in job/validator.py)."""
16
+
17
+
18
+ class ExportError(PingExportError):
19
+ """A runtime export failure: connection, query, filesystem, writer."""
File without changes
@@ -0,0 +1,170 @@
1
+ """ExportEngine — THE export loop, shared by every mode and both dispatch paths.
2
+
3
+ Responsibilities: run the planner, execute each partition, stream rows in
4
+ fetchmany batches to the RowWriter, rotate files for splitrow, log progress every
5
+ fetch_size rows and every partition's final count, handle --test / console
6
+ preview, call the plan's on_success (watermark state) only after full success.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Callable, List, Optional, Sequence
15
+
16
+ from ..applogger import AppLogger
17
+ from ..db.connector import ColumnInfo, Connector
18
+ from ..errors import ExportError
19
+ from ..planner.base import Partition, for_options
20
+ from ..writer.base import RowWriter
21
+ from ..writer.delimited import render_value
22
+ from .options import ExportOptions
23
+ from .util import build_text_cleaner, output_filename
24
+
25
+ WriterFactory = Callable[[ExportOptions, str], RowWriter]
26
+
27
+
28
+ @dataclass
29
+ class ExportStats:
30
+ partitions: int = 0
31
+ rows: int = 0
32
+ files: List[Path] = field(default_factory=list)
33
+
34
+
35
+ class ExportEngine:
36
+ def __init__(
37
+ self,
38
+ options: ExportOptions,
39
+ connector: Connector,
40
+ conn: Any,
41
+ logger: AppLogger,
42
+ writer_factory: WriterFactory,
43
+ ):
44
+ self.options = options
45
+ self.connector = connector
46
+ self.conn = conn
47
+ self.logger = logger
48
+ self.writer_factory = writer_factory
49
+ self.data_date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
50
+
51
+ def run(self) -> ExportStats:
52
+ plan = for_options(self.options).plan(self.options, self.conn, self.connector, self.logger)
53
+ stats = ExportStats()
54
+ for partition in plan.partitions:
55
+ rows, files = self._run_partition(partition)
56
+ stats.partitions += 1
57
+ stats.rows += rows
58
+ stats.files.extend(files)
59
+ if plan.on_success is not None and not self.options.test:
60
+ plan.on_success()
61
+ return stats
62
+
63
+ # -- one partition ------------------------------------------------------
64
+
65
+ def _run_partition(self, partition: Partition) -> "tuple[int, List[Path]]":
66
+ opts = self.options
67
+ if partition.where_display:
68
+ self.logger.info("EXPORT", "PARTITION", partition.where_display)
69
+ elif partition.label:
70
+ self.logger.info("EXPORT", "PARTITION", partition.label)
71
+
72
+ sql = self.connector.rewrite_params(partition.query)
73
+ params = self.connector.adapt_params(partition.params)
74
+ cursor = self.connector.prepare_cursor(self.conn, opts.fetch_size)
75
+ try:
76
+ try:
77
+ cursor.execute(sql, params) if params else cursor.execute(sql)
78
+ except Exception as exc:
79
+ raise ExportError(f"query failed: {exc}") from exc
80
+ first_batch = cursor.fetchmany(opts.fetch_size)
81
+ columns = self.connector.describe(cursor, first_batch)
82
+
83
+ if opts.test or opts.is_console:
84
+ rows = self._preview(first_batch, columns, cursor)
85
+ self.logger.info("TEST" if opts.test else "EXPORT", "PREVIEW",
86
+ f"{rows:,} row(s) shown")
87
+ return rows, []
88
+ return self._write_partition(partition, cursor, first_batch, columns)
89
+ finally:
90
+ cursor.close()
91
+
92
+ def _write_partition(
93
+ self,
94
+ partition: Partition,
95
+ cursor: Any,
96
+ first_batch: Sequence[Sequence[Any]],
97
+ columns: List[ColumnInfo],
98
+ ) -> "tuple[int, List[Path]]":
99
+ opts = self.options
100
+ split = opts.mode == "splitrow"
101
+ files: List[Path] = []
102
+ writer: Optional[RowWriter] = None
103
+ row_id = 0
104
+ rows_in_part = 0
105
+ part_no = 0
106
+
107
+ def open_writer() -> RowWriter:
108
+ nonlocal part_no, rows_in_part
109
+ part_no += 1
110
+ if split:
111
+ path = Path(opts.output_dir) / output_filename(
112
+ opts.base_name, str(part_no), opts.format, opts.gzip
113
+ )
114
+ elif partition.filename is not None:
115
+ path = Path(opts.output_dir) / partition.filename
116
+ else:
117
+ path = Path(opts.output_path)
118
+ w = self.writer_factory(opts, self.data_date)
119
+ w.open(path, columns)
120
+ files.append(path)
121
+ self.logger.info("EXPORT", "FILE", str(path))
122
+ rows_in_part = 0
123
+ return w
124
+
125
+ try:
126
+ writer = open_writer()
127
+ batch: Sequence[Sequence[Any]] = first_batch
128
+ while batch:
129
+ for row in batch:
130
+ if split and rows_in_part >= opts.split_rows:
131
+ writer.close()
132
+ writer = open_writer()
133
+ row_id += 1
134
+ rows_in_part += 1
135
+ writer.write_row(row, row_id)
136
+ self.logger.info("EXPORT", "PROGRESS", f"{row_id:,} rows")
137
+ batch = cursor.fetchmany(opts.fetch_size)
138
+ writer.close()
139
+ except Exception:
140
+ if writer is not None:
141
+ writer.abort()
142
+ raise
143
+ self.logger.info("EXPORT", "PROGRESS", f"{row_id:,} rows (finished)")
144
+ return row_id, files
145
+
146
+ # -- preview (test mode + console output) --------------------------------
147
+
148
+ def _preview(
149
+ self,
150
+ first_batch: Sequence[Sequence[Any]],
151
+ columns: List[ColumnInfo],
152
+ cursor: Any,
153
+ ) -> int:
154
+ opts = self.options
155
+ limit = opts.preview_row
156
+ clean = build_text_cleaner(opts.stripnewline, opts.stripmetachar)
157
+ print(" | ".join(c.name for c in columns))
158
+ shown = 0
159
+ batch = first_batch
160
+ while batch and shown < limit:
161
+ for row in batch:
162
+ if shown >= limit:
163
+ break
164
+ print(" | ".join(render_value(v, clean) for v in row))
165
+ shown += 1
166
+ if shown < limit:
167
+ batch = cursor.fetchmany(opts.fetch_size)
168
+ else:
169
+ break
170
+ return shown
@@ -0,0 +1,48 @@
1
+ """ExportOptions — the resolved, execution-ready options passed to engine/planners/writers.
2
+
3
+ Built by JobExecutor from JobSpec + SharedContext ([Main] defaults filled in,
4
+ paths resolved). Everything downstream reads only this object.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import date
11
+ from pathlib import Path
12
+ from typing import Optional, Tuple
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ExportOptions:
17
+ job_name: str
18
+ source: str
19
+ query: str
20
+ format: Optional[str] # 'csv' | 'txt' | 'parquet' | None = console preview
21
+ output_path: Optional[Path] = None # resolved single output file
22
+ output_dir: Optional[Path] = None # resolved directory for mode exports
23
+ base_name: Optional[str] = None
24
+ gzip: bool = False
25
+ quote: str = '"'
26
+ sep: str = ","
27
+ stripnewline: Optional[str] = None
28
+ stripmetachar: bool = False
29
+ fetch_size: int = 10000
30
+ preview_row: int = 10
31
+ mode: Optional[str] = None
32
+ column: Optional[str] = None
33
+ split_rows: int = 100000
34
+ dates: Tuple[date, ...] = ()
35
+ datefrom: Optional[date] = None
36
+ dateto: Optional[date] = None
37
+ monthsrelative: Optional[int] = None
38
+ row_id_column: Optional[str] = None
39
+ data_date_column: Optional[str] = None
40
+ test: bool = False
41
+
42
+ @property
43
+ def is_console(self) -> bool:
44
+ return self.output_path is None and self.output_dir is None
45
+
46
+ @property
47
+ def is_delimited(self) -> bool:
48
+ return self.format in ("csv", "txt")