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,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}")
|
ping_dataexport/paths.py
ADDED
|
@@ -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
|