Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/guides/mlproject-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ Custom health checks cannot be detected from the built app and are not warned ab

The generated file starts with a `Generated from ... by chapkit mlproject export` header. `export` refuses to overwrite an MLproject without it, so a hand-written file is not lost. Pass `--force` to replace it once; from then on it is regenerated freely.

The header names the module relative to the MLproject's folder, so it holds no machine-specific path and `--check` gives the same result on every machine. A service in a package (a `src/` layout) can be exported straight to the project root:

```bash
chapkit mlproject export src/my_model/service.py -o MLproject
```

The header then reads `Generated from src/my_model/service.py`, and its regenerate command includes `-o MLproject`, because `export` otherwise writes next to the module.

## Options

| Option | Effect |
Expand Down
19 changes: 15 additions & 4 deletions src/chapkit/cli/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,11 +332,12 @@ def build_mlproject(parts: ServiceParts) -> dict[str, Any]:
return document


def render_mlproject(parts: ServiceParts, source: str) -> str:
def render_mlproject(parts: ServiceParts, source: str, output: str | None = None) -> str:
"""Render the MLproject file, with a header saying where it comes from and how to regenerate it."""
command = f"chapkit mlproject export {source}" + (f" -o {output}" if output else "")
header = [
f"# Generated from {source} {GENERATED_MARKER} -- do not edit by hand.",
f"# Regenerate with `chapkit mlproject export {source}`; add --check to fail when it is stale.",
f"# Regenerate with `{command}`; add --check to fail when it is stale.",
]
body = yaml.safe_dump(build_mlproject(parts), sort_keys=False, allow_unicode=True, width=100)
return "\n".join(header) + "\n\n" + body
Expand Down Expand Up @@ -464,6 +465,14 @@ def _print_dropped(dropped: list[str]) -> None:
typer.echo(f"WARNING: not carried over to the MLproject: {setting}", err=True)


def _source_label(module_path: Path, target: Path) -> str:
"""Name the module relative to the MLproject's folder, so the header holds no machine-specific path."""
try:
return Path(os.path.relpath(module_path, target.parent)).as_posix()
except ValueError: # On Windows, a module on another drive has no relative path.
return str(module_path)


def _default_output(module_path: Path) -> Path:
"""Return the existing MLproject next to the module, or where a new one goes."""
for filename in MLPROJECT_FILENAMES:
Expand Down Expand Up @@ -522,8 +531,10 @@ def export_command(
typer.echo(f"Error: {error}", err=True)
raise typer.Exit(code=1) from error

source = module_path.name if target.parent == module_path.parent else str(module_path)
wanted = render_mlproject(parts, source)
source = _source_label(module_path, target)
# Export writes next to the module by default, so a module in another folder needs -o to land here again.
output_flag = None if target.parent == module_path.parent else target.name
wanted = render_mlproject(parts, source, output_flag)
current = target.read_text(encoding="utf-8") if target.is_file() else None

if check:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_cli_mlproject_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,28 @@ def test_export_refuses_to_overwrite_a_hand_written_mlproject(tmp_path: Path) ->
assert "chapkit mlproject export" in (tmp_path / "MLproject").read_text()


def test_export_names_a_module_in_a_subfolder_relative_to_the_mlproject(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A src-layout service gets a portable header that regenerates the same file."""
package = tmp_path / "src" / "pkg"
package.mkdir(parents=True)
module = package / "service.py"
module.write_text(SERVICE_MODULE)
target = tmp_path / "MLproject"

assert CliRunner().invoke(app, ["mlproject", "export", str(module), "-o", str(target)]).exit_code == 0
header = target.read_text().splitlines()[:2]
assert header[0] == "# Generated from src/pkg/service.py by `chapkit mlproject export` -- do not edit by hand."
assert "`chapkit mlproject export src/pkg/service.py -o MLproject`" in header[1]
assert str(tmp_path) not in target.read_text()

# The command in the header, run from the MLproject's folder, reproduces the same file.
monkeypatch.chdir(tmp_path)
check = CliRunner().invoke(app, ["mlproject", "export", "src/pkg/service.py", "-o", "MLproject", "--check"])
assert check.exit_code == 0, check.output


def test_export_writes_to_output_and_updates_an_existing_mlproject_yaml(tmp_path: Path) -> None:
module = _write_service(tmp_path)
other = tmp_path / "out" / "MLproject"
Expand Down
Loading