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.
- eris/__init__.py +43 -0
- eris/__main__.py +5 -0
- eris/_provenance.py +104 -0
- eris/_version.py +3 -0
- eris/api.py +203 -0
- eris/cli/__init__.py +1 -0
- eris/cli/_config_file.py +83 -0
- eris/cli/_shared.py +119 -0
- eris/cli/_utils.py +29 -0
- eris/cli/batch.py +204 -0
- eris/cli/cache.py +152 -0
- eris/cli/discovery.py +80 -0
- eris/cli/embed.py +83 -0
- eris/cli/encode.py +67 -0
- eris/cli/export.py +75 -0
- eris/cli/features.py +35 -0
- eris/cli/info_cmd.py +108 -0
- eris/cli/main.py +92 -0
- eris/cli/models.py +64 -0
- eris/cli/pack.py +28 -0
- eris/cli/validate.py +76 -0
- eris/core/__init__.py +1 -0
- eris/core/aminoacids.py +30 -0
- eris/core/artifact_schema.py +903 -0
- eris/core/artifacts.py +185 -0
- eris/core/base_encoder/__init__.py +7 -0
- eris/core/base_encoder/base_atom_encoder.py +32 -0
- eris/core/base_encoder/base_encoder.py +373 -0
- eris/core/base_encoder/base_residue_encoder.py +68 -0
- eris/core/cache.py +147 -0
- eris/core/config.py +935 -0
- eris/core/hashing.py +23 -0
- eris/core/io.py +869 -0
- eris/core/learned/__init__.py +50 -0
- eris/core/learned/artifacts.py +147 -0
- eris/core/learned/backends/__init__.py +1 -0
- eris/core/learned/backends/gearnet/__init__.py +21 -0
- eris/core/learned/backends/gearnet/backend.py +134 -0
- eris/core/learned/backends/gearnet/config.py +37 -0
- eris/core/learned/backends/gearnet/dependencies.py +64 -0
- eris/core/learned/backends/gearnet/loading.py +83 -0
- eris/core/learned/backends/gearnet/metadata.py +140 -0
- eris/core/learned/backends/gearnet/native_graph.py +365 -0
- eris/core/learned/backends/gearnet/native_model.py +178 -0
- eris/core/learned/backends/gearnet/preprocessing.py +294 -0
- eris/core/learned/checkpoint_acquisition.py +126 -0
- eris/core/learned/checkpoint_cache.py +124 -0
- eris/core/learned/checkpoints.py +171 -0
- eris/core/learned/config.py +39 -0
- eris/core/learned/execution.py +72 -0
- eris/core/learned/registry.py +63 -0
- eris/core/learned/runner.py +376 -0
- eris/core/learned/types.py +126 -0
- eris/core/registry.py +266 -0
- eris/core/representations/__init__.py +13 -0
- eris/core/representations/graph_rep.py +38 -0
- eris/core/representations/matrix_rep.py +33 -0
- eris/core/representations/output_declarations.py +147 -0
- eris/core/representations/point_cloud_rep.py +29 -0
- eris/core/representations/voxel_grid_rep.py +24 -0
- eris/core/residue_identity.py +18 -0
- eris/core/types.py +48 -0
- eris/datasets/__init__.py +29 -0
- eris/datasets/collate.py +585 -0
- eris/datasets/torch_dataset.py +156 -0
- eris/discovery.py +158 -0
- eris/encoders/__init__.py +7 -0
- eris/encoders/_context.py +21 -0
- eris/encoders/clouds/__init__.py +20 -0
- eris/encoders/clouds/atom_point_cloud.py +76 -0
- eris/encoders/clouds/residue_point_cloud.py +71 -0
- eris/encoders/geometry/__init__.py +37 -0
- eris/encoders/geometry/contact_map_bins_ca.py +34 -0
- eris/encoders/geometry/contact_map_ca.py +35 -0
- eris/encoders/geometry/distogram_bins_ca.py +92 -0
- eris/encoders/geometry/distogram_ca.py +54 -0
- eris/encoders/graphs/__init__.py +38 -0
- eris/encoders/graphs/atom_knn_graph.py +103 -0
- eris/encoders/graphs/residue_contact_graph.py +157 -0
- eris/encoders/graphs/residue_knn_graph.py +159 -0
- eris/encoders/grids/__init__.py +14 -0
- eris/encoders/grids/voxel_grid_ca.py +68 -0
- eris/exceptions.py +25 -0
- eris/export/__init__.py +6 -0
- eris/export/pyg.py +211 -0
- eris/export/table.py +116 -0
- eris/export/torch.py +362 -0
- eris/features/__init__.py +7 -0
- eris/features/edge/__init__.py +3 -0
- eris/features/edge/edge.py +110 -0
- eris/features/edge/features_edge.py +319 -0
- eris/features/feature_builders.py +240 -0
- eris/features/feature_registry.py +134 -0
- eris/features/geometry.py +375 -0
- eris/features/graph_stats.py +144 -0
- eris/features/grid/__init__.py +3 -0
- eris/features/grid/features_grid.py +360 -0
- eris/features/grid/grid.py +101 -0
- eris/features/interface.py +129 -0
- eris/features/node/__init__.py +4 -0
- eris/features/node/atom.py +46 -0
- eris/features/node/constants.py +435 -0
- eris/features/node/external.py +513 -0
- eris/features/node/features_atom.py +130 -0
- eris/features/node/features_residue.py +900 -0
- eris/features/node/residue.py +125 -0
- eris/features/rbf.py +37 -0
- eris/features/structure_annotations.py +307 -0
- eris/features/utils.py +120 -0
- eris/pipelines/__init__.py +12 -0
- eris/pipelines/batch_encode.py +558 -0
- eris/pipelines/manifest.py +238 -0
- eris/pipelines/pack.py +413 -0
- eris/pooling.py +242 -0
- eris/py.typed +0 -0
- eris/spatial/__init__.py +1 -0
- eris/spatial/contact_graph.py +38 -0
- eris/spatial/knn.py +145 -0
- eris/spatial/spatial_index.py +54 -0
- erislib-0.1.0.dist-info/METADATA +251 -0
- erislib-0.1.0.dist-info/RECORD +124 -0
- erislib-0.1.0.dist-info/WHEEL +4 -0
- erislib-0.1.0.dist-info/entry_points.txt +2 -0
- 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
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
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."""
|
eris/cli/_config_file.py
ADDED
|
@@ -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"))
|