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.
- ping_dataexport/__init__.py +11 -0
- ping_dataexport/__main__.py +54 -0
- ping_dataexport/api.py +164 -0
- ping_dataexport/applogger.py +104 -0
- ping_dataexport/cli.py +101 -0
- ping_dataexport/config.py +233 -0
- ping_dataexport/db/__init__.py +0 -0
- ping_dataexport/db/connector.py +99 -0
- ping_dataexport/db/oracle_connector.py +86 -0
- ping_dataexport/db/pyodbc_connector.py +82 -0
- ping_dataexport/db/sqlite_connector.py +61 -0
- ping_dataexport/db/type_map.py +118 -0
- ping_dataexport/errors.py +19 -0
- ping_dataexport/export/__init__.py +0 -0
- ping_dataexport/export/engine.py +170 -0
- ping_dataexport/export/options.py +48 -0
- ping_dataexport/export/runner.py +50 -0
- ping_dataexport/export/util.py +65 -0
- ping_dataexport/job/__init__.py +0 -0
- ping_dataexport/job/executor.py +107 -0
- ping_dataexport/job/shared_context.py +22 -0
- ping_dataexport/job/spec.py +251 -0
- ping_dataexport/job/validator.py +87 -0
- ping_dataexport/paths.py +71 -0
- ping_dataexport/planner/__init__.py +0 -0
- ping_dataexport/planner/base.py +71 -0
- ping_dataexport/planner/datelist.py +35 -0
- ping_dataexport/planner/groupby.py +71 -0
- ping_dataexport/planner/monthbydate.py +104 -0
- ping_dataexport/planner/single.py +18 -0
- ping_dataexport/planner/watermark.py +202 -0
- ping_dataexport/writer/__init__.py +0 -0
- ping_dataexport/writer/arrow_map.py +174 -0
- ping_dataexport/writer/base.py +37 -0
- ping_dataexport/writer/delimited.py +108 -0
- ping_dataexport/writer/parquet.py +84 -0
- ping_dataexport-0.1.0.dist-info/METADATA +228 -0
- ping_dataexport-0.1.0.dist-info/RECORD +42 -0
- ping_dataexport-0.1.0.dist-info/WHEEL +5 -0
- ping_dataexport-0.1.0.dist-info/entry_points.txt +2 -0
- ping_dataexport-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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")
|