From ff23546e8717b00ce3c075d54dbf9cd8ebbe6514 Mon Sep 17 00:00:00 2001 From: Morten Hansen Date: Thu, 12 Nov 2026 22:58:05 +0100 Subject: [PATCH] fix: name the module relative to the MLproject in the export header When the service module was not in the MLproject's folder, the header recorded its absolute path, so a committed MLproject carried a machine-specific path and --check failed on every other machine. A src-layout service had to keep a root main.py only to get a portable header. The header now names the module relative to the MLproject's folder (the absolute path stays only where no relative one exists, such as another drive on Windows), and its regenerate command adds -o when the MLproject is not next to the module. --- docs/guides/mlproject-export.md | 8 ++++++++ src/chapkit/cli/export.py | 19 +++++++++++++++---- tests/test_cli_mlproject_export.py | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) 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"