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,259 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from decimal import Decimal
7
+ from pathlib import Path
8
+ from typing import Any, Iterable, Iterator, Mapping, Sequence
9
+
10
+ from .cobol import cobol_decimal, decode_overpunch, decode_packed_decimal
11
+ from .errors import DataFormatError, OptionalDependencyError
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class FieldSpec:
16
+ name: str
17
+ start: int
18
+ length: int
19
+ kind: str = "text"
20
+ scale: int = 0
21
+ encoding: str | None = None
22
+ strip: bool = True
23
+ signed: bool = True
24
+ byteorder: str = "big"
25
+
26
+ @classmethod
27
+ def from_dict(cls, value: Mapping[str, Any]) -> "FieldSpec":
28
+ return cls(**dict(value))
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class RecordLayout:
33
+ fields: tuple[FieldSpec, ...] = field(default_factory=tuple)
34
+ record_length: int | None = None
35
+ encoding: str = "utf-8"
36
+
37
+ def __post_init__(self) -> None:
38
+ names = [item.name for item in self.fields]
39
+ if len(names) != len(set(names)):
40
+ raise ValueError("layout field names must be unique")
41
+ for item in self.fields:
42
+ if item.start < 1 or item.length < 1:
43
+ raise ValueError(f"invalid field bounds for {item.name}")
44
+ required = max((item.start - 1 + item.length for item in self.fields), default=0)
45
+ if self.record_length is not None and self.record_length < required:
46
+ raise ValueError("record_length is shorter than the final field")
47
+
48
+ @classmethod
49
+ def from_dict(cls, value: Mapping[str, Any]) -> "RecordLayout":
50
+ return cls(
51
+ fields=tuple(FieldSpec.from_dict(item) for item in value.get("fields", [])),
52
+ record_length=value.get("record_length"),
53
+ encoding=value.get("encoding", "utf-8"),
54
+ )
55
+
56
+ @classmethod
57
+ def from_json(cls, path: str | Path) -> "RecordLayout":
58
+ with Path(path).open(encoding="utf-8") as stream:
59
+ return cls.from_dict(json.load(stream))
60
+
61
+ def parse(self, record: bytes) -> dict[str, Any]:
62
+ if self.record_length is not None and len(record) != self.record_length:
63
+ raise DataFormatError(
64
+ f"expected record length {self.record_length}, received {len(record)}"
65
+ )
66
+ result: dict[str, Any] = {}
67
+ for spec in self.fields:
68
+ raw = record[spec.start - 1 : spec.start - 1 + spec.length]
69
+ if len(raw) != spec.length:
70
+ raise DataFormatError(f"record is too short for field {spec.name}")
71
+ result[spec.name] = _decode_field(raw, spec, self.encoding)
72
+ return result
73
+
74
+
75
+ def _decode_field(raw: bytes, spec: FieldSpec, default_encoding: str) -> Any:
76
+ kind = spec.kind.lower().replace("-", "_")
77
+ if kind in {"packed", "packed_decimal", "comp_3", "comp3"}:
78
+ return decode_packed_decimal(raw, spec.scale)
79
+ if kind in {"binary", "binary_int", "comp", "comp_5", "comp5"}:
80
+ return int.from_bytes(raw, byteorder=spec.byteorder, signed=spec.signed)
81
+
82
+ try:
83
+ text = raw.decode(spec.encoding or default_encoding)
84
+ except UnicodeDecodeError as exc:
85
+ raise DataFormatError(f"cannot decode field {spec.name}") from exc
86
+ value = text.strip() if spec.strip else text
87
+ if kind in {"text", "string", "alphanumeric", "x"}:
88
+ return value
89
+ if kind in {"integer", "int", "display_integer"}:
90
+ return int(value or "0")
91
+ if kind in {"decimal", "display_decimal", "numeric"}:
92
+ return cobol_decimal(value or "0", spec.scale)
93
+ if kind in {"zoned", "zoned_decimal", "overpunch"}:
94
+ return decode_overpunch(value or "0", spec.scale)
95
+ if kind == "bytes":
96
+ return raw
97
+ raise DataFormatError(f"unsupported field kind {spec.kind!r}")
98
+
99
+
100
+ def _source_files(source: str | Path) -> list[Path]:
101
+ path = Path(source)
102
+ if path.is_file():
103
+ return [path]
104
+ if path.is_dir():
105
+ return sorted(item for item in path.rglob("*") if item.is_file() and not item.name.startswith("."))
106
+ raise FileNotFoundError(path)
107
+
108
+
109
+ def _records_from_file(
110
+ path: Path,
111
+ *,
112
+ mode: str,
113
+ record_length: int | None,
114
+ allow_short: bool,
115
+ ) -> Iterator[tuple[int, bytes]]:
116
+ with path.open("rb") as stream:
117
+ if mode == "line":
118
+ for number, raw in enumerate(stream, 1):
119
+ yield number, raw.rstrip(b"\r\n")
120
+ return
121
+ if mode != "fixed":
122
+ raise ValueError("mode must be 'line' or 'fixed'")
123
+ if not record_length:
124
+ raise ValueError("record_length is required in fixed mode")
125
+ number = 0
126
+ while True:
127
+ raw = stream.read(record_length)
128
+ if not raw:
129
+ break
130
+ number += 1
131
+ if len(raw) != record_length and not allow_short:
132
+ raise DataFormatError(
133
+ f"{path}: trailing record has {len(raw)} of {record_length} bytes"
134
+ )
135
+ yield number, raw
136
+
137
+
138
+ def iter_sequential(
139
+ source: str | Path,
140
+ layout: RecordLayout,
141
+ *,
142
+ mode: str = "line",
143
+ on_error: str = "raise",
144
+ allow_short: bool = False,
145
+ ) -> Iterator[dict[str, Any]]:
146
+ """Stream line-sequential or fixed-block records as dictionaries."""
147
+ if on_error not in {"raise", "skip", "null"}:
148
+ raise ValueError("on_error must be raise, skip, or null")
149
+ for path in _source_files(source):
150
+ for number, raw in _records_from_file(
151
+ path,
152
+ mode=mode,
153
+ record_length=layout.record_length,
154
+ allow_short=allow_short,
155
+ ):
156
+ try:
157
+ yield layout.parse(raw)
158
+ except (DataFormatError, ValueError) as exc:
159
+ if on_error == "raise":
160
+ raise DataFormatError(f"{path}:{number}: {exc}") from exc
161
+ if on_error == "null":
162
+ yield {item.name: None for item in layout.fields}
163
+
164
+
165
+ def _json_safe(value: Any) -> Any:
166
+ if isinstance(value, Decimal):
167
+ return format(value, "f")
168
+ if isinstance(value, bytes):
169
+ return value.hex()
170
+ return value
171
+
172
+
173
+ def _destination_file(destination: str | Path, extension: str) -> Path:
174
+ path = Path(destination)
175
+ if path.suffix.lower() == extension:
176
+ path.parent.mkdir(parents=True, exist_ok=True)
177
+ return path
178
+ path.mkdir(parents=True, exist_ok=True)
179
+ return path / f"part-00000{extension}"
180
+
181
+
182
+ def _write_csv(
183
+ rows: Iterable[Mapping[str, Any]],
184
+ fields: Sequence[str],
185
+ destination: Path,
186
+ delimiter: str,
187
+ ) -> int:
188
+ count = 0
189
+ with destination.open("w", encoding="utf-8", newline="") as stream:
190
+ writer = csv.DictWriter(stream, fieldnames=fields, delimiter=delimiter)
191
+ writer.writeheader()
192
+ for row in rows:
193
+ writer.writerow({name: _json_safe(row.get(name)) for name in fields})
194
+ count += 1
195
+ return count
196
+
197
+
198
+ def _write_parquet(
199
+ rows: Iterable[Mapping[str, Any]],
200
+ fields: Sequence[str],
201
+ destination: Path,
202
+ batch_size: int,
203
+ ) -> int:
204
+ try:
205
+ import pyarrow as arrow
206
+ import pyarrow.parquet as parquet
207
+ except ImportError as exc:
208
+ raise OptionalDependencyError("install mainframe-migration-toolkit[parquet]") from exc
209
+
210
+ writer = None
211
+ batch: list[dict[str, Any]] = []
212
+ count = 0
213
+ try:
214
+ for row in rows:
215
+ batch.append({name: row.get(name) for name in fields})
216
+ if len(batch) >= batch_size:
217
+ table = arrow.Table.from_pylist(batch)
218
+ writer = writer or parquet.ParquetWriter(destination, table.schema)
219
+ writer.write_table(table)
220
+ count += len(batch)
221
+ batch.clear()
222
+ if batch:
223
+ table = arrow.Table.from_pylist(batch)
224
+ writer = writer or parquet.ParquetWriter(destination, table.schema)
225
+ writer.write_table(table)
226
+ count += len(batch)
227
+ if writer is None:
228
+ schema = arrow.schema([(name, arrow.string()) for name in fields])
229
+ writer = parquet.ParquetWriter(destination, schema)
230
+ finally:
231
+ if writer is not None:
232
+ writer.close()
233
+ return count
234
+
235
+
236
+ def convert_sequential(
237
+ source: str | Path,
238
+ destination: str | Path,
239
+ layout: RecordLayout,
240
+ *,
241
+ output_format: str | None = None,
242
+ mode: str = "line",
243
+ delimiter: str = ",",
244
+ on_error: str = "raise",
245
+ batch_size: int = 10_000,
246
+ ) -> dict[str, Any]:
247
+ """Convert sequential records to one CSV or Parquet part file."""
248
+ selected = (output_format or Path(destination).suffix.lstrip(".") or "csv").lower()
249
+ extension = ".parquet" if selected == "parquet" else ".csv"
250
+ output = _destination_file(destination, extension)
251
+ rows = iter_sequential(source, layout, mode=mode, on_error=on_error)
252
+ fields = [item.name for item in layout.fields]
253
+ if selected == "csv":
254
+ count = _write_csv(rows, fields, output, delimiter)
255
+ elif selected == "parquet":
256
+ count = _write_parquet(rows, fields, output, batch_size)
257
+ else:
258
+ raise ValueError("output_format must be csv or parquet")
259
+ return {"source": str(source), "output": str(output), "format": selected, "rows": count}