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,11 @@
|
|
|
1
|
+
"""Ping Python Data Export — universal database export to CSV/TXT/Parquet.
|
|
2
|
+
|
|
3
|
+
Universal Data Export for data platforms. Row-streaming only: data flows
|
|
4
|
+
cursor -> writer preserving native driver types. Pandas/numpy are banned in this
|
|
5
|
+
project (they silently coerce column types).
|
|
6
|
+
|
|
7
|
+
The package uses relative imports only and has no import-time side effects, so
|
|
8
|
+
the directory can be copied into any project and imported directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""CLI entry point — the ONLY place exceptions become exit codes.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 success (all jobs in parallel mode), 1 on any
|
|
4
|
+
validation/config/export failure. No arguments prints help and exits 0.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from .api import run_cli_args, run_jobfile
|
|
13
|
+
from .applogger import AppLogger
|
|
14
|
+
from .cli import build_parser, parse_args
|
|
15
|
+
from .errors import PingExportError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
19
|
+
if argv is None:
|
|
20
|
+
argv = sys.argv[1:]
|
|
21
|
+
if not argv:
|
|
22
|
+
build_parser().print_help()
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
args = parse_args(argv)
|
|
26
|
+
logger = AppLogger()
|
|
27
|
+
try:
|
|
28
|
+
if args.parallel is not None and not args.job:
|
|
29
|
+
results = run_jobfile(
|
|
30
|
+
jobfile=args.jobfile,
|
|
31
|
+
configfile=args.configfile,
|
|
32
|
+
workers=args.parallel or None,
|
|
33
|
+
test=args.test,
|
|
34
|
+
logger=logger,
|
|
35
|
+
)
|
|
36
|
+
return 0 if all(r.ok for r in results) else 1
|
|
37
|
+
|
|
38
|
+
if args.parallel is not None and args.job:
|
|
39
|
+
logger.warning("VALIDATION", "PARALLEL", "--parallel is ignored with --job")
|
|
40
|
+
|
|
41
|
+
run_cli_args(args, logger)
|
|
42
|
+
return 0
|
|
43
|
+
except PingExportError as exc:
|
|
44
|
+
logger.error("RESULT", "FAILED", str(exc))
|
|
45
|
+
return 1
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
logger.error("RESULT", "INTERRUPTED", "cancelled by user")
|
|
48
|
+
return 130
|
|
49
|
+
finally:
|
|
50
|
+
logger.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
sys.exit(main())
|
ping_dataexport/api.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Public library API — the entry point for local scripts and Airflow.
|
|
2
|
+
|
|
3
|
+
from ping_dataexport.api import run_job, run_jobfile
|
|
4
|
+
|
|
5
|
+
result = run_job("DailySales", jobfile="job.ini", configfile="config.ini")
|
|
6
|
+
|
|
7
|
+
- run_job / run_cli_args raise PingExportError subclasses on failure (Airflow-friendly:
|
|
8
|
+
the exception fails the task). No sys.exit anywhere in library code.
|
|
9
|
+
- run_jobfile (parallel dispatch) never lets one job stop the others: it returns an
|
|
10
|
+
ExportResult per section; check .status.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from dataclasses import dataclass, field, replace
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional, Union
|
|
21
|
+
|
|
22
|
+
from .applogger import AppLogger
|
|
23
|
+
from .cli import CliArgs
|
|
24
|
+
from .config import ConfigReader
|
|
25
|
+
from .errors import ConfigError
|
|
26
|
+
from .job.executor import JobExecutor
|
|
27
|
+
from .job.shared_context import SharedContext
|
|
28
|
+
from .job.spec import JobSpecBuilder
|
|
29
|
+
from .job.validator import JobValidator
|
|
30
|
+
|
|
31
|
+
COMPLETED = "COMPLETED"
|
|
32
|
+
FAILED = "FAILED"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ExportResult:
|
|
37
|
+
name: str
|
|
38
|
+
status: str # COMPLETED | FAILED
|
|
39
|
+
partitions: int = 0
|
|
40
|
+
rows: int = 0
|
|
41
|
+
files: List[str] = field(default_factory=list)
|
|
42
|
+
seconds: float = 0.0
|
|
43
|
+
error: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ok(self) -> bool:
|
|
47
|
+
return self.status == COMPLETED
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_cli_args(args: CliArgs, logger: Optional[AppLogger] = None) -> ExportResult:
|
|
51
|
+
"""Build -> validate -> execute one job from a CliArgs bundle. Raises on failure."""
|
|
52
|
+
logger = logger or AppLogger()
|
|
53
|
+
ctx = SharedContext(args.configfile, logger)
|
|
54
|
+
job_reader = ConfigReader(args.jobfile) if args.job else None
|
|
55
|
+
spec = JobSpecBuilder(args, ctx.main, job_reader).build(args.job)
|
|
56
|
+
JobValidator.validate(spec)
|
|
57
|
+
started = time.monotonic()
|
|
58
|
+
stats = JobExecutor(ctx).execute(spec)
|
|
59
|
+
return ExportResult(
|
|
60
|
+
name=spec.name,
|
|
61
|
+
status=COMPLETED,
|
|
62
|
+
partitions=stats.partitions,
|
|
63
|
+
rows=stats.rows,
|
|
64
|
+
files=[str(f) for f in stats.files],
|
|
65
|
+
seconds=time.monotonic() - started,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_export(
|
|
70
|
+
source: str,
|
|
71
|
+
query: str,
|
|
72
|
+
*,
|
|
73
|
+
configfile: Union[str, Path] = "config.ini",
|
|
74
|
+
logger: Optional[AppLogger] = None,
|
|
75
|
+
**options: object,
|
|
76
|
+
) -> ExportResult:
|
|
77
|
+
"""Ad-hoc export: run_export("DB01", "SELECT ...", output="out.csv", gzip=True).
|
|
78
|
+
|
|
79
|
+
`options` accepts any CliArgs field (output, outputdir, basename, mode, column, …).
|
|
80
|
+
"""
|
|
81
|
+
args = CliArgs(source=source, query=query, configfile=str(configfile), **options)
|
|
82
|
+
return run_cli_args(args, logger)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
run = run_export
|
|
86
|
+
export = run_export
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def run_job(
|
|
90
|
+
job: str,
|
|
91
|
+
*,
|
|
92
|
+
jobfile: Union[str, Path] = "job.ini",
|
|
93
|
+
configfile: Union[str, Path] = "config.ini",
|
|
94
|
+
test: bool = False,
|
|
95
|
+
overrides: Optional[Dict[str, object]] = None,
|
|
96
|
+
logger: Optional[AppLogger] = None,
|
|
97
|
+
) -> ExportResult:
|
|
98
|
+
"""Run one job.ini section. `overrides` are CliArgs fields that win over the section."""
|
|
99
|
+
args = CliArgs(
|
|
100
|
+
job=job, jobfile=str(jobfile), configfile=str(configfile), test=test,
|
|
101
|
+
**(overrides or {}),
|
|
102
|
+
)
|
|
103
|
+
return run_cli_args(args, logger)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_jobfile(
|
|
107
|
+
*,
|
|
108
|
+
jobfile: Union[str, Path] = "job.ini",
|
|
109
|
+
configfile: Union[str, Path] = "config.ini",
|
|
110
|
+
workers: Optional[int] = None,
|
|
111
|
+
test: bool = False,
|
|
112
|
+
logger: Optional[AppLogger] = None,
|
|
113
|
+
) -> List[ExportResult]:
|
|
114
|
+
"""Parallel mode: every section of the job file, each through the same
|
|
115
|
+
build -> validate -> resolve -> execute pipeline. One failure never stops the
|
|
116
|
+
others; the caller checks statuses (the CLI exits 1 if any failed)."""
|
|
117
|
+
logger = logger or AppLogger()
|
|
118
|
+
ctx = SharedContext(configfile, logger)
|
|
119
|
+
job_reader = ConfigReader(jobfile)
|
|
120
|
+
sections = job_reader.sections()
|
|
121
|
+
if not sections:
|
|
122
|
+
raise ConfigError(f"no job sections found in {jobfile}")
|
|
123
|
+
|
|
124
|
+
jobfile_base = Path(jobfile).stem
|
|
125
|
+
logs_dir: Optional[Path] = None
|
|
126
|
+
if ctx.main.export_folder:
|
|
127
|
+
logs_dir = Path(ctx.main.export_folder).expanduser() / "logs"
|
|
128
|
+
logger.open_master_file(logs_dir / f"log_{jobfile_base}.txt")
|
|
129
|
+
|
|
130
|
+
worker_count = min(workers if workers and workers > 0 else (os.cpu_count() or 1),
|
|
131
|
+
len(sections))
|
|
132
|
+
logger.master("INFO", "PARALLEL", "START",
|
|
133
|
+
f"{len(sections)} job(s), {worker_count} worker(s)")
|
|
134
|
+
|
|
135
|
+
def run_section(section: str) -> ExportResult:
|
|
136
|
+
started = time.monotonic()
|
|
137
|
+
try:
|
|
138
|
+
args = CliArgs(job=section, jobfile=str(jobfile), configfile=str(configfile),
|
|
139
|
+
test=test)
|
|
140
|
+
spec = JobSpecBuilder(args, ctx.main, job_reader).build(section)
|
|
141
|
+
if logs_dir is not None:
|
|
142
|
+
spec = replace(spec, logfile=str(logs_dir / f"log_{jobfile_base}_{section}.txt"))
|
|
143
|
+
JobValidator.validate(spec)
|
|
144
|
+
stats = JobExecutor(ctx).execute(spec)
|
|
145
|
+
logger.master("INFO", "PARALLEL", "COMPLETED", section)
|
|
146
|
+
return ExportResult(
|
|
147
|
+
name=spec.name, status=COMPLETED, partitions=stats.partitions,
|
|
148
|
+
rows=stats.rows, files=[str(f) for f in stats.files],
|
|
149
|
+
seconds=time.monotonic() - started,
|
|
150
|
+
)
|
|
151
|
+
except Exception as exc: # one failure never stops the others
|
|
152
|
+
logger.master("ERROR", "PARALLEL", "FAILED", f"{section}: {exc}")
|
|
153
|
+
return ExportResult(name=section, status=FAILED, error=str(exc),
|
|
154
|
+
seconds=time.monotonic() - started)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
with ThreadPoolExecutor(max_workers=worker_count) as pool:
|
|
158
|
+
results = list(pool.map(run_section, sections))
|
|
159
|
+
succeeded = sum(1 for r in results if r.ok)
|
|
160
|
+
logger.master("INFO", "PARALLEL", "SUMMARY",
|
|
161
|
+
f"{succeeded}/{len(results)} job(s) succeeded")
|
|
162
|
+
return results
|
|
163
|
+
finally:
|
|
164
|
+
logger.close_master_file()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Structured logger — one fixed line format for every run.
|
|
2
|
+
|
|
3
|
+
Line format (6 fields): timestamp | jobname | LEVEL | TYPE | OP | detail
|
|
4
|
+
- stdout: " | " separated
|
|
5
|
+
- file: tab separated
|
|
6
|
+
|
|
7
|
+
Instance-based (no singleton). The current job name and per-job log file live in
|
|
8
|
+
contextvars, so parallel jobs on worker threads write to their own file while the
|
|
9
|
+
dispatcher writes PARALLEL lines to a master file. Passwords must be masked by the
|
|
10
|
+
caller (DBConfig.safe_items()).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
from contextvars import ContextVar
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional, TextIO, Union
|
|
21
|
+
|
|
22
|
+
INFO = "INFO"
|
|
23
|
+
WARN = "WARN"
|
|
24
|
+
ERROR = "ERROR"
|
|
25
|
+
|
|
26
|
+
_TS_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AppLogger:
|
|
30
|
+
def __init__(self, stdout: Optional[TextIO] = None):
|
|
31
|
+
self._stdout = stdout if stdout is not None else sys.stdout
|
|
32
|
+
self._job_name: ContextVar[str] = ContextVar("ping_job_name", default="-")
|
|
33
|
+
self._job_file: ContextVar[Optional[TextIO]] = ContextVar("ping_job_file", default=None)
|
|
34
|
+
self._master_file: Optional[TextIO] = None
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
# -- context ------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def set_job_name(self, name: str) -> None:
|
|
40
|
+
self._job_name.set(name)
|
|
41
|
+
|
|
42
|
+
def job_name(self) -> str:
|
|
43
|
+
return self._job_name.get()
|
|
44
|
+
|
|
45
|
+
def open_job_file(self, path: Union[str, Path]) -> None:
|
|
46
|
+
"""Open (append) the per-job log file for the current thread/context."""
|
|
47
|
+
self.close_job_file()
|
|
48
|
+
p = Path(path)
|
|
49
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
self._job_file.set(open(p, "a", encoding="utf-8", newline="\n"))
|
|
51
|
+
|
|
52
|
+
def close_job_file(self) -> None:
|
|
53
|
+
fh = self._job_file.get()
|
|
54
|
+
if fh is not None:
|
|
55
|
+
try:
|
|
56
|
+
fh.close()
|
|
57
|
+
finally:
|
|
58
|
+
self._job_file.set(None)
|
|
59
|
+
|
|
60
|
+
def open_master_file(self, path: Union[str, Path]) -> None:
|
|
61
|
+
self.close_master_file()
|
|
62
|
+
p = Path(path)
|
|
63
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
self._master_file = open(p, "a", encoding="utf-8", newline="\n")
|
|
65
|
+
|
|
66
|
+
def close_master_file(self) -> None:
|
|
67
|
+
if self._master_file is not None:
|
|
68
|
+
try:
|
|
69
|
+
self._master_file.close()
|
|
70
|
+
finally:
|
|
71
|
+
self._master_file = None
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
self.close_job_file()
|
|
75
|
+
self.close_master_file()
|
|
76
|
+
|
|
77
|
+
# -- emit ---------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def log(self, level: str, type_: str, op: str, detail: str = "") -> None:
|
|
80
|
+
"""Write to stdout + the current context's job file."""
|
|
81
|
+
self._emit(level, type_, op, detail, self._job_file.get())
|
|
82
|
+
|
|
83
|
+
def master(self, level: str, type_: str, op: str, detail: str = "") -> None:
|
|
84
|
+
"""Write to stdout + the master file (parallel-dispatch lines)."""
|
|
85
|
+
self._emit(level, type_, op, detail, self._master_file)
|
|
86
|
+
|
|
87
|
+
def info(self, type_: str, op: str, detail: str = "") -> None:
|
|
88
|
+
self.log(INFO, type_, op, detail)
|
|
89
|
+
|
|
90
|
+
def warning(self, type_: str, op: str, detail: str = "") -> None:
|
|
91
|
+
self.log(WARN, type_, op, detail)
|
|
92
|
+
|
|
93
|
+
def error(self, type_: str, op: str, detail: str = "") -> None:
|
|
94
|
+
self.log(ERROR, type_, op, detail)
|
|
95
|
+
|
|
96
|
+
def _emit(self, level: str, type_: str, op: str, detail: str, fh: Optional[TextIO]) -> None:
|
|
97
|
+
fields = [datetime.now().strftime(_TS_FORMAT), self._job_name.get(), level, type_, op]
|
|
98
|
+
if detail != "":
|
|
99
|
+
fields.append(detail)
|
|
100
|
+
with self._lock:
|
|
101
|
+
print(" | ".join(fields), file=self._stdout)
|
|
102
|
+
if fh is not None:
|
|
103
|
+
fh.write("\t".join(fields) + "\n")
|
|
104
|
+
fh.flush()
|
ping_dataexport/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""argparse CLI — the short/long option names are a stable public surface.
|
|
2
|
+
|
|
3
|
+
Flags default to None (not False) so the JobSpecBuilder can tell "absent on CLI"
|
|
4
|
+
from "explicitly off" when layering CLI > job.ini > [Main] > defaults.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
PROG = "python -m ping_dataexport"
|
|
14
|
+
|
|
15
|
+
MODES = ("groupby", "date", "relativedate", "splitrow", "watermark", "monthbydate")
|
|
16
|
+
FORMATS = ("csv", "txt", "parquet")
|
|
17
|
+
STRIP_NEWLINE_MODES = ("space", "blank", "escape", "doubleescape")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CliArgs:
|
|
22
|
+
source: Optional[str] = None
|
|
23
|
+
query: Optional[str] = None
|
|
24
|
+
output: Optional[str] = None
|
|
25
|
+
outputdir: Optional[str] = None
|
|
26
|
+
basename: Optional[str] = None
|
|
27
|
+
format: Optional[str] = None
|
|
28
|
+
gzip: Optional[bool] = None
|
|
29
|
+
quote: Optional[str] = None
|
|
30
|
+
sep: Optional[str] = None
|
|
31
|
+
stripnewline: Optional[str] = None
|
|
32
|
+
stripmetachar: Optional[bool] = None
|
|
33
|
+
fetchsize: Optional[int] = None
|
|
34
|
+
mode: Optional[str] = None
|
|
35
|
+
column: Optional[str] = None
|
|
36
|
+
row: Optional[int] = None
|
|
37
|
+
datefrom: Optional[str] = None
|
|
38
|
+
dateto: Optional[str] = None
|
|
39
|
+
daysrelative: Optional[int] = None
|
|
40
|
+
monthsrelative: Optional[int] = None
|
|
41
|
+
removeexisting: Optional[str] = None
|
|
42
|
+
configfile: str = "config.ini"
|
|
43
|
+
jobfile: str = "job.ini"
|
|
44
|
+
job: Optional[str] = None
|
|
45
|
+
name: Optional[str] = None
|
|
46
|
+
logfile: Optional[str] = None
|
|
47
|
+
test: bool = False
|
|
48
|
+
parallel: Optional[int] = None # None = off, 0 = default worker count, >0 = workers
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
52
|
+
p = argparse.ArgumentParser(
|
|
53
|
+
prog=PROG,
|
|
54
|
+
description="Universal database data export to CSV/TXT/Parquet.",
|
|
55
|
+
allow_abbrev=False,
|
|
56
|
+
)
|
|
57
|
+
p.add_argument("-s", "--source", help="DB config section name (e.g. DB01)")
|
|
58
|
+
p.add_argument("-q", "--query", help="SQL query (required unless --job is used)")
|
|
59
|
+
|
|
60
|
+
p.add_argument("-o", "--output", help="single output file path")
|
|
61
|
+
p.add_argument("-od", "--outputdir", help="output directory (required with --mode)")
|
|
62
|
+
p.add_argument("-bn", "--basename", help="base filename for mode exports")
|
|
63
|
+
|
|
64
|
+
p.add_argument("-f", "--format", choices=FORMATS, help="output format (auto from extension)")
|
|
65
|
+
p.add_argument("-gz", "--gzip", action="store_true", default=None,
|
|
66
|
+
help="gzip compress output (csv/txt only)")
|
|
67
|
+
|
|
68
|
+
p.add_argument("-quo", "--quote", help='quote character (default ")')
|
|
69
|
+
p.add_argument("-sp", "--sep", help="field separator (default ,) — \\t maps to tab")
|
|
70
|
+
p.add_argument("-stnl", "--stripnewline", choices=STRIP_NEWLINE_MODES,
|
|
71
|
+
help="newline handling in text columns")
|
|
72
|
+
p.add_argument("-stmc", "--stripmetachar", action="store_true", default=None,
|
|
73
|
+
help="strip invisible/control chars from text columns")
|
|
74
|
+
p.add_argument("-fs", "--fetchsize", type=int, help="cursor fetch/array size")
|
|
75
|
+
|
|
76
|
+
p.add_argument("-m", "--mode", choices=MODES, help="export mode")
|
|
77
|
+
p.add_argument("-col", "--column", help="column name for the mode")
|
|
78
|
+
p.add_argument("-row", "--row", type=int, help="rows per file (splitrow)")
|
|
79
|
+
p.add_argument("-df", "--datefrom", help="start date yyyy-MM-dd")
|
|
80
|
+
p.add_argument("-dt", "--dateto", help="end date yyyy-MM-dd")
|
|
81
|
+
p.add_argument("-dr", "--daysrelative", type=int, help="days before datefrom (relativedate)")
|
|
82
|
+
p.add_argument("-mr", "--monthsrelative", type=int,
|
|
83
|
+
help="months before datefrom (monthbydate)")
|
|
84
|
+
p.add_argument("-rm", "--removeexisting", metavar="YES",
|
|
85
|
+
help="Yes/True: clear resolved outputdir before export")
|
|
86
|
+
|
|
87
|
+
p.add_argument("-cf", "--configfile", default="config.ini", help="config INI path")
|
|
88
|
+
p.add_argument("-jf", "--jobfile", default="job.ini", help="job INI path")
|
|
89
|
+
p.add_argument("-j", "--job", help="job section name")
|
|
90
|
+
p.add_argument("-n", "--name", help="job name")
|
|
91
|
+
p.add_argument("-lf", "--logfile", help="log file path")
|
|
92
|
+
p.add_argument("-t", "--test", action="store_true", default=False,
|
|
93
|
+
help="test mode: preview only, no files")
|
|
94
|
+
p.add_argument("-pl", "--parallel", nargs="?", const=0, type=int, metavar="N",
|
|
95
|
+
help="run ALL job sections in parallel with N workers (default: CPU count)")
|
|
96
|
+
return p
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_args(argv: List[str]) -> CliArgs:
|
|
100
|
+
ns = build_parser().parse_args(argv)
|
|
101
|
+
return CliArgs(**vars(ns))
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""INI configuration layer: ConfigReader + MainConfig + DBConfig.
|
|
2
|
+
|
|
3
|
+
- RawConfigParser with NO interpolation: values legitimately contain % and $.
|
|
4
|
+
- Keys are case-preserving on read, case-insensitive on lookup.
|
|
5
|
+
- Inline comments are NOT stripped: ConnectionString values contain ';'.
|
|
6
|
+
- UTF-8 with BOM tolerance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import configparser
|
|
13
|
+
from dataclasses import dataclass, field, fields
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
16
|
+
|
|
17
|
+
from .errors import ConfigError
|
|
18
|
+
|
|
19
|
+
_TRUE_VALUES = {"yes", "true", "1", "y", "on"}
|
|
20
|
+
_FALSE_VALUES = {"no", "false", "0", "n", "off"}
|
|
21
|
+
|
|
22
|
+
#: DB section Type values understood by the connector factory (db/connector.py).
|
|
23
|
+
KNOWN_DB_TYPES = frozenset(
|
|
24
|
+
{"sqlite", "mssql-odbc", "freetds", "oracle-odbc", "odbc", "oracle", "sqlalchemy"}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
MAIN_SECTION = "Main"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_bool(value: str, *, section: str, key: str) -> bool:
|
|
31
|
+
v = value.strip().lower()
|
|
32
|
+
if v in _TRUE_VALUES:
|
|
33
|
+
return True
|
|
34
|
+
if v in _FALSE_VALUES:
|
|
35
|
+
return False
|
|
36
|
+
raise ConfigError(f"[{section}] {key}: expected Yes/No or True/False, got {value!r}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_int(value: str, *, section: str, key: str) -> int:
|
|
40
|
+
try:
|
|
41
|
+
return int(value.strip())
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise ConfigError(f"[{section}] {key}: expected an integer, got {value!r}") from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConfigReader:
|
|
47
|
+
"""Reads one INI file (config.ini or job.ini) with case-insensitive lookups."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, path: Union[str, Path]):
|
|
50
|
+
self.path = Path(path)
|
|
51
|
+
if not self.path.is_file():
|
|
52
|
+
raise ConfigError(f"config file not found: {self.path}")
|
|
53
|
+
parser = configparser.RawConfigParser()
|
|
54
|
+
parser.optionxform = str # preserve key case
|
|
55
|
+
try:
|
|
56
|
+
with open(self.path, "r", encoding="utf-8-sig") as fh:
|
|
57
|
+
parser.read_file(fh, source=str(self.path))
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
raise ConfigError(f"cannot read {self.path}: {exc}") from exc
|
|
60
|
+
except (configparser.Error, UnicodeDecodeError) as exc:
|
|
61
|
+
raise ConfigError(f"cannot parse {self.path}: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
# section lower-name -> (original name, {key lower -> (original key, value)})
|
|
64
|
+
self._sections: Dict[str, Tuple[str, Dict[str, Tuple[str, str]]]] = {}
|
|
65
|
+
for sec in parser.sections():
|
|
66
|
+
items: Dict[str, Tuple[str, str]] = {}
|
|
67
|
+
for key, value in parser.items(sec):
|
|
68
|
+
items[key.lower()] = (key, value)
|
|
69
|
+
self._sections[sec.lower()] = (sec, items)
|
|
70
|
+
|
|
71
|
+
def sections(self) -> List[str]:
|
|
72
|
+
"""Section names in file order, original case."""
|
|
73
|
+
return [orig for orig, _ in self._sections.values()]
|
|
74
|
+
|
|
75
|
+
def has_section(self, section: str) -> bool:
|
|
76
|
+
return section.lower() in self._sections
|
|
77
|
+
|
|
78
|
+
def section_items(self, section: str) -> Dict[str, str]:
|
|
79
|
+
"""Ordered {original-case key: value} for a section; ConfigError if missing."""
|
|
80
|
+
entry = self._sections.get(section.lower())
|
|
81
|
+
if entry is None:
|
|
82
|
+
raise ConfigError(f"section [{section}] not found in {self.path}")
|
|
83
|
+
return {orig_key: value for orig_key, value in entry[1].values()}
|
|
84
|
+
|
|
85
|
+
def get(self, section: str, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
86
|
+
entry = self._sections.get(section.lower())
|
|
87
|
+
if entry is None:
|
|
88
|
+
return default
|
|
89
|
+
item = entry[1].get(key.lower())
|
|
90
|
+
return item[1] if item is not None else default
|
|
91
|
+
|
|
92
|
+
def get_required(self, section: str, key: str) -> str:
|
|
93
|
+
value = self.get(section, key)
|
|
94
|
+
if value is None or value.strip() == "":
|
|
95
|
+
raise ConfigError(f"[{section}] {key} is required in {self.path}")
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class MainConfig:
|
|
101
|
+
"""config.ini [Main] defaults. A missing [Main] section yields all defaults."""
|
|
102
|
+
|
|
103
|
+
export_folder: Optional[str] = None
|
|
104
|
+
temp_folder: Optional[str] = None
|
|
105
|
+
fetch_size: int = 10000
|
|
106
|
+
preview_row: int = 10
|
|
107
|
+
force_make_dir: bool = True
|
|
108
|
+
row_id_column: Optional[str] = None
|
|
109
|
+
data_date_column: Optional[str] = None
|
|
110
|
+
log_file: Optional[str] = None
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_reader(cls, reader: ConfigReader) -> "MainConfig":
|
|
114
|
+
if not reader.has_section(MAIN_SECTION):
|
|
115
|
+
return cls()
|
|
116
|
+
|
|
117
|
+
def opt(key: str) -> Optional[str]:
|
|
118
|
+
value = reader.get(MAIN_SECTION, key)
|
|
119
|
+
return value.strip() if value is not None and value.strip() != "" else None
|
|
120
|
+
|
|
121
|
+
kwargs: Dict[str, object] = {}
|
|
122
|
+
if opt("ExportFolder") is not None:
|
|
123
|
+
kwargs["export_folder"] = opt("ExportFolder")
|
|
124
|
+
if opt("TempFolder") is not None:
|
|
125
|
+
kwargs["temp_folder"] = opt("TempFolder")
|
|
126
|
+
if opt("FetchSize") is not None:
|
|
127
|
+
kwargs["fetch_size"] = parse_int(opt("FetchSize"), section=MAIN_SECTION, key="FetchSize")
|
|
128
|
+
if opt("PreviewRow") is not None:
|
|
129
|
+
kwargs["preview_row"] = parse_int(opt("PreviewRow"), section=MAIN_SECTION, key="PreviewRow")
|
|
130
|
+
if opt("ForceMakeDir") is not None:
|
|
131
|
+
kwargs["force_make_dir"] = parse_bool(
|
|
132
|
+
opt("ForceMakeDir"), section=MAIN_SECTION, key="ForceMakeDir"
|
|
133
|
+
)
|
|
134
|
+
if opt("RowIDColumn") is not None:
|
|
135
|
+
kwargs["row_id_column"] = opt("RowIDColumn")
|
|
136
|
+
if opt("DataDateColumn") is not None:
|
|
137
|
+
kwargs["data_date_column"] = opt("DataDateColumn")
|
|
138
|
+
if opt("LogFile") is not None:
|
|
139
|
+
kwargs["log_file"] = opt("LogFile")
|
|
140
|
+
cfg = cls(**kwargs) # type: ignore[arg-type]
|
|
141
|
+
if cfg.fetch_size <= 0:
|
|
142
|
+
raise ConfigError(f"[{MAIN_SECTION}] FetchSize must be > 0, got {cfg.fetch_size}")
|
|
143
|
+
if cfg.preview_row <= 0:
|
|
144
|
+
raise ConfigError(f"[{MAIN_SECTION}] PreviewRow must be > 0, got {cfg.preview_row}")
|
|
145
|
+
return cfg
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_MASK = "******"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class DBConfig:
|
|
153
|
+
"""One DB section of config.ini. Password is excluded from repr; use safe_items() for logs."""
|
|
154
|
+
|
|
155
|
+
section: str
|
|
156
|
+
name: str
|
|
157
|
+
type: str
|
|
158
|
+
connection_string: Optional[str] = None
|
|
159
|
+
dsn: Optional[str] = None
|
|
160
|
+
host: Optional[str] = None
|
|
161
|
+
port: Optional[int] = None
|
|
162
|
+
database: Optional[str] = None
|
|
163
|
+
service: Optional[str] = None
|
|
164
|
+
sid: Optional[str] = None
|
|
165
|
+
username: Optional[str] = None
|
|
166
|
+
password: Optional[str] = field(default=None, repr=False)
|
|
167
|
+
encoding: Optional[str] = None
|
|
168
|
+
driver: Optional[str] = None # ODBC driver name override, e.g. "ODBC Driver 17 for SQL Server"
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def from_reader(cls, reader: ConfigReader, section: str) -> "DBConfig":
|
|
172
|
+
if not reader.has_section(section):
|
|
173
|
+
raise ConfigError(f"DB section [{section}] not found in {reader.path}")
|
|
174
|
+
if section.lower() == MAIN_SECTION.lower():
|
|
175
|
+
raise ConfigError(f"[{MAIN_SECTION}] is not a DB section")
|
|
176
|
+
|
|
177
|
+
def opt(key: str) -> Optional[str]:
|
|
178
|
+
value = reader.get(section, key)
|
|
179
|
+
return value.strip() if value is not None and value.strip() != "" else None
|
|
180
|
+
|
|
181
|
+
connection_string = opt("ConnectionString")
|
|
182
|
+
dsn = opt("DSN")
|
|
183
|
+
raw_type = opt("Type")
|
|
184
|
+
if raw_type is not None:
|
|
185
|
+
db_type = raw_type.lower()
|
|
186
|
+
if db_type not in KNOWN_DB_TYPES:
|
|
187
|
+
raise ConfigError(
|
|
188
|
+
f"[{section}] Type={raw_type!r} is not supported "
|
|
189
|
+
f"(known: {', '.join(sorted(KNOWN_DB_TYPES))})"
|
|
190
|
+
)
|
|
191
|
+
elif connection_string is not None or dsn is not None:
|
|
192
|
+
db_type = "odbc"
|
|
193
|
+
else:
|
|
194
|
+
raise ConfigError(f"[{section}] Type is required (or provide ConnectionString/DSN)")
|
|
195
|
+
|
|
196
|
+
# Password wins over PasswordBase64 when both are present.
|
|
197
|
+
password = opt("Password")
|
|
198
|
+
password_b64 = opt("PasswordBase64")
|
|
199
|
+
if password is None and password_b64 is not None:
|
|
200
|
+
try:
|
|
201
|
+
password = base64.b64decode(password_b64, validate=True).decode("utf-8")
|
|
202
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
203
|
+
raise ConfigError(
|
|
204
|
+
f"[{section}] PasswordBase64 is not valid base64-encoded UTF-8: {exc}"
|
|
205
|
+
) from None
|
|
206
|
+
|
|
207
|
+
port_value = opt("Port")
|
|
208
|
+
return cls(
|
|
209
|
+
section=section,
|
|
210
|
+
name=opt("Name") or section,
|
|
211
|
+
type=db_type,
|
|
212
|
+
connection_string=connection_string,
|
|
213
|
+
dsn=dsn,
|
|
214
|
+
host=opt("Host"),
|
|
215
|
+
port=parse_int(port_value, section=section, key="Port") if port_value else None,
|
|
216
|
+
database=opt("Database"),
|
|
217
|
+
service=opt("Service"),
|
|
218
|
+
sid=opt("SID"),
|
|
219
|
+
username=opt("Username"),
|
|
220
|
+
password=password,
|
|
221
|
+
encoding=opt("Encoding"),
|
|
222
|
+
driver=opt("Driver"),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def safe_items(self) -> List[Tuple[str, str]]:
|
|
226
|
+
"""(key, value) pairs for DATABASE | PROPERTY logging, password masked, Nones skipped."""
|
|
227
|
+
out: List[Tuple[str, str]] = []
|
|
228
|
+
for f in fields(self):
|
|
229
|
+
value = getattr(self, f.name)
|
|
230
|
+
if value is None:
|
|
231
|
+
continue
|
|
232
|
+
out.append((f.name, _MASK if f.name == "password" else str(value)))
|
|
233
|
+
return out
|
|
File without changes
|