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.
Files changed (52) hide show
  1. mainframe_migration_toolkit-0.2.0.dist-info/METADATA +16 -0
  2. mainframe_migration_toolkit-0.2.0.dist-info/RECORD +52 -0
  3. mainframe_migration_toolkit-0.2.0.dist-info/WHEEL +4 -0
  4. mainframe_migration_toolkit-0.2.0.dist-info/entry_points.txt +3 -0
  5. mainframe_toolkit/__init__.py +92 -0
  6. mainframe_toolkit/__main__.py +5 -0
  7. mainframe_toolkit/_workspace/.claude/skills/analyze-mainframe-similarity/SKILL.md +30 -0
  8. mainframe_toolkit/_workspace/.claude/skills/migrate-mainframe-job/SKILL.md +63 -0
  9. mainframe_toolkit/_workspace/.claude/skills/validate-golden-dataset/SKILL.md +12 -0
  10. mainframe_toolkit/_workspace/AGENTS.md +10 -0
  11. mainframe_toolkit/_workspace/CLAUDE.md +2 -0
  12. mainframe_toolkit/_workspace/validator-java/.mvn/wrapper/maven-wrapper.properties +3 -0
  13. mainframe_toolkit/_workspace/validator-java/mvnw +295 -0
  14. mainframe_toolkit/_workspace/validator-java/mvnw.cmd +189 -0
  15. mainframe_toolkit/_workspace/validator-java/pom.xml +116 -0
  16. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/AvroValueFormatter.java +152 -0
  17. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/CsvTabularReader.java +111 -0
  18. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/DataFormat.java +62 -0
  19. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/Difference.java +34 -0
  20. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/InputFileSet.java +45 -0
  21. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/InputOptions.java +23 -0
  22. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/JsonReportWriter.java +40 -0
  23. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/MultiFileTabularReader.java +80 -0
  24. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/Normalization.java +16 -0
  25. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ParquetTabularReader.java +61 -0
  26. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/TabularReader.java +13 -0
  27. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/TabularReaderFactory.java +29 -0
  28. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValidationOptions.java +40 -0
  29. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValidationReport.java +44 -0
  30. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValidationService.java +395 -0
  31. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValidatorCli.java +190 -0
  32. mainframe_toolkit/_workspace/validator-java/src/main/java/io/mainframe/migration/validator/ValueNormalizer.java +27 -0
  33. mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/DirectoryValidationTest.java +70 -0
  34. mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/JsonReportWriterTest.java +43 -0
  35. mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/KeyedValidationTest.java +89 -0
  36. mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/ParquetValidationTest.java +146 -0
  37. mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/ValidationServiceTest.java +137 -0
  38. mainframe_toolkit/_workspace/validator-java/src/test/java/io/mainframe/migration/validator/ValidatorCliTest.java +102 -0
  39. mainframe_toolkit/cli.py +361 -0
  40. mainframe_toolkit/cobol.py +106 -0
  41. mainframe_toolkit/copybook.py +558 -0
  42. mainframe_toolkit/errors.py +15 -0
  43. mainframe_toolkit/external.py +48 -0
  44. mainframe_toolkit/io.py +202 -0
  45. mainframe_toolkit/jcl.py +126 -0
  46. mainframe_toolkit/pipeline.py +322 -0
  47. mainframe_toolkit/sequential.py +259 -0
  48. mainframe_toolkit/similarity.py +1171 -0
  49. mainframe_toolkit/sorting.py +60 -0
  50. mainframe_toolkit/specs.py +312 -0
  51. mainframe_toolkit/synthetic.py +108 -0
  52. mainframe_toolkit/workspace.py +133 -0
@@ -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)
@@ -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]}"