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,37 @@
|
|
|
1
|
+
"""RowWriter interface — one implementation per output format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, List, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from ..db.connector import ColumnInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RowWriter(ABC):
|
|
13
|
+
"""open() -> write_row()* -> close(). abort() removes the partial file on failure."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._path: Optional[Path] = None
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def open(self, path: Path, columns: List[ColumnInfo]) -> None:
|
|
20
|
+
"""Create/truncate the target file for the given result columns."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def write_row(self, row: Sequence[Any], row_id: int) -> None:
|
|
24
|
+
"""Write one row. row_id is the engine's running counter (audit column)."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def close(self) -> None:
|
|
28
|
+
"""Flush and close; must be idempotent."""
|
|
29
|
+
|
|
30
|
+
def abort(self) -> None:
|
|
31
|
+
"""Close and delete the partial output of a failed partition."""
|
|
32
|
+
try:
|
|
33
|
+
self.close()
|
|
34
|
+
finally:
|
|
35
|
+
if self._path is not None and self._path.exists():
|
|
36
|
+
self._path.unlink()
|
|
37
|
+
self._path = None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""DelimitedRowWriter — CSV/TXT with gzip, strip rules, audit columns.
|
|
2
|
+
|
|
3
|
+
Hand-rolled (NOT the csv module) so the quoting rules are exact and ours:
|
|
4
|
+
- csv: minimal quoting — a field is quoted only when it contains the separator,
|
|
5
|
+
the quote char, or a newline; embedded quotes are doubled.
|
|
6
|
+
- txt: tab separator forced, quoting disabled.
|
|
7
|
+
- NULL -> empty string; UTF-8; '\n' line endings; create/truncate.
|
|
8
|
+
- Value rendering preserves type fidelity: Decimal via str() (no float round-trip),
|
|
9
|
+
dates yyyy-MM-dd, timestamps yyyy-MM-dd HH:mm:ss[.ffffff], True/False booleans.
|
|
10
|
+
- Audit columns (RowID / DataDate) are appended unconditionally.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import decimal
|
|
17
|
+
import gzip
|
|
18
|
+
import io
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, List, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
from ..db.connector import STRING, ColumnInfo
|
|
23
|
+
from ..export.options import ExportOptions
|
|
24
|
+
from ..export.util import build_text_cleaner
|
|
25
|
+
from .base import RowWriter
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_value(value: Any, clean_text: Callable[[str], str]) -> str:
|
|
29
|
+
"""One DB value -> its delimited-text form. Strip rules apply to strings only."""
|
|
30
|
+
if value is None:
|
|
31
|
+
return ""
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return clean_text(value)
|
|
34
|
+
if isinstance(value, bool):
|
|
35
|
+
return "True" if value else "False"
|
|
36
|
+
if isinstance(value, decimal.Decimal):
|
|
37
|
+
return str(value)
|
|
38
|
+
if isinstance(value, datetime.datetime):
|
|
39
|
+
base = value.strftime("%Y-%m-%d %H:%M:%S")
|
|
40
|
+
return f"{base}.{value.microsecond:06d}" if value.microsecond else base
|
|
41
|
+
if isinstance(value, datetime.date):
|
|
42
|
+
return value.isoformat()
|
|
43
|
+
if isinstance(value, datetime.time):
|
|
44
|
+
return value.isoformat()
|
|
45
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
46
|
+
return bytes(value).hex()
|
|
47
|
+
return str(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DelimitedRowWriter(RowWriter):
|
|
51
|
+
def __init__(self, options: ExportOptions, data_date: str):
|
|
52
|
+
super().__init__()
|
|
53
|
+
if options.format == "txt":
|
|
54
|
+
self.sep = "\t"
|
|
55
|
+
self.quote: Optional[str] = None # txt: no quoting
|
|
56
|
+
else:
|
|
57
|
+
self.sep = options.sep
|
|
58
|
+
self.quote = options.quote
|
|
59
|
+
self.gzip = options.gzip
|
|
60
|
+
self.row_id_column = options.row_id_column
|
|
61
|
+
self.data_date_column = options.data_date_column
|
|
62
|
+
self.data_date = data_date
|
|
63
|
+
self._clean = build_text_cleaner(options.stripnewline, options.stripmetachar)
|
|
64
|
+
self._fh: Optional[io.TextIOBase] = None
|
|
65
|
+
self._is_string_col: List[bool] = []
|
|
66
|
+
|
|
67
|
+
def open(self, path: Path, columns: List[ColumnInfo]) -> None:
|
|
68
|
+
self._path = path
|
|
69
|
+
if self.gzip:
|
|
70
|
+
self._fh = gzip.open(path, "wt", encoding="utf-8", newline="\n")
|
|
71
|
+
else:
|
|
72
|
+
self._fh = open(path, "w", encoding="utf-8", newline="\n")
|
|
73
|
+
self._is_string_col = [c.kind == STRING for c in columns]
|
|
74
|
+
header = [c.name for c in columns]
|
|
75
|
+
if self.row_id_column:
|
|
76
|
+
header.append(self.row_id_column)
|
|
77
|
+
if self.data_date_column:
|
|
78
|
+
header.append(self.data_date_column)
|
|
79
|
+
self._fh.write(self.sep.join(self._quote_field(h) for h in header) + "\n")
|
|
80
|
+
|
|
81
|
+
def write_row(self, row: Sequence[Any], row_id: int) -> None:
|
|
82
|
+
fields = [
|
|
83
|
+
self._quote_field(render_value(v, self._clean if is_str else _identity))
|
|
84
|
+
for v, is_str in zip(row, self._is_string_col)
|
|
85
|
+
]
|
|
86
|
+
if self.row_id_column:
|
|
87
|
+
fields.append(str(row_id))
|
|
88
|
+
if self.data_date_column:
|
|
89
|
+
fields.append(self._quote_field(self.data_date))
|
|
90
|
+
self._fh.write(self.sep.join(fields) + "\n")
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
if self._fh is not None:
|
|
94
|
+
try:
|
|
95
|
+
self._fh.close()
|
|
96
|
+
finally:
|
|
97
|
+
self._fh = None
|
|
98
|
+
|
|
99
|
+
def _quote_field(self, text: str) -> str:
|
|
100
|
+
if self.quote is None:
|
|
101
|
+
return text
|
|
102
|
+
if self.quote in text or self.sep in text or "\n" in text or "\r" in text:
|
|
103
|
+
return self.quote + text.replace(self.quote, self.quote * 2) + self.quote
|
|
104
|
+
return text
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _identity(value: str) -> str:
|
|
108
|
+
return value
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""ParquetRowWriter — PyArrow ParquetWriter lifecycle; ArrowRowMapper does the mapping.
|
|
2
|
+
|
|
3
|
+
- Snappy compression; --gzip is rejected at validation (parquet compresses itself).
|
|
4
|
+
- Rows buffer per-column and flush as a typed RecordBatch every fetch_size rows,
|
|
5
|
+
so memory stays bounded regardless of result size.
|
|
6
|
+
- Audit columns: RowID -> int64, DataDate -> string; a clash with a query column
|
|
7
|
+
name (case-insensitive) skips that audit column.
|
|
8
|
+
- Split mode reuses the same schema across part files; row_id keeps counting
|
|
9
|
+
(the engine owns the counter).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, List, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from ..db.connector import BIGINT, STRING, ColumnInfo
|
|
18
|
+
from ..export.options import ExportOptions
|
|
19
|
+
from ..export.util import build_text_cleaner
|
|
20
|
+
from .arrow_map import ArrowRowMapper
|
|
21
|
+
from .base import RowWriter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ParquetRowWriter(RowWriter):
|
|
25
|
+
def __init__(self, options: ExportOptions, data_date: str):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.fetch_size = options.fetch_size
|
|
28
|
+
self.data_date = data_date
|
|
29
|
+
self._clean = build_text_cleaner(options.stripnewline, options.stripmetachar)
|
|
30
|
+
self._row_id_column = options.row_id_column
|
|
31
|
+
self._data_date_column = options.data_date_column
|
|
32
|
+
self._writer: Optional[Any] = None
|
|
33
|
+
self._mapper: Optional[ArrowRowMapper] = None
|
|
34
|
+
self._buffers: List[List[Any]] = []
|
|
35
|
+
self._buffered = 0
|
|
36
|
+
self._audit_row_id = False
|
|
37
|
+
self._audit_data_date = False
|
|
38
|
+
|
|
39
|
+
def open(self, path: Path, columns: List[ColumnInfo]) -> None:
|
|
40
|
+
import pyarrow.parquet as pq
|
|
41
|
+
|
|
42
|
+
self._path = path
|
|
43
|
+
query_names = {c.name.lower() for c in columns}
|
|
44
|
+
effective = list(columns)
|
|
45
|
+
self._audit_row_id = bool(
|
|
46
|
+
self._row_id_column and self._row_id_column.lower() not in query_names
|
|
47
|
+
)
|
|
48
|
+
if self._audit_row_id:
|
|
49
|
+
effective.append(ColumnInfo(self._row_id_column, BIGINT))
|
|
50
|
+
self._audit_data_date = bool(
|
|
51
|
+
self._data_date_column and self._data_date_column.lower() not in query_names
|
|
52
|
+
)
|
|
53
|
+
if self._audit_data_date:
|
|
54
|
+
effective.append(ColumnInfo(self._data_date_column, STRING))
|
|
55
|
+
|
|
56
|
+
self._mapper = ArrowRowMapper(effective, self._clean)
|
|
57
|
+
self._writer = pq.ParquetWriter(str(path), self._mapper.schema, compression="snappy")
|
|
58
|
+
self._buffers = self._mapper.new_buffers()
|
|
59
|
+
self._buffered = 0
|
|
60
|
+
|
|
61
|
+
def write_row(self, row: Sequence[Any], row_id: int) -> None:
|
|
62
|
+
values = list(row)
|
|
63
|
+
if self._audit_row_id:
|
|
64
|
+
values.append(row_id)
|
|
65
|
+
if self._audit_data_date:
|
|
66
|
+
values.append(self.data_date)
|
|
67
|
+
self._mapper.append_row(self._buffers, values)
|
|
68
|
+
self._buffered += 1
|
|
69
|
+
if self._buffered >= self.fetch_size:
|
|
70
|
+
self._flush()
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
if self._writer is not None:
|
|
74
|
+
try:
|
|
75
|
+
if self._buffered:
|
|
76
|
+
self._flush()
|
|
77
|
+
finally:
|
|
78
|
+
self._writer.close()
|
|
79
|
+
self._writer = None
|
|
80
|
+
|
|
81
|
+
def _flush(self) -> None:
|
|
82
|
+
self._writer.write_batch(self._mapper.to_record_batch(self._buffers))
|
|
83
|
+
self._buffers = self._mapper.new_buffers()
|
|
84
|
+
self._buffered = 0
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ping-dataexport
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Universal database data export to CSV/TXT/Parquet — streaming, typed, no pandas
|
|
5
|
+
Author: vorapol
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: export,database,csv,parquet,etl,data-platform,airflow
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyarrow>=18
|
|
19
|
+
Provides-Extra: odbc
|
|
20
|
+
Requires-Dist: pyodbc>=5; extra == "odbc"
|
|
21
|
+
Provides-Extra: oracle
|
|
22
|
+
Requires-Dist: oracledb>=2; extra == "oracle"
|
|
23
|
+
Provides-Extra: drivers
|
|
24
|
+
Requires-Dist: pyodbc>=5; extra == "drivers"
|
|
25
|
+
Requires-Dist: oracledb>=2; extra == "drivers"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# ping-dataexport
|
|
31
|
+
|
|
32
|
+
Universal database data export: connect to a database, run a SQL query, stream
|
|
33
|
+
the result to **CSV / TXT / Parquet**. Built for data-platform work — exporting
|
|
34
|
+
legacy databases into files a data lake or warehouse can ingest.
|
|
35
|
+
|
|
36
|
+
- **Type fidelity first** — rows stream cursor → writer with native driver
|
|
37
|
+
types. `DECIMAL(p,s)` lands in Parquet as `decimal128(p,s)`, ints with NULLs
|
|
38
|
+
stay ints, dates stay dates. No pandas, ever.
|
|
39
|
+
- **Databases** — SQLite (stdlib, zero setup), SQL Server (MS ODBC / FreeTDS),
|
|
40
|
+
Oracle (native thin mode or ODBC), any ODBC source.
|
|
41
|
+
- **Two front doors** — a CLI and a library API (`ping_dataexport.api`) built
|
|
42
|
+
for Airflow: exceptions instead of exit codes, results as objects.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install ping-dataexport # base (pyarrow) — sqlite works out of the box
|
|
48
|
+
pip install ping-dataexport[odbc] # + pyodbc (SQL Server, FreeTDS, ODBC)
|
|
49
|
+
pip install ping-dataexport[oracle] # + oracledb (Oracle native)
|
|
50
|
+
pip install ping-dataexport[drivers] # + both
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 1. Create a config.ini
|
|
54
|
+
|
|
55
|
+
One `[Main]` section for defaults, one section per database source:
|
|
56
|
+
|
|
57
|
+
```ini
|
|
58
|
+
[Main]
|
|
59
|
+
ExportFolder=./output ; relative output/log paths resolve under this
|
|
60
|
+
FetchSize=10000
|
|
61
|
+
ForceMakeDir=Yes
|
|
62
|
+
RowIDColumn=row_id ; optional audit column names
|
|
63
|
+
DataDateColumn=data_date
|
|
64
|
+
|
|
65
|
+
[SQLITE01]
|
|
66
|
+
Type=sqlite
|
|
67
|
+
Database=./demo.sqlite
|
|
68
|
+
|
|
69
|
+
[DB01]
|
|
70
|
+
Type=mssql-odbc
|
|
71
|
+
Host=host
|
|
72
|
+
Port=1433
|
|
73
|
+
Database=DB
|
|
74
|
+
Username=user
|
|
75
|
+
Password=secret
|
|
76
|
+
|
|
77
|
+
[ORA01]
|
|
78
|
+
Type=oracle
|
|
79
|
+
Host=orahost
|
|
80
|
+
Port=1521
|
|
81
|
+
Service=ORCLPDB
|
|
82
|
+
Username=scott
|
|
83
|
+
Password=tiger
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`Type` values: `sqlite`, `mssql-odbc`, `freetds`, `oracle`, `oracle-odbc`,
|
|
87
|
+
`odbc`. ODBC sources also accept a raw `ConnectionString=` instead of
|
|
88
|
+
Host/Port/Database. A full sample ships as `config.sample.ini` in the source
|
|
89
|
+
distribution.
|
|
90
|
+
|
|
91
|
+
Instead of `Password=` you can use `PasswordBase64=` with the base64-encoded
|
|
92
|
+
password (obfuscation, not encryption). If both are present, `Password` wins.
|
|
93
|
+
|
|
94
|
+
```ini
|
|
95
|
+
PasswordBase64=c2VjcmV0 ; base64("secret")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 2. Run an export (CLI)
|
|
99
|
+
|
|
100
|
+
`ping-dataexport` and `python -m ping_dataexport` are equivalent. The config
|
|
101
|
+
file defaults to `./config.ini` (`-cf` to point elsewhere).
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# preview to the console (no -o/-od): prints the first rows
|
|
105
|
+
ping-dataexport -s SQLITE01 -q "SELECT * FROM sales"
|
|
106
|
+
|
|
107
|
+
# single file — format inferred from the extension (.csv/.txt/.parquet)
|
|
108
|
+
ping-dataexport -s SQLITE01 -q "SELECT * FROM sales" -o sales.parquet
|
|
109
|
+
ping-dataexport -s SQLITE01 -q "SELECT * FROM sales" -o sales.csv -gz # gzip
|
|
110
|
+
|
|
111
|
+
# validate + preview only, write nothing
|
|
112
|
+
ping-dataexport -s SQLITE01 -q "SELECT * FROM sales" -o sales.csv -t
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Common options:
|
|
116
|
+
|
|
117
|
+
| Option | Meaning |
|
|
118
|
+
|--------|---------|
|
|
119
|
+
| `-s` / `-q` | source section name / SQL query |
|
|
120
|
+
| `-o` | single output file (format from extension, or `-f`) |
|
|
121
|
+
| `-od` / `-bn` | output directory + base filename (required for `-m` modes) |
|
|
122
|
+
| `-f` | `csv`, `txt`, `parquet` |
|
|
123
|
+
| `-gz` | gzip the output (CSV/TXT only) |
|
|
124
|
+
| `-sp` / `-quo` | field separator / quote character |
|
|
125
|
+
| `-stnl space\|blank`, `-stmc` | strip newlines / metacharacters inside values |
|
|
126
|
+
| `-fs` | fetch size (rows per batch) |
|
|
127
|
+
| `-rm Yes` | clear the output directory before exporting |
|
|
128
|
+
| `-cf` / `-lf` | config file path / log file path |
|
|
129
|
+
| `-t` | test mode: validate + preview, no files |
|
|
130
|
+
|
|
131
|
+
## 3. Export modes (partitioned outputs)
|
|
132
|
+
|
|
133
|
+
Modes split one query into multiple files. All need `-od` + `-bn`; partition
|
|
134
|
+
values are always bound SQL parameters.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# one file per distinct value of a column
|
|
138
|
+
ping-dataexport -s DB01 -q "SELECT * FROM sales" -m groupby -col region -od by_region -bn sales
|
|
139
|
+
|
|
140
|
+
# one file per day in a date range
|
|
141
|
+
ping-dataexport -s DB01 -q "SELECT * FROM sales" -m date -col txn_date \
|
|
142
|
+
-df 2026-01-01 -dt 2026-01-31 -od daily -bn sales
|
|
143
|
+
|
|
144
|
+
# N days back from an anchor date / N months of month-files
|
|
145
|
+
ping-dataexport -s DB01 -q "..." -m relativedate -col txn_date -df 2026-01-10 -dr 7 -od rel -bn r
|
|
146
|
+
ping-dataexport -s DB01 -q "..." -m monthbydate -col txn_date -df 2026-03-15 -mr 3 -od monthly -bn m
|
|
147
|
+
|
|
148
|
+
# rotate a new file every N rows
|
|
149
|
+
ping-dataexport -s DB01 -q "SELECT * FROM sales ORDER BY id" -m splitrow -row 100000 -od split -bn part
|
|
150
|
+
|
|
151
|
+
# incremental: exports only rows newer than the last run (state file in -od)
|
|
152
|
+
ping-dataexport -s DB01 -q "SELECT * FROM sales" -m watermark -col txn_date -od incr -bn sales
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## 4. Job files (job.ini)
|
|
156
|
+
|
|
157
|
+
Put recurring exports in a job file — keys map 1:1 to the CLI options:
|
|
158
|
+
|
|
159
|
+
```ini
|
|
160
|
+
[DailySales]
|
|
161
|
+
Source=DB01
|
|
162
|
+
Query=SELECT id, txn_date, amount FROM sales
|
|
163
|
+
WHERE region = 'TH'
|
|
164
|
+
Mode=date
|
|
165
|
+
Column=txn_date
|
|
166
|
+
DateFrom=2026-01-01
|
|
167
|
+
DateTo=2026-01-31
|
|
168
|
+
OutputDir=out/daily_sales
|
|
169
|
+
BaseName=sales
|
|
170
|
+
Format=csv
|
|
171
|
+
|
|
172
|
+
[FullDump]
|
|
173
|
+
Source=DB01
|
|
174
|
+
Query=SELECT * FROM sales
|
|
175
|
+
Output=out/full_dump.parquet
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
ping-dataexport -j DailySales # one section (job.ini by default, -jf elsewhere)
|
|
180
|
+
ping-dataexport -j DailySales -od other_dir # CLI options override the section
|
|
181
|
+
ping-dataexport -pl 4 # ALL sections in parallel, 4 workers
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A full sample ships as `job.sample.ini` in the source distribution.
|
|
185
|
+
|
|
186
|
+
## 5. Use as a library (Python / Airflow)
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from ping_dataexport.api import run_export, run_job, run_jobfile
|
|
190
|
+
|
|
191
|
+
# ad-hoc — any CLI option works as a keyword argument
|
|
192
|
+
result = run_export("DB01", "SELECT * FROM sales",
|
|
193
|
+
configfile="config.ini", output="sales.parquet")
|
|
194
|
+
print(result.status, result.rows, result.files)
|
|
195
|
+
|
|
196
|
+
# one job.ini section / every section in parallel
|
|
197
|
+
run_job("DailySales", jobfile="job.ini", configfile="config.ini")
|
|
198
|
+
results = run_jobfile(jobfile="job.ini", configfile="config.ini", workers=4)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Failures raise exceptions (`ConfigError`, `JobValidationError`, `ExportError`)
|
|
202
|
+
— never `sys.exit` — so an Airflow task fails cleanly. `run_jobfile` is the
|
|
203
|
+
exception: one job's failure never stops the others; check each
|
|
204
|
+
`ExportResult.status`.
|
|
205
|
+
|
|
206
|
+
The package also works with **no pip install at all**: copy the
|
|
207
|
+
`ping_dataexport/` folder next to your code and import it — relative imports
|
|
208
|
+
only, no metadata lookups.
|
|
209
|
+
|
|
210
|
+
## Output and logs
|
|
211
|
+
|
|
212
|
+
- Relative output paths resolve under `ExportFolder`; `ForceMakeDir=Yes`
|
|
213
|
+
creates missing directories.
|
|
214
|
+
- Every run logs structured lines to stdout and to a log file:
|
|
215
|
+
`-lf path` > `LogFile=` in config.ini > default
|
|
216
|
+
`{ExportFolder}/logs/log_{jobname}.txt`.
|
|
217
|
+
- CLI exit codes: `0` success, `1` failure (in `-pl` parallel mode: `1` if any
|
|
218
|
+
job failed).
|
|
219
|
+
|
|
220
|
+
## ODBC prerequisites (ODBC sources only)
|
|
221
|
+
|
|
222
|
+
The Python drivers install via pip, but ODBC drivers are OS-level installs:
|
|
223
|
+
Microsoft ODBC Driver 17/18 for SQL Server, FreeTDS (`apt install tdsodbc`), or
|
|
224
|
+
your Oracle ODBC driver. `Type=sqlite` and `Type=oracle` (thin mode) need none.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
Apache-2.0.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
ping_dataexport/__init__.py,sha256=X7yvlh0O2PpianqHs6AoWVs-dqk96uXkAsakLj6oWBQ,452
|
|
2
|
+
ping_dataexport/__main__.py,sha256=yV5QogtDfzTy4gfX6eDyypddJqOpuBhVHSBm6w1CQ10,1530
|
|
3
|
+
ping_dataexport/api.py,sha256=0VAtlTJ1ht3cNSHvGkfoDlVjiEgTEJo1Vk4bEU0ktEA,5914
|
|
4
|
+
ping_dataexport/applogger.py,sha256=ELa-TkDJWPlQu21IvpvMz1PwWxqlKiK7oCpqyiVyRKA,3750
|
|
5
|
+
ping_dataexport/cli.py,sha256=Rg-e39zdUeyqRzZt2KQEAokFpMg9IHdQmd0ZGfXe4Lg,4468
|
|
6
|
+
ping_dataexport/config.py,sha256=Ui6OgVnanV7IsY-uBZWOY7oiM-5BkU6hx6s4iJHaeK8,9180
|
|
7
|
+
ping_dataexport/errors.py,sha256=gX-UNqcq374Znq9K-8LRmBvUtCGxhz1xo2tspMGImXQ,583
|
|
8
|
+
ping_dataexport/paths.py,sha256=G6whXV4RnRCnIEypRwpvSBnbnsNVkDU7H-Kn_I7L1iI,2684
|
|
9
|
+
ping_dataexport/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
ping_dataexport/db/connector.py,sha256=wHHryAk-k4QtUjrYUAyXnb9AoC6VgWE6o1OY2cOcwBw,3239
|
|
11
|
+
ping_dataexport/db/oracle_connector.py,sha256=RAAr-ABjXnDLzXikyTVjwlvWEGPjFtMAK1DvpMPAczU,3079
|
|
12
|
+
ping_dataexport/db/pyodbc_connector.py,sha256=lXdP8TareRIyWKzMLnY1kmSfMDuQrlUV_-2UbhmYgCw,3355
|
|
13
|
+
ping_dataexport/db/sqlite_connector.py,sha256=CVmnbWMD0epcVnQHagwN8Z1_MwYHRF8L5fIAcyesLnA,2354
|
|
14
|
+
ping_dataexport/db/type_map.py,sha256=nJ7xmyYhQG9nRbtp7jTwXc69tAFcLTB9eQfWsZlPutY,4286
|
|
15
|
+
ping_dataexport/export/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
ping_dataexport/export/engine.py,sha256=YFIH6QJDpzuHhPoKxmcMOzRQwF2SP61RTGjU7KsqZnQ,6144
|
|
17
|
+
ping_dataexport/export/options.py,sha256=qyMAt8Chk1z3DZRmbisNFLUqi1y1SEOFIARb2RNoRCg,1500
|
|
18
|
+
ping_dataexport/export/runner.py,sha256=ruwJQy0SOX_x0C2loI6ZwqTHaniay1io9MnJoN_qQUM,1750
|
|
19
|
+
ping_dataexport/export/util.py,sha256=US5Xx3xkQsUjDH4tpZtYFq7X0Ofau0sHsRdpPU02Ncw,2461
|
|
20
|
+
ping_dataexport/job/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
ping_dataexport/job/executor.py,sha256=7AWNk8thyeCtch9Uiny0vDTTs-6qKt_Ut8zBt6bvZ80,4161
|
|
22
|
+
ping_dataexport/job/shared_context.py,sha256=q3oBVLj0XmFbd-uQNsCWQLjDTy1VLxSOewUK6jQkR3A,719
|
|
23
|
+
ping_dataexport/job/spec.py,sha256=Zrnw0YOVqv9XNuucCFaxzF47cTMQBVKAFd-dHa8R-q8,8995
|
|
24
|
+
ping_dataexport/job/validator.py,sha256=bPLwzzD0SkNygW6rdriitQbranahaeSi2FcASjYA_ZA,3669
|
|
25
|
+
ping_dataexport/planner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
ping_dataexport/planner/base.py,sha256=qM9bPPms-E_I-vtjr15azbtvEJDimrv-HR9AekOxQNc,2334
|
|
27
|
+
ping_dataexport/planner/datelist.py,sha256=djYxFTiRtIcWDt90gBQYydiycSKUvHOd-P0SFLO_Tvw,1271
|
|
28
|
+
ping_dataexport/planner/groupby.py,sha256=Opo9r_Rh9xx8d_OP0J10cco_40qq7TVArojkRmpvw9U,2569
|
|
29
|
+
ping_dataexport/planner/monthbydate.py,sha256=M6GkWZc0bpMeVs107pBDmv6uyNMOQDx_-Li539D78Wc,4331
|
|
30
|
+
ping_dataexport/planner/single.py,sha256=kVb755hnjHHld16O7tUSkduzrubf9w2Quaf9nS5aHm8,622
|
|
31
|
+
ping_dataexport/planner/watermark.py,sha256=Rc1Z_aQJBcYeD57Mq-3Li27by_Nq1mlqTZCS0gX9NeU,7764
|
|
32
|
+
ping_dataexport/writer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
|
+
ping_dataexport/writer/arrow_map.py,sha256=KYeABbs_Dm34qT19CqNmAWTzwrjWQ8e8VZMfPTyhvjw,6148
|
|
34
|
+
ping_dataexport/writer/base.py,sha256=Pc16NVbXf9nnpHax52jim8wcWMRAZnLXvYgNG8Glf7M,1170
|
|
35
|
+
ping_dataexport/writer/delimited.py,sha256=zr4sX_4sDakLuaUZ-I43VnmhrAoZEzrZSqOGjpugSAg,4123
|
|
36
|
+
ping_dataexport/writer/parquet.py,sha256=Neu2OW2XXYFcCUEMVgnIUIlANkp4rSzcqfwr_biGuDM,3274
|
|
37
|
+
ping_dataexport-0.1.0.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
|
|
38
|
+
ping_dataexport-0.1.0.dist-info/METADATA,sha256=0CkxcMRl-ND5V299OFsgtimnvz-woMQsYW8xwkBUSWA,8016
|
|
39
|
+
ping_dataexport-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
40
|
+
ping_dataexport-0.1.0.dist-info/entry_points.txt,sha256=GLTn1c1dKXLLib0N7MOzLRnRTgvwZOwigRqxCPphenw,66
|
|
41
|
+
ping_dataexport-0.1.0.dist-info/top_level.txt,sha256=_-lMZI38na42QmuQQF-WscRvuXvYyD6QzpXR8BClBJg,16
|
|
42
|
+
ping_dataexport-0.1.0.dist-info/RECORD,,
|