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,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