diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b11fe6..c3f44f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Core domain model: models, versions with semantic version numbers, runs and aliases, with the rules for releasing, renumbering and deleting a version. +- Interfaces (ports) for the metadata store and the artifact store, and provisional ones for the executor, the serving backend and the image registry. + ## [0.1.0a1] - 2026-11-11 First pre-release: the project foundations and the command line skeleton. The model lifecycle features are not implemented yet. diff --git a/README.md b/README.md index 68e8f23..f23b147 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ miniOPs starts where data engineering ends. It does **not** ingest, clean, join | Target | Training | Serving | State | |---|---|---|---| | `local` | subprocess in the model's venv | local FastAPI (uvicorn) | SQLite + filesystem | -| `docker` | container | container | SQLite + filesystem (mounted) | +| `docker` | container | container | same as `local` | | `gcp` | Vertex AI custom training | Cloud Run | GCS + miniOPs metadata on GCP | `local` and `docker` share the same state, so a model trained locally can be served in Docker right away. Moving a version to GCP (or back) is a single `miniops migrate`. diff --git a/pyproject.toml b/pyproject.toml index 157834b..4cd2773 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,9 +70,9 @@ line-length = 100 # What each code means: https://docs.astral.sh/ruff/rules/ # PTH keeps us on pathlib, which saves a lot of trouble on Windows. select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "N", "RUF", "PTH", "D"] -# Module docstrings are optional: write one only when it says -# more than the file name does. -ignore = ["D100", "D104"] +# Docstrings are optional on modules and on magic methods: write one only +# when it says more than the name does. +ignore = ["D100", "D104", "D105"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/src/miniops/core/__init__.py b/src/miniops/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/miniops/core/entities.py b/src/miniops/core/entities.py new file mode 100644 index 0000000..98f309e --- /dev/null +++ b/src/miniops/core/entities.py @@ -0,0 +1,281 @@ +import re +from collections.abc import Mapping +from dataclasses import dataclass, field, replace +from datetime import datetime +from enum import StrEnum +from typing import Self +from uuid import UUID + +from miniops.core.errors import InvalidStateError +from miniops.core.versions import SemVer + +type Json = str | int | float | bool | list[Json] | dict[str, Json] | None + +# Fits a Cloud Run service name, so one name works as a directory, an image and a service. +_NAME = re.compile(r"[a-z]([a-z0-9-]{0,47}[a-z0-9])?") +_SHA256 = re.compile(r"[0-9a-f]{64}") +_KEY = re.compile(r"[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*") + + +def _require_name(kind: str, name: str) -> None: + if _NAME.fullmatch(name) is None: + raise ValueError( + f"Invalid {kind} name: {name!r}. Use lowercase letters, digits and '-', " + "starting with a letter, at most 49 characters." + ) + + +def _require_digest(digest: str) -> None: + if _SHA256.fullmatch(digest) is None: + raise ValueError(f"Not a SHA-256 digest: {digest!r}") + + +def _require_aware(moment: datetime) -> None: + if moment.tzinfo is None: + raise ValueError("Timestamps need a time zone, for example datetime.now(UTC).") + + +class VersionStatus(StrEnum): + """Where a version is in its life.""" + + DRAFT = "draft" + RELEASED = "released" + DELETED = "deleted" + + +class RunStatus(StrEnum): + """How a run is doing or how it ended.""" + + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + INTERRUPTED = "interrupted" + + +@dataclass(frozen=True) +class ArtifactRef: + """A file in the artifact store: where it is and what it contains.""" + + key: str + digest: str + size: int + + def __post_init__(self) -> None: + # Keys are relative and use "/", so a store can move to another disk, system or bucket. + if _KEY.fullmatch(self.key) is None or {".", ".."} & set(self.key.split("/")): + raise ValueError(f"Not a valid artifact key: {self.key!r}") + _require_digest(self.digest) + if self.size < 0: + raise ValueError(f"Artifact size cannot be negative: {self.size}") + + +@dataclass(frozen=True) +class DatasetRef: + """Where the training data came from and a fingerprint of its content. + + miniOPs never stores the data itself. + """ + + uri: str + fingerprint: str + + def __post_init__(self) -> None: + _require_digest(self.fingerprint) + + +@dataclass(frozen=True) +class Column: + """One column of a model's input or output.""" + + name: str + dtype: str + nullable: bool = False + + +@dataclass(frozen=True) +class Signature: + """The columns a model expects and the columns it returns.""" + + inputs: tuple[Column, ...] + outputs: tuple[Column, ...] + + +@dataclass(frozen=True) +class VersionRef: + """Points at a released version by number and content. + + It keeps its meaning in a store that does not have that version, for example after a migration. + """ + + number: SemVer + digest: str + + def __post_init__(self) -> None: + _require_digest(self.digest) + + +@dataclass(frozen=True, kw_only=True) +class Model: + """A model in the catalog.""" + + id: UUID + name: str + flavor: str + task: str + created_at: datetime + + def __post_init__(self) -> None: + _require_name("model", self.name) + _require_aware(self.created_at) + + +@dataclass(frozen=True, kw_only=True) +class Run: + """One training attempt inside a draft version.""" + + id: UUID + version_id: UUID + started_at: datetime + miniops_version: str + status: RunStatus = RunStatus.RUNNING + parent_run_id: UUID | None = None + params: Mapping[str, Json] = field(default_factory=dict) + metrics: Mapping[str, float] = field(default_factory=dict) + dataset: DatasetRef | None = None + snapshot: ArtifactRef | None = None + lock_digest: str | None = None + code_commit: str | None = None + artifact: ArtifactRef | None = None + signature: Signature | None = None + finished_at: datetime | None = None + error: str | None = None + + def __post_init__(self) -> None: + _require_aware(self.started_at) + if self.lock_digest is not None: + _require_digest(self.lock_digest) + if self.status is RunStatus.RUNNING: + if self.finished_at is not None: + raise ValueError("A running run cannot have an end time.") + elif self.finished_at is None: + raise ValueError("A finished run needs an end time.") + else: + _require_aware(self.finished_at) + + def finish( + self, + status: RunStatus, + at: datetime, + *, + metrics: Mapping[str, float] | None = None, + artifact: ArtifactRef | None = None, + signature: Signature | None = None, + error: str | None = None, + ) -> Self: + """Record how the run ended. + + Raises: + InvalidStateError: If the run has already ended. + """ + if self.status is not RunStatus.RUNNING: + raise InvalidStateError(f"Run {self.id} has already ended as {self.status}.") + if status is RunStatus.RUNNING: + raise ValueError("A run cannot end as running.") + return replace( + self, + status=status, + finished_at=at, + metrics=self.metrics if metrics is None else metrics, + artifact=artifact, + signature=signature, + error=error, + ) + + +@dataclass(frozen=True, kw_only=True) +class ModelVersion: + """One version of a model: first a draft that collects runs, then released and immutable.""" + + id: UUID + model_id: UUID + number: SemVer + created_at: datetime + status: VersionStatus = VersionStatus.DRAFT + base: VersionRef | None = None + released_run_id: UUID | None = None + content_digest: str | None = None + released_at: datetime | None = None + + def __post_init__(self) -> None: + _require_aware(self.created_at) + if self.status is VersionStatus.DRAFT: + if (self.released_run_id, self.content_digest, self.released_at) != (None, None, None): + raise ValueError("A draft cannot have a released run, a digest or a release date.") + return + if self.released_run_id is None or self.content_digest is None or self.released_at is None: + raise ValueError("A released version needs its run, its digest and its release date.") + _require_digest(self.content_digest) + _require_aware(self.released_at) + + def renumber(self, number: SemVer) -> Self: + """Give a draft another number, for example after a refused release. + + Raises: + InvalidStateError: If the version is not a draft. + """ + self._require_draft("renumbered") + return replace(self, number=number) + + def release(self, run: Run, content_digest: str, at: datetime) -> Self: + """Freeze the draft with one of its runs. + + Raises: + InvalidStateError: If the version is not a draft, or the run is not a successful + run of this version with a model artifact. + """ + self._require_draft("released") + if run.version_id != self.id: + raise InvalidStateError(f"Run {run.id} does not belong to version {self.number}.") + if run.status is not RunStatus.SUCCEEDED or run.artifact is None: + raise InvalidStateError( + f"Run {run.id} cannot be released: it must be successful and have a model artifact." + ) + return replace( + self, + status=VersionStatus.RELEASED, + released_run_id=run.id, + content_digest=content_digest, + released_at=at, + ) + + def delete(self) -> Self: + """Turn a released version into a tombstone, so its number is never used again. + + Raises: + InvalidStateError: If the version is not released. A draft is simply removed. + """ + if self.status is not VersionStatus.RELEASED: + raise InvalidStateError( + f"Version {self.number} is {self.status}, only a released one leaves a tombstone." + ) + return replace(self, status=VersionStatus.DELETED) + + def _require_draft(self, action: str) -> None: + if self.status is not VersionStatus.DRAFT: + raise InvalidStateError( + f"Version {self.number} is {self.status}, only a draft can be {action}." + ) + + +@dataclass(frozen=True, kw_only=True) +class Alias: + """A movable name, such as ``champion``, for a released version of a model.""" + + model_id: UUID + name: str + version_id: UUID + updated_at: datetime + + def __post_init__(self) -> None: + _require_name("alias", self.name) + _require_aware(self.updated_at) diff --git a/src/miniops/core/errors.py b/src/miniops/core/errors.py new file mode 100644 index 0000000..10e3578 --- /dev/null +++ b/src/miniops/core/errors.py @@ -0,0 +1,18 @@ +class MiniopsError(Exception): + """Base class for errors that miniOPs reports to the user.""" + + +class InvalidStateError(MiniopsError): + """An operation does not fit the current state, like releasing a released version.""" + + +class AlreadyExistsError(MiniopsError): + """Something with the same identity is already in the store.""" + + +class NotFoundError(MiniopsError): + """The store has no such model, version, run or artifact.""" + + +class IntegrityError(MiniopsError): + """Stored content does not match the digest recorded for it.""" diff --git a/src/miniops/core/versions.py b/src/miniops/core/versions.py new file mode 100644 index 0000000..b5287b6 --- /dev/null +++ b/src/miniops/core/versions.py @@ -0,0 +1,59 @@ +import re +from dataclasses import dataclass +from enum import StrEnum +from typing import Self + +# No leading zeros, so "1.02.0" and "1.2.0" cannot both name the same version. +_PATTERN = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)") + + +class Bump(StrEnum): + """The part of a version number that a change raises.""" + + PATCH = "patch" + MINOR = "minor" + MAJOR = "major" + + +@dataclass(frozen=True, order=True) +class SemVer: + """A model version number such as ``1.2.0``.""" + + major: int + minor: int + patch: int + + def __post_init__(self) -> None: + if min(self.major, self.minor, self.patch) < 0: + raise ValueError( + f"Version parts cannot be negative: {self.major}.{self.minor}.{self.patch}" + ) + + @classmethod + def parse(cls, text: str) -> Self: + """Read a version from text such as ``"1.2.0"``. + + Raises: + ValueError: If the text is not MAJOR.MINOR.PATCH. + """ + match = _PATTERN.fullmatch(text) + if match is None: + raise ValueError( + f"Not a valid version: {text!r}. Expected MAJOR.MINOR.PATCH, for example 1.0.0." + ) + major, minor, patch = (int(part) for part in match.groups()) + return cls(major, minor, patch) + + def bump(self, kind: Bump) -> Self: + """Return the next version for the given kind of change.""" + if kind is Bump.MAJOR: + return type(self)(self.major + 1, 0, 0) + if kind is Bump.MINOR: + return type(self)(self.major, self.minor + 1, 0) + return type(self)(self.major, self.minor, self.patch + 1) + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + +FIRST_VERSION = SemVer(1, 0, 0) diff --git a/src/miniops/ports/__init__.py b/src/miniops/ports/__init__.py new file mode 100644 index 0000000..96c0061 --- /dev/null +++ b/src/miniops/ports/__init__.py @@ -0,0 +1,6 @@ +from miniops.ports.artifacts import ArtifactStore +from miniops.ports.executor import Executor +from miniops.ports.metadata import MetadataStore +from miniops.ports.serving import ImageRegistry, ServingBackend + +__all__ = ["ArtifactStore", "Executor", "ImageRegistry", "MetadataStore", "ServingBackend"] diff --git a/src/miniops/ports/artifacts.py b/src/miniops/ports/artifacts.py new file mode 100644 index 0000000..005e9c2 --- /dev/null +++ b/src/miniops/ports/artifacts.py @@ -0,0 +1,28 @@ +from pathlib import Path +from typing import Protocol + +from miniops.core.entities import ArtifactRef + + +class ArtifactStore(Protocol): + """Keeps files: model artifacts, reports and code snapshots. + + Keys are relative to the store and use "/", so the same key works on a disk and in a bucket. + """ + + def put(self, source: Path, key: str) -> ArtifactRef: + """Copy a local file into the store and return its reference, digest included.""" + + def get(self, ref: ArtifactRef, destination: Path) -> None: + """Copy a stored file to a local path. + + Raises: + NotFoundError: If the store has no file under this key. + IntegrityError: If the stored content does not match the digest in the reference. + """ + + def exists(self, key: str) -> bool: + """Tell whether the store has a file under this key.""" + + def delete(self, key: str) -> None: + """Remove a file. A missing key is not an error.""" diff --git a/src/miniops/ports/executor.py b/src/miniops/ports/executor.py new file mode 100644 index 0000000..dd1fea5 --- /dev/null +++ b/src/miniops/ports/executor.py @@ -0,0 +1,13 @@ +from pathlib import Path +from typing import Protocol + + +class Executor(Protocol): + """Runs a training or tuning job on a target: in a subprocess, a container or a cloud job.""" + + def run(self, job: Path, workdir: Path) -> Path: + """Run the job described in the ``job`` file and return the path of its result file. + + An executor never touches the metadata store. The host reads the result file and + records what it says. + """ diff --git a/src/miniops/ports/metadata.py b/src/miniops/ports/metadata.py new file mode 100644 index 0000000..cec2ac9 --- /dev/null +++ b/src/miniops/ports/metadata.py @@ -0,0 +1,77 @@ +from collections.abc import Sequence +from typing import Protocol +from uuid import UUID + +from miniops.core.entities import Alias, Model, ModelVersion, Run +from miniops.core.versions import SemVer + + +class MetadataStore(Protocol): + """The catalog of models, versions, runs and aliases. + + Only the miniOPs process on the host writes to it. Entities are immutable, so a change + is always "build the new value, then store it here". + """ + + def add_model(self, model: Model) -> None: + """Register a model. + + Raises: + AlreadyExistsError: If the store has a model with this name. + """ + + def get_model(self, name: str) -> Model | None: + """Return the model with this name, or None.""" + + def list_models(self) -> Sequence[Model]: + """Return all models, ordered by name.""" + + def add_version(self, version: ModelVersion) -> None: + """Add a version to its model. + + Raises: + AlreadyExistsError: If the model has this number already, even as a deleted version. + """ + + def update_version(self, version: ModelVersion) -> None: + """Replace the stored version that has the same id. + + Raises: + NotFoundError: If the store has no version with this id. + AlreadyExistsError: If another version of the model has the new number. + """ + + def get_version(self, model_id: UUID, number: SemVer) -> ModelVersion | None: + """Return the version of a model with this number, or None.""" + + def list_versions(self, model_id: UUID) -> Sequence[ModelVersion]: + """Return the versions of a model, highest number first.""" + + def add_run(self, run: Run) -> None: + """Add a run to its version. + + Raises: + AlreadyExistsError: If the store has a run with this id. + """ + + def update_run(self, run: Run) -> None: + """Replace the stored run that has the same id. + + Raises: + NotFoundError: If the store has no run with this id. + """ + + def get_run(self, run_id: UUID) -> Run | None: + """Return the run with this id, or None.""" + + def list_runs(self, version_id: UUID) -> Sequence[Run]: + """Return the runs of a version, oldest first.""" + + def set_alias(self, alias: Alias) -> None: + """Create the alias, or move it when the model already has one with this name.""" + + def get_alias(self, model_id: UUID, name: str) -> Alias | None: + """Return the alias of a model with this name, or None.""" + + def list_aliases(self, model_id: UUID) -> Sequence[Alias]: + """Return the aliases of a model, ordered by name.""" diff --git a/src/miniops/ports/serving.py b/src/miniops/ports/serving.py new file mode 100644 index 0000000..cd0dd1f --- /dev/null +++ b/src/miniops/ports/serving.py @@ -0,0 +1,9 @@ +from typing import Protocol + + +class ServingBackend(Protocol): + """Starts, stops and lists deployments on a target.""" + + +class ImageRegistry(Protocol): + """Stores container images for a target.""" diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_entities.py b/tests/core/test_entities.py new file mode 100644 index 0000000..e1e3f58 --- /dev/null +++ b/tests/core/test_entities.py @@ -0,0 +1,205 @@ +from dataclasses import replace +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from miniops.core.entities import ( + Alias, + ArtifactRef, + DatasetRef, + Model, + ModelVersion, + Run, + RunStatus, + VersionRef, + VersionStatus, +) +from miniops.core.errors import InvalidStateError +from miniops.core.versions import FIRST_VERSION, SemVer + +NOW = datetime(2026, 11, 11, 12, 0, tzinfo=UTC) +LATER = datetime(2026, 11, 11, 12, 5, tzinfo=UTC) +DIGEST = "a" * 64 +ARTIFACT = ArtifactRef(key="models/churn/model.skops", digest=DIGEST, size=1024) + + +def draft() -> ModelVersion: + return ModelVersion(id=uuid4(), model_id=uuid4(), number=FIRST_VERSION, created_at=NOW) + + +def running_run(version: ModelVersion) -> Run: + return Run(id=uuid4(), version_id=version.id, started_at=NOW, miniops_version="0.1.0a1") + + +def successful_run(version: ModelVersion) -> Run: + return running_run(version).finish( + RunStatus.SUCCEEDED, LATER, metrics={"roc_auc": 0.87}, artifact=ARTIFACT + ) + + +def released() -> ModelVersion: + version = draft() + return version.release(successful_run(version), DIGEST, LATER) + + +@pytest.mark.parametrize("name", ["churn", "churn-v2", "a", "x" * 49]) +def test_model_accepts_names_that_work_everywhere(name: str) -> None: + model = Model( + id=uuid4(), name=name, flavor="sklearn", task="binary_classification", created_at=NOW + ) + + assert model.name == name + + +@pytest.mark.parametrize( + "name", ["", "Churn", "2churn", "churn_v2", "churn-", "my model", "x" * 50] +) +def test_model_rejects_other_names(name: str) -> None: + with pytest.raises(ValueError, match="Invalid model name"): + Model(id=uuid4(), name=name, flavor="sklearn", task="binary_classification", created_at=NOW) + + +def test_timestamps_need_a_time_zone() -> None: + with pytest.raises(ValueError, match="time zone"): + ModelVersion( + id=uuid4(), model_id=uuid4(), number=FIRST_VERSION, created_at=datetime(2026, 11, 11) + ) + + +@pytest.mark.parametrize( + "key", ["", "/abs/model.skops", "a/../b", "./a", "a//b", "a\\b", "C:/a", "a/"] +) +def test_artifact_keys_are_relative_and_portable(key: str) -> None: + with pytest.raises(ValueError, match="artifact key"): + ArtifactRef(key=key, digest=DIGEST, size=1) + + +@pytest.mark.parametrize("digest", ["", "abc", "A" * 64, "g" * 64]) +def test_digests_are_sha256_in_lowercase_hex(digest: str) -> None: + with pytest.raises(ValueError, match="SHA-256"): + ArtifactRef(key="a/b", digest=digest, size=1) + with pytest.raises(ValueError, match="SHA-256"): + DatasetRef(uri="data/train.parquet", fingerprint=digest) + with pytest.raises(ValueError, match="SHA-256"): + VersionRef(number=FIRST_VERSION, digest=digest) + + +def test_artifact_size_cannot_be_negative() -> None: + with pytest.raises(ValueError, match="negative"): + ArtifactRef(key="a/b", digest=DIGEST, size=-1) + + +def test_a_draft_can_be_renumbered() -> None: + version = draft().renumber(SemVer(2, 0, 0)) + + assert version.number == SemVer(2, 0, 0) + assert version.status is VersionStatus.DRAFT + + +def test_a_draft_has_nothing_released() -> None: + with pytest.raises(ValueError, match="draft"): + replace(draft(), content_digest=DIGEST) + + +def test_release_freezes_the_draft_with_its_run() -> None: + version = draft() + run = successful_run(version) + + result = version.release(run, DIGEST, LATER) + + assert result.status is VersionStatus.RELEASED + assert result.released_run_id == run.id + assert result.content_digest == DIGEST + assert result.released_at == LATER + + +def test_release_refuses_a_run_of_another_version() -> None: + with pytest.raises(InvalidStateError, match="does not belong"): + draft().release(successful_run(draft()), DIGEST, LATER) + + +def test_release_refuses_a_run_that_is_not_successful() -> None: + version = draft() + failed = running_run(version).finish(RunStatus.FAILED, LATER, error="out of memory") + + with pytest.raises(InvalidStateError, match="cannot be released"): + version.release(failed, DIGEST, LATER) + + +def test_release_refuses_a_run_without_a_model_artifact() -> None: + version = draft() + trial = running_run(version).finish(RunStatus.SUCCEEDED, LATER, metrics={"roc_auc": 0.8}) + + with pytest.raises(InvalidStateError, match="cannot be released"): + version.release(trial, DIGEST, LATER) + + +def test_a_released_version_cannot_change() -> None: + version = released() + + with pytest.raises(InvalidStateError, match="only a draft"): + version.renumber(SemVer(1, 0, 1)) + with pytest.raises(InvalidStateError, match="only a draft"): + version.release(successful_run(version), DIGEST, LATER) + + +def test_a_released_version_needs_its_run_digest_and_date() -> None: + with pytest.raises(ValueError, match="released version needs"): + replace(released(), released_at=None) + + +def test_deleting_a_released_version_leaves_a_tombstone() -> None: + version = released().delete() + + assert version.status is VersionStatus.DELETED + assert version.content_digest == DIGEST + + +def test_a_draft_leaves_no_tombstone() -> None: + with pytest.raises(InvalidStateError, match="tombstone"): + draft().delete() + + +def test_a_run_starts_as_running_and_records_how_it_ended() -> None: + run = successful_run(draft()) + + assert run.status is RunStatus.SUCCEEDED + assert run.finished_at == LATER + assert run.metrics == {"roc_auc": 0.87} + assert run.artifact == ARTIFACT + + +def test_a_run_ends_only_once() -> None: + with pytest.raises(InvalidStateError, match="already ended"): + successful_run(draft()).finish(RunStatus.FAILED, LATER) + + +def test_a_run_cannot_end_as_running() -> None: + with pytest.raises(ValueError, match="cannot end as running"): + running_run(draft()).finish(RunStatus.RUNNING, LATER) + + +def test_run_state_and_end_time_have_to_agree() -> None: + run = running_run(draft()) + + with pytest.raises(ValueError, match="running run"): + replace(run, finished_at=LATER) + with pytest.raises(ValueError, match="needs an end time"): + replace(run, status=RunStatus.FAILED) + with pytest.raises(ValueError, match="time zone"): + replace(run, status=RunStatus.FAILED, finished_at=datetime(2026, 11, 11)) + + +def test_a_run_checks_its_lock_digest() -> None: + with pytest.raises(ValueError, match="SHA-256"): + replace(running_run(draft()), lock_digest="not-a-digest") + assert replace(running_run(draft()), lock_digest=DIGEST).lock_digest == DIGEST + + +def test_alias_names_follow_the_same_rule_as_model_names() -> None: + alias = Alias(model_id=uuid4(), name="champion", version_id=uuid4(), updated_at=NOW) + + assert alias.name == "champion" + with pytest.raises(ValueError, match="Invalid alias name"): + replace(alias, name="Champion") diff --git a/tests/core/test_versions.py b/tests/core/test_versions.py new file mode 100644 index 0000000..0bf165e --- /dev/null +++ b/tests/core/test_versions.py @@ -0,0 +1,42 @@ +import pytest + +from miniops.core.versions import FIRST_VERSION, Bump, SemVer + + +def test_parse_reads_three_numbers() -> None: + assert SemVer.parse("1.20.3") == SemVer(1, 20, 3) + + +@pytest.mark.parametrize( + "text", ["", "1.0", "1.0.0.0", "v1.0.0", "1.0.0a1", "01.0.0", "1.0.0 ", "1.-1.0"] +) +def test_parse_rejects_anything_else(text: str) -> None: + with pytest.raises(ValueError, match="Not a valid version"): + SemVer.parse(text) + + +def test_negative_parts_are_rejected() -> None: + with pytest.raises(ValueError, match="negative"): + SemVer(1, -1, 0) + + +def test_text_form_round_trips() -> None: + assert str(SemVer.parse("2.0.11")) == "2.0.11" + + +def test_versions_order_by_number_not_by_text() -> None: + versions = [SemVer.parse(text) for text in ("1.10.0", "1.2.0", "2.0.0", "1.2.1")] + + assert [str(version) for version in sorted(versions)] == ["1.2.0", "1.2.1", "1.10.0", "2.0.0"] + + +@pytest.mark.parametrize( + ("kind", "expected"), + [(Bump.PATCH, "1.2.4"), (Bump.MINOR, "1.3.0"), (Bump.MAJOR, "2.0.0")], +) +def test_bump_raises_one_part_and_resets_the_lower_ones(kind: Bump, expected: str) -> None: + assert str(SemVer(1, 2, 3).bump(kind)) == expected + + +def test_a_new_model_starts_at_one_zero_zero() -> None: + assert str(FIRST_VERSION) == "1.0.0" diff --git a/tests/ports/__init__.py b/tests/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ports/test_ports.py b/tests/ports/test_ports.py new file mode 100644 index 0000000..4161d72 --- /dev/null +++ b/tests/ports/test_ports.py @@ -0,0 +1,21 @@ +import pytest + +from miniops import ports + + +@pytest.mark.parametrize("name", ports.__all__) +def test_every_port_is_an_interface_not_an_implementation(name: str) -> None: + port = getattr(ports, name) + + with pytest.raises(TypeError, match="Protocols cannot be instantiated"): + port() + + +def test_the_package_exposes_the_five_ports() -> None: + assert sorted(ports.__all__) == [ + "ArtifactStore", + "Executor", + "ImageRegistry", + "MetadataStore", + "ServingBackend", + ]