erislib 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 (124) hide show
  1. eris/__init__.py +43 -0
  2. eris/__main__.py +5 -0
  3. eris/_provenance.py +104 -0
  4. eris/_version.py +3 -0
  5. eris/api.py +203 -0
  6. eris/cli/__init__.py +1 -0
  7. eris/cli/_config_file.py +83 -0
  8. eris/cli/_shared.py +119 -0
  9. eris/cli/_utils.py +29 -0
  10. eris/cli/batch.py +204 -0
  11. eris/cli/cache.py +152 -0
  12. eris/cli/discovery.py +80 -0
  13. eris/cli/embed.py +83 -0
  14. eris/cli/encode.py +67 -0
  15. eris/cli/export.py +75 -0
  16. eris/cli/features.py +35 -0
  17. eris/cli/info_cmd.py +108 -0
  18. eris/cli/main.py +92 -0
  19. eris/cli/models.py +64 -0
  20. eris/cli/pack.py +28 -0
  21. eris/cli/validate.py +76 -0
  22. eris/core/__init__.py +1 -0
  23. eris/core/aminoacids.py +30 -0
  24. eris/core/artifact_schema.py +903 -0
  25. eris/core/artifacts.py +185 -0
  26. eris/core/base_encoder/__init__.py +7 -0
  27. eris/core/base_encoder/base_atom_encoder.py +32 -0
  28. eris/core/base_encoder/base_encoder.py +373 -0
  29. eris/core/base_encoder/base_residue_encoder.py +68 -0
  30. eris/core/cache.py +147 -0
  31. eris/core/config.py +935 -0
  32. eris/core/hashing.py +23 -0
  33. eris/core/io.py +869 -0
  34. eris/core/learned/__init__.py +50 -0
  35. eris/core/learned/artifacts.py +147 -0
  36. eris/core/learned/backends/__init__.py +1 -0
  37. eris/core/learned/backends/gearnet/__init__.py +21 -0
  38. eris/core/learned/backends/gearnet/backend.py +134 -0
  39. eris/core/learned/backends/gearnet/config.py +37 -0
  40. eris/core/learned/backends/gearnet/dependencies.py +64 -0
  41. eris/core/learned/backends/gearnet/loading.py +83 -0
  42. eris/core/learned/backends/gearnet/metadata.py +140 -0
  43. eris/core/learned/backends/gearnet/native_graph.py +365 -0
  44. eris/core/learned/backends/gearnet/native_model.py +178 -0
  45. eris/core/learned/backends/gearnet/preprocessing.py +294 -0
  46. eris/core/learned/checkpoint_acquisition.py +126 -0
  47. eris/core/learned/checkpoint_cache.py +124 -0
  48. eris/core/learned/checkpoints.py +171 -0
  49. eris/core/learned/config.py +39 -0
  50. eris/core/learned/execution.py +72 -0
  51. eris/core/learned/registry.py +63 -0
  52. eris/core/learned/runner.py +376 -0
  53. eris/core/learned/types.py +126 -0
  54. eris/core/registry.py +266 -0
  55. eris/core/representations/__init__.py +13 -0
  56. eris/core/representations/graph_rep.py +38 -0
  57. eris/core/representations/matrix_rep.py +33 -0
  58. eris/core/representations/output_declarations.py +147 -0
  59. eris/core/representations/point_cloud_rep.py +29 -0
  60. eris/core/representations/voxel_grid_rep.py +24 -0
  61. eris/core/residue_identity.py +18 -0
  62. eris/core/types.py +48 -0
  63. eris/datasets/__init__.py +29 -0
  64. eris/datasets/collate.py +585 -0
  65. eris/datasets/torch_dataset.py +156 -0
  66. eris/discovery.py +158 -0
  67. eris/encoders/__init__.py +7 -0
  68. eris/encoders/_context.py +21 -0
  69. eris/encoders/clouds/__init__.py +20 -0
  70. eris/encoders/clouds/atom_point_cloud.py +76 -0
  71. eris/encoders/clouds/residue_point_cloud.py +71 -0
  72. eris/encoders/geometry/__init__.py +37 -0
  73. eris/encoders/geometry/contact_map_bins_ca.py +34 -0
  74. eris/encoders/geometry/contact_map_ca.py +35 -0
  75. eris/encoders/geometry/distogram_bins_ca.py +92 -0
  76. eris/encoders/geometry/distogram_ca.py +54 -0
  77. eris/encoders/graphs/__init__.py +38 -0
  78. eris/encoders/graphs/atom_knn_graph.py +103 -0
  79. eris/encoders/graphs/residue_contact_graph.py +157 -0
  80. eris/encoders/graphs/residue_knn_graph.py +159 -0
  81. eris/encoders/grids/__init__.py +14 -0
  82. eris/encoders/grids/voxel_grid_ca.py +68 -0
  83. eris/exceptions.py +25 -0
  84. eris/export/__init__.py +6 -0
  85. eris/export/pyg.py +211 -0
  86. eris/export/table.py +116 -0
  87. eris/export/torch.py +362 -0
  88. eris/features/__init__.py +7 -0
  89. eris/features/edge/__init__.py +3 -0
  90. eris/features/edge/edge.py +110 -0
  91. eris/features/edge/features_edge.py +319 -0
  92. eris/features/feature_builders.py +240 -0
  93. eris/features/feature_registry.py +134 -0
  94. eris/features/geometry.py +375 -0
  95. eris/features/graph_stats.py +144 -0
  96. eris/features/grid/__init__.py +3 -0
  97. eris/features/grid/features_grid.py +360 -0
  98. eris/features/grid/grid.py +101 -0
  99. eris/features/interface.py +129 -0
  100. eris/features/node/__init__.py +4 -0
  101. eris/features/node/atom.py +46 -0
  102. eris/features/node/constants.py +435 -0
  103. eris/features/node/external.py +513 -0
  104. eris/features/node/features_atom.py +130 -0
  105. eris/features/node/features_residue.py +900 -0
  106. eris/features/node/residue.py +125 -0
  107. eris/features/rbf.py +37 -0
  108. eris/features/structure_annotations.py +307 -0
  109. eris/features/utils.py +120 -0
  110. eris/pipelines/__init__.py +12 -0
  111. eris/pipelines/batch_encode.py +558 -0
  112. eris/pipelines/manifest.py +238 -0
  113. eris/pipelines/pack.py +413 -0
  114. eris/pooling.py +242 -0
  115. eris/py.typed +0 -0
  116. eris/spatial/__init__.py +1 -0
  117. eris/spatial/contact_graph.py +38 -0
  118. eris/spatial/knn.py +145 -0
  119. eris/spatial/spatial_index.py +54 -0
  120. erislib-0.1.0.dist-info/METADATA +251 -0
  121. erislib-0.1.0.dist-info/RECORD +124 -0
  122. erislib-0.1.0.dist-info/WHEEL +4 -0
  123. erislib-0.1.0.dist-info/entry_points.txt +2 -0
  124. erislib-0.1.0.dist-info/licenses/LICENSE +21 -0
eris/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Eris public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from eris._version import __version__
6
+ from eris.api import embed, encode, pool
7
+ from eris.cli._config_file import ConfigIssue
8
+ from eris.cli.validate import validate_config
9
+ from eris.discovery import (
10
+ get_embedder_info,
11
+ get_representation_info,
12
+ list_embedders,
13
+ list_representations,
14
+ )
15
+ from eris.exceptions import RepresentationNotFoundError
16
+ from eris.export.pyg import to_pyg
17
+ from eris.export.table import feature_table
18
+ from eris.export.torch import to_torch
19
+ from eris.pipelines.batch_encode import batch_embed, batch_encode
20
+ from eris.pipelines.pack import pack_artifacts as pack
21
+
22
+ __all__ = [
23
+ "ConfigIssue",
24
+ "RepresentationNotFoundError",
25
+ "__version__",
26
+ "batch_embed",
27
+ "batch_encode",
28
+ "embed",
29
+ "encode",
30
+ "feature_table",
31
+ "get_embedder_info",
32
+ "get_representation_info",
33
+ "list_embedders",
34
+ "list_representations",
35
+ "pack",
36
+ "pool",
37
+ "to_pyg",
38
+ "to_torch",
39
+ "validate_config",
40
+ ]
41
+
42
+ # register built-in encoders
43
+ from eris import encoders as _encoders # noqa: F401
eris/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for ``python -m eris``."""
2
+
3
+ from eris.cli.main import main
4
+
5
+ raise SystemExit(main())
eris/_provenance.py ADDED
@@ -0,0 +1,104 @@
1
+ """Provenance sidecar (``provenance/1``) shared by the ecosystem CLIs.
2
+
3
+ Copied into each library rather than imported, so the libraries stay independent.
4
+ Keep copies in sync with the contract's ``provenance.schema.json``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import platform
12
+ from datetime import UTC, datetime
13
+ from importlib import metadata
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable, Mapping
18
+ from pathlib import Path
19
+
20
+ SCHEMA_VERSION = "provenance/1"
21
+ _CHUNK = 1 << 20
22
+
23
+
24
+ def utc_now() -> str:
25
+ """Return the current UTC time as ``YYYY-MM-DDTHH:MM:SSZ``."""
26
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
27
+
28
+
29
+ def sha256_path(path: Path) -> str:
30
+ r"""Hash a file, or a directory as the sorted ``relative/path\\tfile_sha256`` lines of its files."""
31
+ if path.is_dir():
32
+ lines = (
33
+ f"{p.relative_to(path).as_posix()}\t{_sha256_file(p)}\n"
34
+ for p in sorted(path.rglob("*"))
35
+ if p.is_file()
36
+ )
37
+ return hashlib.sha256("".join(lines).encode()).hexdigest()
38
+ return _sha256_file(path)
39
+
40
+
41
+ def _sha256_file(path: Path) -> str:
42
+ digest = hashlib.sha256()
43
+ with path.open("rb") as handle:
44
+ for chunk in iter(lambda: handle.read(_CHUNK), b""):
45
+ digest.update(chunk)
46
+ return digest.hexdigest()
47
+
48
+
49
+ def canonical_json(value: Any) -> str:
50
+ """Serialize deterministically; paths and other non-JSON scalars become strings."""
51
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
52
+
53
+
54
+ def fingerprint(parameters: Mapping[str, Any]) -> str:
55
+ """Return ``sha256:<hex>`` of the canonical JSON of ``parameters``."""
56
+ return "sha256:" + hashlib.sha256(canonical_json(parameters).encode()).hexdigest()
57
+
58
+
59
+ def _package_version(name: str) -> str | None:
60
+ try:
61
+ return metadata.version(name)
62
+ except metadata.PackageNotFoundError:
63
+ return None
64
+
65
+
66
+ def write_provenance(
67
+ output: Path,
68
+ *,
69
+ tool: str,
70
+ version: str,
71
+ command: str,
72
+ created_at: str,
73
+ parameters: Mapping[str, Any],
74
+ inputs: Iterable[Path] = (),
75
+ packages: Iterable[str] = (),
76
+ output_info: Mapping[str, int] | None = None,
77
+ extensions: Mapping[str, Any] | None = None,
78
+ ) -> Path:
79
+ """Write ``<output>.provenance.json`` next to ``output`` (a file or a directory) and return its path.
80
+
81
+ Call it after ``output`` is complete; ``created_at`` is the ``utc_now()`` taken when the run started.
82
+ """
83
+ document = {
84
+ "schema_version": SCHEMA_VERSION,
85
+ "tool": {"name": tool, "version": version},
86
+ "command": command,
87
+ "created_at": created_at,
88
+ "finished_at": utc_now(),
89
+ "parameters": json.loads(canonical_json(parameters)),
90
+ "config_fingerprint": fingerprint(parameters),
91
+ "inputs": [{"path": str(path), "sha256": sha256_path(path)} for path in inputs],
92
+ "outputs": [{"path": str(output), "sha256": sha256_path(output), **(output_info or {})}],
93
+ "environment": {
94
+ "python": platform.python_version(),
95
+ "platform": platform.platform(),
96
+ "packages": {name: _package_version(name) for name in packages},
97
+ },
98
+ "extensions": dict(extensions or {}),
99
+ }
100
+ target = output.with_name(output.name + ".provenance.json")
101
+ target.write_text(
102
+ json.dumps(document, indent=2, ensure_ascii=False, default=str) + "\n", encoding="utf-8"
103
+ )
104
+ return target
eris/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.1.0"
eris/api.py ADDED
@@ -0,0 +1,203 @@
1
+ """Public encode and embed entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, cast
8
+
9
+ from eris.core.artifacts import StructureArtifact
10
+ from eris.core.config import CanonicalRepresentationConfig
11
+ from eris.core.registry import (
12
+ get_encoder,
13
+ resolve_representation_config,
14
+ validate_resolved_representation_config,
15
+ )
16
+ from eris.core.types import StructureInput
17
+ from eris.exceptions import ConfigError, RepresentationNotFoundError
18
+ from eris.pooling import pool
19
+
20
+ if TYPE_CHECKING:
21
+ from eris.core.learned.config import OutputMode
22
+ from eris.core.learned.execution import Precision
23
+
24
+
25
+ class _NoOverride:
26
+ """Sentinel type for an argument that was not overridden."""
27
+
28
+ def __repr__(self) -> str:
29
+ return "<no override>"
30
+
31
+
32
+ _NO_OVERRIDE = _NoOverride()
33
+
34
+
35
+ def _normalize_chain_id(value: object) -> tuple[str, ...] | None:
36
+ if value is None:
37
+ return None
38
+ if isinstance(value, str):
39
+ normalized = (value,)
40
+ elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
41
+ normalized = tuple(value)
42
+ else:
43
+ raise ConfigError("chain_id must be None, a string, or a sequence of strings")
44
+ if not normalized:
45
+ raise ConfigError("chain_id sequence must not be empty")
46
+ if any(not isinstance(chain, str) for chain in normalized):
47
+ raise ConfigError("chain_id sequence entries must be strings")
48
+ if len(normalized) != len(set(normalized)):
49
+ raise ConfigError("chain_id sequence must not contain duplicates")
50
+ return normalized
51
+
52
+
53
+ def _validate_model_index(value: object) -> None:
54
+ if type(value) is not int or value < 0:
55
+ raise ConfigError("model_index must be a plain integer >= 0")
56
+
57
+
58
+ def _coerce_structure_input(
59
+ input: str | Path | StructureInput, # noqa: A002
60
+ *,
61
+ chain_id: str | Sequence[str] | _NoOverride | None,
62
+ model_index: int | _NoOverride,
63
+ ) -> StructureInput:
64
+ if isinstance(input, StructureInput):
65
+ if chain_id is not _NO_OVERRIDE:
66
+ normalized_chain_id = _normalize_chain_id(chain_id)
67
+ if normalized_chain_id != input.chain_id:
68
+ raise ConfigError(
69
+ f"chain_id override conflicts with StructureInput: {chain_id!r} != {input.chain_id!r}"
70
+ )
71
+ if model_index is not _NO_OVERRIDE:
72
+ _validate_model_index(model_index)
73
+ if model_index != input.model_index:
74
+ raise ConfigError(
75
+ "model_index override conflicts with StructureInput: "
76
+ f"{model_index!r} != {input.model_index!r}"
77
+ )
78
+ return input
79
+
80
+ if not isinstance(input, (str, Path)):
81
+ raise TypeError("input must be a string, pathlib.Path, or StructureInput")
82
+
83
+ effective_chain = None if chain_id is _NO_OVERRIDE else chain_id
84
+ effective_model = 0 if model_index is _NO_OVERRIDE else model_index
85
+ effective_chain = _normalize_chain_id(effective_chain)
86
+ _validate_model_index(effective_model)
87
+ return StructureInput(
88
+ source=str(input),
89
+ chain_id=effective_chain,
90
+ model_index=cast("int", effective_model),
91
+ )
92
+
93
+
94
+ def _resolve_config(
95
+ representation: str,
96
+ *,
97
+ config: CanonicalRepresentationConfig | None,
98
+ params: dict[str, Any],
99
+ ) -> CanonicalRepresentationConfig:
100
+ if config is not None:
101
+ return validate_resolved_representation_config(
102
+ representation,
103
+ config=config,
104
+ params=params,
105
+ )
106
+ return resolve_representation_config(
107
+ representation,
108
+ params=params,
109
+ )
110
+
111
+
112
+ def encode(
113
+ input: str | Path | StructureInput, # noqa: A002
114
+ *,
115
+ representation: str,
116
+ chain_id: str | Sequence[str] | _NoOverride | None = _NO_OVERRIDE,
117
+ model_index: int | _NoOverride = _NO_OVERRIDE,
118
+ config: CanonicalRepresentationConfig | None = None,
119
+ output: str | Path | None = None,
120
+ use_cache: bool = True,
121
+ **params: Any,
122
+ ) -> StructureArtifact:
123
+ """Encode one structure with a canonical explicit representation."""
124
+ if type(use_cache) is not bool:
125
+ raise ConfigError("use_cache must be a bool")
126
+ if output is not None and not isinstance(output, (str, Path)):
127
+ raise TypeError("output must be a string, pathlib.Path, or None")
128
+
129
+ resolved_config = _resolve_config(
130
+ representation,
131
+ config=config,
132
+ params=params,
133
+ )
134
+ structure_input = _coerce_structure_input(
135
+ input,
136
+ chain_id=chain_id,
137
+ model_index=model_index,
138
+ )
139
+ encoder = get_encoder(
140
+ representation,
141
+ config=resolved_config,
142
+ use_cache=use_cache,
143
+ )
144
+ artifact = encoder.encode(structure_input)
145
+ if output is not None:
146
+ artifact.save(Path(output))
147
+ return artifact
148
+
149
+
150
+ def embed(
151
+ input: str | Path | StructureInput, # noqa: A002
152
+ *,
153
+ representation: str,
154
+ chain_id: str | Sequence[str] | _NoOverride | None = _NO_OVERRIDE,
155
+ model_index: int | _NoOverride = _NO_OVERRIDE,
156
+ output_mode: str = "both",
157
+ checkpoint_path: str | Path | None = None,
158
+ device: str = "auto",
159
+ precision: str = "fp32",
160
+ output: str | Path | None = None,
161
+ use_cache: bool = True,
162
+ ) -> StructureArtifact:
163
+ """Embed one structure with a registered learned representation."""
164
+ from eris.core.learned import StructuralEmbedderRunner, StructuralExecutionConfig # noqa: PLC0415
165
+ from eris.core.learned.registry import learned_registry # noqa: PLC0415
166
+
167
+ try:
168
+ info = learned_registry.get_info(representation)
169
+ except KeyError as error:
170
+ raise RepresentationNotFoundError(f"Unknown embedder {representation!r}") from error
171
+
172
+ if type(use_cache) is not bool:
173
+ raise ConfigError("use_cache must be a bool")
174
+ if output is not None and not isinstance(output, (str, Path)):
175
+ raise TypeError("output must be a string, pathlib.Path, or None")
176
+ if checkpoint_path is not None and not isinstance(checkpoint_path, (str, Path)):
177
+ raise TypeError("checkpoint_path must be a string, pathlib.Path, or None")
178
+
179
+ structure_input = _coerce_structure_input(
180
+ input,
181
+ chain_id=chain_id,
182
+ model_index=model_index,
183
+ )
184
+ config = info.config_type(
185
+ representation_name=info.representation_name,
186
+ output_mode=cast("OutputMode", output_mode),
187
+ )
188
+ execution = StructuralExecutionConfig(device=device, precision=cast("Precision", precision))
189
+
190
+ artifact = StructuralEmbedderRunner().run(
191
+ structure_input,
192
+ config=config,
193
+ model=info.model_descriptor,
194
+ checkpoint_path=checkpoint_path,
195
+ execution=execution,
196
+ use_artifact_cache=use_cache,
197
+ )
198
+ if output is not None:
199
+ artifact.save(Path(output))
200
+ return artifact
201
+
202
+
203
+ __all__ = ["embed", "encode", "pool"]
eris/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Command-line interface."""
@@ -0,0 +1,83 @@
1
+ """``--config`` files whose keys mirror a Typer command's options.
2
+
3
+ Keys are option parameter names (``--max-length`` -> ``max_length``). Values are checked with the
4
+ option's own Click type (through Typer); the callers install accepted files as the context's ``default_map``,
5
+ so the precedence is defaults < config file < options on the command line.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import asdict, dataclass
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import typer
15
+ import yaml
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ConfigIssue:
23
+ """One problem found in a config file; ``path`` is ``""`` for the root."""
24
+
25
+ path: str
26
+ message: str
27
+
28
+
29
+ def read_config(path: Path) -> tuple[dict[str, Any], list[ConfigIssue]]:
30
+ """Parse a YAML (or JSON) config whose root must be a mapping. An empty file is ``{}``."""
31
+ try:
32
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
33
+ except (OSError, yaml.YAMLError) as exc:
34
+ return {}, [ConfigIssue("", f"cannot read config: {exc}")]
35
+ if raw is None:
36
+ return {}, []
37
+ if not isinstance(raw, dict):
38
+ return {}, [ConfigIssue("", f"config root must be a mapping, got {type(raw).__name__}")]
39
+ return raw, []
40
+
41
+
42
+ def check_config(command: Any, raw: dict[str, Any], *, exclude: frozenset[str]) -> list[ConfigIssue]:
43
+ """Check every key and value against ``command``'s options.
44
+
45
+ ``exclude`` names options that must stay on the command line.
46
+ """
47
+ ctx = typer.Context(command)
48
+ # duck-typed: Typer >=0.27 vendors Click, so isinstance checks against `click` fail
49
+ options = {
50
+ p.name: p for p in command.params if getattr(p, "param_type_name", None) == "option" and p.name
51
+ }
52
+ issues: list[ConfigIssue] = []
53
+ for key, value in raw.items():
54
+ param = options.get(key)
55
+ if param is None:
56
+ issues.append(ConfigIssue(str(key), "unknown key"))
57
+ elif key in exclude:
58
+ issues.append(ConfigIssue(key, f"pass {param.opts[0]} on the command line, not in the config"))
59
+ elif param.multiple and not isinstance(value, list):
60
+ issues.append(ConfigIssue(key, f"expected a list, got {type(value).__name__}"))
61
+ # Click would coerce a number into a text or path option
62
+ elif param.type.name in ("str", "path") and not param.multiple and not isinstance(value, str):
63
+ issues.append(ConfigIssue(key, f"expected a string, got {type(value).__name__}"))
64
+ else:
65
+ try:
66
+ param.type_cast_value(ctx, value)
67
+ except typer.BadParameter as exc:
68
+ issues.append(ConfigIssue(key, exc.format_message()))
69
+ except AttributeError: # Click's bool type calls .strip() on non-strings such as 3
70
+ issues.append(ConfigIssue(key, f"invalid value {value!r}"))
71
+ return issues
72
+
73
+
74
+ def report_issues(issues: list[ConfigIssue], *, as_json: bool) -> int:
75
+ """Print issues in the contract's format and return the exit code (0 valid, 2 invalid)."""
76
+ if as_json:
77
+ typer.echo(json.dumps([asdict(issue) for issue in issues]))
78
+ else:
79
+ for issue in issues:
80
+ typer.echo(f"{issue.path or '<root>'}: {issue.message}", err=True)
81
+ if not issues and not as_json:
82
+ typer.echo("config is valid", err=True)
83
+ return 2 if issues else 0
eris/cli/_shared.py ADDED
@@ -0,0 +1,119 @@
1
+ """Shared CLI pieces: ``--config`` handling, config validation and the provenance sidecar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from dataclasses import fields
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+
13
+ import eris
14
+ from eris._provenance import write_provenance
15
+ from eris.cli._config_file import ConfigIssue, check_config, read_config
16
+ from eris.core.registry import get_encoder_info
17
+
18
+ # Options that stay on the command line and are not provenance parameters.
19
+ CONFIG_EXCLUDE = frozenset({"out", "out_path", "artifact_dir", "results", "checkpoint", "config"})
20
+
21
+
22
+ def check_params(representation: str, params: Any) -> list[ConfigIssue]:
23
+ """Check representation parameters against the representation's own config dataclass."""
24
+ try:
25
+ config_type = get_encoder_info(representation).config_type
26
+ except KeyError:
27
+ return [ConfigIssue("representation", f"unknown representation {representation!r}")]
28
+ if not isinstance(params, Mapping):
29
+ return [ConfigIssue("params", f"expected a mapping, got {type(params).__name__}")]
30
+ known = {f.name for f in fields(config_type)}
31
+ issues = [ConfigIssue(f"params.{key}", "unknown parameter") for key in params if key not in known]
32
+ for key in params.keys() & known:
33
+ try:
34
+ config_type(**{key: params[key]})
35
+ except Exception as exc: # noqa: BLE001 - any constructor failure is a config problem
36
+ issues.append(ConfigIssue(f"params.{key}", str(exc)))
37
+ if not issues:
38
+ try:
39
+ config_type(**params)
40
+ except Exception as exc: # noqa: BLE001 - any constructor failure is a config problem
41
+ issues.append(ConfigIssue("params", str(exc)))
42
+ return issues
43
+
44
+
45
+ def config_issues(command: Any, path: Path) -> tuple[dict[str, Any], list[ConfigIssue]]:
46
+ """Read ``path`` and collect every problem with its options and nested ``params``."""
47
+ raw, issues = read_config(path)
48
+ if issues:
49
+ return raw, issues
50
+ return raw, mapping_issues(command, raw)
51
+
52
+
53
+ def mapping_issues(command: Any, raw: dict[str, Any]) -> list[ConfigIssue]:
54
+ """Collect every problem of a parsed config with ``command``'s options and nested ``params``."""
55
+ rest = {key: value for key, value in raw.items() if key != "params"}
56
+ issues = check_config(command, rest, exclude=CONFIG_EXCLUDE)
57
+ if "params" in raw:
58
+ if any(getattr(p, "name", None) == "params" for p in command.params):
59
+ if isinstance(raw.get("representation"), str):
60
+ issues += check_params(raw["representation"], raw["params"])
61
+ elif not isinstance(raw["params"], Mapping):
62
+ issues.append(
63
+ ConfigIssue("params", f"expected a mapping, got {type(raw['params']).__name__}")
64
+ )
65
+ else:
66
+ issues.append(ConfigIssue("params", "unknown key"))
67
+ return issues
68
+
69
+
70
+ def config_callback(ctx: typer.Context, value: Path | None) -> Path | None:
71
+ """Eager ``--config`` callback: install the file as defaults, so flags still win."""
72
+ if value is None:
73
+ return None
74
+ raw, issues = config_issues(ctx.command, value)
75
+ if issues:
76
+ lines = "\n".join(f" {issue.path or '<root>'}: {issue.message}" for issue in issues)
77
+ raise typer.BadParameter(f"{value}:\n{lines}", ctx=ctx, param_hint="'--config'")
78
+ defaults = dict(raw)
79
+ if "params" in defaults: # the option takes a JSON string; the file holds a mapping
80
+ defaults["params"] = json.dumps(defaults["params"])
81
+ ctx.default_map = {**(ctx.default_map or {}), **defaults}
82
+ return value
83
+
84
+
85
+ CONFIG_OPTION = typer.Option(
86
+ "--config",
87
+ callback=config_callback,
88
+ is_eager=True,
89
+ dir_okay=False,
90
+ help="YAML/JSON file with option values; representation parameters go under 'params'.",
91
+ )
92
+
93
+
94
+ def write_run_provenance(
95
+ ctx: typer.Context,
96
+ output: Path,
97
+ *,
98
+ command: str,
99
+ created_at: str,
100
+ inputs: list[Path],
101
+ extensions: dict[str, Any] | None = None,
102
+ ) -> None:
103
+ """Write the provenance sidecar for a finished run."""
104
+ parameters = {k: v for k, v in ctx.params.items() if k not in CONFIG_EXCLUDE}
105
+ for path_option in ("input", "manifest", "folder", "index", "artifact_root"):
106
+ parameters.pop(path_option, None)
107
+ if parameters.get("params") is not None: # record the parsed mapping, not the JSON string
108
+ parameters["params"] = json.loads(parameters["params"])
109
+ write_provenance(
110
+ output,
111
+ tool="eris",
112
+ version=eris.__version__,
113
+ command=command,
114
+ created_at=created_at,
115
+ parameters=parameters,
116
+ inputs=inputs,
117
+ packages=("erislib", "numpy", "polars"),
118
+ extensions=extensions,
119
+ )
eris/cli/_utils.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import NoReturn
6
+
7
+ import typer
8
+ from platformdirs import user_cache_dir
9
+
10
+
11
+ def usage_error(message: str) -> NoReturn:
12
+ typer.echo(message, err=True)
13
+ raise typer.Exit(code=2)
14
+
15
+
16
+ def parse_json_object(params: str | None) -> dict[str, object]:
17
+ if params is None:
18
+ return {}
19
+ try:
20
+ value = json.loads(params)
21
+ except json.JSONDecodeError as error:
22
+ usage_error(f"Invalid JSON for --params: {error.msg}")
23
+ if not isinstance(value, dict):
24
+ usage_error("--params must be a JSON object")
25
+ return value
26
+
27
+
28
+ def cache_root() -> Path:
29
+ return Path(user_cache_dir("eris"))