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,50 @@
1
+ """ExportRunner — connect via the connector, pick the writer, run the engine.
2
+
3
+ Format registry: to add a new output format, add one RowWriter class in writer/
4
+ and one entry in WRITER_REGISTRY.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Callable, Dict
10
+
11
+ from ..applogger import AppLogger
12
+ from ..config import DBConfig
13
+ from ..db.connector import create_connector
14
+ from ..writer.base import RowWriter
15
+ from ..writer.delimited import DelimitedRowWriter
16
+ from ..writer.parquet import ParquetRowWriter
17
+ from .engine import ExportEngine, ExportStats
18
+ from .options import ExportOptions
19
+
20
+ WRITER_REGISTRY: Dict[str, Callable[[ExportOptions, str], RowWriter]] = {
21
+ "csv": DelimitedRowWriter,
22
+ "txt": DelimitedRowWriter,
23
+ "parquet": ParquetRowWriter,
24
+ }
25
+
26
+
27
+ class ExportRunner:
28
+ def __init__(self, logger: AppLogger):
29
+ self.logger = logger
30
+
31
+ def run(self, options: ExportOptions, db: DBConfig) -> ExportStats:
32
+ connector = create_connector(db.type)
33
+ for key, value in db.safe_items():
34
+ self.logger.info("DATABASE", "PROPERTY", f"{key}={value}")
35
+ conn = connector.connect(db)
36
+ self.logger.info("DATABASE", "CONNECTED", db.name)
37
+ try:
38
+ engine = ExportEngine(options, connector, conn, self.logger, self._writer_factory)
39
+ return engine.run()
40
+ finally:
41
+ conn.close()
42
+ self.logger.info("DATABASE", "DISCONNECTED", db.name)
43
+
44
+ @staticmethod
45
+ def _writer_factory(options: ExportOptions, data_date: str) -> RowWriter:
46
+ try:
47
+ factory = WRITER_REGISTRY[options.format]
48
+ except KeyError:
49
+ raise ValueError(f"no writer for format {options.format!r}") from None
50
+ return factory(options, data_date)
@@ -0,0 +1,65 @@
1
+ """Filename building + text-cleanup helpers shared by writers and planners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ #: chars illegal in filenames on Windows/POSIX -> '_' (groupby values become suffixes)
10
+ _ILLEGAL_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
11
+
12
+ #: invisible/control characters removed by --stripmetachar (string columns only):
13
+ #: control chars, DEL, NBSP, zero-width space/joiners, BOM
14
+ _META_CHARS = (
15
+ "".join(chr(c) for c in range(0x00, 0x09))
16
+ + "\x0b\x0c"
17
+ + "".join(chr(c) for c in range(0x0e, 0x20))
18
+ + "\x7f\xa0\u200b\u200c\u200d\ufeff"
19
+ )
20
+ _META_TABLE = {ord(c): None for c in _META_CHARS}
21
+
22
+
23
+ def sanitize_suffix(value: str) -> str:
24
+ """Make a partition value safe as a filename suffix."""
25
+ return _ILLEGAL_FILENAME_CHARS.sub("_", value)
26
+
27
+
28
+ def output_filename(
29
+ base_name: str, suffix: Optional[str], format_: str, gzip: bool
30
+ ) -> str:
31
+ """base[_suffix].ext[.gz] — gzip suffix applies to delimited formats only."""
32
+ ext = {"csv": ".csv", "txt": ".txt", "parquet": ".parquet"}[format_]
33
+ stem = base_name if suffix is None else f"{base_name}_{sanitize_suffix(suffix)}"
34
+ name = stem + ext
35
+ if gzip and format_ != "parquet":
36
+ name += ".gz"
37
+ return name
38
+
39
+
40
+ def build_text_cleaner(stripnewline: Optional[str], stripmetachar: bool):
41
+ """Returns clean(text)->text applying the strip rules; identity when both off.
42
+
43
+ Applies to string-typed column values only, for BOTH delimited and Parquet output.
44
+ """
45
+
46
+ def clean(value: str) -> str:
47
+ if stripmetachar:
48
+ value = value.translate(_META_TABLE)
49
+ if stripnewline == "space":
50
+ value = value.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
51
+ elif stripnewline == "blank":
52
+ value = value.replace("\r\n", "").replace("\n", "").replace("\r", "")
53
+ elif stripnewline == "escape":
54
+ value = value.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\r")
55
+ elif stripnewline == "doubleescape":
56
+ value = value.replace("\r\n", "\\\\n").replace("\n", "\\\\n").replace("\r", "\\\\r")
57
+ return value
58
+
59
+ if stripnewline is None and not stripmetachar:
60
+ return lambda value: value
61
+ return clean
62
+
63
+
64
+ def part_path(directory: Path, base_name: str, suffix: str, format_: str, gzip: bool) -> Path:
65
+ return directory / output_filename(base_name, suffix, format_, gzip)
File without changes
@@ -0,0 +1,107 @@
1
+ """JobExecutor — resolve paths -> removeexisting -> DB config -> ExportOptions -> run.
2
+
3
+ Assumes the spec has already passed JobValidator (both dispatch paths validate).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from ..config import DBConfig
13
+ from ..export.options import ExportOptions
14
+ from ..export.runner import ExportRunner
15
+ from ..export.engine import ExportStats
16
+ from .shared_context import SharedContext
17
+ from .spec import JobSpec
18
+
19
+
20
+ class JobExecutor:
21
+ def __init__(self, ctx: SharedContext):
22
+ self.ctx = ctx
23
+
24
+ def execute(self, spec: JobSpec) -> ExportStats:
25
+ logger = self.ctx.logger
26
+ logger.set_job_name(spec.name)
27
+ self._open_job_log(spec)
28
+ started = time.monotonic()
29
+ try:
30
+ logger.info("CONFIG", "JOB", f"source={spec.source} mode={spec.mode or 'single'}"
31
+ + (" TEST" if spec.test else ""))
32
+ options = self._resolve(spec)
33
+ if options.output_dir is not None and spec.removeexisting and not spec.test:
34
+ removed = self.ctx.resolver.remove_existing(options.output_dir)
35
+ logger.info("PATH", "REMOVEEXISTING", f"{removed} entries removed "
36
+ f"from {options.output_dir}")
37
+ db = DBConfig.from_reader(self.ctx.reader, spec.source)
38
+ stats = ExportRunner(logger).run(options, db)
39
+ elapsed = time.monotonic() - started
40
+ logger.info("RESULT", "COMPLETED", f"{elapsed:.1f} seconds")
41
+ return stats
42
+ except Exception as exc:
43
+ logger.error("RESULT", "FAILED", str(exc))
44
+ raise
45
+ finally:
46
+ logger.close_job_file()
47
+
48
+ # -- helpers ------------------------------------------------------------
49
+
50
+ def _open_job_log(self, spec: JobSpec) -> None:
51
+ """Log-file priority: CLI --logfile > config LogFile > default path > stdout-only."""
52
+ path: Optional[Path] = None
53
+ if spec.logfile:
54
+ path = self.ctx.resolver.resolve(spec.logfile)
55
+ elif self.ctx.main.log_file:
56
+ path = self.ctx.resolver.resolve(self.ctx.main.log_file)
57
+ else:
58
+ default = self.ctx.resolver.default_log_path(spec.name)
59
+ if default is not None:
60
+ path = default
61
+ if path is not None:
62
+ self.ctx.logger.open_job_file(path)
63
+
64
+ def _resolve(self, spec: JobSpec) -> ExportOptions:
65
+ resolver = self.ctx.resolver
66
+ main = self.ctx.main
67
+ output_path = None
68
+ output_dir = None
69
+ if spec.output is not None:
70
+ output_path = resolver.resolve(spec.output)
71
+ if spec.gzip and spec.format != "parquet" and output_path.suffix != ".gz":
72
+ output_path = output_path.with_name(output_path.name + ".gz")
73
+ if not spec.test:
74
+ resolver.ensure_parent_dir(output_path)
75
+ self.ctx.logger.info("PATH", "OUTPUT", str(output_path))
76
+ elif spec.outputdir is not None:
77
+ output_dir = resolver.resolve(spec.outputdir)
78
+ if not spec.test:
79
+ resolver.ensure_dir(output_dir)
80
+ self.ctx.logger.info("PATH", "OUTPUTDIR", str(output_dir))
81
+
82
+ return ExportOptions(
83
+ job_name=spec.name,
84
+ source=spec.source,
85
+ query=spec.query,
86
+ format=spec.format,
87
+ output_path=output_path,
88
+ output_dir=output_dir,
89
+ base_name=spec.basename,
90
+ gzip=spec.gzip,
91
+ quote=spec.quote,
92
+ sep=spec.sep,
93
+ stripnewline=spec.stripnewline,
94
+ stripmetachar=spec.stripmetachar,
95
+ fetch_size=spec.fetchsize or main.fetch_size,
96
+ preview_row=main.preview_row,
97
+ mode=spec.mode,
98
+ column=spec.column,
99
+ split_rows=spec.row,
100
+ dates=spec.dates,
101
+ datefrom=spec.datefrom,
102
+ dateto=spec.dateto,
103
+ monthsrelative=spec.monthsrelative,
104
+ row_id_column=main.row_id_column,
105
+ data_date_column=main.data_date_column,
106
+ test=spec.test,
107
+ )
@@ -0,0 +1,22 @@
1
+ """SharedContext — config.ini [Main] defaults computed once, shared across jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Union
7
+
8
+ from ..applogger import AppLogger
9
+ from ..config import ConfigReader, MainConfig
10
+ from ..paths import PathResolver
11
+
12
+
13
+ class SharedContext:
14
+ def __init__(self, config_path: Union[str, Path], logger: AppLogger):
15
+ self.config_path = Path(config_path)
16
+ self.reader = ConfigReader(self.config_path)
17
+ self.main = MainConfig.from_reader(self.reader)
18
+ self.resolver = PathResolver(
19
+ export_folder=self.main.export_folder,
20
+ force_make_dir=self.main.force_make_dir,
21
+ )
22
+ self.logger = logger
@@ -0,0 +1,251 @@
1
+ """JobSpec (frozen, validated) + JobSpecBuilder (CLI > job.ini > [Main] > defaults)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from datetime import date, datetime, timedelta
7
+ from typing import Optional, Tuple
8
+
9
+ from ..cli import CliArgs
10
+ from ..config import ConfigReader, MainConfig
11
+ from ..errors import JobValidationError
12
+
13
+ DEFAULT_QUOTE = '"'
14
+ DEFAULT_SEP = ","
15
+ DEFAULT_SPLIT_ROWS = 100000
16
+
17
+ _TRUEISH = {"yes", "true", "1", "y", "on"}
18
+ _FALSEISH = {"no", "false", "0", "n", "off"}
19
+
20
+ #: job.ini key per CliArgs/JobSpec field
21
+ _JOB_KEYS = {
22
+ "name": "Name",
23
+ "source": "Source",
24
+ "query": "Query",
25
+ "output": "Output",
26
+ "outputdir": "OutputDir",
27
+ "basename": "BaseName",
28
+ "format": "Format",
29
+ "gzip": "Gzip",
30
+ "quote": "Quote",
31
+ "sep": "Sep",
32
+ "stripnewline": "StripNewLine",
33
+ "stripmetachar": "StripMetaChar",
34
+ "fetchsize": "FetchSize",
35
+ "mode": "Mode",
36
+ "column": "Column",
37
+ "row": "Row",
38
+ "datefrom": "DateFrom",
39
+ "dateto": "DateTo",
40
+ "daysrelative": "DaysRelative",
41
+ "monthsrelative": "MonthsRelative",
42
+ "removeexisting": "RemoveExisting",
43
+ }
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class JobSpec:
48
+ """Immutable description of one job after layered resolution.
49
+
50
+ `format` is the resolved output format ('csv' | 'txt' | 'parquet') or None for
51
+ console preview. `dates` is the pre-built inclusive date list for the
52
+ date/relativedate modes.
53
+ """
54
+
55
+ name: str
56
+ source: Optional[str]
57
+ query: Optional[str]
58
+ output: Optional[str] = None
59
+ outputdir: Optional[str] = None
60
+ basename: Optional[str] = None
61
+ format: Optional[str] = None
62
+ gzip: bool = False
63
+ quote: str = DEFAULT_QUOTE
64
+ sep: str = DEFAULT_SEP
65
+ stripnewline: Optional[str] = None
66
+ stripmetachar: bool = False
67
+ fetchsize: Optional[int] = None # None -> [Main] FetchSize at execution
68
+ mode: Optional[str] = None
69
+ column: Optional[str] = None
70
+ row: int = DEFAULT_SPLIT_ROWS
71
+ datefrom: Optional[date] = None
72
+ dateto: Optional[date] = None
73
+ daysrelative: Optional[int] = None
74
+ monthsrelative: Optional[int] = None
75
+ removeexisting: bool = False
76
+ test: bool = False
77
+ logfile: Optional[str] = None
78
+ dates: Tuple[date, ...] = ()
79
+
80
+ @property
81
+ def is_console(self) -> bool:
82
+ return self.output is None and self.outputdir is None
83
+
84
+
85
+ def parse_strict_date(value: str, *, what: str) -> date:
86
+ """Strict yyyy-MM-dd — anything else is a validation error (exit 1)."""
87
+ try:
88
+ parsed = datetime.strptime(value.strip(), "%Y-%m-%d").date()
89
+ except ValueError:
90
+ raise JobValidationError(f"{what} must be yyyy-MM-dd, got {value!r}") from None
91
+ # strptime accepts e.g. '2026-1-2'; enforce the canonical zero-padded form
92
+ if parsed.isoformat() != value.strip():
93
+ raise JobValidationError(f"{what} must be yyyy-MM-dd, got {value!r}")
94
+ return parsed
95
+
96
+
97
+ def _parse_boolish(value: str, *, what: str) -> bool:
98
+ v = value.strip().lower()
99
+ if v in _TRUEISH:
100
+ return True
101
+ if v in _FALSEISH:
102
+ return False
103
+ raise JobValidationError(f"{what} must be Yes/No or True/False, got {value!r}")
104
+
105
+
106
+ def _parse_intish(value: str, *, what: str) -> int:
107
+ try:
108
+ return int(str(value).strip())
109
+ except ValueError:
110
+ raise JobValidationError(f"{what} must be an integer, got {value!r}") from None
111
+
112
+
113
+ class JobSpecBuilder:
114
+ """Layered resolution: CLI args > job.ini section > config.ini [Main] > defaults."""
115
+
116
+ def __init__(
117
+ self,
118
+ cli: CliArgs,
119
+ main: MainConfig,
120
+ job_reader: Optional[ConfigReader] = None,
121
+ *,
122
+ today: Optional[date] = None,
123
+ ):
124
+ self.cli = cli
125
+ self.main = main
126
+ self.job_reader = job_reader
127
+ self.today = today or date.today()
128
+
129
+ def build(self, section: Optional[str] = None) -> JobSpec:
130
+ """Build the spec for one job section (or pure ad-hoc CLI when section is None)."""
131
+ if section is not None:
132
+ if self.job_reader is None:
133
+ raise JobValidationError(f"--job {section} given but no job file was loaded")
134
+ if not self.job_reader.has_section(section):
135
+ raise JobValidationError(
136
+ f"job section [{section}] not found in {self.job_reader.path}"
137
+ )
138
+
139
+ def layered(field: str) -> Optional[str]:
140
+ cli_value = getattr(self.cli, field)
141
+ if cli_value is not None:
142
+ return str(cli_value) if not isinstance(cli_value, bool) else "Yes"
143
+ if section is not None:
144
+ job_value = self.job_reader.get(section, _JOB_KEYS[field])
145
+ if job_value is not None and job_value.strip() != "":
146
+ return job_value.strip() if field != "query" else job_value
147
+ return None
148
+
149
+ mode = layered("mode")
150
+ if mode is not None:
151
+ mode = mode.lower()
152
+
153
+ name = (
154
+ layered("name")
155
+ or (section if section is not None else None)
156
+ or "JOB_" + datetime.now().strftime("%Y%m%d_%H%M%S")
157
+ )
158
+
159
+ gzip_v = layered("gzip")
160
+ stripmeta_v = layered("stripmetachar")
161
+ remove_v = layered("removeexisting")
162
+ fetch_v = layered("fetchsize")
163
+ row_v = layered("row")
164
+ days_v = layered("daysrelative")
165
+ months_v = layered("monthsrelative")
166
+ datefrom_v = layered("datefrom")
167
+ dateto_v = layered("dateto")
168
+
169
+ datefrom = parse_strict_date(datefrom_v, what="--datefrom") if datefrom_v else None
170
+ dateto = parse_strict_date(dateto_v, what="--dateto") if dateto_v else None
171
+ if mode == "monthbydate" and datefrom is None:
172
+ datefrom = self.today # anchor defaults to today
173
+
174
+ output = layered("output")
175
+ outputdir = layered("outputdir")
176
+ basename = layered("basename")
177
+ fmt = layered("format")
178
+ if fmt is not None:
179
+ fmt = fmt.lower()
180
+ if fmt == "text":
181
+ fmt = "txt"
182
+
183
+ sep = layered("sep")
184
+ if sep is not None and sep == "\\t":
185
+ sep = "\t"
186
+
187
+ spec = JobSpec(
188
+ name=name,
189
+ source=layered("source"),
190
+ query=layered("query"),
191
+ output=output,
192
+ outputdir=outputdir,
193
+ basename=basename,
194
+ format=self._resolve_format(fmt, output, outputdir),
195
+ gzip=_parse_boolish(gzip_v, what="Gzip") if gzip_v is not None else False,
196
+ quote=layered("quote") if layered("quote") is not None else DEFAULT_QUOTE,
197
+ sep=sep if sep is not None else DEFAULT_SEP,
198
+ stripnewline=(layered("stripnewline") or None),
199
+ stripmetachar=(
200
+ _parse_boolish(stripmeta_v, what="StripMetaChar") if stripmeta_v else False
201
+ ),
202
+ fetchsize=_parse_intish(fetch_v, what="FetchSize") if fetch_v else None,
203
+ mode=mode,
204
+ column=layered("column"),
205
+ row=_parse_intish(row_v, what="Row") if row_v else DEFAULT_SPLIT_ROWS,
206
+ datefrom=datefrom,
207
+ dateto=dateto,
208
+ daysrelative=_parse_intish(days_v, what="DaysRelative") if days_v else None,
209
+ monthsrelative=_parse_intish(months_v, what="MonthsRelative") if months_v else None,
210
+ removeexisting=(
211
+ _parse_boolish(remove_v, what="RemoveExisting") if remove_v else False
212
+ ),
213
+ test=self.cli.test,
214
+ logfile=self.cli.logfile,
215
+ )
216
+ return self._with_dates(spec)
217
+
218
+ @staticmethod
219
+ def _resolve_format(
220
+ fmt: Optional[str], output: Optional[str], outputdir: Optional[str]
221
+ ) -> Optional[str]:
222
+ if fmt is not None:
223
+ return fmt
224
+ if output is not None:
225
+ lower = output.lower()
226
+ if lower.endswith(".gz"):
227
+ lower = lower[:-3]
228
+ for ext, resolved in ((".csv", "csv"), (".txt", "txt"), (".parquet", "parquet")):
229
+ if lower.endswith(ext):
230
+ return resolved
231
+ return "csv"
232
+ if outputdir is not None:
233
+ return "csv"
234
+ return None # console preview
235
+
236
+ def _with_dates(self, spec: JobSpec) -> JobSpec:
237
+ """Pre-build the inclusive date list for date/relativedate (Python, never SQL)."""
238
+ dates: Tuple[date, ...] = ()
239
+ if spec.mode == "date" and spec.datefrom and spec.dateto and spec.datefrom <= spec.dateto:
240
+ dates = tuple(
241
+ spec.datefrom + timedelta(days=i)
242
+ for i in range((spec.dateto - spec.datefrom).days + 1)
243
+ )
244
+ elif spec.mode == "relativedate" and spec.datefrom and spec.daysrelative is not None:
245
+ start = spec.datefrom - timedelta(days=spec.daysrelative)
246
+ dates = tuple(
247
+ start + timedelta(days=i) for i in range((spec.datefrom - start).days + 1)
248
+ )
249
+ if dates:
250
+ return replace(spec, dates=dates)
251
+ return spec
@@ -0,0 +1,87 @@
1
+ """JobValidator — the full mode/argument rule table.
2
+
3
+ One place for every mode/argument rule, enforced identically for the sequential
4
+ path and every parallel job section. Raises JobValidationError; __main__ maps it
5
+ to exit code 1.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..cli import MODES, STRIP_NEWLINE_MODES
11
+ from ..errors import JobValidationError
12
+ from .spec import JobSpec
13
+
14
+ _MODES_NEEDING_COLUMN = {"groupby", "date", "relativedate", "watermark", "monthbydate"}
15
+
16
+
17
+ class JobValidator:
18
+ @staticmethod
19
+ def validate(spec: JobSpec) -> None:
20
+ e = JobValidator._error
21
+
22
+ if not spec.source:
23
+ e(spec, "--source is required (or provide Source in the job section)")
24
+ if not spec.query:
25
+ e(spec, "--query is required (or provide Query in the job section)")
26
+
27
+ if spec.output and (spec.outputdir or spec.basename):
28
+ e(spec, "--output cannot be mixed with --outputdir/--basename")
29
+
30
+ if spec.mode:
31
+ if spec.mode not in MODES:
32
+ e(spec, f"unknown mode {spec.mode!r} (expected one of: {', '.join(MODES)})")
33
+ if not (spec.outputdir and spec.basename):
34
+ e(spec, f"--mode {spec.mode} requires both --outputdir and --basename")
35
+ elif spec.outputdir or spec.basename:
36
+ e(spec, "--outputdir/--basename require --mode")
37
+
38
+ if spec.mode in _MODES_NEEDING_COLUMN and not spec.column:
39
+ e(spec, f"--mode {spec.mode} requires --column")
40
+
41
+ if spec.mode == "date":
42
+ if not (spec.datefrom and spec.dateto):
43
+ e(spec, "--mode date requires --datefrom and --dateto")
44
+ if spec.datefrom > spec.dateto:
45
+ e(spec, f"--datefrom {spec.datefrom} is after --dateto {spec.dateto}")
46
+ if spec.mode == "relativedate":
47
+ if not spec.datefrom or spec.daysrelative is None:
48
+ e(spec, "--mode relativedate requires --datefrom and --daysrelative")
49
+ if spec.daysrelative < 0:
50
+ e(spec, "--daysrelative must be >= 0")
51
+ if spec.mode == "monthbydate":
52
+ if spec.monthsrelative is None:
53
+ e(spec, "--mode monthbydate requires --monthsrelative")
54
+ if spec.monthsrelative < 0:
55
+ e(spec, "--monthsrelative must be >= 0")
56
+ if spec.mode == "splitrow" and spec.row <= 0:
57
+ e(spec, "--row must be > 0")
58
+
59
+ if spec.removeexisting:
60
+ if not spec.mode:
61
+ e(spec, "--removeexisting requires --mode")
62
+ if spec.mode == "watermark":
63
+ e(spec, "--removeexisting is blocked with watermark (would destroy state)")
64
+
65
+ if spec.gzip and spec.format == "parquet":
66
+ e(spec, "--gzip cannot be used with parquet (built-in compression)")
67
+
68
+ if spec.stripnewline and spec.stripnewline not in STRIP_NEWLINE_MODES:
69
+ e(
70
+ spec,
71
+ f"unknown stripnewline {spec.stripnewline!r} "
72
+ f"(expected one of: {', '.join(STRIP_NEWLINE_MODES)})",
73
+ )
74
+
75
+ if spec.format in ("csv", "txt"):
76
+ effective_sep = "\t" if spec.format == "txt" else spec.sep
77
+ if spec.quote == effective_sep:
78
+ e(spec, "quote character must differ from the separator")
79
+ if len(spec.sep) != 1 or len(spec.quote) != 1:
80
+ e(spec, "separator and quote must each be a single character")
81
+
82
+ if spec.fetchsize is not None and spec.fetchsize <= 0:
83
+ e(spec, "--fetchsize must be > 0")
84
+
85
+ @staticmethod
86
+ def _error(spec: JobSpec, message: str) -> None:
87
+ raise JobValidationError(f"job {spec.name}: {message}")
@@ -0,0 +1,71 @@
1
+ """PathResolver: ExportFolder defaulting, ForceMakeDir, guarded removeexisting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Optional, Union
8
+
9
+ from .errors import ExportError
10
+
11
+
12
+ class PathResolver:
13
+ def __init__(self, export_folder: Optional[str] = None, force_make_dir: bool = True):
14
+ self.export_folder = export_folder
15
+ self.force_make_dir = force_make_dir
16
+
17
+ def resolve(self, path: Union[str, Path]) -> Path:
18
+ """Absolute path stays; relative path is joined under ExportFolder (or CWD)."""
19
+ p = Path(path).expanduser()
20
+ if not p.is_absolute():
21
+ base = Path(self.export_folder).expanduser() if self.export_folder else Path.cwd()
22
+ p = base / p
23
+ return p.resolve()
24
+
25
+ def ensure_dir(self, dir_path: Union[str, Path]) -> Path:
26
+ p = Path(dir_path)
27
+ if p.exists():
28
+ if not p.is_dir():
29
+ raise ExportError(f"not a directory: {p}")
30
+ return p
31
+ if not self.force_make_dir:
32
+ raise ExportError(f"directory does not exist (ForceMakeDir=No): {p}")
33
+ p.mkdir(parents=True, exist_ok=True)
34
+ return p
35
+
36
+ def ensure_parent_dir(self, file_path: Union[str, Path]) -> Path:
37
+ p = Path(file_path)
38
+ self.ensure_dir(p.parent)
39
+ return p
40
+
41
+ @staticmethod
42
+ def remove_existing(dir_path: Union[str, Path]) -> int:
43
+ """Delete the contents of a RESOLVED directory recursively; returns entries removed.
44
+
45
+ Safety guard: refuses a filesystem root or an unresolved relative path.
46
+ The directory itself is kept (recreated content lands in the same dir).
47
+ """
48
+ p = Path(dir_path)
49
+ if not p.is_absolute():
50
+ raise ExportError(f"removeexisting requires a resolved absolute path, got: {p}")
51
+ p = p.resolve()
52
+ if p == Path(p.anchor):
53
+ raise ExportError(f"refusing removeexisting on filesystem root: {p}")
54
+ if not p.exists():
55
+ return 0
56
+ if not p.is_dir():
57
+ raise ExportError(f"removeexisting target is not a directory: {p}")
58
+ removed = 0
59
+ for child in p.iterdir():
60
+ if child.is_dir() and not child.is_symlink():
61
+ shutil.rmtree(child)
62
+ else:
63
+ child.unlink()
64
+ removed += 1
65
+ return removed
66
+
67
+ def default_log_path(self, job_name: str) -> Optional[Path]:
68
+ """{ExportFolder}/logs/log_{jobname}.txt — None when no ExportFolder (stdout-only)."""
69
+ if not self.export_folder:
70
+ return None
71
+ return Path(self.export_folder).expanduser() / "logs" / f"log_{job_name}.txt"
File without changes