claimtrace 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.
- claimtrace/__init__.py +19 -0
- claimtrace/__main__.py +5 -0
- claimtrace/_finite.py +23 -0
- claimtrace/_simple_yaml.py +184 -0
- claimtrace/api.py +368 -0
- claimtrace/cli.py +1657 -0
- claimtrace/completion.py +1631 -0
- claimtrace/constants.py +13 -0
- claimtrace/errors.py +21 -0
- claimtrace/export/__init__.py +5 -0
- claimtrace/export/directory.py +223 -0
- claimtrace/export/experimental/__init__.py +2 -0
- claimtrace/export/experimental/datacite.py +19 -0
- claimtrace/export/experimental/ro_crate.py +22 -0
- claimtrace/export/readme.py +705 -0
- claimtrace/export/zip.py +23 -0
- claimtrace/importers/__init__.py +17 -0
- claimtrace/importers/base.py +96 -0
- claimtrace/importers/chimerax.py +953 -0
- claimtrace/importers/directory.py +43 -0
- claimtrace/importers/experimental/__init__.py +2 -0
- claimtrace/importers/experimental/chimerax.py +64 -0
- claimtrace/importers/experimental/molviewspec.py +76 -0
- claimtrace/importers/generic.py +42 -0
- claimtrace/importers/image.py +182 -0
- claimtrace/importers/molviewspec.py +1330 -0
- claimtrace/importers/pymol.py +1297 -0
- claimtrace/models/__init__.py +68 -0
- claimtrace/models/artifacts.py +5 -0
- claimtrace/models/provenance.py +5 -0
- claimtrace/models/semantic_layer.py +566 -0
- claimtrace/models/validation.py +68 -0
- claimtrace/package/__init__.py +2 -0
- claimtrace/package/checksums.py +74 -0
- claimtrace/package/layout.py +229 -0
- claimtrace/package/paths.py +102 -0
- claimtrace/package/registry.py +373 -0
- claimtrace/package/resources.py +183 -0
- claimtrace/path_hints.py +22 -0
- claimtrace/py.typed +1 -0
- claimtrace/resources/schema/semantic-layer.schema.json +2213 -0
- claimtrace/resources/schema/validation-report.schema.json +278 -0
- claimtrace/resources/templates/ligand-binding-pose.yml +48 -0
- claimtrace/resources/templates/simple-structure-view.yml +32 -0
- claimtrace/resources/validation/rules.yaml +138 -0
- claimtrace/schemas.py +29 -0
- claimtrace/validation/__init__.py +5 -0
- claimtrace/validation/engine.py +2688 -0
- claimtrace/validation/findings.py +36 -0
- claimtrace/validation/rules.py +67 -0
- claimtrace/visual_semantics.py +394 -0
- claimtrace-0.1.0.dist-info/METADATA +497 -0
- claimtrace-0.1.0.dist-info/RECORD +57 -0
- claimtrace-0.1.0.dist-info/WHEEL +5 -0
- claimtrace-0.1.0.dist-info/entry_points.txt +2 -0
- claimtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
- claimtrace-0.1.0.dist-info/top_level.txt +1 -0
claimtrace/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""ClaimTrace public API."""
|
|
2
|
+
|
|
3
|
+
from claimtrace.api import ClaimTracePackage, export_package, init_package, load_package, validate_package
|
|
4
|
+
from claimtrace.constants import CAL_SCHEMA_VERSION, PACKAGE_VERSION
|
|
5
|
+
from claimtrace.errors import ClaimTraceError, SchemaVersionError, SecurityError, ValidationError
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CAL_SCHEMA_VERSION",
|
|
9
|
+
"PACKAGE_VERSION",
|
|
10
|
+
"ClaimTraceError",
|
|
11
|
+
"SchemaVersionError",
|
|
12
|
+
"SecurityError",
|
|
13
|
+
"ValidationError",
|
|
14
|
+
"ClaimTracePackage",
|
|
15
|
+
"init_package",
|
|
16
|
+
"load_package",
|
|
17
|
+
"validate_package",
|
|
18
|
+
"export_package",
|
|
19
|
+
]
|
claimtrace/__main__.py
ADDED
claimtrace/_finite.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Recursive finite-number checks for JSON- and YAML-derived data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ensure_finite(value: T, path: str = "$") -> T:
|
|
13
|
+
"""Return *value* after rejecting NaN and infinite floats anywhere inside it."""
|
|
14
|
+
|
|
15
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
16
|
+
raise ValueError(f"Non-finite numeric value is not allowed at {path}.")
|
|
17
|
+
if isinstance(value, dict):
|
|
18
|
+
for key, child in value.items():
|
|
19
|
+
ensure_finite(child, f"{path}.{key}")
|
|
20
|
+
elif isinstance(value, (list, tuple, set)):
|
|
21
|
+
for index, child in enumerate(value):
|
|
22
|
+
ensure_finite(child, f"{path}[{index}]")
|
|
23
|
+
return value
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Small YAML reader/writer used when PyYAML is unavailable.
|
|
2
|
+
|
|
3
|
+
The fallback intentionally supports only the answer-template subset used by ClaimTrace:
|
|
4
|
+
mappings, lists, strings, booleans, numbers and null-like values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from claimtrace._finite import ensure_finite
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def loads(text: str) -> Any:
|
|
16
|
+
stripped = text.strip()
|
|
17
|
+
if not stripped:
|
|
18
|
+
return {}
|
|
19
|
+
try:
|
|
20
|
+
return ensure_finite(
|
|
21
|
+
json.loads(stripped, parse_constant=_reject_json_constant),
|
|
22
|
+
"answers",
|
|
23
|
+
)
|
|
24
|
+
except json.JSONDecodeError:
|
|
25
|
+
pass
|
|
26
|
+
try:
|
|
27
|
+
import yaml # type: ignore
|
|
28
|
+
except ImportError:
|
|
29
|
+
lines = _prepare_lines(text)
|
|
30
|
+
value, index = _parse_block(lines, 0, 0)
|
|
31
|
+
if index < len(lines):
|
|
32
|
+
raise ValueError(f"Could not parse YAML near line {index + 1}")
|
|
33
|
+
return ensure_finite(value, "answers")
|
|
34
|
+
try:
|
|
35
|
+
value = yaml.safe_load(text) or {}
|
|
36
|
+
ensure_finite(value, "answers")
|
|
37
|
+
return value
|
|
38
|
+
except yaml.YAMLError as exc:
|
|
39
|
+
mark = getattr(exc, "problem_mark", None)
|
|
40
|
+
location = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else ""
|
|
41
|
+
raise ValueError(f"Invalid answers YAML{location}: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dumps(data: Any) -> str:
|
|
45
|
+
ensure_finite(data, "answers")
|
|
46
|
+
try:
|
|
47
|
+
import yaml # type: ignore
|
|
48
|
+
|
|
49
|
+
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
|
50
|
+
except ImportError:
|
|
51
|
+
return _dump_value(data, 0).rstrip() + "\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_path(path: str) -> Any:
|
|
55
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
56
|
+
return loads(handle.read())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def dump_path(data: Any, path: str) -> None:
|
|
60
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
61
|
+
handle.write(dumps(data))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _prepare_lines(text: str) -> list[tuple[int, str]]:
|
|
65
|
+
prepared: list[tuple[int, str]] = []
|
|
66
|
+
for raw in text.splitlines():
|
|
67
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
68
|
+
continue
|
|
69
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
70
|
+
prepared.append((indent, raw.strip()))
|
|
71
|
+
return prepared
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _reject_json_constant(value: str) -> None:
|
|
75
|
+
raise ValueError(f"Non-finite numeric value {value} is not valid JSON.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_block(lines: list[tuple[int, str]], index: int, indent: int) -> tuple[Any, int]:
|
|
79
|
+
if index >= len(lines):
|
|
80
|
+
return {}, index
|
|
81
|
+
is_list = lines[index][0] == indent and lines[index][1].startswith("- ")
|
|
82
|
+
result: Any = [] if is_list else {}
|
|
83
|
+
while index < len(lines):
|
|
84
|
+
line_indent, content = lines[index]
|
|
85
|
+
if line_indent < indent:
|
|
86
|
+
break
|
|
87
|
+
if line_indent > indent:
|
|
88
|
+
raise ValueError(f"Unexpected indentation at line {index + 1}")
|
|
89
|
+
if is_list:
|
|
90
|
+
if not content.startswith("- "):
|
|
91
|
+
break
|
|
92
|
+
item_text = content[2:].strip()
|
|
93
|
+
if not item_text:
|
|
94
|
+
item, index = _parse_block(lines, index + 1, indent + 2)
|
|
95
|
+
result.append(item)
|
|
96
|
+
continue
|
|
97
|
+
if ":" in item_text and not item_text.startswith(("'", '"')):
|
|
98
|
+
key, value_text = item_text.split(":", 1)
|
|
99
|
+
item = {key.strip(): _parse_scalar(value_text.strip())} if value_text.strip() else {}
|
|
100
|
+
index += 1
|
|
101
|
+
if index < len(lines) and lines[index][0] > indent:
|
|
102
|
+
nested, index = _parse_block(lines, index, indent + 2)
|
|
103
|
+
if isinstance(nested, dict):
|
|
104
|
+
item.update(nested)
|
|
105
|
+
else:
|
|
106
|
+
item[key.strip()] = nested
|
|
107
|
+
result.append(item)
|
|
108
|
+
continue
|
|
109
|
+
result.append(_parse_scalar(item_text))
|
|
110
|
+
index += 1
|
|
111
|
+
continue
|
|
112
|
+
if ":" not in content:
|
|
113
|
+
raise ValueError(f"Expected key/value mapping at line {index + 1}")
|
|
114
|
+
key, value_text = content.split(":", 1)
|
|
115
|
+
key = key.strip()
|
|
116
|
+
value_text = value_text.strip()
|
|
117
|
+
index += 1
|
|
118
|
+
if value_text:
|
|
119
|
+
result[key] = _parse_scalar(value_text)
|
|
120
|
+
elif index < len(lines) and lines[index][0] > indent:
|
|
121
|
+
result[key], index = _parse_block(lines, index, indent + 2)
|
|
122
|
+
else:
|
|
123
|
+
result[key] = {}
|
|
124
|
+
return result, index
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_scalar(value: str) -> Any:
|
|
128
|
+
if value in {"null", "Null", "NULL", "~"}:
|
|
129
|
+
return None
|
|
130
|
+
if value in {"true", "True", "TRUE"}:
|
|
131
|
+
return True
|
|
132
|
+
if value in {"false", "False", "FALSE"}:
|
|
133
|
+
return False
|
|
134
|
+
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
|
135
|
+
return value[1:-1]
|
|
136
|
+
try:
|
|
137
|
+
return int(value)
|
|
138
|
+
except ValueError:
|
|
139
|
+
pass
|
|
140
|
+
try:
|
|
141
|
+
return float(value)
|
|
142
|
+
except ValueError:
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _dump_value(data: Any, indent: int) -> str:
|
|
147
|
+
prefix = " " * indent
|
|
148
|
+
if isinstance(data, dict):
|
|
149
|
+
lines = []
|
|
150
|
+
for key, value in data.items():
|
|
151
|
+
if isinstance(value, (dict, list)):
|
|
152
|
+
lines.append(f"{prefix}{key}:")
|
|
153
|
+
lines.append(_dump_value(value, indent + 2))
|
|
154
|
+
else:
|
|
155
|
+
lines.append(f"{prefix}{key}: {_format_scalar(value)}")
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
if isinstance(data, list):
|
|
158
|
+
lines = []
|
|
159
|
+
for item in data:
|
|
160
|
+
if isinstance(item, dict):
|
|
161
|
+
lines.append(f"{prefix}-")
|
|
162
|
+
lines.append(_dump_value(item, indent + 2))
|
|
163
|
+
elif isinstance(item, list):
|
|
164
|
+
lines.append(f"{prefix}-")
|
|
165
|
+
lines.append(_dump_value(item, indent + 2))
|
|
166
|
+
else:
|
|
167
|
+
lines.append(f"{prefix}- {_format_scalar(item)}")
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
return f"{prefix}{_format_scalar(data)}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _format_scalar(value: Any) -> str:
|
|
173
|
+
if value is None:
|
|
174
|
+
return "null"
|
|
175
|
+
if value is True:
|
|
176
|
+
return "true"
|
|
177
|
+
if value is False:
|
|
178
|
+
return "false"
|
|
179
|
+
if isinstance(value, (int, float)):
|
|
180
|
+
return str(value)
|
|
181
|
+
text = str(value)
|
|
182
|
+
if not text or text.strip() != text or any(ch in text for ch in [":", "#", "[", "]", "{", "}"]):
|
|
183
|
+
return json.dumps(text)
|
|
184
|
+
return text
|
claimtrace/api.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Stable Python API for ClaimTrace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from claimtrace.completion import (
|
|
12
|
+
apply_answers,
|
|
13
|
+
check_answers,
|
|
14
|
+
completed_answer_sections,
|
|
15
|
+
completion_tasks_for_template,
|
|
16
|
+
ensure_scene_for_images,
|
|
17
|
+
load_answers,
|
|
18
|
+
)
|
|
19
|
+
from claimtrace.constants import SEMANTIC_LAYER_FILENAME
|
|
20
|
+
from claimtrace.errors import PackageError
|
|
21
|
+
from claimtrace.importers.base import default_importers, detect_importer
|
|
22
|
+
from claimtrace.models import Claim, ProvenancedString, SemanticLayer
|
|
23
|
+
from claimtrace.models.semantic_layer import ClaimType, ConfidenceValue, ReviewStatus, SourceType, utc_now
|
|
24
|
+
from claimtrace.models.validation import ValidationReport
|
|
25
|
+
from claimtrace.package.layout import (
|
|
26
|
+
_validate_force_replace_root,
|
|
27
|
+
append_extraction_report,
|
|
28
|
+
create_package_root,
|
|
29
|
+
load_semantic_layer,
|
|
30
|
+
read_completion_tasks,
|
|
31
|
+
write_completion_tasks,
|
|
32
|
+
write_semantic_layer,
|
|
33
|
+
)
|
|
34
|
+
from claimtrace.package.registry import infer_role, register_local_artifact, replay_destination_for
|
|
35
|
+
from claimtrace.path_hints import windows_path_hint
|
|
36
|
+
from claimtrace.package.resources import synchronize_resource_bindings
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ClaimTracePackage:
|
|
40
|
+
"""A package directory loaded with its CAL semantic layer."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, root: Path, semantic_layer: SemanticLayer):
|
|
43
|
+
self.root = Path(root)
|
|
44
|
+
self.semantic_layer = semantic_layer
|
|
45
|
+
|
|
46
|
+
def save(self) -> None:
|
|
47
|
+
"""Write `semantic_layer.json` to disk."""
|
|
48
|
+
|
|
49
|
+
synchronize_resource_bindings(self.semantic_layer)
|
|
50
|
+
write_semantic_layer(self.root, self.semantic_layer)
|
|
51
|
+
|
|
52
|
+
def add_claim(
|
|
53
|
+
self,
|
|
54
|
+
text: str,
|
|
55
|
+
claim_type: str = "unknown",
|
|
56
|
+
figure_ref: str = "unknown",
|
|
57
|
+
*,
|
|
58
|
+
scope: str = "panel",
|
|
59
|
+
) -> Claim:
|
|
60
|
+
"""Add or replace a claim in the CAL."""
|
|
61
|
+
|
|
62
|
+
claim = Claim(
|
|
63
|
+
id=f"claim:{len(self.semantic_layer.claims) + 1:03d}",
|
|
64
|
+
claim_text=ProvenancedString(
|
|
65
|
+
value=text,
|
|
66
|
+
source_type=SourceType.user_asserted,
|
|
67
|
+
confidence=ConfidenceValue.unknown,
|
|
68
|
+
review_status=ReviewStatus.confirmed,
|
|
69
|
+
reviewer="api",
|
|
70
|
+
reviewed_at=utc_now(),
|
|
71
|
+
),
|
|
72
|
+
claim_type=ClaimType(claim_type),
|
|
73
|
+
figure_ref=figure_ref,
|
|
74
|
+
scope=scope,
|
|
75
|
+
scene_ids=[scene.id for scene in self.semantic_layer.scenes],
|
|
76
|
+
claim_status="reviewable",
|
|
77
|
+
review_status=ReviewStatus.confirmed,
|
|
78
|
+
)
|
|
79
|
+
self.semantic_layer.claims.append(claim)
|
|
80
|
+
self.semantic_layer.add_activity(
|
|
81
|
+
"api_add_claim",
|
|
82
|
+
f"Added claim {claim.id}.",
|
|
83
|
+
generated_ids=[claim.id],
|
|
84
|
+
)
|
|
85
|
+
self.save()
|
|
86
|
+
return claim
|
|
87
|
+
|
|
88
|
+
def add_artifact(
|
|
89
|
+
self,
|
|
90
|
+
path: str | Path,
|
|
91
|
+
role: str | None = None,
|
|
92
|
+
*,
|
|
93
|
+
detached: bool = False,
|
|
94
|
+
scene_id: str | None = None,
|
|
95
|
+
):
|
|
96
|
+
"""Register a local artifact and copy it into the package by default."""
|
|
97
|
+
|
|
98
|
+
source = Path(path)
|
|
99
|
+
inferred_role = infer_role(source, role)
|
|
100
|
+
if scene_id is not None and inferred_role.value != "render_image":
|
|
101
|
+
raise ValueError("--scene/scene_id can only be used with a rendered-image artifact.")
|
|
102
|
+
if scene_id is not None and not any(
|
|
103
|
+
scene.id == scene_id for scene in self.semantic_layer.scenes
|
|
104
|
+
):
|
|
105
|
+
raise ValueError(f"Unknown scene id for rendered panel: {scene_id}")
|
|
106
|
+
artifact = register_local_artifact(
|
|
107
|
+
self.root,
|
|
108
|
+
self.semantic_layer,
|
|
109
|
+
source,
|
|
110
|
+
role=role,
|
|
111
|
+
copy=True,
|
|
112
|
+
detached=detached,
|
|
113
|
+
relative_destination=replay_destination_for(self.semantic_layer, source),
|
|
114
|
+
)
|
|
115
|
+
if artifact.role.value == "render_image":
|
|
116
|
+
ensure_scene_for_images(
|
|
117
|
+
self.semantic_layer,
|
|
118
|
+
[artifact.id],
|
|
119
|
+
scene_id=scene_id,
|
|
120
|
+
)
|
|
121
|
+
self.semantic_layer.add_activity(
|
|
122
|
+
"api_add_artifact",
|
|
123
|
+
f"Added artifact {artifact.id}.",
|
|
124
|
+
generated_ids=[artifact.id],
|
|
125
|
+
)
|
|
126
|
+
self.save()
|
|
127
|
+
return artifact
|
|
128
|
+
|
|
129
|
+
def complete_from_answers(self, answers_path: str | Path) -> "ClaimTracePackage":
|
|
130
|
+
"""Apply a user-friendly answer file to the package."""
|
|
131
|
+
|
|
132
|
+
answers_path = Path(answers_path)
|
|
133
|
+
answers = load_answers(answers_path)
|
|
134
|
+
problems = check_answers(
|
|
135
|
+
answers,
|
|
136
|
+
self.semantic_layer,
|
|
137
|
+
root=self.root,
|
|
138
|
+
answer_base=answers_path.parent,
|
|
139
|
+
)
|
|
140
|
+
if problems:
|
|
141
|
+
raise ValueError("Answer file needs updates:\n" + "\n".join(f"- {problem}" for problem in problems))
|
|
142
|
+
apply_answers(self.root, self.semantic_layer, answers, answer_base=answers_path.parent)
|
|
143
|
+
self.save()
|
|
144
|
+
completed_sections = completed_answer_sections(answers)
|
|
145
|
+
tasks = read_completion_tasks(self.root)
|
|
146
|
+
for task in tasks:
|
|
147
|
+
if task.status != "waived":
|
|
148
|
+
task.status = "answered" if task.section in completed_sections else "open"
|
|
149
|
+
write_completion_tasks(self.root, tasks)
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def validate(self, mode: str = "draft") -> ValidationReport:
|
|
153
|
+
"""Run validation and return a validation report."""
|
|
154
|
+
|
|
155
|
+
from claimtrace.validation.engine import validate_package
|
|
156
|
+
|
|
157
|
+
return validate_package(self.root, mode=mode)
|
|
158
|
+
|
|
159
|
+
def export(
|
|
160
|
+
self,
|
|
161
|
+
output: str | Path,
|
|
162
|
+
mode: str = "archive",
|
|
163
|
+
zip_output: bool | None = None,
|
|
164
|
+
*,
|
|
165
|
+
force: bool = False,
|
|
166
|
+
) -> Path:
|
|
167
|
+
"""Export this package as a directory or zip archive."""
|
|
168
|
+
|
|
169
|
+
from claimtrace.export.directory import export_package
|
|
170
|
+
|
|
171
|
+
output_path = Path(output)
|
|
172
|
+
if zip_output is None:
|
|
173
|
+
zip_output = output_path.suffix.lower() == ".zip"
|
|
174
|
+
return export_package(self.root, output_path, mode=mode, zip_output=zip_output, force=force)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def init_package(
|
|
178
|
+
native: str | Path | list[str | Path] | None = None,
|
|
179
|
+
image: str | Path | list[str | Path] | None = None,
|
|
180
|
+
out: str | Path | None = None,
|
|
181
|
+
template: str = "simple-structure-view",
|
|
182
|
+
artifacts: list[str | Path] | None = None,
|
|
183
|
+
title: str = "Untitled ClaimTrace package",
|
|
184
|
+
force: bool = False,
|
|
185
|
+
blank: bool = False,
|
|
186
|
+
) -> ClaimTracePackage:
|
|
187
|
+
"""Initialize a ClaimTrace package from native, image and optional evidence artifacts."""
|
|
188
|
+
|
|
189
|
+
root = Path(out) if out is not None else Path("scene-package")
|
|
190
|
+
native_paths = _coerce_paths(native)
|
|
191
|
+
image_paths = _coerce_paths(image)
|
|
192
|
+
artifact_paths = _coerce_paths(artifacts)
|
|
193
|
+
input_paths = native_paths + image_paths + artifact_paths
|
|
194
|
+
if not input_paths and not blank:
|
|
195
|
+
raise PackageError("No input files were provided. Add --native, --image or --artifact, or pass --blank.")
|
|
196
|
+
_ensure_inputs_exist(input_paths)
|
|
197
|
+
if force and root.exists() and root.is_dir() and any(root.iterdir()):
|
|
198
|
+
_validate_force_replace_root(root)
|
|
199
|
+
_ensure_inputs_outside_output(input_paths, root)
|
|
200
|
+
_ensure_supported_inputs(native_paths, {".pml", ".cxc", ".mvsj", ".mvsx"}, "native scene")
|
|
201
|
+
_ensure_supported_inputs(
|
|
202
|
+
image_paths,
|
|
203
|
+
{".png", ".jpg", ".jpeg", ".tif", ".tiff", ".gif", ".webp", ".svg"},
|
|
204
|
+
"rendered image",
|
|
205
|
+
)
|
|
206
|
+
tasks = completion_tasks_for_template(template)
|
|
207
|
+
root_was_absent = not root.exists()
|
|
208
|
+
root_was_empty = root.exists() and root.is_dir() and not any(root.iterdir())
|
|
209
|
+
backup_container: Path | None = None
|
|
210
|
+
backup_root: Path | None = None
|
|
211
|
+
if force and root.exists() and root.is_dir() and any(root.iterdir()):
|
|
212
|
+
backup_container = Path(
|
|
213
|
+
tempfile.mkdtemp(
|
|
214
|
+
prefix=f".{root.name}.claimtrace-backup-",
|
|
215
|
+
dir=root.parent.resolve(),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
backup_root = backup_container / "original"
|
|
219
|
+
root.rename(backup_root)
|
|
220
|
+
try:
|
|
221
|
+
semantic = create_package_root(
|
|
222
|
+
root,
|
|
223
|
+
title=title,
|
|
224
|
+
force=force and backup_root is None,
|
|
225
|
+
)
|
|
226
|
+
semantic.package.profile = template
|
|
227
|
+
importers = default_importers(include_experimental=False)
|
|
228
|
+
for path in native_paths:
|
|
229
|
+
_register_and_import(root, semantic, Path(path), importers)
|
|
230
|
+
for path in image_paths:
|
|
231
|
+
_register_and_import(root, semantic, Path(path), importers)
|
|
232
|
+
for path in artifact_paths:
|
|
233
|
+
source = Path(path)
|
|
234
|
+
artifact = register_local_artifact(
|
|
235
|
+
root,
|
|
236
|
+
semantic,
|
|
237
|
+
source,
|
|
238
|
+
copy=True,
|
|
239
|
+
relative_destination=replay_destination_for(semantic, source),
|
|
240
|
+
)
|
|
241
|
+
semantic.add_activity(
|
|
242
|
+
"artifact_registration",
|
|
243
|
+
f"Registered artifact {artifact.id}.",
|
|
244
|
+
generated_ids=[artifact.id],
|
|
245
|
+
)
|
|
246
|
+
ensure_scene_for_images(semantic)
|
|
247
|
+
synchronize_resource_bindings(semantic)
|
|
248
|
+
write_semantic_layer(root, semantic)
|
|
249
|
+
write_completion_tasks(root, tasks + read_completion_tasks(root))
|
|
250
|
+
package = ClaimTracePackage(root=root, semantic_layer=semantic)
|
|
251
|
+
if backup_container is not None:
|
|
252
|
+
shutil.rmtree(backup_container)
|
|
253
|
+
return package
|
|
254
|
+
except Exception:
|
|
255
|
+
if backup_root is not None and backup_root.exists():
|
|
256
|
+
if root.exists():
|
|
257
|
+
if root.is_dir() and not root.is_symlink():
|
|
258
|
+
shutil.rmtree(root)
|
|
259
|
+
else:
|
|
260
|
+
root.unlink()
|
|
261
|
+
backup_root.rename(root)
|
|
262
|
+
if backup_container is not None:
|
|
263
|
+
shutil.rmtree(backup_container)
|
|
264
|
+
elif (root_was_absent or root_was_empty) and root.exists():
|
|
265
|
+
shutil.rmtree(root)
|
|
266
|
+
if root_was_empty:
|
|
267
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def load_package(package: str | Path) -> ClaimTracePackage:
|
|
272
|
+
"""Load an existing ClaimTrace package directory."""
|
|
273
|
+
|
|
274
|
+
root = Path(package)
|
|
275
|
+
if not (root / SEMANTIC_LAYER_FILENAME).exists():
|
|
276
|
+
raise PackageError(f"Missing {SEMANTIC_LAYER_FILENAME}: {root}")
|
|
277
|
+
return ClaimTracePackage(root=root, semantic_layer=load_semantic_layer(root))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def validate_package(package: str | Path | ClaimTracePackage, mode: str = "draft") -> ValidationReport:
|
|
281
|
+
"""Validate a package directory."""
|
|
282
|
+
|
|
283
|
+
from claimtrace.validation.engine import validate_package as _validate
|
|
284
|
+
|
|
285
|
+
root = package.root if isinstance(package, ClaimTracePackage) else Path(package)
|
|
286
|
+
return _validate(root, mode=mode)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def export_package(
|
|
290
|
+
package: str | Path | ClaimTracePackage,
|
|
291
|
+
output: str | Path,
|
|
292
|
+
mode: str = "archive",
|
|
293
|
+
zip_output: bool | None = None,
|
|
294
|
+
*,
|
|
295
|
+
force: bool = False,
|
|
296
|
+
) -> Path:
|
|
297
|
+
"""Export a package as a directory or zip archive."""
|
|
298
|
+
|
|
299
|
+
from claimtrace.export.directory import export_package as _export
|
|
300
|
+
|
|
301
|
+
root = package.root if isinstance(package, ClaimTracePackage) else Path(package)
|
|
302
|
+
output_path = Path(output)
|
|
303
|
+
if zip_output is None:
|
|
304
|
+
zip_output = output_path.suffix == ".zip"
|
|
305
|
+
return _export(root, output_path, mode=mode, zip_output=zip_output, force=force)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _register_and_import(root: Path, semantic: SemanticLayer, source: Path, importers: list[Any]) -> None:
|
|
309
|
+
artifact = register_local_artifact(root, semantic, source, copy=True)
|
|
310
|
+
package_path = root / str(artifact.path)
|
|
311
|
+
plugin = detect_importer(package_path, importers)
|
|
312
|
+
result = plugin.import_into(package_path, artifact.id, semantic)
|
|
313
|
+
append_extraction_report(root, result.importer_name, result.fact_dicts())
|
|
314
|
+
existing_tasks = read_completion_tasks(root)
|
|
315
|
+
write_completion_tasks(root, existing_tasks + result.tasks)
|
|
316
|
+
semantic.add_activity(
|
|
317
|
+
"static_import",
|
|
318
|
+
f"Imported {artifact.path} with {result.importer_name}.",
|
|
319
|
+
used_artifact_ids=[artifact.id],
|
|
320
|
+
importer=result.importer_name,
|
|
321
|
+
warnings=result.warnings,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _coerce_paths(value: Any) -> list[Path]:
|
|
326
|
+
if value is None:
|
|
327
|
+
return []
|
|
328
|
+
if isinstance(value, (str, Path)):
|
|
329
|
+
return [Path(value)]
|
|
330
|
+
return [Path(item) for item in value]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _ensure_inputs_exist(paths: list[Path]) -> None:
|
|
334
|
+
for path in paths:
|
|
335
|
+
if not path.exists():
|
|
336
|
+
raise PackageError(
|
|
337
|
+
f"Input file not found: {path}. "
|
|
338
|
+
"Run from the directory containing the file or pass an absolute path."
|
|
339
|
+
f"{windows_path_hint(path)}"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _ensure_inputs_outside_output(paths: list[Path], root: Path) -> None:
|
|
344
|
+
root_resolved = root.resolve(strict=False)
|
|
345
|
+
for path in paths:
|
|
346
|
+
if path.resolve().is_relative_to(root_resolved):
|
|
347
|
+
raise PackageError(
|
|
348
|
+
f"Input file is inside the output package and could be deleted or overwritten: {path}. "
|
|
349
|
+
"Move the input outside --out or choose a different output directory."
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _ensure_supported_inputs(paths: list[Path], suffixes: set[str], label: str) -> None:
|
|
354
|
+
allowed = ", ".join(sorted(suffixes))
|
|
355
|
+
for path in paths:
|
|
356
|
+
if not path.is_file():
|
|
357
|
+
raise PackageError(f"{label.capitalize()} input must be a regular file: {path}")
|
|
358
|
+
if path.suffix.lower() not in suffixes:
|
|
359
|
+
raise PackageError(
|
|
360
|
+
f"Unsupported {label} format for {path}. Supported extensions: {allowed}. "
|
|
361
|
+
"Use --artifact for other supporting files."
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def semantic_to_json(semantic: SemanticLayer) -> str:
|
|
366
|
+
"""Serialize a CAL object as stable pretty JSON."""
|
|
367
|
+
|
|
368
|
+
return json.dumps(semantic.model_dump(mode="json", exclude_none=True), indent=2) + "\n"
|