meds-algorithm 0.4.0a4__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 (46) hide show
  1. meds_algorithm/ARCHITECTURE.md +376 -0
  2. meds_algorithm/CONTRACT.md +567 -0
  3. meds_algorithm/__init__.py +94 -0
  4. meds_algorithm/algorithms.py +157 -0
  5. meds_algorithm/artifacts/__init__.py +1 -0
  6. meds_algorithm/artifacts/inputs.py +38 -0
  7. meds_algorithm/artifacts/manifest.py +142 -0
  8. meds_algorithm/artifacts/publication.py +68 -0
  9. meds_algorithm/artifacts/references.py +79 -0
  10. meds_algorithm/artifacts/results.py +76 -0
  11. meds_algorithm/configuration.py +64 -0
  12. meds_algorithm/conformance/__init__.py +1 -0
  13. meds_algorithm/conformance/assertions.py +62 -0
  14. meds_algorithm/conformance/end_to_end.py +150 -0
  15. meds_algorithm/conformance/meds_dev.py +127 -0
  16. meds_algorithm/conformance/reports.py +49 -0
  17. meds_algorithm/conformance/signal.py +141 -0
  18. meds_algorithm/conformance/types.py +29 -0
  19. meds_algorithm/contracts/__init__.py +1 -0
  20. meds_algorithm/contracts/commands.py +182 -0
  21. meds_algorithm/contracts/enums.py +28 -0
  22. meds_algorithm/data/__init__.py +1 -0
  23. meds_algorithm/data/index.py +56 -0
  24. meds_algorithm/data/labels.py +55 -0
  25. meds_algorithm/data/predicates.py +60 -0
  26. meds_algorithm/data/validation.py +25 -0
  27. meds_algorithm/evaluation/__init__.py +1 -0
  28. meds_algorithm/evaluation/predictions.py +56 -0
  29. meds_algorithm/integrations/__init__.py +1 -0
  30. meds_algorithm/integrations/meds_dev.py +51 -0
  31. meds_algorithm/requests.py +262 -0
  32. meds_algorithm/runner.py +232 -0
  33. meds_algorithm/runtime/__init__.py +1 -0
  34. meds_algorithm/runtime/process.py +50 -0
  35. meds_algorithm/runtime/provenance.py +31 -0
  36. meds_algorithm/runtime/resources.py +97 -0
  37. meds_algorithm/serialization.py +122 -0
  38. meds_algorithm/spec.py +23 -0
  39. meds_algorithm/tasks/__init__.py +1 -0
  40. meds_algorithm/tasks/spec.py +136 -0
  41. meds_algorithm/version.py +3 -0
  42. meds_algorithm-0.4.0a4.dist-info/METADATA +212 -0
  43. meds_algorithm-0.4.0a4.dist-info/RECORD +46 -0
  44. meds_algorithm-0.4.0a4.dist-info/WHEEL +4 -0
  45. meds_algorithm-0.4.0a4.dist-info/entry_points.txt +4 -0
  46. meds_algorithm-0.4.0a4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,182 @@
1
+ """Derived legacy contract views and compatibility dispatch.
2
+
3
+ Profile ABCs own lifecycle order; typed requests own argument definitions. These views keep existing
4
+ manifest readers and callers working without maintaining a second specification.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import MISSING, dataclass
12
+ from pathlib import Path
13
+ from types import MappingProxyType
14
+ from typing import Any
15
+
16
+ from ..algorithms import profile_classes, request_type
17
+ from ..artifacts.references import ArtifactReference
18
+ from ..requests import PredictionIndex, TrainingLabels
19
+ from ..tasks.spec import TaskSpec
20
+ from .enums import ArtifactType as ArtifactType
21
+ from .enums import CommandName as CommandName
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ArtifactInput:
26
+ """Required upstream envelope; payload format/identity checks remain algorithm-owned."""
27
+
28
+ argument: str
29
+ artifact_type: ArtifactType
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class StepSpec:
34
+ """Executable boundary specification, independent of the computational backend."""
35
+
36
+ artifact_type: ArtifactType
37
+ output_argument: str
38
+ artifact_inputs: tuple[ArtifactInput, ...] = ()
39
+ external_arguments: tuple[str, ...] = ()
40
+ requires_splits: bool = False
41
+ label_access: str = "none"
42
+ task_conditioned: bool = False
43
+
44
+ def validate(self, arguments: Mapping[str, Any]) -> None:
45
+ paths = (
46
+ self.output_argument,
47
+ *self.external_arguments,
48
+ *(item.argument for item in self.artifact_inputs),
49
+ )
50
+ for key in paths:
51
+ value = arguments.get(key)
52
+ if not isinstance(value, (str, Path)) or not str(value).strip():
53
+ raise ValueError(f"requires {key}=<path>")
54
+ if "do_overwrite" in arguments and not isinstance(arguments["do_overwrite"], bool):
55
+ raise ValueError("do_overwrite must be a boolean")
56
+ if self.label_access == "none" and "external_labels_dir" in arguments:
57
+ raise ValueError("use external_index_dir for keys; this step cannot consume training labels")
58
+ splits = arguments.get("splits")
59
+ if (self.requires_splits or splits is not None) and (
60
+ not isinstance(splits, (list, tuple))
61
+ or not splits
62
+ or any(not isinstance(s, str) or not s.strip() for s in splits)
63
+ or len(set(splits)) != len(splits)
64
+ ):
65
+ raise ValueError("splits must be an explicit nonempty list of unique split names")
66
+ if (
67
+ not self.task_conditioned
68
+ and "external_task_criteria_file" in arguments
69
+ and self.label_access != "context_only"
70
+ ):
71
+ raise ValueError("this command is task-independent; do not pass external_task_criteria_file")
72
+
73
+
74
+ def _step_spec(request):
75
+ artifacts = []
76
+ external = []
77
+ requires_splits = False
78
+ label_access = "none"
79
+ task_conditioned = False
80
+ for field, kind in request.argument_fields():
81
+ if field.name in ("output", "execution"):
82
+ continue
83
+ required = field.default is MISSING and field.default_factory is MISSING
84
+ if isinstance(kind, type) and issubclass(kind, ArtifactReference):
85
+ artifacts.append(ArtifactInput(field.metadata["argument"], kind.kind))
86
+ elif kind is PredictionIndex:
87
+ external.append("external_index_dir")
88
+ requires_splits = True
89
+ elif kind is TrainingLabels:
90
+ external.append("external_labels_dir")
91
+ label_access = "train_tuning"
92
+ elif kind is TaskSpec:
93
+ external.append("external_task_criteria_file")
94
+ task_conditioned = True
95
+ elif field.name == "context":
96
+ label_access = "context_only"
97
+ elif required and "argument" in field.metadata:
98
+ external.append(field.metadata["argument"])
99
+ return StepSpec(
100
+ request.result_type.kind,
101
+ request.output_argument,
102
+ tuple(artifacts),
103
+ tuple(external),
104
+ requires_splits,
105
+ label_access,
106
+ task_conditioned,
107
+ )
108
+
109
+
110
+ ALGORITHM_PROFILES = MappingProxyType(
111
+ {cls.profile: tuple(CommandName(step) for step in cls.steps) for cls in profile_classes()}
112
+ )
113
+ STEP_SPECS = MappingProxyType(
114
+ {
115
+ step: _step_spec(request_type(profile, step))
116
+ for profile, steps in ALGORITHM_PROFILES.items()
117
+ for step in steps
118
+ }
119
+ )
120
+ ZERO_SHOT_TASK_COMMANDS = MappingProxyType(
121
+ {
122
+ profile: frozenset(step for step in steps if STEP_SPECS[step].task_conditioned)
123
+ for profile, steps in ALGORITHM_PROFILES.items()
124
+ if profile.startswith("zero_shot_")
125
+ }
126
+ )
127
+
128
+
129
+ class Command(ABC):
130
+ """Implement run; dispatch validates argument shapes before invoking algorithm code."""
131
+
132
+ name: CommandName
133
+
134
+ @abstractmethod
135
+ def run(self, arguments: Mapping[str, Any]) -> Path:
136
+ """Validate upstream compatibility, run, and return a published artifact directory."""
137
+
138
+ def __call__(self, arguments: Mapping[str, Any]) -> Path:
139
+ STEP_SPECS[self.name].validate(arguments)
140
+ return self.run(arguments)
141
+
142
+
143
+ class LegacyAlgorithmRepository:
144
+ """One registry for the union of declared profiles; explicit selection when ambiguous."""
145
+
146
+ def __init__(self, profiles: Sequence[str], commands: Mapping[CommandName, type[Command]]) -> None:
147
+ if isinstance(profiles, str) or not profiles or len(set(profiles)) != len(profiles):
148
+ raise ValueError("profiles must be a nonempty sequence of unique profile names")
149
+ unknown = set(profiles) - ALGORITHM_PROFILES.keys()
150
+ if unknown:
151
+ raise ValueError(f"Unknown algorithm profiles: {sorted(unknown)}")
152
+ expected = {command for profile in profiles for command in ALGORITHM_PROFILES[profile]}
153
+ if set(commands) != expected:
154
+ raise ValueError(f"Declared profiles require {sorted(expected)}; got {sorted(commands)}")
155
+ for name, implementation in commands.items():
156
+ if not isinstance(implementation, type) or not issubclass(implementation, Command):
157
+ raise TypeError(f"{name} must register a Command subclass")
158
+ if implementation.name != name:
159
+ raise ValueError(f"{name} registers a class named {implementation.name}")
160
+ self.profiles = tuple(profiles)
161
+ self.commands = dict(commands)
162
+
163
+ def select_profile(self, profile: str | None = None) -> str:
164
+ if profile is None:
165
+ if len(self.profiles) != 1:
166
+ raise ValueError("Several profiles are declared; select one with --profile")
167
+ return self.profiles[0]
168
+ if profile not in self.profiles:
169
+ raise ValueError(f"Profile {profile!r} is not declared: {self.profiles}")
170
+ return profile
171
+
172
+ def profile_commands(self, profile: str | None = None) -> tuple[CommandName, ...]:
173
+ return ALGORITHM_PROFILES[self.select_profile(profile)]
174
+
175
+ def run(
176
+ self, name: CommandName | str, arguments: Mapping[str, Any], *, profile: str | None = None
177
+ ) -> Path:
178
+ selected = self.select_profile(profile)
179
+ command_name = CommandName(name)
180
+ if command_name not in ALGORITHM_PROFILES[selected]:
181
+ raise ValueError(f"{command_name.value} is not part of the {selected} algorithm profile")
182
+ return self.commands[command_name]()(arguments)
@@ -0,0 +1,28 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class CommandName(StrEnum):
5
+ preprocess_data = "preprocess_data"
6
+ pretrain = "pretrain"
7
+ train_supervised = "train_supervised"
8
+ predict_supervised = "predict_supervised"
9
+ train_finetune = "train_finetune"
10
+ predict_finetune = "predict_finetune"
11
+ infer_embeddings = "infer_embeddings"
12
+ train_probe = "train_probe"
13
+ predict_probe = "predict_probe"
14
+ predict_zero_shot_direct = "predict_zero_shot_direct"
15
+ infer_trajectories = "infer_trajectories"
16
+ predict_zero_shot_materialized = "predict_zero_shot_materialized"
17
+ predict_only = "predict_only"
18
+
19
+
20
+ class ArtifactType(StrEnum):
21
+ patients = "patients"
22
+ pretrained_algorithm = "pretrained_algorithm"
23
+ embeddings = "embeddings"
24
+ trajectories = "trajectories"
25
+ supervised_algorithm = "supervised_algorithm"
26
+ finetuned_algorithm = "finetuned_algorithm"
27
+ probe_algorithm = "probe_algorithm"
28
+ predictions = "predictions"
@@ -0,0 +1 @@
1
+ """Dataset boundaries; index projection is separate from access to label values."""
@@ -0,0 +1,56 @@
1
+ """Strict split selection and key-only projection. Requires the optional data extra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ from .validation import INDEX_COLUMNS, validate_keys, validate_splits
9
+
10
+
11
+ def split_files(root: Path | str, splits: Sequence[str] | None = None) -> list[Path]:
12
+ """Select every requested split or fail; never broaden a missing split to all files."""
13
+ root = Path(root)
14
+ if isinstance(splits, str):
15
+ raise TypeError("splits must be a sequence, not a string")
16
+ if splits is not None:
17
+ validate_splits(splits)
18
+ candidates = sorted(root.rglob("*.parquet")) if root.is_dir() else [root]
19
+ candidates = [p for p in candidates if p.is_file() and p.suffix == ".parquet"]
20
+ if not candidates:
21
+ raise FileNotFoundError(f"No parquet index files under {root}")
22
+ split_names = {"train", "tuning", "held_out", *(splits or ())}
23
+ for path in candidates:
24
+ parts = path.relative_to(root).parts[:-1] if root.is_dir() else (root.parent.name,)
25
+ identities = {path.stem, *parts, *([root.name] if root.is_dir() else [])} & split_names
26
+ if len(identities) > 1:
27
+ raise ValueError(f"Ambiguous split identity for {path}: {sorted(identities)}")
28
+ if splits is None:
29
+ return candidates
30
+ selected: set[Path] = set()
31
+ for split in splits:
32
+ matches = []
33
+ for path in candidates:
34
+ # A single file must also prove which split it represents; its unrelated ancestors do not.
35
+ parts = path.relative_to(root).parts[:-1] if root.is_dir() else (root.parent.name,)
36
+ if path.stem == split or split in parts or (root.is_dir() and root.name == split):
37
+ matches.append(path)
38
+ if not matches:
39
+ raise FileNotFoundError(f"No parquet files for requested split {split!r} under {root}")
40
+ selected.update(matches)
41
+ return sorted(selected)
42
+
43
+
44
+ def read_index(root: Path | str, splits: Sequence[str] | None = None):
45
+ """Project keys at the Parquet read, never loading label columns into the algorithm boundary."""
46
+ import polars as pl
47
+
48
+ frames = []
49
+ for path in split_files(root, splits):
50
+ schema = pl.read_parquet_schema(path)
51
+ if not all(column in schema for column in INDEX_COLUMNS):
52
+ raise ValueError(f"Index file {path} is missing required key columns")
53
+ frames.append(pl.read_parquet(path, columns=list(INDEX_COLUMNS)))
54
+ index = pl.concat(frames)
55
+ validate_keys(index, source="requested index")
56
+ return index
@@ -0,0 +1,55 @@
1
+ """Explicit training/context targets and strict split-scoped outcome reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .index import read_index, split_files
10
+ from .validation import INDEX_COLUMNS, validate_keys, validate_splits
11
+
12
+ if TYPE_CHECKING:
13
+ import polars as pl
14
+
15
+
16
+ def read_targets(root: Path | str, splits: Sequence[str], *, allow_unlabelled=False) -> pl.DataFrame | None:
17
+ import polars as pl
18
+
19
+ validate_splits(splits)
20
+ files = split_files(root, splits)
21
+ schemas = [pl.read_parquet_schema(path) for path in files]
22
+ if any(not all(key in schema for key in INDEX_COLUMNS) for schema in schemas):
23
+ raise ValueError("target files are missing required key columns")
24
+ labelled = ["boolean_value" in schema for schema in schemas]
25
+ if not any(labelled) and allow_unlabelled:
26
+ read_index(root, splits)
27
+ return None
28
+ if not all(labelled):
29
+ raise ValueError("every selected target file must contain boolean_value; mixed schemas are invalid")
30
+ frame = pl.concat([pl.read_parquet(path, columns=[*INDEX_COLUMNS, "boolean_value"]) for path in files])
31
+ validate_keys(frame, source="targets")
32
+ if frame["boolean_value"].dtype != pl.Boolean or frame["boolean_value"].null_count():
33
+ raise ValueError("boolean_value must contain non-null Boolean outcomes")
34
+ return frame
35
+
36
+
37
+ def read_context_labels(
38
+ labels_dir: Path | str,
39
+ context_splits: Sequence[str],
40
+ evaluated_splits: Sequence[str],
41
+ *,
42
+ evaluation_index_dir: Path | str | None = None,
43
+ ) -> pl.DataFrame:
44
+ """Read disjoint labelled context; neither split names nor keys may overlap evaluation."""
45
+ validate_splits(context_splits)
46
+ validate_splits(evaluated_splits)
47
+ if set(context_splits) & set(evaluated_splits):
48
+ raise ValueError("context splits must be disjoint from evaluated splits")
49
+ context = read_targets(labels_dir, context_splits)
50
+ evaluated = read_index(
51
+ labels_dir if evaluation_index_dir is None else evaluation_index_dir, evaluated_splits
52
+ )
53
+ if context.select(INDEX_COLUMNS).join(evaluated, on=list(INDEX_COLUMNS), how="inner").height:
54
+ raise ValueError("context keys must be disjoint from evaluated keys")
55
+ return context
@@ -0,0 +1,60 @@
1
+ """Compose feature predicates without overriding the dataset's definitions."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ RESOLVED_PREDICATES_FILENAME = "resolved_feature_predicates.yaml"
9
+
10
+
11
+ class _UniqueKeysLoader(yaml.SafeLoader):
12
+ def construct_mapping(self, node, deep=False):
13
+ self.flatten_mapping(node)
14
+ mapping = {}
15
+ for key_node, value_node in node.value:
16
+ key = self.construct_object(key_node, deep=deep)
17
+ if not isinstance(key, str) or not key.strip():
18
+ raise ValueError("Predicate YAML keys must be nonempty strings")
19
+ if key in mapping:
20
+ raise ValueError(f"Duplicate YAML key: {key}")
21
+ mapping[key] = self.construct_object(value_node, deep=deep)
22
+ return mapping
23
+
24
+
25
+ def _digest(data: bytes) -> str:
26
+ return "sha256:" + hashlib.sha256(data).hexdigest()
27
+
28
+
29
+ def _read(path: Path):
30
+ data = path.read_bytes()
31
+ try:
32
+ document = yaml.load(data, Loader=_UniqueKeysLoader)
33
+ except yaml.YAMLError as error:
34
+ raise ValueError(f"Invalid predicates YAML in {path}: {error}") from error
35
+ if not isinstance(document, dict) or set(document) != {"predicates"}:
36
+ raise ValueError(f"{path} must contain only a predicates mapping, not task/cohort configuration")
37
+ predicates = document["predicates"]
38
+ if not isinstance(predicates, dict) or any(
39
+ not isinstance(definition, dict) or not definition for definition in predicates.values()
40
+ ):
41
+ raise ValueError(f"{path} predicates must map names to nonempty definitions")
42
+ return predicates, {"path": str(path.resolve()), "digest": _digest(data)}
43
+
44
+
45
+ def compose_feature_predicates(base: Path | None, additional: Path) -> tuple[bytes, dict]:
46
+ """Return deterministic YAML and provenance from the same source snapshots.
47
+
48
+ Accept predicate-only documents in MEDS-DEV's shape. Reject duplicate names even when definitions
49
+ agree. Expression semantics and dataset compatibility remain the algorithm's responsibility.
50
+ """
51
+ predicates, base_source = ({}, None) if base is None else _read(Path(base))
52
+ additions, additional_source = _read(Path(additional))
53
+ if duplicates := predicates.keys() & additions.keys():
54
+ raise ValueError(f"Additional predicates duplicate dataset names: {sorted(duplicates)}")
55
+ content = yaml.safe_dump({"predicates": predicates | additions}, sort_keys=True).encode("utf-8")
56
+ return content, {
57
+ "base": base_source,
58
+ "additional": additional_source,
59
+ "resolved": {"path": RESOLVED_PREDICATES_FILENAME, "digest": _digest(content)},
60
+ }
@@ -0,0 +1,25 @@
1
+ """Shared key and split validation without importing a dataframe engine at module import."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ INDEX_COLUMNS = ("subject_id", "prediction_time")
6
+
7
+
8
+ def validate_splits(splits: Sequence[str]) -> None:
9
+ if (
10
+ isinstance(splits, str)
11
+ or not isinstance(splits, Sequence)
12
+ or not splits
13
+ or any(not isinstance(s, str) or not s.strip() for s in splits)
14
+ or len(set(splits)) != len(splits)
15
+ ):
16
+ raise ValueError("splits must be a nonempty sequence of unique nonblank names")
17
+
18
+
19
+ def validate_keys(frame, *, source="index") -> None:
20
+ if not all(column in frame.columns for column in INDEX_COLUMNS):
21
+ raise ValueError(f"{source} is missing required key columns")
22
+ if any(frame[column].null_count() for column in INDEX_COLUMNS):
23
+ raise ValueError(f"{source} contains null keys")
24
+ if frame.select(INDEX_COLUMNS).unique().height != frame.height:
25
+ raise ValueError(f"{source} contains duplicate keys")
@@ -0,0 +1 @@
1
+ """Keyed prediction validation and post-score truth attachment."""
@@ -0,0 +1,56 @@
1
+ """Keyed probabilities and post-score ground-truth attachment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ..data.labels import read_targets
10
+ from ..data.validation import INDEX_COLUMNS, validate_keys
11
+
12
+ if TYPE_CHECKING:
13
+ import polars as pl
14
+ PREDICTED_PROBABILITY = "predicted_boolean_probability"
15
+
16
+
17
+ def check_decoded(index: pl.DataFrame, predictions: pl.DataFrame) -> pl.DataFrame:
18
+ """Require unique, non-null matching keys and finite probabilities in [0, 1]."""
19
+ import polars as pl
20
+
21
+ validate_keys(index, source="requested index")
22
+ validate_keys(predictions, source="decoded predictions")
23
+ keys = list(INDEX_COLUMNS)
24
+ missing = index.select(keys).join(predictions.select(keys), on=keys, how="anti")
25
+ extra = predictions.select(keys).join(index.select(keys), on=keys, how="anti")
26
+ if missing.height or extra.height:
27
+ raise ValueError(f"decoded predictions miss {missing.height} and add {extra.height} requested keys")
28
+ if PREDICTED_PROBABILITY not in predictions.columns:
29
+ raise ValueError(f"decoded predictions lack {PREDICTED_PROBABILITY}")
30
+ values = predictions[PREDICTED_PROBABILITY]
31
+ if (
32
+ values.dtype not in (pl.Float32, pl.Float64)
33
+ or values.null_count()
34
+ or not values.is_finite().all()
35
+ or not values.is_between(0.0, 1.0).all()
36
+ ):
37
+ raise ValueError(f"{PREDICTED_PROBABILITY} must be a finite non-null probability in [0, 1]")
38
+ return predictions
39
+
40
+
41
+ def attach_ground_truth(
42
+ predictions: pl.DataFrame, labels_dir: Path | str, splits: Sequence[str]
43
+ ) -> pl.DataFrame:
44
+ """Join truth only after scores are fixed; an entirely unlabelled index is supported.
45
+
46
+ This function cannot enforce when the caller scores. Its result must never feed back into
47
+ preprocessing, algorithm fitting, selection or scoring. Missing splits/mixed label schemas fail.
48
+ """
49
+ validate_keys(predictions, source="predictions")
50
+ labels = read_targets(labels_dir, splits, allow_unlabelled=True)
51
+ if labels is None:
52
+ return predictions
53
+ joined = predictions.drop("boolean_value", strict=False).join(labels, on=list(INDEX_COLUMNS), how="left")
54
+ if joined.height != predictions.height or joined["boolean_value"].null_count():
55
+ raise ValueError("every prediction key must have exactly one ground-truth label")
56
+ return joined
@@ -0,0 +1 @@
1
+ """Repository registration and external runner adapters."""
@@ -0,0 +1,51 @@
1
+ """Install this repository's descriptor into a MEDS-DEV checkout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ from ..artifacts.publication import staged_directory
11
+
12
+
13
+ def register(repo: Path, meds_dev: Path, name: str, profile: str | None = None) -> Path:
14
+ source = repo.resolve()
15
+ descriptors = {p.parent.name: p for p in (source / "algorithms").glob("*/model.yaml")}
16
+ if profile is None:
17
+ if len(descriptors) != 1:
18
+ raise ValueError("Select --profile when the repository has several descriptors")
19
+ profile = next(iter(descriptors))
20
+ if profile not in descriptors:
21
+ raise ValueError(f"No descriptor for {profile!r}; available: {sorted(descriptors)}")
22
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", name):
23
+ raise ValueError("name must be a single alphanumeric, underscore or hyphen identifier")
24
+ target = meds_dev.resolve() / "src" / "MEDS_DEV" / "models" / f"{name}__{profile}"
25
+ if not target.parent.is_dir():
26
+ raise FileNotFoundError(f"{meds_dev} has no src/MEDS_DEV/models directory")
27
+ requirements = (source / "requirements.txt").read_text()
28
+ # A file URL needs no quoting: uv reads quotes in requirement files literally, and pip splits on spaces.
29
+ requirements = (
30
+ "\n".join(
31
+ f"-e {source.as_uri()}" if line.strip() == "-e ." else line for line in requirements.splitlines()
32
+ )
33
+ + "\n"
34
+ )
35
+ with staged_directory(target) as staging:
36
+ shutil.copy2(descriptors[profile], staging / "model.yaml")
37
+ (staging / "requirements.txt").write_text(requirements)
38
+ predicates = source / "predicates.yaml"
39
+ if predicates.is_file():
40
+ shutil.copy2(predicates, staging / "predicates.yaml")
41
+ return target
42
+
43
+
44
+ def main() -> None:
45
+ parser = argparse.ArgumentParser()
46
+ parser.add_argument("--meds-dev", type=Path, required=True)
47
+ parser.add_argument("--repo", type=Path, default=Path.cwd())
48
+ parser.add_argument("--name", required=True)
49
+ parser.add_argument("--profile")
50
+ args = parser.parse_args()
51
+ print(register(args.repo, args.meds_dev, args.name, args.profile))