xtalate 0.1.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 (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 +444 -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 +209 -0
  18. xtalate/exporters/xyz.py +67 -0
  19. xtalate/parsers/__init__.py +31 -0
  20. xtalate/parsers/_common.py +84 -0
  21. xtalate/parsers/extxyz.py +463 -0
  22. xtalate/parsers/poscar.py +462 -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.dist-info/METADATA +161 -0
  45. xtalate-0.1.0.dist-info/RECORD +49 -0
  46. xtalate-0.1.0.dist-info/WHEEL +4 -0
  47. xtalate-0.1.0.dist-info/entry_points.txt +2 -0
  48. xtalate-0.1.0.dist-info/licenses/LICENSE +201 -0
  49. xtalate-0.1.0.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,444 @@
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.recovery import RecoveryError
34
+ from xtalate.registry import default_registry
35
+ from xtalate.sdk import ParseError
36
+ from xtalate.validation import (
37
+ ToleranceProfile,
38
+ ValidationEngine,
39
+ ValidationReport,
40
+ rethreshold,
41
+ )
42
+
43
+ # Exit codes (Appendix A §A.2).
44
+ EXIT_OK = 0
45
+ EXIT_USAGE = 1
46
+ EXIT_REFUSED = 2
47
+ EXIT_VALIDATION_FAILED = 3
48
+ EXIT_PARSE_ERROR = 4
49
+ EXIT_STRICT_WARNINGS = 5
50
+
51
+
52
+ class _Parser(argparse.ArgumentParser):
53
+ """Argparse exits ``2`` on usage errors, but ``2`` is our *refused* code — remap to ``1``."""
54
+
55
+ def error(self, message: str) -> Any: # noqa: ANN401 - argparse signature.
56
+ self.print_usage(sys.stderr)
57
+ print(f"{self.prog}: error: {message}", file=sys.stderr)
58
+ raise SystemExit(EXIT_USAGE)
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ parser = _build_parser()
63
+ args = parser.parse_args(argv)
64
+ if not getattr(args, "command", None):
65
+ parser.print_help()
66
+ return EXIT_USAGE
67
+ registry = default_registry()
68
+ try:
69
+ handler = {
70
+ "inspect": _cmd_inspect,
71
+ "convert": _cmd_convert,
72
+ "validate": _cmd_validate,
73
+ "capabilities": _cmd_capabilities,
74
+ }[args.command]
75
+ return handler(args, registry)
76
+ except ParseError as exc:
77
+ for issue in exc.issues:
78
+ print(f"parse error [{issue.code}]: {issue.message}", file=sys.stderr)
79
+ return EXIT_PARSE_ERROR
80
+ except RecoveryError as exc:
81
+ # An invalid --recover preset (a bad choice or missing parameter) is a caller error, not
82
+ # a refusal: surface it as a clean usage message, never a traceback (per the engine docs).
83
+ print(f"error: invalid --recover preset: {exc}", file=sys.stderr)
84
+ return EXIT_USAGE
85
+ except _UsageError as exc:
86
+ print(f"error: {exc}", file=sys.stderr)
87
+ return EXIT_USAGE
88
+
89
+
90
+ class _UsageError(Exception):
91
+ """A caller mistake surfaced after argparse (bad --recover, unknown profile) → exit 1."""
92
+
93
+
94
+ # --- commands ------------------------------------------------------------------------------------
95
+
96
+
97
+ def _cmd_inspect(args: argparse.Namespace, registry: Registry) -> int:
98
+ data = _read_bytes(args.file)
99
+ report = DiscoveryEngine(registry).discover(
100
+ data, filename=Path(args.file).name, format_override=args.format
101
+ )
102
+ if args.report:
103
+ _write_json(args.report, report.model_dump(mode="json"))
104
+ if args.json:
105
+ print(_json(report.model_dump(mode="json")))
106
+ else:
107
+ print(render.render_discovery(report))
108
+ return EXIT_OK
109
+
110
+
111
+ def _cmd_convert(args: argparse.Namespace, registry: Registry) -> int:
112
+ data = _read_bytes(args.file)
113
+ source, source_format = _parse_source(registry, data, args.file, args.format)
114
+ tolerance_name = _tolerance_name(args.tolerance_profile)
115
+ result = ConversionEngine(registry).convert(
116
+ source,
117
+ source_format_id=source_format,
118
+ target_format_id=args.to,
119
+ source_filename=Path(args.file).name,
120
+ target_filename=Path(args.output).name if args.output else None,
121
+ mode=args.mode,
122
+ recovery_choices=_parse_recover(args.recover),
123
+ acknowledge_loss=args.acknowledge_loss,
124
+ acknowledge_parse_warnings=args.acknowledge_parse_warnings,
125
+ tolerance_profile=tolerance_name,
126
+ )
127
+ report = result.report
128
+
129
+ if args.report:
130
+ _write_json(args.report, report.model_dump(mode="json"))
131
+ if args.validation_report and result.validation is not None:
132
+ _write_json(args.validation_report, result.validation.model_dump(mode="json"))
133
+
134
+ if args.json:
135
+ print(
136
+ _json(
137
+ {
138
+ "conversion_report": report.model_dump(mode="json"),
139
+ "validation_report": (
140
+ result.validation.model_dump(mode="json") if result.validation else None
141
+ ),
142
+ }
143
+ )
144
+ )
145
+ else:
146
+ print(render.render_conversion(report))
147
+ if result.validation is not None:
148
+ print()
149
+ print(render.render_validation(result.validation))
150
+
151
+ # The output file is written regardless of --json — the reports and the artifact are
152
+ # independent outputs. In --json mode only the stdout dump is suppressed (it would corrupt the
153
+ # JSON stream); the file-write notice always goes to stderr so stdout stays pure.
154
+ _emit_output(args, result.output, human=not args.json)
155
+
156
+ return _convert_exit_code(report, result.validation, args.mode)
157
+
158
+
159
+ def _cmd_validate(args: argparse.Namespace, registry: Registry) -> int:
160
+ if args.source or args.output:
161
+ report = _validate_full_reparse(args, registry)
162
+ else:
163
+ report = _validate_rethreshold(args)
164
+
165
+ if args.validation_report and (args.source or args.output):
166
+ _write_json(args.validation_report, report.model_dump(mode="json"))
167
+ if args.json:
168
+ print(_json(report.model_dump(mode="json")))
169
+ else:
170
+ print(render.render_validation(report))
171
+
172
+ return EXIT_VALIDATION_FAILED if report.status == "failed" else EXIT_OK
173
+
174
+
175
+ def _cmd_capabilities(args: argparse.Namespace, registry: Registry) -> int:
176
+ matrix = registry.capability_matrix()
177
+ format_ids = {p.format_id for p in registry.parsers()} | {
178
+ e.format_id for e in registry.exporters()
179
+ }
180
+ if args.format_id:
181
+ if args.format_id not in format_ids:
182
+ raise _UsageError(f"unknown format {args.format_id!r}; known: {sorted(format_ids)}")
183
+ format_ids = {args.format_id}
184
+
185
+ declarations: dict[str, dict[str, Any]] = {}
186
+ for fid in format_ids:
187
+ directions: dict[str, Any] = {}
188
+ for direction in ("read", "write"):
189
+ try:
190
+ directions[direction] = matrix.get(fid, direction)
191
+ except KeyError:
192
+ continue
193
+ declarations[fid] = directions
194
+
195
+ if args.json:
196
+ payload = {
197
+ fid: {d: caps.model_dump(mode="json") for d, caps in dirs.items()}
198
+ for fid, dirs in declarations.items()
199
+ }
200
+ print(_json(payload))
201
+ else:
202
+ print(render.render_capabilities(declarations))
203
+ return EXIT_OK
204
+
205
+
206
+ # --- validate helpers ----------------------------------------------------------------------------
207
+
208
+
209
+ def _validate_full_reparse(args: argparse.Namespace, registry: Registry) -> ValidationReport:
210
+ """Offline full re-parse re-validation (Part 5 §4.5): reconstruct the expected object from the
211
+ source file + the Conversion Report's write plan, re-parse the output, and diff."""
212
+ if not (args.source and args.output and args.conversion_report):
213
+ raise _UsageError(
214
+ "full re-parse re-validation needs --output, --source, and --conversion-report"
215
+ )
216
+ conversion = ConversionReport.model_validate_json(Path(args.conversion_report).read_text())
217
+ if conversion.supplied:
218
+ # The fabricated values (e.g. a recovery lattice) are not in the source and not stored in
219
+ # the report, so the expected object cannot be faithfully rebuilt offline. Refuse rather
220
+ # than validate against a wrong reference (that would be silently wrong — worse than
221
+ # refusing). Re-thresholding the original ValidationReport still works (Part 5 §4.5).
222
+ raise _UsageError(
223
+ "offline full re-parse re-validation is unavailable for conversions with "
224
+ "recovery-supplied fields in v0.1 (the fabricated values cannot be reconstructed from "
225
+ "the source); re-threshold the original ValidationReport instead (omit --source)"
226
+ )
227
+ target_format = conversion.target.get("format_id")
228
+ if not isinstance(target_format, str):
229
+ raise _UsageError("conversion report has no target.format_id")
230
+
231
+ source_bytes = _read_bytes(args.source)
232
+ source, _ = _parse_source(registry, source_bytes, args.source, None)
233
+ plan = {capability_path(e.path) for e in conversion.preserved}
234
+ expected = build_expected_object(source, plan, target_format)
235
+ output_bytes = _read_bytes(args.output)
236
+ return ValidationEngine(registry).validate(
237
+ expected=expected,
238
+ output=output_bytes,
239
+ target_format_id=target_format,
240
+ conversion_report=conversion,
241
+ tolerance=ToleranceProfile.named(_tolerance_name(args.tolerance_profile)),
242
+ )
243
+
244
+
245
+ def _validate_rethreshold(args: argparse.Namespace) -> ValidationReport:
246
+ """Re-threshold a stored Validation Report under a new profile (Part 5 §4.5) — no re-parse."""
247
+ if not args.validation_report:
248
+ raise _UsageError(
249
+ "re-thresholding needs --validation-report REPORT.json (and no --source/--output)"
250
+ )
251
+ stored = ValidationReport.model_validate_json(Path(args.validation_report).read_text())
252
+ return rethreshold(stored, ToleranceProfile.named(_tolerance_name(args.tolerance_profile)))
253
+
254
+
255
+ # --- shared helpers ------------------------------------------------------------------------------
256
+
257
+
258
+ def _parse_source(
259
+ registry: Registry, data: bytes, path: str, format_override: str | None
260
+ ) -> tuple[Any, str]:
261
+ """Sniff + parse a source file, returning (canonical, format_id). Reuses the Discovery Engine's
262
+ sniff-then-parse so the CLI and inspect agree on what a file is (no second detection path)."""
263
+ from xtalate.discovery import Sniffer
264
+
265
+ fmt = format_override or Sniffer(registry).sniff(data, Path(path).name).format_id
266
+ if fmt is None:
267
+ raise ParseError(
268
+ [
269
+ _unknown_format_issue(
270
+ "could not determine the source format; pass --format to override"
271
+ )
272
+ ]
273
+ )
274
+ if fmt not in {p.format_id for p in registry.parsers()}:
275
+ raise ParseError([_unknown_format_issue(f"no parser registered for format {fmt!r}")])
276
+ import io
277
+
278
+ canonical = registry.get_parser(fmt).parse(io.BytesIO(data), filename=Path(path).name).canonical
279
+ return canonical, fmt
280
+
281
+
282
+ def _unknown_format_issue(message: str) -> Any:
283
+ from xtalate.sdk import ParseIssue
284
+
285
+ return ParseIssue(severity="error", code="UNKNOWN_FORMAT", message=message)
286
+
287
+
288
+ def _parse_recover(specs: list[str] | None) -> dict[str, dict[str, Any]]:
289
+ """Parse repeated ``--recover SCENARIO=CHOICE[,param=value…]`` into ``recovery_choices``."""
290
+ choices: dict[str, dict[str, Any]] = {}
291
+ for spec in specs or []:
292
+ if "=" not in spec:
293
+ raise _UsageError(f"--recover {spec!r} must be SCENARIO=CHOICE[,param=value…]")
294
+ scenario, rest = spec.split("=", 1)
295
+ parts = rest.split(",")
296
+ choice = parts[0]
297
+ params: dict[str, Any] = {}
298
+ for param in parts[1:]:
299
+ if "=" not in param:
300
+ raise _UsageError(f"--recover parameter {param!r} must be name=value")
301
+ name, value = param.split("=", 1)
302
+ params[name] = _coerce(value)
303
+ choices[scenario] = {"choice": choice, "parameters": params}
304
+ return choices
305
+
306
+
307
+ def _coerce(value: str) -> Any:
308
+ """Coerce a CLI parameter string to int, then float, else leave it a string."""
309
+ for cast in (int, float):
310
+ try:
311
+ return cast(value)
312
+ except ValueError:
313
+ continue
314
+ return value
315
+
316
+
317
+ def _tolerance_name(value: str | None) -> str:
318
+ """v0.1 supports only named profiles (default/strict/loose); a FILE table is a v0.2 seam."""
319
+ name = value or "default"
320
+ try:
321
+ ToleranceProfile.named(name)
322
+ except ValueError as exc:
323
+ raise _UsageError(str(exc)) from exc
324
+ return name
325
+
326
+
327
+ def _convert_exit_code(
328
+ report: ConversionReport, validation: ValidationReport | None, mode: str
329
+ ) -> int:
330
+ if report.status == "refused":
331
+ return EXIT_REFUSED
332
+ if validation is None:
333
+ return EXIT_OK
334
+ if validation.status == "failed":
335
+ return EXIT_VALIDATION_FAILED
336
+ if validation.status == "passed_with_warnings" and mode == "strict":
337
+ return EXIT_STRICT_WARNINGS
338
+ return EXIT_OK
339
+
340
+
341
+ def _emit_output(args: argparse.Namespace, output: bytes | None, *, human: bool) -> None:
342
+ if output is None:
343
+ return
344
+ if args.output:
345
+ Path(args.output).write_bytes(output)
346
+ # Status line to stderr so a --json run keeps stdout as clean JSON.
347
+ print(f"Wrote {args.to} output to {args.output}", file=sys.stderr)
348
+ elif human:
349
+ # No -o and human mode: dump the converted bytes to stdout for a quick look. In --json mode
350
+ # with no -o there is nowhere clean to put the artifact, so it is simply not emitted.
351
+ print(f"\n----- {args.to} output -----")
352
+ sys.stdout.write(output.decode())
353
+
354
+
355
+ def _read_bytes(path: str) -> bytes:
356
+ try:
357
+ return Path(path).read_bytes()
358
+ except OSError as exc:
359
+ raise _UsageError(f"cannot read {path}: {exc}") from exc
360
+
361
+
362
+ def _write_json(path: str, payload: Any) -> None:
363
+ Path(path).write_text(_json(payload) + "\n")
364
+
365
+
366
+ def _json(payload: Any) -> str:
367
+ return json.dumps(payload, indent=2, ensure_ascii=False)
368
+
369
+
370
+ # --- argument parser -----------------------------------------------------------------------------
371
+
372
+
373
+ def _build_parser() -> argparse.ArgumentParser:
374
+ parser = _Parser(
375
+ prog="xtalate", description="Audit-first computational-chemistry file conversion."
376
+ )
377
+ parser.add_argument("--version", action="version", version=f"xtalate {__version__}")
378
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
379
+
380
+ p_inspect = sub.add_parser(
381
+ "inspect", help="Run the Information Discovery Engine (✓/✗ inventory)."
382
+ )
383
+ p_inspect.add_argument("file")
384
+ p_inspect.add_argument("--format", metavar="FORMAT_ID", help="Override format sniffing.")
385
+ p_inspect.add_argument("--report", metavar="PATH", help="Write the DiscoveryReport JSON here.")
386
+ p_inspect.add_argument("--json", action="store_true", help="Print the DiscoveryReport JSON.")
387
+
388
+ p_convert = sub.add_parser(
389
+ "convert", help="Full pipeline: parse → pre-flight → recovery → export → validate."
390
+ )
391
+ p_convert.add_argument("file")
392
+ p_convert.add_argument("--to", required=True, metavar="FORMAT_ID", help="Target format.")
393
+ p_convert.add_argument("-o", "--output", metavar="PATH", help="Write the converted file here.")
394
+ p_convert.add_argument("--format", metavar="FORMAT_ID", help="Override source format sniffing.")
395
+ p_convert.add_argument("--mode", choices=("permissive", "strict"), default="permissive")
396
+ p_convert.add_argument(
397
+ "--recover",
398
+ action="append",
399
+ metavar="SCENARIO=CHOICE[,param=value…]",
400
+ help="Preset recovery choice (repeatable).",
401
+ )
402
+ p_convert.add_argument("--acknowledge-loss", action="store_true")
403
+ p_convert.add_argument("--acknowledge-parse-warnings", action="store_true")
404
+ p_convert.add_argument("--tolerance-profile", metavar="NAME", help="default|strict|loose.")
405
+ p_convert.add_argument("--report", metavar="PATH", help="Write the ConversionReport JSON here.")
406
+ p_convert.add_argument(
407
+ "--validation-report", metavar="PATH", help="Write the ValidationReport JSON here."
408
+ )
409
+ p_convert.add_argument(
410
+ "--json", action="store_true", help="Print both reports as one JSON object."
411
+ )
412
+
413
+ p_validate = sub.add_parser(
414
+ "validate", help="Offline re-parse re-validation, or re-threshold a stored report."
415
+ )
416
+ p_validate.add_argument(
417
+ "--output", metavar="FILE", help="Converted output file (full re-parse mode)."
418
+ )
419
+ p_validate.add_argument(
420
+ "--source", metavar="FILE", help="Original source file (full re-parse mode)."
421
+ )
422
+ p_validate.add_argument(
423
+ "--conversion-report", metavar="PATH", help="ConversionReport JSON (full re-parse mode)."
424
+ )
425
+ p_validate.add_argument(
426
+ "--validation-report",
427
+ metavar="PATH",
428
+ help="Write the report (full re-parse), or — alone — read it to re-threshold.",
429
+ )
430
+ p_validate.add_argument("--tolerance-profile", metavar="NAME", help="default|strict|loose.")
431
+ p_validate.add_argument("--json", action="store_true", help="Print the ValidationReport JSON.")
432
+
433
+ p_caps = sub.add_parser("capabilities", help="Print the Capability Matrix.")
434
+ p_caps.add_argument("format_id", nargs="?", metavar="FORMAT_ID", help="Limit to one format.")
435
+ p_caps.add_argument("--json", action="store_true", help="Print the matrix JSON.")
436
+
437
+ return parser
438
+
439
+
440
+ __all__ = ["main"]
441
+
442
+
443
+ if __name__ == "__main__": # pragma: no cover - module-run convenience.
444
+ raise SystemExit(main())