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,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))