xtalate 0.1.0.dev0__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 (49) hide show
  1. xtalate/__init__.py +10 -0
  2. xtalate/capabilities/__init__.py +19 -0
  3. xtalate/capabilities/registry.py +126 -0
  4. xtalate/cli/__init__.py +11 -0
  5. xtalate/cli/main.py +431 -0
  6. xtalate/cli/render.py +126 -0
  7. xtalate/conversion/__init__.py +48 -0
  8. xtalate/conversion/engine.py +560 -0
  9. xtalate/conversion/preflight.py +127 -0
  10. xtalate/conversion/report.py +74 -0
  11. xtalate/discovery/__init__.py +28 -0
  12. xtalate/discovery/engine.py +202 -0
  13. xtalate/discovery/report.py +50 -0
  14. xtalate/discovery/sniffer.py +95 -0
  15. xtalate/exporters/__init__.py +34 -0
  16. xtalate/exporters/extxyz.py +140 -0
  17. xtalate/exporters/poscar.py +180 -0
  18. xtalate/exporters/xyz.py +67 -0
  19. xtalate/parsers/__init__.py +31 -0
  20. xtalate/parsers/_common.py +57 -0
  21. xtalate/parsers/extxyz.py +452 -0
  22. xtalate/parsers/poscar.py +428 -0
  23. xtalate/parsers/xyz.py +286 -0
  24. xtalate/py.typed +0 -0
  25. xtalate/recovery/__init__.py +41 -0
  26. xtalate/recovery/engine.py +321 -0
  27. xtalate/recovery/scenarios.py +86 -0
  28. xtalate/registry.py +28 -0
  29. xtalate/schema/__init__.py +40 -0
  30. xtalate/schema/arrays.py +115 -0
  31. xtalate/schema/elements.py +150 -0
  32. xtalate/schema/models.py +283 -0
  33. xtalate/schema/paths.py +88 -0
  34. xtalate/schema/presence.py +152 -0
  35. xtalate/sdk/__init__.py +27 -0
  36. xtalate/sdk/capabilities.py +57 -0
  37. xtalate/sdk/plugins.py +72 -0
  38. xtalate/sdk/results.py +45 -0
  39. xtalate/validation/__init__.py +25 -0
  40. xtalate/validation/engine.py +585 -0
  41. xtalate/validation/report.py +48 -0
  42. xtalate/validation/rethreshold.py +122 -0
  43. xtalate/validation/tolerance.py +125 -0
  44. xtalate-0.1.0.dev0.dist-info/METADATA +161 -0
  45. xtalate-0.1.0.dev0.dist-info/RECORD +49 -0
  46. xtalate-0.1.0.dev0.dist-info/WHEEL +4 -0
  47. xtalate-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  48. xtalate-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
  49. xtalate-0.1.0.dev0.dist-info/licenses/NOTICE +11 -0
xtalate/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Xtalate — the trusted translation layer between computational chemistry file formats.
2
+
3
+ A converter that tells you exactly what it kept, what it lost, and why (MASTER_SPEC Part 0 §1).
4
+ v0.1 is the complete pure-Python library + CLI: format sniffing, an Information Discovery Engine,
5
+ the Capability-Matrix-driven Conversion Engine with explicit Recovery, and the automatic
6
+ Validation Engine — for XYZ, extXYZ, POSCAR, and CONTCAR. The Service (v0.5) and Web UI (v0.6)
7
+ build on this core without re-implementing any of it.
8
+ """
9
+
10
+ __version__ = "0.1.0"
@@ -0,0 +1,19 @@
1
+ """Capability Matrix — registry and query API over ``FormatCapabilities`` (Part 3 §4).
2
+
3
+ Assembles the capability declarations that plugins produce (the data model lives in
4
+ ``sdk``) into a queryable registry; it is consumer, not owner, of that model, and
5
+ never executes format logic (Part 1 §2). Depends on ``schema`` and ``sdk``.
6
+ Implemented in M2.
7
+ """
8
+
9
+ from xtalate.capabilities.registry import (
10
+ CapabilityMatrix,
11
+ InvalidCapabilityDeclaration,
12
+ Registry,
13
+ )
14
+
15
+ __all__ = [
16
+ "CapabilityMatrix",
17
+ "InvalidCapabilityDeclaration",
18
+ "Registry",
19
+ ]
@@ -0,0 +1,126 @@
1
+ """Plugin registry and Capability Matrix (MASTER_SPEC Part 3 §4).
2
+
3
+ The registry owns the explicit list of registered parsers/exporters and the assembled
4
+ Capability Matrix; the *data model* it assembles lives in ``sdk`` (§4.1). Registration is
5
+ **explicit** — ``register_parser(...)`` / ``register_exporter(...)`` — not entry-point
6
+ discovery: that mechanism is only required once third-party plugins exist (§7.1, v0.3+),
7
+ and the interfaces are identical, so the switch later is a loading change, not a rewrite.
8
+
9
+ At registration the plugin's ``capabilities()`` declaration is validated against the
10
+ canonical schema paths and its wildcards expanded (§4.1): "the registry rejects
11
+ declarations with unknown paths ... which keeps the matrix and the schema from drifting."
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from xtalate.schema.paths import expand_capability_path, is_valid_path
17
+ from xtalate.sdk import (
18
+ CapabilityLevel,
19
+ ExporterPlugin,
20
+ FieldCapability,
21
+ FormatCapabilities,
22
+ ParserPlugin,
23
+ )
24
+
25
+
26
+ class InvalidCapabilityDeclaration(ValueError):
27
+ """A plugin declared a capability against an unknown canonical path, or a declaration
28
+ inconsistent with the plugin registering it (mismatched id/direction)."""
29
+
30
+
31
+ def _validate_and_expand(
32
+ caps: FormatCapabilities, *, expected_direction: str
33
+ ) -> FormatCapabilities:
34
+ """Return a copy of ``caps`` with every wildcard ``fields`` key expanded to concrete
35
+ leaf paths. Raises ``InvalidCapabilityDeclaration`` on any unknown path or a
36
+ direction mismatch."""
37
+ if caps.direction != expected_direction:
38
+ raise InvalidCapabilityDeclaration(
39
+ f"{caps.format_id!r}: declared direction {caps.direction!r} but registered as "
40
+ f"{expected_direction!r}"
41
+ )
42
+
43
+ # Expand wildcards first, then let concrete keys override, so a specific declaration
44
+ # (e.g. "simulation.extra": full) beats a broad one (e.g. "simulation.*": none).
45
+ expanded: dict[str, FieldCapability] = {}
46
+ wildcard_keys = [k for k in caps.fields if k.endswith(".*")]
47
+ concrete_keys = [k for k in caps.fields if not k.endswith(".*")]
48
+ try:
49
+ for key in wildcard_keys:
50
+ for leaf in expand_capability_path(key):
51
+ expanded[leaf] = caps.fields[key]
52
+ for key in concrete_keys:
53
+ (leaf,) = expand_capability_path(key) # validates; a concrete path returns itself
54
+ expanded[leaf] = caps.fields[key]
55
+ except ValueError as exc:
56
+ raise InvalidCapabilityDeclaration(f"{caps.format_id!r}: {exc}") from exc
57
+
58
+ for path in caps.required_fields:
59
+ if not is_valid_path(path):
60
+ raise InvalidCapabilityDeclaration(
61
+ f"{caps.format_id!r}: required_fields contains unknown canonical path {path!r}"
62
+ )
63
+
64
+ return caps.model_copy(update={"fields": expanded})
65
+
66
+
67
+ class CapabilityMatrix:
68
+ """Queryable view over the registered capability declarations (§4.3). Keyed by
69
+ ``(format_id, direction)``; a path not declared for a format reads as ``NONE`` —
70
+ "the format cannot express it" is the safe default (§4.3)."""
71
+
72
+ def __init__(self, declarations: dict[tuple[str, str], FormatCapabilities]) -> None:
73
+ self._declarations = declarations
74
+
75
+ def get(self, format_id: str, direction: str) -> FormatCapabilities:
76
+ try:
77
+ return self._declarations[(format_id, direction)]
78
+ except KeyError:
79
+ raise KeyError(
80
+ f"no {direction!r} capabilities registered for format {format_id!r}"
81
+ ) from None
82
+
83
+ def field_capability(self, format_id: str, direction: str, path: str) -> FieldCapability:
84
+ """Capability of ``format_id`` (in ``direction``) for one canonical path. An
85
+ undeclared path defaults to ``NONE`` (§4.3)."""
86
+ caps = self.get(format_id, direction)
87
+ return caps.fields.get(path, FieldCapability(level=CapabilityLevel.NONE))
88
+
89
+
90
+ class Registry:
91
+ """Explicit-list registry of parsers and exporters (§4.1, §7.1)."""
92
+
93
+ def __init__(self) -> None:
94
+ self._parsers: dict[str, ParserPlugin] = {}
95
+ self._exporters: dict[str, ExporterPlugin] = {}
96
+ self._declarations: dict[tuple[str, str], FormatCapabilities] = {}
97
+
98
+ def register_parser(self, parser: ParserPlugin) -> None:
99
+ if parser.format_id in self._parsers:
100
+ raise ValueError(f"a parser is already registered for format {parser.format_id!r}")
101
+ caps = _validate_and_expand(parser.capabilities(), expected_direction="read")
102
+ self._parsers[parser.format_id] = parser
103
+ self._declarations[(parser.format_id, "read")] = caps
104
+
105
+ def register_exporter(self, exporter: ExporterPlugin) -> None:
106
+ if exporter.format_id in self._exporters:
107
+ raise ValueError(f"an exporter is already registered for format {exporter.format_id!r}")
108
+ caps = _validate_and_expand(exporter.capabilities(), expected_direction="write")
109
+ self._exporters[exporter.format_id] = exporter
110
+ self._declarations[(exporter.format_id, "write")] = caps
111
+
112
+ def parsers(self) -> list[ParserPlugin]:
113
+ return list(self._parsers.values())
114
+
115
+ def exporters(self) -> list[ExporterPlugin]:
116
+ return list(self._exporters.values())
117
+
118
+ def get_parser(self, format_id: str) -> ParserPlugin:
119
+ return self._parsers[format_id]
120
+
121
+ def get_exporter(self, format_id: str) -> ExporterPlugin:
122
+ return self._exporters[format_id]
123
+
124
+ def capability_matrix(self) -> CapabilityMatrix:
125
+ """Assemble the current registrations into a queryable matrix (§4.1)."""
126
+ return CapabilityMatrix(dict(self._declarations))
@@ -0,0 +1,11 @@
1
+ """Xtalate CLI — a thin presenter over the engines (MASTER_SPEC Appendix A).
2
+
3
+ Contains no scientific logic; emits the report schemas of Parts 3-5 verbatim as JSON (``--json``)
4
+ or renders them as a terminal inventory, and honors the preset-only recovery model (Part 10 §2).
5
+ The command implementations live in :mod:`xtalate.cli.main`; this package exposes the
6
+ console-script entry point ``xtalate``.
7
+ """
8
+
9
+ from xtalate.cli.main import main
10
+
11
+ __all__ = ["main"]
xtalate/cli/main.py ADDED
@@ -0,0 +1,431 @@
1
+ """The ``xtalate`` command-line interface (MASTER_SPEC Appendix A).
2
+
3
+ A thin presenter over the engines (Part 1 §2): it parses arguments, calls the library, and either
4
+ renders the report schemas of Parts 3–5 as a terminal inventory or emits them verbatim as JSON
5
+ (``--json``). It contains no scientific logic. Recovery is **preset-only** — the ``--recover`` flag
6
+ is the CLI form of ``recovery_choices`` — and a conversion needing a choice the caller did not
7
+ supply *refuses* (exit 2), never prompts: interactive recovery belongs to the job-driven UI, and a
8
+ second consent flow in a TTY would be a second thing to keep honest (Appendix A, rejected note).
9
+
10
+ Exit codes (§A.2) make the CLI CI-native without parsing stdout:
11
+ ``0`` ok · ``2`` refused · ``3`` validation failed · ``4`` parse error ·
12
+ ``5`` passed-with-warnings under ``--mode strict`` · ``1`` usage/internal error.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from xtalate import __version__
24
+ from xtalate.capabilities import Registry
25
+ from xtalate.cli import render
26
+ from xtalate.conversion import (
27
+ ConversionEngine,
28
+ ConversionReport,
29
+ build_expected_object,
30
+ capability_path,
31
+ )
32
+ from xtalate.discovery import DiscoveryEngine
33
+ from xtalate.registry import default_registry
34
+ from xtalate.sdk import ParseError
35
+ from xtalate.validation import (
36
+ ToleranceProfile,
37
+ ValidationEngine,
38
+ ValidationReport,
39
+ rethreshold,
40
+ )
41
+
42
+ # Exit codes (Appendix A §A.2).
43
+ EXIT_OK = 0
44
+ EXIT_USAGE = 1
45
+ EXIT_REFUSED = 2
46
+ EXIT_VALIDATION_FAILED = 3
47
+ EXIT_PARSE_ERROR = 4
48
+ EXIT_STRICT_WARNINGS = 5
49
+
50
+
51
+ class _Parser(argparse.ArgumentParser):
52
+ """Argparse exits ``2`` on usage errors, but ``2`` is our *refused* code — remap to ``1``."""
53
+
54
+ def error(self, message: str) -> Any: # noqa: ANN401 - argparse signature.
55
+ self.print_usage(sys.stderr)
56
+ print(f"{self.prog}: error: {message}", file=sys.stderr)
57
+ raise SystemExit(EXIT_USAGE)
58
+
59
+
60
+ def main(argv: list[str] | None = None) -> int:
61
+ parser = _build_parser()
62
+ args = parser.parse_args(argv)
63
+ if not getattr(args, "command", None):
64
+ parser.print_help()
65
+ return EXIT_USAGE
66
+ registry = default_registry()
67
+ try:
68
+ handler = {
69
+ "inspect": _cmd_inspect,
70
+ "convert": _cmd_convert,
71
+ "validate": _cmd_validate,
72
+ "capabilities": _cmd_capabilities,
73
+ }[args.command]
74
+ return handler(args, registry)
75
+ except ParseError as exc:
76
+ for issue in exc.issues:
77
+ print(f"parse error [{issue.code}]: {issue.message}", file=sys.stderr)
78
+ return EXIT_PARSE_ERROR
79
+ except _UsageError as exc:
80
+ print(f"error: {exc}", file=sys.stderr)
81
+ return EXIT_USAGE
82
+
83
+
84
+ class _UsageError(Exception):
85
+ """A caller mistake surfaced after argparse (bad --recover, unknown profile) → exit 1."""
86
+
87
+
88
+ # --- commands ------------------------------------------------------------------------------------
89
+
90
+
91
+ def _cmd_inspect(args: argparse.Namespace, registry: Registry) -> int:
92
+ data = _read_bytes(args.file)
93
+ report = DiscoveryEngine(registry).discover(
94
+ data, filename=Path(args.file).name, format_override=args.format
95
+ )
96
+ if args.report:
97
+ _write_json(args.report, report.model_dump(mode="json"))
98
+ if args.json:
99
+ print(_json(report.model_dump(mode="json")))
100
+ else:
101
+ print(render.render_discovery(report))
102
+ return EXIT_OK
103
+
104
+
105
+ def _cmd_convert(args: argparse.Namespace, registry: Registry) -> int:
106
+ data = _read_bytes(args.file)
107
+ source, source_format = _parse_source(registry, data, args.file, args.format)
108
+ tolerance_name = _tolerance_name(args.tolerance_profile)
109
+ result = ConversionEngine(registry).convert(
110
+ source,
111
+ source_format_id=source_format,
112
+ target_format_id=args.to,
113
+ source_filename=Path(args.file).name,
114
+ target_filename=Path(args.output).name if args.output else None,
115
+ mode=args.mode,
116
+ recovery_choices=_parse_recover(args.recover),
117
+ acknowledge_loss=args.acknowledge_loss,
118
+ acknowledge_parse_warnings=args.acknowledge_parse_warnings,
119
+ tolerance_profile=tolerance_name,
120
+ )
121
+ report = result.report
122
+
123
+ if args.report:
124
+ _write_json(args.report, report.model_dump(mode="json"))
125
+ if args.validation_report and result.validation is not None:
126
+ _write_json(args.validation_report, result.validation.model_dump(mode="json"))
127
+
128
+ if args.json:
129
+ print(
130
+ _json(
131
+ {
132
+ "conversion_report": report.model_dump(mode="json"),
133
+ "validation_report": (
134
+ result.validation.model_dump(mode="json") if result.validation else None
135
+ ),
136
+ }
137
+ )
138
+ )
139
+ else:
140
+ print(render.render_conversion(report))
141
+ if result.validation is not None:
142
+ print()
143
+ print(render.render_validation(result.validation))
144
+ _emit_output(args, result.output)
145
+
146
+ return _convert_exit_code(report, result.validation, args.mode)
147
+
148
+
149
+ def _cmd_validate(args: argparse.Namespace, registry: Registry) -> int:
150
+ if args.source or args.output:
151
+ report = _validate_full_reparse(args, registry)
152
+ else:
153
+ report = _validate_rethreshold(args)
154
+
155
+ if args.validation_report and (args.source or args.output):
156
+ _write_json(args.validation_report, report.model_dump(mode="json"))
157
+ if args.json:
158
+ print(_json(report.model_dump(mode="json")))
159
+ else:
160
+ print(render.render_validation(report))
161
+
162
+ return EXIT_VALIDATION_FAILED if report.status == "failed" else EXIT_OK
163
+
164
+
165
+ def _cmd_capabilities(args: argparse.Namespace, registry: Registry) -> int:
166
+ matrix = registry.capability_matrix()
167
+ format_ids = {p.format_id for p in registry.parsers()} | {
168
+ e.format_id for e in registry.exporters()
169
+ }
170
+ if args.format_id:
171
+ if args.format_id not in format_ids:
172
+ raise _UsageError(f"unknown format {args.format_id!r}; known: {sorted(format_ids)}")
173
+ format_ids = {args.format_id}
174
+
175
+ declarations: dict[str, dict[str, Any]] = {}
176
+ for fid in format_ids:
177
+ directions: dict[str, Any] = {}
178
+ for direction in ("read", "write"):
179
+ try:
180
+ directions[direction] = matrix.get(fid, direction)
181
+ except KeyError:
182
+ continue
183
+ declarations[fid] = directions
184
+
185
+ if args.json:
186
+ payload = {
187
+ fid: {d: caps.model_dump(mode="json") for d, caps in dirs.items()}
188
+ for fid, dirs in declarations.items()
189
+ }
190
+ print(_json(payload))
191
+ else:
192
+ print(render.render_capabilities(declarations))
193
+ return EXIT_OK
194
+
195
+
196
+ # --- validate helpers ----------------------------------------------------------------------------
197
+
198
+
199
+ def _validate_full_reparse(args: argparse.Namespace, registry: Registry) -> ValidationReport:
200
+ """Offline full re-parse re-validation (Part 5 §4.5): reconstruct the expected object from the
201
+ source file + the Conversion Report's write plan, re-parse the output, and diff."""
202
+ if not (args.source and args.output and args.conversion_report):
203
+ raise _UsageError(
204
+ "full re-parse re-validation needs --output, --source, and --conversion-report"
205
+ )
206
+ conversion = ConversionReport.model_validate_json(Path(args.conversion_report).read_text())
207
+ if conversion.supplied:
208
+ # The fabricated values (e.g. a recovery lattice) are not in the source and not stored in
209
+ # the report, so the expected object cannot be faithfully rebuilt offline. Refuse rather
210
+ # than validate against a wrong reference (that would be silently wrong — worse than
211
+ # refusing). Re-thresholding the original ValidationReport still works (Part 5 §4.5).
212
+ raise _UsageError(
213
+ "offline full re-parse re-validation is unavailable for conversions with "
214
+ "recovery-supplied fields in v0.1 (the fabricated values cannot be reconstructed from "
215
+ "the source); re-threshold the original ValidationReport instead (omit --source)"
216
+ )
217
+ target_format = conversion.target.get("format_id")
218
+ if not isinstance(target_format, str):
219
+ raise _UsageError("conversion report has no target.format_id")
220
+
221
+ source_bytes = _read_bytes(args.source)
222
+ source, _ = _parse_source(registry, source_bytes, args.source, None)
223
+ plan = {capability_path(e.path) for e in conversion.preserved}
224
+ expected = build_expected_object(source, plan, target_format)
225
+ output_bytes = _read_bytes(args.output)
226
+ return ValidationEngine(registry).validate(
227
+ expected=expected,
228
+ output=output_bytes,
229
+ target_format_id=target_format,
230
+ conversion_report=conversion,
231
+ tolerance=ToleranceProfile.named(_tolerance_name(args.tolerance_profile)),
232
+ )
233
+
234
+
235
+ def _validate_rethreshold(args: argparse.Namespace) -> ValidationReport:
236
+ """Re-threshold a stored Validation Report under a new profile (Part 5 §4.5) — no re-parse."""
237
+ if not args.validation_report:
238
+ raise _UsageError(
239
+ "re-thresholding needs --validation-report REPORT.json (and no --source/--output)"
240
+ )
241
+ stored = ValidationReport.model_validate_json(Path(args.validation_report).read_text())
242
+ return rethreshold(stored, ToleranceProfile.named(_tolerance_name(args.tolerance_profile)))
243
+
244
+
245
+ # --- shared helpers ------------------------------------------------------------------------------
246
+
247
+
248
+ def _parse_source(
249
+ registry: Registry, data: bytes, path: str, format_override: str | None
250
+ ) -> tuple[Any, str]:
251
+ """Sniff + parse a source file, returning (canonical, format_id). Reuses the Discovery Engine's
252
+ sniff-then-parse so the CLI and inspect agree on what a file is (no second detection path)."""
253
+ from xtalate.discovery import Sniffer
254
+
255
+ fmt = format_override or Sniffer(registry).sniff(data, Path(path).name).format_id
256
+ if fmt is None:
257
+ raise ParseError(
258
+ [
259
+ _unknown_format_issue(
260
+ "could not determine the source format; pass --format to override"
261
+ )
262
+ ]
263
+ )
264
+ if fmt not in {p.format_id for p in registry.parsers()}:
265
+ raise ParseError([_unknown_format_issue(f"no parser registered for format {fmt!r}")])
266
+ import io
267
+
268
+ canonical = registry.get_parser(fmt).parse(io.BytesIO(data), filename=Path(path).name).canonical
269
+ return canonical, fmt
270
+
271
+
272
+ def _unknown_format_issue(message: str) -> Any:
273
+ from xtalate.sdk import ParseIssue
274
+
275
+ return ParseIssue(severity="error", code="UNKNOWN_FORMAT", message=message)
276
+
277
+
278
+ def _parse_recover(specs: list[str] | None) -> dict[str, dict[str, Any]]:
279
+ """Parse repeated ``--recover SCENARIO=CHOICE[,param=value…]`` into ``recovery_choices``."""
280
+ choices: dict[str, dict[str, Any]] = {}
281
+ for spec in specs or []:
282
+ if "=" not in spec:
283
+ raise _UsageError(f"--recover {spec!r} must be SCENARIO=CHOICE[,param=value…]")
284
+ scenario, rest = spec.split("=", 1)
285
+ parts = rest.split(",")
286
+ choice = parts[0]
287
+ params: dict[str, Any] = {}
288
+ for param in parts[1:]:
289
+ if "=" not in param:
290
+ raise _UsageError(f"--recover parameter {param!r} must be name=value")
291
+ name, value = param.split("=", 1)
292
+ params[name] = _coerce(value)
293
+ choices[scenario] = {"choice": choice, "parameters": params}
294
+ return choices
295
+
296
+
297
+ def _coerce(value: str) -> Any:
298
+ """Coerce a CLI parameter string to int, then float, else leave it a string."""
299
+ for cast in (int, float):
300
+ try:
301
+ return cast(value)
302
+ except ValueError:
303
+ continue
304
+ return value
305
+
306
+
307
+ def _tolerance_name(value: str | None) -> str:
308
+ """v0.1 supports only named profiles (default/strict/loose); a FILE table is a v0.2 seam."""
309
+ name = value or "default"
310
+ try:
311
+ ToleranceProfile.named(name)
312
+ except ValueError as exc:
313
+ raise _UsageError(str(exc)) from exc
314
+ return name
315
+
316
+
317
+ def _convert_exit_code(
318
+ report: ConversionReport, validation: ValidationReport | None, mode: str
319
+ ) -> int:
320
+ if report.status == "refused":
321
+ return EXIT_REFUSED
322
+ if validation is None:
323
+ return EXIT_OK
324
+ if validation.status == "failed":
325
+ return EXIT_VALIDATION_FAILED
326
+ if validation.status == "passed_with_warnings" and mode == "strict":
327
+ return EXIT_STRICT_WARNINGS
328
+ return EXIT_OK
329
+
330
+
331
+ def _emit_output(args: argparse.Namespace, output: bytes | None) -> None:
332
+ if output is None:
333
+ return
334
+ if args.output:
335
+ Path(args.output).write_bytes(output)
336
+ print(f"\nWrote {args.to} output to {args.output}")
337
+ else:
338
+ print(f"\n----- {args.to} output -----")
339
+ sys.stdout.write(output.decode())
340
+
341
+
342
+ def _read_bytes(path: str) -> bytes:
343
+ try:
344
+ return Path(path).read_bytes()
345
+ except OSError as exc:
346
+ raise _UsageError(f"cannot read {path}: {exc}") from exc
347
+
348
+
349
+ def _write_json(path: str, payload: Any) -> None:
350
+ Path(path).write_text(_json(payload) + "\n")
351
+
352
+
353
+ def _json(payload: Any) -> str:
354
+ return json.dumps(payload, indent=2, ensure_ascii=False)
355
+
356
+
357
+ # --- argument parser -----------------------------------------------------------------------------
358
+
359
+
360
+ def _build_parser() -> argparse.ArgumentParser:
361
+ parser = _Parser(
362
+ prog="xtalate", description="Audit-first computational-chemistry file conversion."
363
+ )
364
+ parser.add_argument("--version", action="version", version=f"xtalate {__version__}")
365
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
366
+
367
+ p_inspect = sub.add_parser(
368
+ "inspect", help="Run the Information Discovery Engine (✓/✗ inventory)."
369
+ )
370
+ p_inspect.add_argument("file")
371
+ p_inspect.add_argument("--format", metavar="FORMAT_ID", help="Override format sniffing.")
372
+ p_inspect.add_argument("--report", metavar="PATH", help="Write the DiscoveryReport JSON here.")
373
+ p_inspect.add_argument("--json", action="store_true", help="Print the DiscoveryReport JSON.")
374
+
375
+ p_convert = sub.add_parser(
376
+ "convert", help="Full pipeline: parse → pre-flight → recovery → export → validate."
377
+ )
378
+ p_convert.add_argument("file")
379
+ p_convert.add_argument("--to", required=True, metavar="FORMAT_ID", help="Target format.")
380
+ p_convert.add_argument("-o", "--output", metavar="PATH", help="Write the converted file here.")
381
+ p_convert.add_argument("--format", metavar="FORMAT_ID", help="Override source format sniffing.")
382
+ p_convert.add_argument("--mode", choices=("permissive", "strict"), default="permissive")
383
+ p_convert.add_argument(
384
+ "--recover",
385
+ action="append",
386
+ metavar="SCENARIO=CHOICE[,param=value…]",
387
+ help="Preset recovery choice (repeatable).",
388
+ )
389
+ p_convert.add_argument("--acknowledge-loss", action="store_true")
390
+ p_convert.add_argument("--acknowledge-parse-warnings", action="store_true")
391
+ p_convert.add_argument("--tolerance-profile", metavar="NAME", help="default|strict|loose.")
392
+ p_convert.add_argument("--report", metavar="PATH", help="Write the ConversionReport JSON here.")
393
+ p_convert.add_argument(
394
+ "--validation-report", metavar="PATH", help="Write the ValidationReport JSON here."
395
+ )
396
+ p_convert.add_argument(
397
+ "--json", action="store_true", help="Print both reports as one JSON object."
398
+ )
399
+
400
+ p_validate = sub.add_parser(
401
+ "validate", help="Offline re-parse re-validation, or re-threshold a stored report."
402
+ )
403
+ p_validate.add_argument(
404
+ "--output", metavar="FILE", help="Converted output file (full re-parse mode)."
405
+ )
406
+ p_validate.add_argument(
407
+ "--source", metavar="FILE", help="Original source file (full re-parse mode)."
408
+ )
409
+ p_validate.add_argument(
410
+ "--conversion-report", metavar="PATH", help="ConversionReport JSON (full re-parse mode)."
411
+ )
412
+ p_validate.add_argument(
413
+ "--validation-report",
414
+ metavar="PATH",
415
+ help="Write the report (full re-parse), or — alone — read it to re-threshold.",
416
+ )
417
+ p_validate.add_argument("--tolerance-profile", metavar="NAME", help="default|strict|loose.")
418
+ p_validate.add_argument("--json", action="store_true", help="Print the ValidationReport JSON.")
419
+
420
+ p_caps = sub.add_parser("capabilities", help="Print the Capability Matrix.")
421
+ p_caps.add_argument("format_id", nargs="?", metavar="FORMAT_ID", help="Limit to one format.")
422
+ p_caps.add_argument("--json", action="store_true", help="Print the matrix JSON.")
423
+
424
+ return parser
425
+
426
+
427
+ __all__ = ["main"]
428
+
429
+
430
+ if __name__ == "__main__": # pragma: no cover - module-run convenience.
431
+ raise SystemExit(main())