mainframe-migration-toolkit 0.2.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.
- mainframe_migration_toolkit-0.2.0.dist-info/METADATA +16 -0
- mainframe_migration_toolkit-0.2.0.dist-info/RECORD +52 -0
- mainframe_migration_toolkit-0.2.0.dist-info/WHEEL +4 -0
- mainframe_migration_toolkit-0.2.0.dist-info/entry_points.txt +3 -0
- mainframe_toolkit/__init__.py +92 -0
- mainframe_toolkit/__main__.py +5 -0
- mainframe_toolkit/_workspace/.claude/skills/analyze-mainframe-similarity/SKILL.md +30 -0
- mainframe_toolkit/_workspace/.claude/skills/migrate-mainframe-job/SKILL.md +63 -0
- mainframe_toolkit/_workspace/.claude/skills/validate-golden-dataset/SKILL.md +12 -0
- mainframe_toolkit/_workspace/AGENTS.md +10 -0
- mainframe_toolkit/_workspace/CLAUDE.md +2 -0
- mainframe_toolkit/_workspace/validator-java/.mvn/wrapper/maven-wrapper.properties +3 -0
- mainframe_toolkit/_workspace/validator-java/mvnw +295 -0
- mainframe_toolkit/_workspace/validator-java/mvnw.cmd +189 -0
- mainframe_toolkit/_workspace/validator-java/pom.xml +116 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/AvroValueFormatter.java +152 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/CsvTabularReader.java +111 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/DataFormat.java +62 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/Difference.java +34 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/InputFileSet.java +45 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/InputOptions.java +23 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/JsonReportWriter.java +40 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/MultiFileTabularReader.java +80 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/Normalization.java +16 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ParquetTabularReader.java +61 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/TabularReader.java +13 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/TabularReaderFactory.java +29 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValidationOptions.java +40 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValidationReport.java +44 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValidationService.java +395 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValidatorCli.java +190 -0
- mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValueNormalizer.java +27 -0
- mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/DirectoryValidationTest.java +70 -0
- mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/JsonReportWriterTest.java +43 -0
- mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/KeyedValidationTest.java +89 -0
- mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/ParquetValidationTest.java +146 -0
- mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/ValidationServiceTest.java +137 -0
- mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/ValidatorCliTest.java +102 -0
- mainframe_toolkit/cli.py +361 -0
- mainframe_toolkit/cobol.py +106 -0
- mainframe_toolkit/copybook.py +558 -0
- mainframe_toolkit/errors.py +15 -0
- mainframe_toolkit/external.py +48 -0
- mainframe_toolkit/io.py +202 -0
- mainframe_toolkit/jcl.py +126 -0
- mainframe_toolkit/pipeline.py +322 -0
- mainframe_toolkit/sequential.py +259 -0
- mainframe_toolkit/similarity.py +1171 -0
- mainframe_toolkit/sorting.py +60 -0
- mainframe_toolkit/specs.py +312 -0
- mainframe_toolkit/synthetic.py +108 -0
- mainframe_toolkit/workspace.py +133 -0
mainframe_toolkit/io.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Iterator, Mapping
|
|
6
|
+
|
|
7
|
+
from .errors import OptionalDependencyError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _spark_options(options: Mapping[str, Any] | None) -> dict[str, str]:
|
|
11
|
+
return {str(key): str(value).lower() if isinstance(value, bool) else str(value) for key, value in (options or {}).items()}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _require_spark() -> None:
|
|
15
|
+
try:
|
|
16
|
+
import pyspark # noqa: F401
|
|
17
|
+
except ImportError as exc:
|
|
18
|
+
raise OptionalDependencyError("install mainframe-migration-toolkit[spark]") from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def s3_uri(bucket_or_uri: str, key: str | None = None) -> str:
|
|
22
|
+
if bucket_or_uri.startswith(("s3://", "s3a://")):
|
|
23
|
+
base = "s3a://" + bucket_or_uri.split("://", 1)[1].rstrip("/")
|
|
24
|
+
else:
|
|
25
|
+
base = f"s3a://{bucket_or_uri.strip('/')}"
|
|
26
|
+
return f"{base}/{key.lstrip('/')}" if key else base
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def configure_s3(
|
|
30
|
+
spark: Any,
|
|
31
|
+
*,
|
|
32
|
+
endpoint: str | None = None,
|
|
33
|
+
region: str | None = None,
|
|
34
|
+
path_style_access: bool | None = None,
|
|
35
|
+
access_key: str | None = None,
|
|
36
|
+
secret_key: str | None = None,
|
|
37
|
+
session_token: str | None = None,
|
|
38
|
+
) -> Any:
|
|
39
|
+
"""Configure the Hadoop S3A connector without persisting credentials."""
|
|
40
|
+
_require_spark()
|
|
41
|
+
try:
|
|
42
|
+
(
|
|
43
|
+
spark._jvm.java.lang.Thread.currentThread()
|
|
44
|
+
.getContextClassLoader()
|
|
45
|
+
.loadClass("org.apache.hadoop.fs.s3a.S3AFileSystem")
|
|
46
|
+
)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
raise OptionalDependencyError(
|
|
49
|
+
"Spark is missing the hadoop-aws connector required for s3a:// paths"
|
|
50
|
+
) from exc
|
|
51
|
+
configuration = spark.sparkContext._jsc.hadoopConfiguration()
|
|
52
|
+
configuration.set("fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem")
|
|
53
|
+
if endpoint:
|
|
54
|
+
configuration.set("fs.s3a.endpoint", endpoint)
|
|
55
|
+
if region:
|
|
56
|
+
configuration.set("fs.s3a.endpoint.region", region)
|
|
57
|
+
if path_style_access is not None:
|
|
58
|
+
configuration.set("fs.s3a.path.style.access", str(path_style_access).lower())
|
|
59
|
+
credentials = (access_key, secret_key, session_token)
|
|
60
|
+
if any(credentials) and not (access_key and secret_key):
|
|
61
|
+
raise ValueError("access_key and secret_key must be supplied together")
|
|
62
|
+
if access_key and secret_key:
|
|
63
|
+
configuration.set("fs.s3a.access.key", access_key)
|
|
64
|
+
configuration.set("fs.s3a.secret.key", secret_key)
|
|
65
|
+
if session_token:
|
|
66
|
+
configuration.set("fs.s3a.session.token", session_token)
|
|
67
|
+
return spark
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_csv(
|
|
71
|
+
spark: Any,
|
|
72
|
+
path: str | Path,
|
|
73
|
+
*,
|
|
74
|
+
delimiter: str = ",",
|
|
75
|
+
header: bool = True,
|
|
76
|
+
schema: Any = None,
|
|
77
|
+
infer_schema: bool = False,
|
|
78
|
+
encoding: str = "UTF-8",
|
|
79
|
+
options: Mapping[str, Any] | None = None,
|
|
80
|
+
) -> Any:
|
|
81
|
+
_require_spark()
|
|
82
|
+
reader_options = {
|
|
83
|
+
"sep": delimiter,
|
|
84
|
+
"header": str(header).lower(),
|
|
85
|
+
"inferSchema": str(infer_schema).lower(),
|
|
86
|
+
"encoding": encoding,
|
|
87
|
+
}
|
|
88
|
+
reader_options.update(_spark_options(options))
|
|
89
|
+
reader = spark.read.options(**reader_options)
|
|
90
|
+
if schema is not None:
|
|
91
|
+
reader = reader.schema(schema)
|
|
92
|
+
return reader.csv(str(path))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_csv(
|
|
96
|
+
dataframe: Any,
|
|
97
|
+
path: str | Path,
|
|
98
|
+
*,
|
|
99
|
+
delimiter: str = ",",
|
|
100
|
+
header: bool = True,
|
|
101
|
+
mode: str = "overwrite",
|
|
102
|
+
coalesce: int | None = None,
|
|
103
|
+
options: Mapping[str, Any] | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
_require_spark()
|
|
106
|
+
target = dataframe.coalesce(coalesce) if coalesce else dataframe
|
|
107
|
+
writer_options = {"sep": delimiter, "header": str(header).lower()}
|
|
108
|
+
writer_options.update(_spark_options(options))
|
|
109
|
+
target.write.mode(mode).options(**writer_options).csv(str(path))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_parquet(
|
|
113
|
+
spark: Any,
|
|
114
|
+
path: str | Path,
|
|
115
|
+
*,
|
|
116
|
+
options: Mapping[str, Any] | None = None,
|
|
117
|
+
) -> Any:
|
|
118
|
+
_require_spark()
|
|
119
|
+
return spark.read.options(**_spark_options(options)).parquet(str(path))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_parquet(
|
|
123
|
+
dataframe: Any,
|
|
124
|
+
path: str | Path,
|
|
125
|
+
*,
|
|
126
|
+
mode: str = "overwrite",
|
|
127
|
+
partition_by: list[str] | tuple[str, ...] | None = None,
|
|
128
|
+
options: Mapping[str, Any] | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
_require_spark()
|
|
131
|
+
writer = dataframe.write.mode(mode).options(**_spark_options(options))
|
|
132
|
+
if partition_by:
|
|
133
|
+
writer = writer.partitionBy(*partition_by)
|
|
134
|
+
writer.parquet(str(path))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read_dataset(
|
|
138
|
+
spark: Any,
|
|
139
|
+
path: str | Path,
|
|
140
|
+
*,
|
|
141
|
+
format: str | None = None,
|
|
142
|
+
options: Mapping[str, Any] | None = None,
|
|
143
|
+
**kwargs: Any,
|
|
144
|
+
) -> Any:
|
|
145
|
+
selected = (format or Path(str(path)).suffix.lstrip(".") or "parquet").lower()
|
|
146
|
+
if selected == "csv":
|
|
147
|
+
return read_csv(spark, path, options=options, **kwargs)
|
|
148
|
+
if selected == "parquet":
|
|
149
|
+
return read_parquet(spark, path, options=options)
|
|
150
|
+
raise ValueError("format must be csv or parquet")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_dataset(
|
|
154
|
+
dataframe: Any,
|
|
155
|
+
path: str | Path,
|
|
156
|
+
*,
|
|
157
|
+
format: str | None = None,
|
|
158
|
+
options: Mapping[str, Any] | None = None,
|
|
159
|
+
**kwargs: Any,
|
|
160
|
+
) -> None:
|
|
161
|
+
selected = (format or Path(str(path)).suffix.lstrip(".") or "parquet").lower()
|
|
162
|
+
if selected == "csv":
|
|
163
|
+
write_csv(dataframe, path, options=options, **kwargs)
|
|
164
|
+
return
|
|
165
|
+
if selected == "parquet":
|
|
166
|
+
write_parquet(dataframe, path, options=options, **kwargs)
|
|
167
|
+
return
|
|
168
|
+
raise ValueError("format must be csv or parquet")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def read_s3_csv(spark: Any, bucket_or_uri: str, key: str | None = None, **kwargs: Any) -> Any:
|
|
172
|
+
return read_csv(spark, s3_uri(bucket_or_uri, key), **kwargs)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def write_s3_csv(dataframe: Any, bucket_or_uri: str, key: str | None = None, **kwargs: Any) -> None:
|
|
176
|
+
write_csv(dataframe, s3_uri(bucket_or_uri, key), **kwargs)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_s3_parquet(spark: Any, bucket_or_uri: str, key: str | None = None, **kwargs: Any) -> Any:
|
|
180
|
+
return read_parquet(spark, s3_uri(bucket_or_uri, key), **kwargs)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def write_s3_parquet(dataframe: Any, bucket_or_uri: str, key: str | None = None, **kwargs: Any) -> None:
|
|
184
|
+
write_parquet(dataframe, s3_uri(bucket_or_uri, key), **kwargs)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def read_delimited_rows(
|
|
188
|
+
path: str | Path,
|
|
189
|
+
*,
|
|
190
|
+
delimiter: str = ",",
|
|
191
|
+
encoding: str = "utf-8",
|
|
192
|
+
header: bool = True,
|
|
193
|
+
fieldnames: list[str] | None = None,
|
|
194
|
+
) -> Iterator[dict[str, str]]:
|
|
195
|
+
"""Stream a local delimited file for control data and unit tests."""
|
|
196
|
+
with Path(path).open(encoding=encoding, newline="") as stream:
|
|
197
|
+
if header:
|
|
198
|
+
yield from csv.DictReader(stream, delimiter=delimiter)
|
|
199
|
+
else:
|
|
200
|
+
if not fieldnames:
|
|
201
|
+
raise ValueError("fieldnames are required when header is false")
|
|
202
|
+
yield from csv.DictReader(stream, delimiter=delimiter, fieldnames=fieldnames)
|
mainframe_toolkit/jcl.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DDStatement:
|
|
11
|
+
name: str
|
|
12
|
+
raw: str
|
|
13
|
+
datasets: list[str] = field(default_factory=list)
|
|
14
|
+
disposition: str | None = None
|
|
15
|
+
dummy: bool = False
|
|
16
|
+
inline: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class JCLStep:
|
|
21
|
+
order: int
|
|
22
|
+
name: str
|
|
23
|
+
program: str | None
|
|
24
|
+
procedure: str | None
|
|
25
|
+
condition: str | None
|
|
26
|
+
parameters: str | None
|
|
27
|
+
raw: str
|
|
28
|
+
dd: list[DDStatement] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return asdict(self)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class JCLJob:
|
|
36
|
+
name: str | None
|
|
37
|
+
steps: list[JCLStep]
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {"name": self.name, "steps": [step.to_dict() for step in self.steps]}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_jcl(path_or_text: str | Path, *, from_string: bool = False) -> JCLJob:
|
|
44
|
+
if from_string:
|
|
45
|
+
text = str(path_or_text)
|
|
46
|
+
else:
|
|
47
|
+
text = Path(path_or_text).read_text(encoding="utf-8", errors="replace")
|
|
48
|
+
statements = _logical_statements(text)
|
|
49
|
+
job_name: str | None = None
|
|
50
|
+
steps: list[JCLStep] = []
|
|
51
|
+
current: JCLStep | None = None
|
|
52
|
+
for name, operation, body in statements:
|
|
53
|
+
upper_operation = operation.upper()
|
|
54
|
+
if upper_operation == "JOB":
|
|
55
|
+
job_name = name.upper() if name else None
|
|
56
|
+
continue
|
|
57
|
+
if upper_operation == "EXEC":
|
|
58
|
+
program_match = re.search(r"(?:^|,)\s*PGM\s*=\s*([A-Z0-9@$#_-]+)", body, flags=re.IGNORECASE)
|
|
59
|
+
proc_match = re.search(r"(?:^|,)\s*PROC\s*=\s*([A-Z0-9@$#_-]+)", body, flags=re.IGNORECASE)
|
|
60
|
+
if not program_match and not proc_match:
|
|
61
|
+
proc_match = re.match(r"\s*([A-Z0-9@$#_-]+)", body, flags=re.IGNORECASE)
|
|
62
|
+
current = JCLStep(
|
|
63
|
+
order=len(steps) + 1,
|
|
64
|
+
name=(name or f"STEP{len(steps) + 1:03d}").upper(),
|
|
65
|
+
program=program_match.group(1).upper() if program_match else None,
|
|
66
|
+
procedure=proc_match.group(1).upper() if proc_match else None,
|
|
67
|
+
condition=_parameter(body, "COND"),
|
|
68
|
+
parameters=_parameter(body, "PARM"),
|
|
69
|
+
raw=body.strip(),
|
|
70
|
+
)
|
|
71
|
+
steps.append(current)
|
|
72
|
+
continue
|
|
73
|
+
if upper_operation == "DD" and current is not None:
|
|
74
|
+
dd_name = name.upper() if name else (current.dd[-1].name if current.dd else "UNNAMED")
|
|
75
|
+
statement = DDStatement(
|
|
76
|
+
name=dd_name,
|
|
77
|
+
raw=body.strip(),
|
|
78
|
+
datasets=[match.rstrip(",") for match in re.findall(r"\bDSN(?:AME)?\s*=\s*([^,\s]+)", body, flags=re.IGNORECASE)],
|
|
79
|
+
disposition=_parameter(body, "DISP"),
|
|
80
|
+
dummy=bool(re.search(r"\bDUMMY\b", body, flags=re.IGNORECASE)),
|
|
81
|
+
inline=bool(re.match(r"\s*(?:\*|DATA\b)", body, flags=re.IGNORECASE)),
|
|
82
|
+
)
|
|
83
|
+
current.dd.append(statement)
|
|
84
|
+
return JCLJob(job_name, steps)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _logical_statements(text: str) -> list[tuple[str, str, str]]:
|
|
88
|
+
result: list[tuple[str, str, str]] = []
|
|
89
|
+
active: tuple[str, str, str] | None = None
|
|
90
|
+
in_inline = False
|
|
91
|
+
for raw_line in text.splitlines():
|
|
92
|
+
if in_inline:
|
|
93
|
+
if raw_line.strip() == "/*":
|
|
94
|
+
in_inline = False
|
|
95
|
+
continue
|
|
96
|
+
if not raw_line.startswith("//"):
|
|
97
|
+
continue
|
|
98
|
+
in_inline = False
|
|
99
|
+
if raw_line.startswith("//*") or raw_line.strip() in {"", "//"}:
|
|
100
|
+
continue
|
|
101
|
+
if not raw_line.startswith("//"):
|
|
102
|
+
continue
|
|
103
|
+
content = raw_line[2:]
|
|
104
|
+
match = re.match(r"([^\s]*)\s+([A-Za-z0-9]+)\s*(.*)$", content)
|
|
105
|
+
if match and match.group(2).upper() in {"JOB", "EXEC", "DD", "PROC", "PEND"}:
|
|
106
|
+
if active is not None:
|
|
107
|
+
result.append(active)
|
|
108
|
+
active = (match.group(1), match.group(2), match.group(3))
|
|
109
|
+
elif active is not None:
|
|
110
|
+
active = (active[0], active[1], f"{active[2]} {content.strip()}")
|
|
111
|
+
if active is not None and active[1].upper() == "DD" and re.match(r"\s*(?:\*|DATA\b)", active[2], flags=re.IGNORECASE):
|
|
112
|
+
result.append(active)
|
|
113
|
+
active = None
|
|
114
|
+
in_inline = True
|
|
115
|
+
if active is not None:
|
|
116
|
+
result.append(active)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parameter(body: str, name: str) -> str | None:
|
|
121
|
+
match = re.search(
|
|
122
|
+
rf"(?:^|,)\s*{re.escape(name)}\s*=\s*(\([^)]*\)|'[^']*'|\"[^\"]*\"|[^,\s]+)",
|
|
123
|
+
body,
|
|
124
|
+
flags=re.IGNORECASE,
|
|
125
|
+
)
|
|
126
|
+
return match.group(1).strip() if match else None
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Iterable, Mapping
|
|
12
|
+
|
|
13
|
+
from .io import write_dataset
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PipelineRunError(RuntimeError):
|
|
17
|
+
def __init__(self, program: str, cause: BaseException):
|
|
18
|
+
super().__init__(f"program {program} failed: {cause}")
|
|
19
|
+
self.program = program
|
|
20
|
+
self.cause = cause
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProgramReturnCodeError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DatasetRegistry:
|
|
28
|
+
"""In-memory handoff between converted programs."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, initial: Mapping[str, Any] | None = None):
|
|
31
|
+
self._datasets: dict[str, Any] = dict(initial or {})
|
|
32
|
+
self._versions: dict[str, int] = {name: 1 for name in self._datasets}
|
|
33
|
+
|
|
34
|
+
def get(self, name: str) -> Any:
|
|
35
|
+
try:
|
|
36
|
+
return self._datasets[name]
|
|
37
|
+
except KeyError as exc:
|
|
38
|
+
available = ", ".join(sorted(self._datasets)) or "<none>"
|
|
39
|
+
raise KeyError(f"dataset {name!r} is unavailable; available: {available}") from exc
|
|
40
|
+
|
|
41
|
+
def put(self, name: str, value: Any) -> int:
|
|
42
|
+
self._datasets[name] = value
|
|
43
|
+
self._versions[name] = self._versions.get(name, 0) + 1
|
|
44
|
+
return self._versions[name]
|
|
45
|
+
|
|
46
|
+
def contains(self, name: str) -> bool:
|
|
47
|
+
return name in self._datasets
|
|
48
|
+
|
|
49
|
+
def snapshot(self) -> dict[str, Any]:
|
|
50
|
+
return dict(self._datasets)
|
|
51
|
+
|
|
52
|
+
def version(self, name: str) -> int:
|
|
53
|
+
return self._versions.get(name, 0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class PipelineContext:
|
|
58
|
+
spark: Any
|
|
59
|
+
job_name: str
|
|
60
|
+
audit_dir: str | Path
|
|
61
|
+
datasets: DatasetRegistry = field(default_factory=DatasetRegistry)
|
|
62
|
+
run_id: str = field(default_factory=lambda: _default_run_id())
|
|
63
|
+
audit_format: str = "parquet"
|
|
64
|
+
audit_options: Mapping[str, Any] = field(default_factory=dict)
|
|
65
|
+
persist_intermediates: bool = True
|
|
66
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
_program: str | None = field(default=None, init=False, repr=False)
|
|
68
|
+
_step: int = field(default=0, init=False, repr=False)
|
|
69
|
+
_published: list[dict[str, Any]] = field(default_factory=list, init=False, repr=False)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def run_dir(self) -> Path:
|
|
73
|
+
return Path(self.audit_dir) / _safe_name(self.job_name) / _safe_name(self.run_id)
|
|
74
|
+
|
|
75
|
+
def input(self, name: str) -> Any:
|
|
76
|
+
return self.datasets.get(name)
|
|
77
|
+
|
|
78
|
+
def publish(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
value: Any,
|
|
82
|
+
*,
|
|
83
|
+
audit: bool = True,
|
|
84
|
+
format: str | None = None,
|
|
85
|
+
options: Mapping[str, Any] | None = None,
|
|
86
|
+
) -> Any:
|
|
87
|
+
if self._program is None:
|
|
88
|
+
raise RuntimeError("publish must be called while a program is executing")
|
|
89
|
+
if self.persist_intermediates and _is_spark_dataframe(value):
|
|
90
|
+
value = value.persist()
|
|
91
|
+
version = self.datasets.put(name, value)
|
|
92
|
+
item: dict[str, Any] = {
|
|
93
|
+
"dataset": name,
|
|
94
|
+
"version": version,
|
|
95
|
+
"program": self._program,
|
|
96
|
+
"audited": audit,
|
|
97
|
+
}
|
|
98
|
+
if audit:
|
|
99
|
+
item["audit_path"] = self._write_audit(
|
|
100
|
+
name,
|
|
101
|
+
value,
|
|
102
|
+
format=format or self.audit_format,
|
|
103
|
+
options=options,
|
|
104
|
+
)
|
|
105
|
+
self._published.append(item)
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
def _write_audit(
|
|
109
|
+
self,
|
|
110
|
+
name: str,
|
|
111
|
+
value: Any,
|
|
112
|
+
*,
|
|
113
|
+
format: str,
|
|
114
|
+
options: Mapping[str, Any] | None,
|
|
115
|
+
) -> str:
|
|
116
|
+
selected = format.lower()
|
|
117
|
+
base = self.run_dir / f"{self._step:03d}_{_safe_name(self._program or 'program')}" / _safe_name(name)
|
|
118
|
+
if _is_spark_dataframe(value):
|
|
119
|
+
write_dataset(
|
|
120
|
+
value,
|
|
121
|
+
base,
|
|
122
|
+
format=selected,
|
|
123
|
+
mode="overwrite",
|
|
124
|
+
options={**self.audit_options, **dict(options or {})},
|
|
125
|
+
)
|
|
126
|
+
return str(base)
|
|
127
|
+
|
|
128
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
output = base / "part-00000.jsonl"
|
|
130
|
+
rows: Iterable[Any]
|
|
131
|
+
if isinstance(value, Mapping):
|
|
132
|
+
rows = [value]
|
|
133
|
+
elif isinstance(value, (str, bytes)):
|
|
134
|
+
rows = [{"value": value.decode() if isinstance(value, bytes) else value}]
|
|
135
|
+
else:
|
|
136
|
+
try:
|
|
137
|
+
rows = iter(value)
|
|
138
|
+
except TypeError:
|
|
139
|
+
rows = [{"value": value}]
|
|
140
|
+
with output.open("w", encoding="utf-8") as stream:
|
|
141
|
+
for row in rows:
|
|
142
|
+
stream.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
|
|
143
|
+
return str(output)
|
|
144
|
+
|
|
145
|
+
def _begin_program(self, name: str, step: int) -> int:
|
|
146
|
+
self._program = name
|
|
147
|
+
self._step = step
|
|
148
|
+
before = len(self._published)
|
|
149
|
+
return before
|
|
150
|
+
|
|
151
|
+
def _finish_program(self) -> None:
|
|
152
|
+
self._program = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class ProgramResult:
|
|
157
|
+
outputs: Mapping[str, Any] = field(default_factory=dict)
|
|
158
|
+
return_code: int = 0
|
|
159
|
+
messages: tuple[str, ...] = field(default_factory=tuple)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
ProgramFunction = Callable[[PipelineContext], Mapping[str, Any] | ProgramResult | None]
|
|
163
|
+
ProgramCondition = Callable[[PipelineContext, Mapping[str, int]], bool]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class PipelineProgram:
|
|
168
|
+
name: str
|
|
169
|
+
function: ProgramFunction
|
|
170
|
+
audit_outputs: bool = True
|
|
171
|
+
audit_format: str | None = None
|
|
172
|
+
allowed_return_codes: tuple[int, ...] = (0, 4)
|
|
173
|
+
condition: ProgramCondition | None = None
|
|
174
|
+
|
|
175
|
+
def execute(self, context: PipelineContext) -> Mapping[str, Any] | ProgramResult | None:
|
|
176
|
+
return self.function(context)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class PipelineRunner:
|
|
180
|
+
def __init__(self, context: PipelineContext, programs: Iterable[PipelineProgram]):
|
|
181
|
+
self.context = context
|
|
182
|
+
self.programs = list(programs)
|
|
183
|
+
names = [program.name for program in self.programs]
|
|
184
|
+
if len(names) != len(set(names)):
|
|
185
|
+
raise ValueError("program names must be unique")
|
|
186
|
+
|
|
187
|
+
def run(self, *, through: str | None = None) -> dict[str, Any]:
|
|
188
|
+
selected = self._selected_programs(through)
|
|
189
|
+
manifest: dict[str, Any] = {
|
|
190
|
+
"job": self.context.job_name,
|
|
191
|
+
"run_id": self.context.run_id,
|
|
192
|
+
"started_at": _now(),
|
|
193
|
+
"through": through,
|
|
194
|
+
"status": "running",
|
|
195
|
+
"programs": [],
|
|
196
|
+
"metadata": self.context.metadata,
|
|
197
|
+
}
|
|
198
|
+
self._write_manifest(manifest)
|
|
199
|
+
return_codes: dict[str, int] = {}
|
|
200
|
+
for index, program in enumerate(selected, 1):
|
|
201
|
+
try:
|
|
202
|
+
should_run = program.condition is None or program.condition(self.context, return_codes)
|
|
203
|
+
except BaseException as exc:
|
|
204
|
+
record = {
|
|
205
|
+
"step": index,
|
|
206
|
+
"program": program.name,
|
|
207
|
+
"status": "failed",
|
|
208
|
+
"phase": "condition",
|
|
209
|
+
"error_type": type(exc).__name__,
|
|
210
|
+
"error": str(exc),
|
|
211
|
+
"traceback": "".join(traceback.format_exception(exc)),
|
|
212
|
+
}
|
|
213
|
+
manifest["programs"].append(record)
|
|
214
|
+
manifest.update(status="failed", failed_program=program.name, finished_at=_now())
|
|
215
|
+
self._write_manifest(manifest)
|
|
216
|
+
raise PipelineRunError(program.name, exc) from exc
|
|
217
|
+
if not should_run:
|
|
218
|
+
manifest["programs"].append(
|
|
219
|
+
{
|
|
220
|
+
"step": index,
|
|
221
|
+
"program": program.name,
|
|
222
|
+
"status": "skipped",
|
|
223
|
+
"reason": "JCL condition evaluated to false",
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
self._write_manifest(manifest)
|
|
227
|
+
continue
|
|
228
|
+
started = time.perf_counter()
|
|
229
|
+
published_before = self.context._begin_program(program.name, index)
|
|
230
|
+
record: dict[str, Any] = {
|
|
231
|
+
"step": index,
|
|
232
|
+
"program": program.name,
|
|
233
|
+
"status": "running",
|
|
234
|
+
"started_at": _now(),
|
|
235
|
+
}
|
|
236
|
+
manifest["programs"].append(record)
|
|
237
|
+
self._write_manifest(manifest)
|
|
238
|
+
try:
|
|
239
|
+
execution_result = program.execute(self.context)
|
|
240
|
+
if isinstance(execution_result, ProgramResult):
|
|
241
|
+
return_code = execution_result.return_code
|
|
242
|
+
outputs = execution_result.outputs
|
|
243
|
+
record["messages"] = list(execution_result.messages)
|
|
244
|
+
else:
|
|
245
|
+
return_code = 0
|
|
246
|
+
outputs = execution_result
|
|
247
|
+
record["return_code"] = return_code
|
|
248
|
+
return_codes[program.name] = return_code
|
|
249
|
+
if return_code not in program.allowed_return_codes:
|
|
250
|
+
raise ProgramReturnCodeError(
|
|
251
|
+
f"return code {return_code} is not allowed; expected one of {program.allowed_return_codes}"
|
|
252
|
+
)
|
|
253
|
+
if outputs is not None:
|
|
254
|
+
if not isinstance(outputs, Mapping):
|
|
255
|
+
raise TypeError("program must return a dataset mapping or None")
|
|
256
|
+
already = {
|
|
257
|
+
item["dataset"]
|
|
258
|
+
for item in self.context._published[published_before:]
|
|
259
|
+
}
|
|
260
|
+
for name, value in outputs.items():
|
|
261
|
+
if name not in already:
|
|
262
|
+
self.context.publish(
|
|
263
|
+
name,
|
|
264
|
+
value,
|
|
265
|
+
audit=program.audit_outputs,
|
|
266
|
+
format=program.audit_format,
|
|
267
|
+
)
|
|
268
|
+
record["status"] = "success"
|
|
269
|
+
record["outputs"] = self.context._published[published_before:]
|
|
270
|
+
except BaseException as exc:
|
|
271
|
+
record.update(
|
|
272
|
+
status="failed",
|
|
273
|
+
error_type=type(exc).__name__,
|
|
274
|
+
error=str(exc),
|
|
275
|
+
traceback="".join(traceback.format_exception(exc)),
|
|
276
|
+
)
|
|
277
|
+
manifest.update(status="failed", failed_program=program.name, finished_at=_now())
|
|
278
|
+
record["duration_ms"] = round((time.perf_counter() - started) * 1000, 3)
|
|
279
|
+
self.context._finish_program()
|
|
280
|
+
self._write_manifest(manifest)
|
|
281
|
+
raise PipelineRunError(program.name, exc) from exc
|
|
282
|
+
record["duration_ms"] = round((time.perf_counter() - started) * 1000, 3)
|
|
283
|
+
record["finished_at"] = _now()
|
|
284
|
+
self.context._finish_program()
|
|
285
|
+
self._write_manifest(manifest)
|
|
286
|
+
manifest.update(status="success", finished_at=_now())
|
|
287
|
+
self._write_manifest(manifest)
|
|
288
|
+
return manifest
|
|
289
|
+
|
|
290
|
+
def _selected_programs(self, through: str | None) -> list[PipelineProgram]:
|
|
291
|
+
if through is None:
|
|
292
|
+
return self.programs
|
|
293
|
+
for index, program in enumerate(self.programs):
|
|
294
|
+
if program.name.casefold() == through.casefold():
|
|
295
|
+
return self.programs[: index + 1]
|
|
296
|
+
raise ValueError(f"unknown checkpoint program {through!r}")
|
|
297
|
+
|
|
298
|
+
def _write_manifest(self, manifest: Mapping[str, Any]) -> None:
|
|
299
|
+
path = self.context.run_dir / "run-manifest.json"
|
|
300
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
temporary = path.with_suffix(".tmp")
|
|
302
|
+
with temporary.open("w", encoding="utf-8") as stream:
|
|
303
|
+
json.dump(manifest, stream, ensure_ascii=False, indent=2, default=str)
|
|
304
|
+
stream.write("\n")
|
|
305
|
+
temporary.replace(path)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _is_spark_dataframe(value: Any) -> bool:
|
|
309
|
+
return hasattr(value, "write") and hasattr(value, "schema") and hasattr(value, "sparkSession")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _safe_name(value: str) -> str:
|
|
313
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._") or "unnamed"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _now() -> str:
|
|
317
|
+
return datetime.now(timezone.utc).isoformat()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _default_run_id() -> str:
|
|
321
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
322
|
+
return f"{stamp}-{uuid.uuid4().hex[:8]}"
|