diff --git a/projects/openshell-middleware-manager/README.md b/projects/openshell-middleware-manager/README.md index c404e68d..c5d6f36e 100644 --- a/projects/openshell-middleware-manager/README.md +++ b/projects/openshell-middleware-manager/README.md @@ -70,6 +70,11 @@ cargo run --locked -- 127.0.0.1:50051 The output path must not exist. Pin an OpenShell tag when you need repeatable builds. Use `--openshell-version latest` when you want the newest release. +The starter templates still use the older `max_body_bytes` binding field, so +`omm create` does not yet support v0.0.116. Validation stops without publishing +that starter. `omm update` can refresh an existing service already compatible +with the newer contract, such as Egress Gate. + Run `omm --help` for all options. By default, `omm` derives the Python package name from the project name. Use `--package-name` to set it yourself. @@ -94,6 +99,20 @@ regenerates Python protobuf and gRPC bindings when needed, updates `uv.lock` or `Cargo.lock`, and writes the version and protocol checksum to the manifest. The manifest must name `openshell-middleware-manager` as its generator. +Python updates run `uv sync` and then `uv run pytest` by default. If a project +needs additional build steps, supply its normal validation command: + +```sh +omm update /path/to/egress-gate \ + --openshell-version v0.0.116 --check-command 'make check' +``` + +The command runs in the staged project with its isolated Python environment, +after binding generation and dependency sync. Arguments are split with shell +quoting rules, but no shell is invoked; use a project script for pipelines or +multiple commands. A failure prevents publication. The protobuf compiler runs +in a separate environment from the project's runtime dependencies. + ## What you get Each project contains: diff --git a/projects/openshell-middleware-manager/src/openshell_middleware_manager/cli.py b/projects/openshell-middleware-manager/src/openshell_middleware_manager/cli.py index 0708c232..475cab11 100644 --- a/projects/openshell-middleware-manager/src/openshell_middleware_manager/cli.py +++ b/projects/openshell-middleware-manager/src/openshell_middleware_manager/cli.py @@ -5,6 +5,7 @@ from __future__ import annotations +import shlex from enum import Enum from pathlib import Path from typing import Annotated @@ -101,12 +102,24 @@ def update( help="OpenShell release tag (for example v0.0.86), or latest.", ), ] = "latest", + check_command: Annotated[ + str | None, + typer.Option( + "--check-command", + help="Python project validation command instead of pytest (no shell expansion).", + ), + ] = None, ) -> None: """Update an existing middleware project's OpenShell contract and generated files.""" try: + try: + command = shlex.split(check_command) if check_command is not None else None + except ValueError as error: + raise ProjectError(f"invalid check command: {error}") from error result = update_project( project_dir=project, requested_version=openshell_version, + check_command=command, ) except ProjectError as error: _report_error(error) diff --git a/projects/openshell-middleware-manager/src/openshell_middleware_manager/generator.py b/projects/openshell-middleware-manager/src/openshell_middleware_manager/generator.py index c8b95910..4711c9fb 100644 --- a/projects/openshell-middleware-manager/src/openshell_middleware_manager/generator.py +++ b/projects/openshell-middleware-manager/src/openshell_middleware_manager/generator.py @@ -26,6 +26,7 @@ from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timezone +from functools import partial from importlib.resources import files from pathlib import Path @@ -266,6 +267,7 @@ def update_project( *, project_dir: Path, requested_version: str = "latest", + check_command: Sequence[str] | None = None, download_proto: DownloadProto | None = None, command_runner: CommandRunner | None = None, ) -> ProjectResult: @@ -276,11 +278,19 @@ def update_project( project_dir = project_dir.resolve() project_stat = project_dir.stat(follow_symlinks=False) metadata = _read_project_metadata(project_dir) + if check_command is not None and (not check_command or metadata.language != "python"): + raise ProjectError( + "check command must be non-empty and is supported for Python projects only" + ) if command_runner is None: _preflight_language(metadata.language) version = _normalize_version(requested_version) downloader = download_proto if download_proto is not None else _download_proto - runner = command_runner if command_runner is not None else _prepare_project + runner = ( + command_runner + if command_runner is not None + else partial(_prepare_project, check_command=check_command) + ) legacy_reservation, reservation = _acquire_output_locks(project_dir, version) staging_path: Path | None = None @@ -1066,9 +1076,15 @@ def _write_manifest( (project_dir / _MANIFEST_FILENAME).write_text(json.dumps(manifest, indent=2) + "\n") -def _prepare_project(language: str, project_dir: Path, package_name: str) -> None: +def _prepare_project( + language: str, + project_dir: Path, + package_name: str, + *, + check_command: Sequence[str] | None = None, +) -> None: if language == "python": - _prepare_python_project(project_dir, package_name) + _prepare_python_project(project_dir, package_name, check_command=check_command) else: _prepare_rust_project(project_dir) @@ -1103,7 +1119,9 @@ def _run( ) from error -def _prepare_python_project(project_dir: Path, package_name: str) -> None: +def _prepare_python_project( + project_dir: Path, package_name: str, *, check_command: Sequence[str] | None = None +) -> None: uv = _require_command("uv") bindings_dir = project_dir / "src" / package_name / "bindings" proto_path = project_dir / "proto" / "supervisor_middleware.proto" @@ -1151,7 +1169,7 @@ def _prepare_python_project(project_dir: Path, package_name: str) -> None: "run", "--project", str(project_dir), - "pytest", + *(check_command if check_command is not None else ("pytest",)), ), cwd=project_dir, environment=process_environment, diff --git a/projects/openshell-middleware-manager/tests/test_cli.py b/projects/openshell-middleware-manager/tests/test_cli.py index 2034799a..a0cb3f13 100644 --- a/projects/openshell-middleware-manager/tests/test_cli.py +++ b/projects/openshell-middleware-manager/tests/test_cli.py @@ -3,6 +3,7 @@ from pathlib import Path +import pytest from typer.testing import CliRunner from openshell_middleware_manager import cli @@ -85,13 +86,15 @@ def fake_create_project(**options): assert "omm: error: output exists" in result.stderr -def test_cli_reports_update_success(monkeypatch, tmp_path: Path) -> None: +@pytest.mark.parametrize("check_command", [None, 'python "scripts/check project.py"']) +def test_cli_reports_update_success(monkeypatch, tmp_path: Path, check_command: str | None) -> None: destination = tmp_path / "audit" def fake_update_project(**options): assert options == { "project_dir": destination, "requested_version": "v1.2.3", + "check_command": ["python", "scripts/check project.py"] if check_command else None, } return ProjectResult( destination=destination, @@ -109,6 +112,7 @@ def fake_update_project(**options): str(destination), "--openshell-version", "v1.2.3", + *(["--check-command", check_command] if check_command else []), ], ) @@ -117,6 +121,12 @@ def fake_update_project(**options): assert "OpenShell contract: v1.2.3" in result.stdout +def test_cli_rejects_malformed_check_command() -> None: + result = runner.invoke(cli.app, ["update", "--check-command", 'python "unfinished']) + assert result.exit_code == 1 + assert "invalid check command" in result.stderr + + def test_cli_reports_update_error(monkeypatch, tmp_path: Path) -> None: def fake_update_project(**options): del options diff --git a/projects/openshell-middleware-manager/tests/test_generator.py b/projects/openshell-middleware-manager/tests/test_generator.py index 66bf660e..5ccff60e 100644 --- a/projects/openshell-middleware-manager/tests/test_generator.py +++ b/projects/openshell-middleware-manager/tests/test_generator.py @@ -137,6 +137,51 @@ def updated_proto(version: str) -> tuple[bytes, str]: assert not list(tmp_path.glob(".audit-headers.openshell-middleware-manager.*")) +@pytest.mark.parametrize("fails", [False, True]) +def test_update_custom_check_runs_before_publication( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fails: bool +) -> None: + destination = tmp_path / "audit" + create_project( + name="audit", + language="python", + requested_version="v0.0.86", + destination=destination, + download_proto=local_proto, + command_runner=no_op_runner, + ) + original = (destination / ".openshell-middleware-manifest.json").read_bytes() + + def check(language, project, package, *, check_command) -> None: + assert project != destination + assert check_command == ("make", "check") + assert (destination / ".openshell-middleware-manifest.json").read_bytes() == original + no_op_runner(language, project, package) + if fails: + raise ProjectError("custom check failed") + + monkeypatch.setattr(generator, "_preflight_language", lambda language: None) + monkeypatch.setattr(generator, "_prepare_project", check) + + def update() -> None: + update_project( + project_dir=destination, + requested_version="v1.2.3", + download_proto=local_proto, + check_command=("make", "check"), + ) + + if fails: + with pytest.raises(ProjectError, match="custom check failed"): + update() + assert (destination / ".openshell-middleware-manifest.json").read_bytes() == original + else: + update() + assert (destination / ".openshell-middleware-manifest.json").read_bytes() != original + with pytest.raises(ProjectError, match="non-empty"): + update_project(project_dir=destination, check_command=()) + + def test_failed_update_keeps_original_project_unchanged(tmp_path: Path) -> None: destination = tmp_path / "audit" create_project( @@ -1230,7 +1275,7 @@ def test_prepare_project_dispatches_by_language( monkeypatch.setattr( generator, "_prepare_python_project", - lambda path, package: calls.append(("python", path, package)), + lambda path, package, *, check_command: calls.append(("python", path, package)), ) monkeypatch.setattr( generator, @@ -1268,8 +1313,9 @@ def test_run_passes_environment_to_subprocess(tmp_path: Path) -> None: ) +@pytest.mark.parametrize("check_command", [None, ("make", "check")]) def test_prepare_python_generates_relative_import_and_smoke_checks( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, check_command: tuple[str, ...] | None ) -> None: bindings = tmp_path / "src" / "audit_headers" / "bindings" bindings.mkdir(parents=True) @@ -1280,7 +1326,10 @@ def test_prepare_python_generates_relative_import_and_smoke_checks( monkeypatch.setattr(generator, "_require_command", lambda command: f"/tools/{command}") def fake_run(command, *, cwd, environment=None) -> None: - del cwd, environment + assert cwd == tmp_path + if "grpc_tools.protoc" not in command: + assert environment is not None + assert "UV_PROJECT_ENVIRONMENT" in environment calls.append(tuple(command)) if "grpc_tools.protoc" in command: (bindings / "supervisor_middleware_pb2_grpc.py").write_text( @@ -1289,13 +1338,19 @@ def fake_run(command, *, cwd, environment=None) -> None: monkeypatch.setattr(generator, "_run", fake_run) - generator._prepare_python_project(tmp_path, "audit_headers") + generator._prepare_python_project(tmp_path, "audit_headers", check_command=check_command) generated = (bindings / "supervisor_middleware_pb2_grpc.py").read_text() assert generated.startswith("from . import supervisor_middleware_pb2") assert len(calls) == 3 assert calls[1][1] == "sync" - assert calls[2][-1] == "pytest" + assert calls[2] == ( + "/tools/uv", + "run", + "--project", + str(tmp_path), + *(check_command or ("pytest",)), + ) def test_prepare_python_rejects_unexpected_generated_import(