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.
Files changed (57) hide show
  1. claimtrace/__init__.py +19 -0
  2. claimtrace/__main__.py +5 -0
  3. claimtrace/_finite.py +23 -0
  4. claimtrace/_simple_yaml.py +184 -0
  5. claimtrace/api.py +368 -0
  6. claimtrace/cli.py +1657 -0
  7. claimtrace/completion.py +1631 -0
  8. claimtrace/constants.py +13 -0
  9. claimtrace/errors.py +21 -0
  10. claimtrace/export/__init__.py +5 -0
  11. claimtrace/export/directory.py +223 -0
  12. claimtrace/export/experimental/__init__.py +2 -0
  13. claimtrace/export/experimental/datacite.py +19 -0
  14. claimtrace/export/experimental/ro_crate.py +22 -0
  15. claimtrace/export/readme.py +705 -0
  16. claimtrace/export/zip.py +23 -0
  17. claimtrace/importers/__init__.py +17 -0
  18. claimtrace/importers/base.py +96 -0
  19. claimtrace/importers/chimerax.py +953 -0
  20. claimtrace/importers/directory.py +43 -0
  21. claimtrace/importers/experimental/__init__.py +2 -0
  22. claimtrace/importers/experimental/chimerax.py +64 -0
  23. claimtrace/importers/experimental/molviewspec.py +76 -0
  24. claimtrace/importers/generic.py +42 -0
  25. claimtrace/importers/image.py +182 -0
  26. claimtrace/importers/molviewspec.py +1330 -0
  27. claimtrace/importers/pymol.py +1297 -0
  28. claimtrace/models/__init__.py +68 -0
  29. claimtrace/models/artifacts.py +5 -0
  30. claimtrace/models/provenance.py +5 -0
  31. claimtrace/models/semantic_layer.py +566 -0
  32. claimtrace/models/validation.py +68 -0
  33. claimtrace/package/__init__.py +2 -0
  34. claimtrace/package/checksums.py +74 -0
  35. claimtrace/package/layout.py +229 -0
  36. claimtrace/package/paths.py +102 -0
  37. claimtrace/package/registry.py +373 -0
  38. claimtrace/package/resources.py +183 -0
  39. claimtrace/path_hints.py +22 -0
  40. claimtrace/py.typed +1 -0
  41. claimtrace/resources/schema/semantic-layer.schema.json +2213 -0
  42. claimtrace/resources/schema/validation-report.schema.json +278 -0
  43. claimtrace/resources/templates/ligand-binding-pose.yml +48 -0
  44. claimtrace/resources/templates/simple-structure-view.yml +32 -0
  45. claimtrace/resources/validation/rules.yaml +138 -0
  46. claimtrace/schemas.py +29 -0
  47. claimtrace/validation/__init__.py +5 -0
  48. claimtrace/validation/engine.py +2688 -0
  49. claimtrace/validation/findings.py +36 -0
  50. claimtrace/validation/rules.py +67 -0
  51. claimtrace/visual_semantics.py +394 -0
  52. claimtrace-0.1.0.dist-info/METADATA +497 -0
  53. claimtrace-0.1.0.dist-info/RECORD +57 -0
  54. claimtrace-0.1.0.dist-info/WHEEL +5 -0
  55. claimtrace-0.1.0.dist-info/entry_points.txt +2 -0
  56. claimtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. 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
@@ -0,0 +1,5 @@
1
+ from claimtrace.cli import app
2
+
3
+
4
+ if __name__ == "__main__":
5
+ app()
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"