From f82353a7727a19ac79b40e32c3d2fe2e025087a9 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Thu, 23 Oct 2025 08:53:02 -0600 Subject: [PATCH] refactor(tests): share directive lookup helper --- Dockerfile | 10 ++++- docker/pyproject.deps.toml | 2 +- entrypoint.sh | 11 ++++- pyproject.toml | 2 +- tests/test_docker_artifacts.py | 77 +++++++++++++++++++++++++++++++++- tests/test_dockerfile.py | 22 +++++++++- tests/test_entrypoints.py | 16 +++++++ uv.lock | 2 +- 8 files changed, 133 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index d53d6cc..75e5efc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,9 @@ FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 AS builder ENV PATH="/root/.local/bin:$PATH" +ENV UV_PYTHON_INSTALL_DIR=/opt/uv -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ WORKDIR /app @@ -20,19 +21,24 @@ RUN --mount=type=cache,target=/root/.cache/uv \ FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 ENV PATH="/root/.local/bin:$PATH" +ENV UV_PYTHON_INSTALL_DIR=/opt/uv ENV PATH="/app/.venv/bin:$PATH" WORKDIR /app RUN useradd --system --create-home app -COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ COPY --from=builder --chown=app:app /app/.venv /app/.venv +COPY --from=builder --chown=app:app /opt/uv /opt/uv COPY --from=builder --chown=app:app /app/mcp_plex ./mcp_plex COPY --from=builder --chown=app:app /app/entrypoint.sh ./entrypoint.sh COPY --from=builder /app/pyproject.toml ./pyproject.toml COPY --from=builder --chown=app:app /app/uv.lock ./uv.lock +RUN mkdir -p /app/mcp_plex.egg-info +RUN chown -R app:app /app + USER app ENTRYPOINT ["./entrypoint.sh"] diff --git a/docker/pyproject.deps.toml b/docker/pyproject.deps.toml index 8bfef18..ce61413 100644 --- a/docker/pyproject.deps.toml +++ b/docker/pyproject.deps.toml @@ -1,6 +1,6 @@ [project] name = "mcp-plex" -version = "2.0.37" +version = "2.0.42" requires-python = ">=3.11,<3.13" dependencies = [ "fastmcp>=2.11.2", diff --git a/entrypoint.sh b/entrypoint.sh index 998b8bf..7e49224 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,4 +1,13 @@ #!/usr/bin/env sh set -e -exec uv run "$@" +if [ "$#" -eq 0 ]; then + set -- load-data +fi + +command="$1" +shift + +command_path="/app/.venv/bin/$command" + +exec "$command_path" "$@" diff --git a/pyproject.toml b/pyproject.toml index 9064cca..e20ae4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "2.0.37" +version = "2.0.42" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" diff --git a/tests/test_docker_artifacts.py b/tests/test_docker_artifacts.py index 1914a52..2631f6f 100644 --- a/tests/test_docker_artifacts.py +++ b/tests/test_docker_artifacts.py @@ -10,6 +10,15 @@ CUDA_IMAGE = "nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04" BUILDER_STAGE_DESCRIPTOR = f"{CUDA_IMAGE} AS builder" RUNTIME_STAGE_DESCRIPTOR = CUDA_IMAGE +UV_INSTALL_DIR = "/opt/uv" +UV_INSTALL_ENV_DIRECTIVE = f"ENV UV_PYTHON_INSTALL_DIR={UV_INSTALL_DIR}" +UV_INSTALL_COPY_DIRECTIVE = ( + f"COPY --from=builder --chown=app:app {UV_INSTALL_DIR} {UV_INSTALL_DIR}" +) +APP_DIRECTORY = "/app" +APP_CHOWN_DIRECTIVE = f"RUN chown -R app:app {APP_DIRECTORY}" +APP_EGG_INFO_DIRECTORY = f"{APP_DIRECTORY}/mcp_plex.egg-info" +APP_EGG_INFO_DIRECTIVE = f"RUN mkdir -p {APP_EGG_INFO_DIRECTORY}" def _extract_stage(contents: str, stage_descriptor: str) -> str: @@ -40,6 +49,15 @@ def _runtime_section(contents: str) -> str: return _extract_stage(contents, RUNTIME_STAGE_DESCRIPTOR) +def _directive_index(section: str, directive: str, *, missing_message: str) -> int: + """Return the position of a directive within a stage or fail with context.""" + + try: + return section.index(directive) + except ValueError as exc: # pragma: no cover - defensive guard + raise AssertionError(missing_message) from exc + + @pytest.fixture() def dockerfile_contents() -> str: """Read the Dockerfile once so individual checks stay lean.""" @@ -91,7 +109,9 @@ def test_dockerfile_copies_project_metadata_for_uv(dockerfile_contents: str) -> """The runtime image must include pyproject metadata so uv can resolve scripts.""" runtime_section = _runtime_section(dockerfile_contents) - expected_phrase = "COPY --from=builder /app/pyproject.toml ./pyproject.toml" + expected_phrase = ( + f"COPY --from=builder {APP_DIRECTORY}/pyproject.toml ./pyproject.toml" + ) assert ( expected_phrase in runtime_section @@ -104,7 +124,8 @@ def test_builder_stage_copies_uv_binaries(dockerfile_contents: str) -> None: builder_section = _builder_section(dockerfile_contents) assert ( - "COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/" in builder_section + "COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/" + in builder_section ), "Builder stage must populate uv tools from the upstream image" @@ -157,3 +178,55 @@ def test_runtime_stage_copies_virtualenv_with_chown(dockerfile_contents: str) -> ), "Runtime stage should retain the project entrypoint" assert "CMD" in runtime_section, "Runtime stage should define a default command" + + +def test_dockerfile_preserves_uv_python_installation(dockerfile_contents: str) -> None: + """Runtime stage should retain the uv-managed Python installation for app user access.""" + + builder_section = _builder_section(dockerfile_contents) + runtime_section = _runtime_section(dockerfile_contents) + + assert ( + UV_INSTALL_ENV_DIRECTIVE in builder_section + ), "Builder stage must pin the uv Python install directory" + assert ( + UV_INSTALL_ENV_DIRECTIVE in runtime_section + ), "Runtime stage must expose the uv Python install directory" + assert ( + UV_INSTALL_COPY_DIRECTIVE in runtime_section + ), "Runtime stage should copy the uv-managed Python interpreter into place" + + +def test_runtime_stage_chowns_app_directory(dockerfile_contents: str) -> None: + """Runtime stage should grant the app user ownership of the project directory.""" + + runtime_section = _runtime_section(dockerfile_contents) + + _directive_index( + runtime_section, + APP_CHOWN_DIRECTIVE, + missing_message="Runtime stage must chown /app so editable builds can create egg-info", + ) + + +def test_runtime_stage_precreates_egg_info_directory( + dockerfile_contents: str, +) -> None: + """Runtime stage should ensure the egg-info directory exists for editable installs.""" + + runtime_section = _runtime_section(dockerfile_contents) + + precreate_index = _directive_index( + runtime_section, + APP_EGG_INFO_DIRECTIVE, + missing_message="Runtime stage must create the egg-info directory before ownership handoff", + ) + chown_index = _directive_index( + runtime_section, + APP_CHOWN_DIRECTIVE, + missing_message="Runtime stage must chown /app so editable builds can create egg-info", + ) + + assert ( + precreate_index < chown_index + ), "Egg-info directory should be created before chowning /app" diff --git a/tests/test_dockerfile.py b/tests/test_dockerfile.py index e09df27..f26bc73 100644 --- a/tests/test_dockerfile.py +++ b/tests/test_dockerfile.py @@ -9,13 +9,33 @@ DOCKERFILE_PATH = PROJECT_ROOT / "Dockerfile" +def read_dockerfile_contents() -> str: + """Load the Dockerfile contents for reuse across tests.""" + + return DOCKERFILE_PATH.read_text(encoding="utf-8") + + def test_uv_binary_path_is_added_before_usage() -> None: """Ensure the Dockerfile exposes uv on PATH before it's invoked.""" - dockerfile_contents = DOCKERFILE_PATH.read_text(encoding="utf-8") + dockerfile_contents = read_dockerfile_contents() expected_line = 'ENV PATH="/root/.local/bin:$PATH"' assert ( expected_line in dockerfile_contents ), "Dockerfile must expose uv installation directory before using uv" + + +def test_uv_binary_is_copied_into_usr_local_bin() -> None: + """Ensure uv binaries are copied into a globally accessible location.""" + + dockerfile_contents = read_dockerfile_contents() + + expected_instruction = ( + "COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/" + ) + + assert ( + expected_instruction in dockerfile_contents + ), "Dockerfile must copy uv binaries into /usr/local/bin" diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 10fcd54..465eba6 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -1,8 +1,13 @@ from __future__ import annotations +from pathlib import Path import runpy +ENTRYPOINT = Path("entrypoint.sh") +VENV_BIN = "/app/.venv/bin" + + def test_loader_module_entrypoint(monkeypatch): captured: dict[str, object] = {} @@ -27,3 +32,14 @@ def fake_main(argv=None): runpy.run_module("mcp_plex.server", run_name="__main__") assert captured["argv"] is None + + +def test_docker_entrypoint_executes_virtualenv_commands() -> None: + """Ensure the container entrypoint dispatches through the prepared virtualenv.""" + + contents = ENTRYPOINT.read_text(encoding="utf-8") + + assert ( + f'"{VENV_BIN}/$command"' in contents + ), "Entrypoint should execute commands from the virtualenv bin directory" + assert "uv run" not in contents, "Entrypoint should not invoke uv run during dispatch" diff --git a/uv.lock b/uv.lock index 5a9020d..193085b 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "2.0.37" +version = "2.0.42" source = { editable = "." } dependencies = [ { name = "fastapi" },