Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- 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.
- Local metadata store in SQLite, the first implementation of the metadata port. Its schema is versioned and upgrades itself when a store is opened, after making a backup.
- `miniops init` creates a project in the current directory: it writes `miniops.toml` and adds `.miniops/` to `.gitignore`.
- `miniops config show` prints the configuration of the project that the current directory belongs to.

### Changed

- An error that miniOPs reports ends with a short message and exit code 1 instead of a traceback.
- pydantic is now a dependency. It validates the configuration files.

## [0.1.0a1] - 2026-11-11

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"pydantic>=2.5.3",
"typer>=0.27",
]

Expand All @@ -44,7 +45,7 @@ Issues = "https://github.com/UltiPro/miniops/issues"
Changelog = "https://github.com/UltiPro/miniops/blob/main/CHANGELOG.md"

[project.scripts]
miniops = "miniops.cli:app"
miniops = "miniops.cli:main"

[dependency-groups]
dev = [
Expand Down
4 changes: 2 additions & 2 deletions src/miniops/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from miniops.cli import app
from miniops.cli import main

if __name__ == "__main__":
app(prog_name="miniops")
main()
14 changes: 13 additions & 1 deletion src/miniops/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
import typer

from miniops.cli.app import app
from miniops.core.errors import MiniopsError

__all__ = ["app", "main"]


__all__ = ["app"]
def main() -> None:
"""Run the command line. An error that miniOPs reports ends with a message, not a traceback."""
try:
app(prog_name="miniops")
except MiniopsError as error:
typer.secho(f"Error: {error}", fg=typer.colors.RED, err=True)
raise SystemExit(1) from None
4 changes: 4 additions & 0 deletions src/miniops/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
import typer

from miniops import __version__
from miniops.cli.config import config_app
from miniops.cli.init import init

app = typer.Typer(
name="miniops",
help="Lightweight, polars-native MLOps CLI.",
no_args_is_help=True,
)
app.command("init")(init)
app.add_typer(config_app, name="config")


def _print_version(value: bool) -> None:
Expand Down
16 changes: 16 additions & 0 deletions src/miniops/cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from pathlib import Path

import typer

from miniops.config.project import find_project

config_app = typer.Typer(help="Look at the configuration.", no_args_is_help=True)


@config_app.command("show")
def show() -> None:
"""Show the configuration of the project in the current directory."""
project = find_project(Path.cwd())
typer.echo(f"Project root {project.root}")
typer.echo(f"Name {project.config.project.name}")
typer.echo(f"Default target {project.config.project.default_target}")
17 changes: 17 additions & 0 deletions src/miniops/cli/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from pathlib import Path
from typing import Annotated

import typer

from miniops.config.project import init_project


def init(
name: Annotated[
str | None,
typer.Option(help="Name of the project. The default is the name of the directory."),
] = None,
) -> None:
"""Create a miniOPs project in the current directory."""
project = init_project(Path.cwd(), name)
typer.echo(f"Created the project {project.config.project.name!r} in {project.root}")
Empty file added src/miniops/config/__init__.py
Empty file.
121 changes: 121 additions & 0 deletions src/miniops/config/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import json
import tomllib
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path

from pydantic import BaseModel, ConfigDict, Field, ValidationError

from miniops.core.errors import AlreadyExistsError, ConfigError

CONFIG_FILE = "miniops.toml"
STATE_DIR = ".miniops"


class Target(StrEnum):
"""Where models are trained and served."""

LOCAL = "local"


class _Table(BaseModel):
# An unknown key is almost always a typo.
model_config = ConfigDict(extra="forbid", frozen=True)


class ProjectSection(_Table):
"""The ``[project]`` table of ``miniops.toml``."""

name: str = Field(min_length=1)
default_target: Target = Target.LOCAL


class ProjectConfig(_Table):
"""The content of ``miniops.toml``."""

project: ProjectSection


@dataclass(frozen=True)
class Project:
"""A miniOPs project: its directory and its configuration."""

root: Path
config: ProjectConfig


def find_project(start: Path) -> Project:
"""Return the project that ``start`` is in: the directory itself or the nearest parent.

Raises:
ConfigError: If there is no project, or its ``miniops.toml`` is not valid.
"""
root = _find_root(start)
if root is None:
raise ConfigError(
f"No miniOPs project in {start} or above it. Create one with `miniops init`."
)
return Project(root=root, config=_load(root / CONFIG_FILE))


def init_project(root: Path, name: str | None = None) -> Project:
"""Turn a directory into a project: write ``miniops.toml`` and keep local state out of git.

The project is named after the directory unless ``name`` is given.

Raises:
AlreadyExistsError: If the directory is a project already, or lies inside one.
ConfigError: If the project would have no name.
"""
existing = _find_root(root)
if existing is not None:
raise AlreadyExistsError(f"There is already a miniOPs project in {existing}.")
name = (name or root.name).strip()
if not name:
raise ConfigError("The project needs a name. Pass one with --name.")

config_file = root / CONFIG_FILE
config_file.write_text(
f'[project]\nname = {json.dumps(name, ensure_ascii=False)}\ndefault_target = "local"\n',
encoding="utf-8",
newline="\n",
)
_ignore_state(root)
return Project(root=root, config=_load(config_file))


def _find_root(start: Path) -> Path | None:
start = start.resolve()
for directory in (start, *start.parents):
if (directory / CONFIG_FILE).is_file():
return directory
return None


def _load(path: Path) -> ProjectConfig:
try:
with path.open("rb") as file:
data = tomllib.load(file)
except tomllib.TOMLDecodeError as error:
raise ConfigError(f"{path} is not valid TOML: {error}") from error
try:
return ProjectConfig.model_validate(data)
except ValidationError as error:
problems = "\n".join(
f" {'.'.join(str(part) for part in problem['loc'])}: {problem['msg']}"
for problem in error.errors()
)
raise ConfigError(f"{path} is not valid:\n{problems}") from error


def _ignore_state(root: Path) -> None:
gitignore = root / ".gitignore"
existing = gitignore.read_bytes() if gitignore.exists() else b""
patterns = {line.strip().strip(b"/") for line in existing.splitlines()}
if STATE_DIR.encode() in patterns:
return
# Append bytes, so the lines that are already in the file keep their line endings.
newline = b"\r\n" if b"\r\n" in existing else b"\n"
separator = b"" if not existing or existing.endswith(b"\n") else newline
with gitignore.open("ab") as file:
file.write(separator + f"{STATE_DIR}/".encode() + newline)
4 changes: 4 additions & 0 deletions src/miniops/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ class IntegrityError(MiniopsError):

class IncompatibleStoreError(MiniopsError):
"""The store was written by a newer miniOPs than the one that opened it."""


class ConfigError(MiniopsError):
"""A configuration file is missing or not valid."""
18 changes: 16 additions & 2 deletions tests/cli/test_app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import platform

import pytest
from typer.testing import CliRunner

from miniops import __version__
Expand All @@ -15,12 +16,25 @@ def test_version_option_prints_short_version() -> None:
assert result.stdout.strip() == f"miniops {__version__}"


def test_help_lists_the_version_command() -> None:
def test_help_shows_usage() -> None:
result = runner.invoke(app, ["--help"])

assert result.exit_code == 0
assert "Usage" in result.stdout
assert "version" in result.stdout


@pytest.mark.parametrize("command", [["init"], ["config"], ["config", "show"], ["version"]])
def test_every_command_exists_and_has_help(command: list[str]) -> None:
result = runner.invoke(app, [*command, "--help"])

assert result.exit_code == 0
assert "Usage" in result.stdout


def test_an_unknown_command_is_refused() -> None:
result = runner.invoke(app, ["deploy-to-the-moon", "--help"])

assert result.exit_code == 2


def test_no_arguments_shows_help() -> None:
Expand Down
37 changes: 37 additions & 0 deletions tests/cli/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from pathlib import Path

import pytest
from typer.testing import CliRunner

from miniops.cli import app
from miniops.config.project import init_project
from miniops.core.errors import ConfigError

runner = CliRunner()


def test_config_show_prints_the_project_from_anywhere_inside_it(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
init_project(tmp_path, "retail-models")
inside = tmp_path / "models"
inside.mkdir()
monkeypatch.chdir(inside)

result = runner.invoke(app, ["config", "show"])

assert result.exit_code == 0
assert result.stdout.splitlines() == [
f"Project root {tmp_path}",
"Name retail-models",
"Default target local",
]


def test_config_show_needs_a_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)

result = runner.invoke(app, ["config", "show"])

assert result.exit_code == 1
assert isinstance(result.exception, ConfigError)
41 changes: 41 additions & 0 deletions tests/cli/test_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from pathlib import Path

import pytest
from typer.testing import CliRunner

from miniops.cli import app
from miniops.config.project import find_project
from miniops.core.errors import AlreadyExistsError

runner = CliRunner()


def test_init_creates_a_project_in_the_current_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)

result = runner.invoke(app, ["init"])

assert result.exit_code == 0
assert repr(tmp_path.name) in result.stdout
assert find_project(tmp_path).config.project.name == tmp_path.name


def test_init_takes_a_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)

result = runner.invoke(app, ["init", "--name", "retail-models"])

assert result.exit_code == 0
assert find_project(tmp_path).config.project.name == "retail-models"


def test_init_twice_is_refused(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
runner.invoke(app, ["init"])

result = runner.invoke(app, ["init"])

assert result.exit_code == 1
assert isinstance(result.exception, AlreadyExistsError)
Empty file added tests/config/__init__.py
Empty file.
Loading
Loading