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/cli.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Sequence
|
|
13
|
+
|
|
14
|
+
from .jcl import parse_jcl
|
|
15
|
+
from .sequential import RecordLayout, convert_sequential
|
|
16
|
+
from .similarity import (
|
|
17
|
+
analyze_codebase,
|
|
18
|
+
apply_ai_reviews,
|
|
19
|
+
write_ai_review_payload,
|
|
20
|
+
write_results,
|
|
21
|
+
)
|
|
22
|
+
from .specs import inspect_project, write_inspection
|
|
23
|
+
from .synthetic import synthetic_rows
|
|
24
|
+
from .workspace import init_workspace
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _json_dump(value: Any, path: str | Path | None = None) -> None:
|
|
28
|
+
content = json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n"
|
|
29
|
+
if path is None or str(path) == "-":
|
|
30
|
+
sys.stdout.write(content)
|
|
31
|
+
return
|
|
32
|
+
output = Path(path)
|
|
33
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
output.write_text(content, encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _inspect(args: argparse.Namespace) -> int:
|
|
38
|
+
result = inspect_project(args.project, strict=args.strict)
|
|
39
|
+
if args.output:
|
|
40
|
+
write_inspection(result, args.output)
|
|
41
|
+
else:
|
|
42
|
+
_json_dump(result.to_dict())
|
|
43
|
+
return 0 if result.valid else 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _init_workspace(args: argparse.Namespace) -> int:
|
|
47
|
+
result = init_workspace(args.destination, force=args.force)
|
|
48
|
+
_json_dump(result.to_dict())
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _jcl_parse(args: argparse.Namespace) -> int:
|
|
53
|
+
_json_dump(parse_jcl(args.source).to_dict(), args.output)
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _copybook_parse(args: argparse.Namespace) -> int:
|
|
58
|
+
from .copybook import layout_to_dict, parse_copybook
|
|
59
|
+
|
|
60
|
+
parsed = parse_copybook(args.source)
|
|
61
|
+
_json_dump(layout_to_dict(parsed), args.output)
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sequential_convert(args: argparse.Namespace) -> int:
|
|
66
|
+
layout = RecordLayout.from_json(args.layout)
|
|
67
|
+
result = convert_sequential(
|
|
68
|
+
args.source,
|
|
69
|
+
args.destination,
|
|
70
|
+
layout,
|
|
71
|
+
output_format=args.format,
|
|
72
|
+
mode=args.mode,
|
|
73
|
+
delimiter=args.delimiter,
|
|
74
|
+
on_error=args.on_error,
|
|
75
|
+
batch_size=args.batch_size,
|
|
76
|
+
)
|
|
77
|
+
_json_dump(result)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _similarity_scan(args: argparse.Namespace) -> int:
|
|
82
|
+
report = analyze_codebase(
|
|
83
|
+
args.root,
|
|
84
|
+
threshold=args.threshold,
|
|
85
|
+
shingle_size=args.shingle_size,
|
|
86
|
+
exhaustive_limit=args.exhaustive_limit,
|
|
87
|
+
)
|
|
88
|
+
write_results(report, args.json, args.csv)
|
|
89
|
+
write_ai_review_payload(report, args.review_request)
|
|
90
|
+
_json_dump(
|
|
91
|
+
{
|
|
92
|
+
**report.statistics,
|
|
93
|
+
"json": str(Path(args.json)),
|
|
94
|
+
"csv": str(Path(args.csv)),
|
|
95
|
+
"review_request": str(Path(args.review_request)),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _similarity_apply(args: argparse.Namespace) -> int:
|
|
102
|
+
json_output = args.json or args.report
|
|
103
|
+
csv_output = args.csv or str(Path(json_output).with_suffix(".csv"))
|
|
104
|
+
result = apply_ai_reviews(
|
|
105
|
+
args.report,
|
|
106
|
+
args.reviews,
|
|
107
|
+
json_path=json_output,
|
|
108
|
+
csv_path=csv_output,
|
|
109
|
+
)
|
|
110
|
+
records = result.records if hasattr(result, "records") else result.get("arquivos", [])
|
|
111
|
+
missing = sum(not row.get("parecer_ia") or not row.get("justificativa_ia") for row in records)
|
|
112
|
+
_json_dump({"json": json_output, "csv": csv_output, "rows": len(records), "reviews_missing": missing})
|
|
113
|
+
return 0 if missing == 0 else 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _synthetic(args: argparse.Namespace) -> int:
|
|
117
|
+
payload = json.loads(Path(args.schema).read_text(encoding="utf-8"))
|
|
118
|
+
schema = payload.get("schema", payload) if isinstance(payload, dict) else payload
|
|
119
|
+
if not isinstance(schema, dict) or not all(isinstance(value, str) for value in schema.values()):
|
|
120
|
+
raise ValueError("schema must be a JSON object mapping field names to types")
|
|
121
|
+
rows = synthetic_rows(schema, count=args.rows, seed=args.seed)
|
|
122
|
+
output = Path(args.output)
|
|
123
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
if args.format == "jsonl":
|
|
125
|
+
with output.open("w", encoding="utf-8") as stream:
|
|
126
|
+
for row in rows:
|
|
127
|
+
stream.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
|
|
128
|
+
else:
|
|
129
|
+
with output.open("w", encoding="utf-8", newline="") as stream:
|
|
130
|
+
writer = csv.DictWriter(stream, fieldnames=list(schema), delimiter=args.delimiter)
|
|
131
|
+
writer.writeheader()
|
|
132
|
+
writer.writerows(rows)
|
|
133
|
+
digest = hashlib.sha256(output.read_bytes()).hexdigest()
|
|
134
|
+
manifest = Path(args.manifest) if args.manifest else output.with_suffix(output.suffix + ".manifest.json")
|
|
135
|
+
_json_dump(
|
|
136
|
+
{
|
|
137
|
+
"synthetic": True,
|
|
138
|
+
"output": str(output),
|
|
139
|
+
"format": args.format,
|
|
140
|
+
"rows": len(rows),
|
|
141
|
+
"seed": args.seed,
|
|
142
|
+
"schema": schema,
|
|
143
|
+
"sha256": digest,
|
|
144
|
+
},
|
|
145
|
+
manifest,
|
|
146
|
+
)
|
|
147
|
+
_json_dump({"output": str(output), "manifest": str(manifest), "rows": len(rows), "seed": args.seed})
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _toolkit_root() -> Path:
|
|
152
|
+
override = os.environ.get("MAINFRAME_TOOLKIT_ROOT")
|
|
153
|
+
if override:
|
|
154
|
+
return Path(override).resolve()
|
|
155
|
+
candidate = Path(__file__).resolve().parents[2]
|
|
156
|
+
return candidate if (candidate / "validator-java" / "pom.xml").is_file() else Path.cwd()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _validator_dir(value: str | None) -> Path:
|
|
160
|
+
return Path(value).resolve() if value else _toolkit_root() / "validator-java"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _maven_executable(directory: Path) -> str:
|
|
164
|
+
wrapper = directory / ("mvnw.cmd" if os.name == "nt" else "mvnw")
|
|
165
|
+
if wrapper.is_file():
|
|
166
|
+
return str(wrapper)
|
|
167
|
+
executable = shutil.which("mvn") or shutil.which("mvn.cmd")
|
|
168
|
+
if not executable:
|
|
169
|
+
raise RuntimeError("Maven 3.9+ or the validator-java Maven wrapper is required")
|
|
170
|
+
return executable
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _golden_build(args: argparse.Namespace) -> int:
|
|
174
|
+
directory = _validator_dir(args.validator_dir)
|
|
175
|
+
pom = directory / "pom.xml"
|
|
176
|
+
if not pom.is_file():
|
|
177
|
+
raise FileNotFoundError(pom)
|
|
178
|
+
command = [_maven_executable(directory), "-q", "-f", str(pom), "package"]
|
|
179
|
+
if args.skip_tests:
|
|
180
|
+
command.insert(-1, "-DskipTests")
|
|
181
|
+
process_environment = None
|
|
182
|
+
java_home = getattr(args, "java_home", None)
|
|
183
|
+
if java_home:
|
|
184
|
+
process_environment = os.environ.copy()
|
|
185
|
+
process_environment["JAVA_HOME"] = str(Path(java_home).resolve())
|
|
186
|
+
return subprocess.run(command, check=False, env=process_environment).returncode
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_validator_jar(args: argparse.Namespace) -> Path:
|
|
190
|
+
if args.jar:
|
|
191
|
+
jar = Path(args.jar).resolve()
|
|
192
|
+
elif os.environ.get("MAINFRAME_VALIDATOR_JAR"):
|
|
193
|
+
jar = Path(os.environ["MAINFRAME_VALIDATOR_JAR"]).resolve()
|
|
194
|
+
else:
|
|
195
|
+
jar = _validator_dir(args.validator_dir) / "target" / "dataset-validator.jar"
|
|
196
|
+
if not jar.is_file() and args.build:
|
|
197
|
+
code = _golden_build(
|
|
198
|
+
argparse.Namespace(
|
|
199
|
+
validator_dir=args.validator_dir,
|
|
200
|
+
skip_tests=False,
|
|
201
|
+
java_home=(str(Path(args.java).resolve().parent.parent) if args.java else None),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
if code != 0:
|
|
205
|
+
raise RuntimeError(f"validator-java build failed with exit code {code}")
|
|
206
|
+
if not jar.is_file():
|
|
207
|
+
raise FileNotFoundError(jar)
|
|
208
|
+
return jar
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _golden_validate(args: argparse.Namespace) -> int:
|
|
212
|
+
requested_java = args.java or os.environ.get("MAINFRAME_JAVA")
|
|
213
|
+
java = str(Path(requested_java).resolve()) if requested_java else shutil.which("java")
|
|
214
|
+
if not java:
|
|
215
|
+
raise RuntimeError("Java 17+ is required to run the dataset validator")
|
|
216
|
+
command = [java, "-jar", str(_resolve_validator_jar(args)), args.expected, args.actual]
|
|
217
|
+
scalar_options = {
|
|
218
|
+
"--format": args.format,
|
|
219
|
+
"--expected-format": args.expected_format,
|
|
220
|
+
"--actual-format": args.actual_format,
|
|
221
|
+
"--delimiter": args.delimiter,
|
|
222
|
+
"--expected-delimiter": args.expected_delimiter,
|
|
223
|
+
"--actual-delimiter": args.actual_delimiter,
|
|
224
|
+
"--header": args.header,
|
|
225
|
+
"--expected-header": args.expected_header,
|
|
226
|
+
"--actual-header": args.actual_header,
|
|
227
|
+
"--charset": args.charset,
|
|
228
|
+
"--max-differences": args.max_differences,
|
|
229
|
+
"--report": args.report,
|
|
230
|
+
}
|
|
231
|
+
for option, value in scalar_options.items():
|
|
232
|
+
if value is not None:
|
|
233
|
+
command.extend((option, str(value)))
|
|
234
|
+
if args.no_header:
|
|
235
|
+
command.append("--no-header")
|
|
236
|
+
if args.trim:
|
|
237
|
+
command.append("--trim")
|
|
238
|
+
if args.normalize_decimal:
|
|
239
|
+
command.append("--normalize-decimal")
|
|
240
|
+
for value in args.normalize:
|
|
241
|
+
command.extend(("--normalize", value))
|
|
242
|
+
for value in args.key:
|
|
243
|
+
command.extend(("--key", value))
|
|
244
|
+
return subprocess.run(command, check=False).returncode
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parser() -> argparse.ArgumentParser:
|
|
248
|
+
parser = argparse.ArgumentParser(prog="mainframe-toolkit")
|
|
249
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
250
|
+
|
|
251
|
+
initialize = subcommands.add_parser("init-workspace", help="materialize a migration workspace")
|
|
252
|
+
initialize.add_argument("destination", nargs="?", default=".")
|
|
253
|
+
initialize.add_argument(
|
|
254
|
+
"--force",
|
|
255
|
+
action="store_true",
|
|
256
|
+
help="update conflicting toolkit-managed files without replacing project inputs",
|
|
257
|
+
)
|
|
258
|
+
initialize.set_defaults(handler=_init_workspace)
|
|
259
|
+
|
|
260
|
+
inspect = subcommands.add_parser("inspect", help="validate the migration project contract")
|
|
261
|
+
inspect.add_argument("project", nargs="?", default=".")
|
|
262
|
+
inspect.add_argument("--output", "-o")
|
|
263
|
+
inspect.add_argument("--strict", action="store_true")
|
|
264
|
+
inspect.set_defaults(handler=_inspect)
|
|
265
|
+
|
|
266
|
+
jcl = subcommands.add_parser("jcl", help="JCL source tools").add_subparsers(dest="jcl_command", required=True)
|
|
267
|
+
jcl_parse = jcl.add_parser("parse")
|
|
268
|
+
jcl_parse.add_argument("source")
|
|
269
|
+
jcl_parse.add_argument("--output", "-o")
|
|
270
|
+
jcl_parse.set_defaults(handler=_jcl_parse)
|
|
271
|
+
|
|
272
|
+
copybook = subcommands.add_parser("copybook", help="copybook layout tools").add_subparsers(dest="copybook_command", required=True)
|
|
273
|
+
copybook_parse = copybook.add_parser("parse")
|
|
274
|
+
copybook_parse.add_argument("source")
|
|
275
|
+
copybook_parse.add_argument("--output", "-o")
|
|
276
|
+
copybook_parse.set_defaults(handler=_copybook_parse)
|
|
277
|
+
|
|
278
|
+
sequential = subcommands.add_parser("sequential", help="sequential dataset tools").add_subparsers(dest="sequential_command", required=True)
|
|
279
|
+
convert = sequential.add_parser("convert")
|
|
280
|
+
convert.add_argument("source")
|
|
281
|
+
convert.add_argument("destination")
|
|
282
|
+
convert.add_argument("--layout", required=True)
|
|
283
|
+
convert.add_argument("--format", choices=("csv", "parquet"))
|
|
284
|
+
convert.add_argument("--mode", choices=("line", "fixed"), default="line")
|
|
285
|
+
convert.add_argument("--delimiter", default=",")
|
|
286
|
+
convert.add_argument("--on-error", choices=("raise", "skip", "null"), default="raise")
|
|
287
|
+
convert.add_argument("--batch-size", type=int, default=10_000)
|
|
288
|
+
convert.set_defaults(handler=_sequential_convert)
|
|
289
|
+
|
|
290
|
+
similarity = subcommands.add_parser("similarity", help="deterministic source clustering").add_subparsers(dest="similarity_command", required=True)
|
|
291
|
+
scan = similarity.add_parser("scan")
|
|
292
|
+
scan.add_argument("root")
|
|
293
|
+
scan.add_argument("--threshold", type=float, default=80.0)
|
|
294
|
+
scan.add_argument("--shingle-size", type=int, default=3)
|
|
295
|
+
scan.add_argument("--exhaustive-limit", type=int, default=80)
|
|
296
|
+
scan.add_argument("--json", default="similarity.json")
|
|
297
|
+
scan.add_argument("--csv", default="similarity.csv")
|
|
298
|
+
scan.add_argument("--review-request", default="similarity-review-request.json")
|
|
299
|
+
scan.set_defaults(handler=_similarity_scan)
|
|
300
|
+
review = similarity.add_parser("apply-review")
|
|
301
|
+
review.add_argument("report")
|
|
302
|
+
review.add_argument("reviews")
|
|
303
|
+
review.add_argument("--json")
|
|
304
|
+
review.add_argument("--csv")
|
|
305
|
+
review.set_defaults(handler=_similarity_apply)
|
|
306
|
+
|
|
307
|
+
synthetic = subcommands.add_parser("synthetic", help="create deterministic fallback rows")
|
|
308
|
+
synthetic.add_argument("--schema", required=True)
|
|
309
|
+
synthetic.add_argument("--output", required=True)
|
|
310
|
+
synthetic.add_argument("--manifest")
|
|
311
|
+
synthetic.add_argument("--rows", type=int, default=10)
|
|
312
|
+
synthetic.add_argument("--seed", default="0")
|
|
313
|
+
synthetic.add_argument("--format", choices=("jsonl", "csv"), default="jsonl")
|
|
314
|
+
synthetic.add_argument("--delimiter", default=",")
|
|
315
|
+
synthetic.set_defaults(handler=_synthetic)
|
|
316
|
+
|
|
317
|
+
golden = subcommands.add_parser("golden", help="Java cell-level dataset validator").add_subparsers(dest="golden_command", required=True)
|
|
318
|
+
build = golden.add_parser("build")
|
|
319
|
+
build.add_argument("--validator-dir")
|
|
320
|
+
build.add_argument("--java-home")
|
|
321
|
+
build.add_argument("--skip-tests", action="store_true")
|
|
322
|
+
build.set_defaults(handler=_golden_build)
|
|
323
|
+
validate = golden.add_parser("validate")
|
|
324
|
+
validate.add_argument("expected")
|
|
325
|
+
validate.add_argument("actual")
|
|
326
|
+
validate.add_argument("--jar")
|
|
327
|
+
validate.add_argument("--java")
|
|
328
|
+
validate.add_argument("--validator-dir")
|
|
329
|
+
validate.add_argument("--build", action=argparse.BooleanOptionalAction, default=True)
|
|
330
|
+
validate.add_argument("--format", choices=("auto", "csv", "parquet"), default="auto")
|
|
331
|
+
validate.add_argument("--expected-format", choices=("auto", "csv", "parquet"))
|
|
332
|
+
validate.add_argument("--actual-format", choices=("auto", "csv", "parquet"))
|
|
333
|
+
validate.add_argument("--delimiter", default=",")
|
|
334
|
+
validate.add_argument("--expected-delimiter")
|
|
335
|
+
validate.add_argument("--actual-delimiter")
|
|
336
|
+
validate.add_argument("--no-header", action="store_true")
|
|
337
|
+
validate.add_argument("--header", choices=("true", "false"))
|
|
338
|
+
validate.add_argument("--expected-header", choices=("true", "false"))
|
|
339
|
+
validate.add_argument("--actual-header", choices=("true", "false"))
|
|
340
|
+
validate.add_argument("--charset", default="UTF-8")
|
|
341
|
+
validate.add_argument("--normalize", action="append", default=[])
|
|
342
|
+
validate.add_argument("--trim", action="store_true")
|
|
343
|
+
validate.add_argument("--normalize-decimal", action="store_true")
|
|
344
|
+
validate.add_argument("--key", action="append", default=[])
|
|
345
|
+
validate.add_argument("--max-differences", type=int, default=100)
|
|
346
|
+
validate.add_argument("--report", "-r", default="-")
|
|
347
|
+
validate.set_defaults(handler=_golden_validate)
|
|
348
|
+
return parser
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
352
|
+
try:
|
|
353
|
+
args = _parser().parse_args(argv)
|
|
354
|
+
return int(args.handler(args))
|
|
355
|
+
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
356
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
357
|
+
return 2
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal, InvalidOperation, ROUND_DOWN, ROUND_HALF_UP
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .errors import DataFormatError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_POSITIVE_OVERPUNCH = "{ABCDEFGHI"
|
|
10
|
+
_NEGATIVE_OVERPUNCH = "}JKLMNOPQR"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cobol_decimal(value: Any, scale: int = 0, default: Decimal | None = None) -> Decimal:
|
|
14
|
+
"""Convert a display numeric value without using binary floating point."""
|
|
15
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
16
|
+
if default is not None:
|
|
17
|
+
return default
|
|
18
|
+
raise DataFormatError("empty COBOL numeric value")
|
|
19
|
+
text = str(value).strip().replace(",", ".")
|
|
20
|
+
if len(text) > 1 and text[-1] in "+-" and text[0] not in "+-":
|
|
21
|
+
text = text[-1] + text[:-1]
|
|
22
|
+
try:
|
|
23
|
+
number = Decimal(text)
|
|
24
|
+
except InvalidOperation as exc:
|
|
25
|
+
raise DataFormatError(f"invalid COBOL numeric value: {value!r}") from exc
|
|
26
|
+
if scale and "." not in text:
|
|
27
|
+
number = number.scaleb(-scale)
|
|
28
|
+
return number
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def quantize_cobol(
|
|
32
|
+
value: Any,
|
|
33
|
+
scale: int,
|
|
34
|
+
*,
|
|
35
|
+
rounded: bool = False,
|
|
36
|
+
) -> Decimal:
|
|
37
|
+
mode = ROUND_HALF_UP if rounded else ROUND_DOWN
|
|
38
|
+
quantum = Decimal(1).scaleb(-scale)
|
|
39
|
+
return cobol_decimal(value).quantize(quantum, rounding=mode)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_numeric(value: Any, *, signed: bool = True, decimal: bool = True) -> bool:
|
|
43
|
+
if value is None:
|
|
44
|
+
return False
|
|
45
|
+
text = str(value).strip()
|
|
46
|
+
if not text:
|
|
47
|
+
return False
|
|
48
|
+
if signed and text[:1] in "+-":
|
|
49
|
+
text = text[1:]
|
|
50
|
+
if decimal and text.count(".") <= 1:
|
|
51
|
+
text = text.replace(".", "", 1)
|
|
52
|
+
return bool(text) and text.isdigit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def decode_overpunch(value: str, scale: int = 0) -> Decimal:
|
|
56
|
+
text = value.strip()
|
|
57
|
+
if not text:
|
|
58
|
+
raise DataFormatError("empty overpunch value")
|
|
59
|
+
last = text[-1].upper()
|
|
60
|
+
if last in _POSITIVE_OVERPUNCH:
|
|
61
|
+
digit = _POSITIVE_OVERPUNCH.index(last)
|
|
62
|
+
sign = 1
|
|
63
|
+
elif last in _NEGATIVE_OVERPUNCH:
|
|
64
|
+
digit = _NEGATIVE_OVERPUNCH.index(last)
|
|
65
|
+
sign = -1
|
|
66
|
+
elif last.isdigit():
|
|
67
|
+
digit = int(last)
|
|
68
|
+
sign = 1
|
|
69
|
+
else:
|
|
70
|
+
raise DataFormatError(f"invalid overpunch sign: {last!r}")
|
|
71
|
+
digits = f"{text[:-1]}{digit}"
|
|
72
|
+
if not digits.isdigit():
|
|
73
|
+
raise DataFormatError(f"invalid overpunch digits: {value!r}")
|
|
74
|
+
return Decimal(sign * int(digits)).scaleb(-scale)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def decode_packed_decimal(data: bytes, scale: int = 0) -> Decimal:
|
|
78
|
+
if not data:
|
|
79
|
+
raise DataFormatError("empty packed decimal")
|
|
80
|
+
nibbles: list[int] = []
|
|
81
|
+
for byte in data:
|
|
82
|
+
nibbles.extend((byte >> 4, byte & 0x0F))
|
|
83
|
+
sign_nibble = nibbles.pop()
|
|
84
|
+
if sign_nibble in (0x0B, 0x0D):
|
|
85
|
+
sign = -1
|
|
86
|
+
elif sign_nibble in (0x0A, 0x0C, 0x0E, 0x0F):
|
|
87
|
+
sign = 1
|
|
88
|
+
else:
|
|
89
|
+
raise DataFormatError(f"invalid packed decimal sign: {sign_nibble:X}")
|
|
90
|
+
if any(digit > 9 for digit in nibbles):
|
|
91
|
+
raise DataFormatError("invalid digit in packed decimal")
|
|
92
|
+
integer = int("".join(str(digit) for digit in nibbles) or "0")
|
|
93
|
+
return Decimal(sign * integer).scaleb(-scale)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def encode_packed_decimal(value: Any, digits: int, scale: int = 0) -> bytes:
|
|
97
|
+
number = quantize_cobol(value, scale, rounded=False)
|
|
98
|
+
negative = number < 0
|
|
99
|
+
scaled = abs(int(number.scaleb(scale)))
|
|
100
|
+
digit_text = f"{scaled:0{digits}d}"
|
|
101
|
+
if len(digit_text) > digits:
|
|
102
|
+
raise DataFormatError(f"value {value!r} exceeds PIC 9({digits})")
|
|
103
|
+
nibbles = [int(char) for char in digit_text] + [0x0D if negative else 0x0C]
|
|
104
|
+
if len(nibbles) % 2:
|
|
105
|
+
nibbles.insert(0, 0)
|
|
106
|
+
return bytes((nibbles[index] << 4) | nibbles[index + 1] for index in range(0, len(nibbles), 2))
|