diff --git a/docs/guides/mlproject-export.md b/docs/guides/mlproject-export.md index 11eb9c39..a804e724 100644 --- a/docs/guides/mlproject-export.md +++ b/docs/guides/mlproject-export.md @@ -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 | diff --git a/src/chapkit/cli/export.py b/src/chapkit/cli/export.py index c8de73ef..65014211 100644 --- a/src/chapkit/cli/export.py +++ b/src/chapkit/cli/export.py @@ -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 @@ -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: @@ -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: diff --git a/tests/test_cli_mlproject_export.py b/tests/test_cli_mlproject_export.py index d8e5c918..391d0137 100644 --- a/tests/test_cli_mlproject_export.py +++ b/tests/test_cli_mlproject_export.py @@ -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"