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.
- xtalate/__init__.py +10 -0
- xtalate/capabilities/__init__.py +19 -0
- xtalate/capabilities/registry.py +126 -0
- xtalate/cli/__init__.py +11 -0
- xtalate/cli/main.py +431 -0
- xtalate/cli/render.py +126 -0
- xtalate/conversion/__init__.py +48 -0
- xtalate/conversion/engine.py +560 -0
- xtalate/conversion/preflight.py +127 -0
- xtalate/conversion/report.py +74 -0
- xtalate/discovery/__init__.py +28 -0
- xtalate/discovery/engine.py +202 -0
- xtalate/discovery/report.py +50 -0
- xtalate/discovery/sniffer.py +95 -0
- xtalate/exporters/__init__.py +34 -0
- xtalate/exporters/extxyz.py +140 -0
- xtalate/exporters/poscar.py +180 -0
- xtalate/exporters/xyz.py +67 -0
- xtalate/parsers/__init__.py +31 -0
- xtalate/parsers/_common.py +57 -0
- xtalate/parsers/extxyz.py +452 -0
- xtalate/parsers/poscar.py +428 -0
- xtalate/parsers/xyz.py +286 -0
- xtalate/py.typed +0 -0
- xtalate/recovery/__init__.py +41 -0
- xtalate/recovery/engine.py +321 -0
- xtalate/recovery/scenarios.py +86 -0
- xtalate/registry.py +28 -0
- xtalate/schema/__init__.py +40 -0
- xtalate/schema/arrays.py +115 -0
- xtalate/schema/elements.py +150 -0
- xtalate/schema/models.py +283 -0
- xtalate/schema/paths.py +88 -0
- xtalate/schema/presence.py +152 -0
- xtalate/sdk/__init__.py +27 -0
- xtalate/sdk/capabilities.py +57 -0
- xtalate/sdk/plugins.py +72 -0
- xtalate/sdk/results.py +45 -0
- xtalate/validation/__init__.py +25 -0
- xtalate/validation/engine.py +585 -0
- xtalate/validation/report.py +48 -0
- xtalate/validation/rethreshold.py +122 -0
- xtalate/validation/tolerance.py +125 -0
- xtalate-0.1.0.dev0.dist-info/METADATA +161 -0
- xtalate-0.1.0.dev0.dist-info/RECORD +49 -0
- xtalate-0.1.0.dev0.dist-info/WHEEL +4 -0
- xtalate-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- xtalate-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- 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))
|
xtalate/cli/__init__.py
ADDED
|
@@ -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())
|