diff --git a/README.md b/README.md index 5bf276b..5c2ea87 100644 --- a/README.md +++ b/README.md @@ -302,33 +302,15 @@ serve = "uvicorn api.main:app --host 0.0.0.0" migrate = "alembic upgrade head" ``` -running `pdm dockerize` from the workspace root installs every workspace member, +### Building a single image for the whole workspace + +Running `pdm dockerize` from the workspace root installs every workspace member, along with their transitive dependencies, into the output directory, and exposes their console scripts (subject to `include_bins`/`exclude_bins`). Members are installed as regular (**non-editable**) packages, so the resulting image is self-contained and never references build-time host paths. -### Running from the workspace root - -Like `pdm install`, `pdm lock` and `pdm sync`, the `dockerize` command must be run from the workspace root, -since members share a single lockfile and a single environment: - -```console -$ cd packages/api && pdm dockerize -[PdmUsageError]: `pdm dockerize` can only be run from the workspace root: /path/to/my-workspace -``` - -Building one image per member is not supported: a workspace produces a single image. -Use `include`/`exclude` to select which scripts that image exposes. - -### Configuration is read from the workspace root only - -**The `tool.pdm.dockerize` and `tool.pdm.scripts` sections are only read from the workspace root `pyproject.toml`.** -Those sections are ignored in members. - -### Which members are included - A member is installed when it is matched by `[tool.pdm.workspace] members`, has a `project.name` and is a distribution (ie. neither `distribution = false` nor `package-type = "application"`). @@ -336,8 +318,6 @@ has a `project.name` and is a distribution Members are treated as implicit `default` group dependencies, so they are excluded by selections dropping that group, such as `--no-default`. -### Dockerfile - Members must be present in the build stage so their wheels can be built: make sure your `.dockerignore` does not exclude the members directory. @@ -347,6 +327,54 @@ COPY packages/ ./packages/ RUN pdm dockerize ``` +### Building one image per member + +Running `pdm dockerize` from a member, or targeting it with `--project`, +builds an image for **that member only**: + +```console +$ cd packages/api && pdm dockerize +$ pdm --project packages/api dockerize # equivalent +``` + +- the member dependencies are installed into `packages/api/dist/docker/lib`, + including the **sibling members it depends on**, as non-editable packages; +- the members it does not depend on are **not** installed; +- like any dockerized project, the member itself is **not** installed: + its sources are exposed through `PYTHONPATH`, so they have to be in the image. + +```dockerfile +COPY packages/api /app +COPY packages/api/dist/docker /app +WORKDIR /app +ENTRYPOINT ["/app/entrypoint"] +``` + +Declare a dependency on a sibling member by name: + +```toml +[project] +name = "api" +dependencies = ["shared"] +``` + +### Where the configuration comes from + +| From the targeted project | From the workspace root | +|----------------------------------|----------------------------------------| +| `tool.pdm.dockerize` | the `pdm.lock` lock file | +| `tool.pdm.scripts` | the Python interpreter | +| the dependencies and the groups | the `tool.pdm.source` package indexes | +| the output directory | the `use_uv` setting | + +**Nothing is inherited from the workspace root**: the `tool.pdm.dockerize` and +`tool.pdm.scripts` sections of the root are ignored when targeting a member, +and vice versa. A member `pdm.toml` is ignored too. + +> [!NOTE] +> Selecting a group only defined in a member, using `--group`, is rejected: +> the workspace lock file only records the groups of the workspace root. + ## Internals This plugin works by subclassing some `pdm` classes to reuse the installation process: diff --git a/src/pdm_dockerize/commands.py b/src/pdm_dockerize/commands.py index 7ec28bf..08e22a6 100644 --- a/src/pdm_dockerize/commands.py +++ b/src/pdm_dockerize/commands.py @@ -28,6 +28,8 @@ from pdm.models.repositories.lock import LockedRepository from pdm.models.requirements import Requirement + from .config import DockerizeSettings + def _split_combined_extras(req: Requirement, package_keys: set[str]) -> list[Requirement] | None: """Split a combined-extras requirement into single-extra requirements. @@ -154,17 +156,35 @@ def get_locked_repository(*args, **kwargs) -> LockedRepository: class DockerizeEnvironment(PythonLocalEnvironment): - """An environment installaing into the dist/docker directory""" + """An environment installing into the dist/docker directory + + In a workspace, ``project`` is always the workspace **root**, so that the + interpreter, the sources, the caches and the ``${PROJECT_ROOT}`` expansion + keep resolving against it, exactly like ``pdm install`` does. + ``member`` is the project the image is built for, and is only used for the + output directory and the ``tool.pdm.dockerize`` settings. + Outside a workspace, both are the same project. + """ def __init__( - self, project: Project, *, target: str | None = None, python: str | None = None + self, + project: Project, + *, + member: Project | None = None, + target: str | None = None, + python: str | None = None, ) -> None: super().__init__(project, python=python) + self.member = member if member is not None else project self.target = Path(target) if target else None @property def packages_path(self) -> Path: - return self.target or self.project.root / "dist/docker" + return self.target or self.member.root / "dist/docker" + + @property + def settings(self) -> DockerizeSettings: + return self.member.pyproject.settings.get("dockerize", {}) class DockerizeCommand(BaseCommand): @@ -183,45 +203,55 @@ class DockerizeCommand(BaseCommand): ) def handle(self, project: Project, options: argparse.Namespace) -> None: - if (workspace := project.workspace_project) is not None: - # Workspace members share the root lockfile and environment, so a - # single image is built from the workspace root, like `pdm install`. - raise PdmUsageError( - f"`pdm dockerize` can only be run from the workspace root: {workspace.root}" + # Workspace members share the root lock file, environment and sources, + # but provide their own dependencies, scripts, settings and output. + # Outside a workspace, both are the very same project. + root = project.workspace_project or project + member = project + + check_project_file(member) + actions.check_lockfile(root) + selection = GroupSelection.from_options(member, options) + hooks = HookManager(member) + env = DockerizeEnvironment(root, member=member, target=options.target) + + if member is not root and not member.is_distribution: + project.core.ui.warn( + f"Workspace member {member.name} is not a distribution: " + "it is not installed and its sources are not exposed." ) - check_project_file(project) - actions.check_lockfile(project) - selection = GroupSelection.from_options(project, options) - hooks = HookManager(project) - env = DockerizeEnvironment(project, target=options.target) requirements = [] selection.validate() for group in selection: - requirements.extend(project.get_dependencies(group)) - if "default" in selection: - # Workspace members are implicit `default` dependencies of the - # workspace root: mirror `pdm.cli.actions.do_lock`. - requirements = project.with_workspace_dependencies(requirements) + requirements.extend(member.get_dependencies(group)) + if "default" in selection and member.is_workspace_root: + # Only the workspace root implicitly depends on every member: + # mirror `pdm.cli.actions.do_lock`. + # `iter_workspace_dependencies()` called on a member would return + # every member of the workspace, installing unrelated siblings. + requirements = root.with_workspace_dependencies(requirements) # Always use the resolvelib resolver to read from pdm.lock, # even when uv is configured as the project resolver. # UvResolver would run `uv lock` (a full re-resolution) which is # inappropriate here — we only need to read existing locked candidates. - config = cast(collections.ChainMap, project.config) + # `Project.config` is a per-instance property, so the override has to be + # applied to the very project performing the resolution. + config = cast(collections.ChainMap, root.config) config.maps.insert(0, {"use_uv": False}) try: - with _adapted_locked_repository(project): - requirements = _adapt_requirements_for_lockfile(requirements, project) - candidates = actions.resolve_candidates_from_lockfile(project, requirements) + with _adapted_locked_repository(root): + requirements = _adapt_requirements_for_lockfile(requirements, root) + candidates = actions.resolve_candidates_from_lockfile(root, requirements) finally: config.maps.pop(0) - use_uv = project.config.get("use_uv", False) + use_uv = root.config.get("use_uv", False) if use_uv: try: project.core.uv_cmd # noqa: B018 — verify uv is available synchronizer = DockerizeUvSynchronizer( - project, + root, env, candidates, dry_run=options.dry_run, @@ -246,7 +276,7 @@ def handle(self, project: Project, options: argparse.Namespace) -> None: synchronizer.synchronize() - script = ProjectEntrypoint(project, hooks).as_script() + script = ProjectEntrypoint(member, hooks).as_script() if options.dry_run: project.core.ui.echo("Dry run: would write the following entrypoint script:") @@ -254,5 +284,8 @@ def handle(self, project: Project, options: argparse.Namespace) -> None: return entrypoint = env.packages_path / "entrypoint" + # The output directory is only created by the installers, + # which do not run when there is nothing to install + entrypoint.parent.mkdir(parents=True, exist_ok=True) entrypoint.write_text(script) os.chmod(entrypoint, 0o555) diff --git a/src/pdm_dockerize/installer.py b/src/pdm_dockerize/installer.py index 1a8dc24..62addc4 100644 --- a/src/pdm_dockerize/installer.py +++ b/src/pdm_dockerize/installer.py @@ -40,9 +40,11 @@ def __init__( rename_pth: bool = False, ) -> None: super().__init__(environment, use_install_cache=use_install_cache, rename_pth=rename_pth) - settings: DockerizeSettings = self.environment.project.pyproject.settings.get( - "dockerize", {} - ) + # A `DockerizeEnvironment` sources the settings from the targeted + # workspace member, which is not always the project owning the lock file. + settings: DockerizeSettings = getattr( + environment, "settings", None + ) or self.environment.project.pyproject.settings.get("dockerize", {}) self.include = filters.parse(settings, "include_bins") self.exclude = filters.parse(settings, "exclude_bins") @@ -119,6 +121,10 @@ class DockerizeUvSynchronizer: This bypasses PDM's Synchronizer/InstallManager pipeline entirely and invokes ``uv`` as a subprocess. After installation, scripts are moved from ``lib/bin/`` to ``bin/`` with include/exclude filtering applied. + + ``project`` is the project owning the lock file: in a workspace, the root. + The settings are sourced from the environment, hence from the targeted + workspace member. """ def __init__( @@ -133,7 +139,7 @@ def __init__( self.environment = environment self.candidates = candidates self.dry_run = dry_run - settings: DockerizeSettings = project.pyproject.settings.get("dockerize", {}) + settings: DockerizeSettings = environment.settings self.include_bins = filters.parse(settings, "include_bins") self.exclude_bins = filters.parse(settings, "exclude_bins") diff --git a/tests/__snapshots__/test_workspace_member/test_member_entrypoint.sh b/tests/__snapshots__/test_workspace_member/test_member_entrypoint.sh new file mode 100644 index 0000000..630e97b --- /dev/null +++ b/tests/__snapshots__/test_workspace_member/test_member_entrypoint.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env sh + +set -eu + +dirname=$(dirname "$0") +cmd=${1:-""} +[ "$cmd" ] && shift +cd "$dirname" > /dev/null + +PYTHONPATH="$(pwd)/src":"$(pwd)/lib" +export PYTHONPATH +PATH="$(pwd)/bin":"$PATH" +export PATH +FROM_MEMBER="1" +export FROM_MEMBER + +usage() { + echo "Available commands" + echo "==================" + echo "serve: echo serving" +} + +case $cmd in + serve) + echo serving "$@" + ;; + *) + usage + ;; +esac diff --git a/tests/conftest.py b/tests/conftest.py index ef67490..7d3333f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import subprocess from pathlib import Path -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Any, Protocol import pytest import tomlkit @@ -70,6 +70,8 @@ def __call__( path: str | None = None, dependencies: list[str] | None = None, scripts: dict[str, str] | None = None, + pdm_scripts: dict[str, Any] | None = None, + dockerize: dict[str, Any] | None = None, distribution: bool = True, ) -> Path: ... @@ -81,6 +83,10 @@ def add_member(project: Project) -> AddMemberFixture: Members must exist on disk: `Project.iter_members()` filters on the presence of a `pyproject.toml` and `iter_workspace_dependencies()` requires the member to be a named distribution. + + `scripts` are `[project.scripts]` console entry points, while `pdm_scripts` + and `dockerize` are the member's `[tool.pdm.scripts]` and + `[tool.pdm.dockerize]` sections. """ def factory( @@ -89,6 +95,8 @@ def factory( path: str | None = None, dependencies: list[str] | None = None, scripts: dict[str, str] | None = None, + pdm_scripts: dict[str, Any] | None = None, + dockerize: dict[str, Any] | None = None, distribution: bool = True, ) -> Path: module = name.replace("-", "_") @@ -112,8 +120,15 @@ def factory( "project": metadata, "build-system": {"requires": ["pdm-backend"], "build-backend": "pdm.backend"}, } + tool_pdm: dict = {} if not distribution: - data["tool"] = {"pdm": {"distribution": False}} + tool_pdm["distribution"] = False + if pdm_scripts: + tool_pdm["scripts"] = pdm_scripts + if dockerize is not None: + tool_pdm["dockerize"] = dockerize + if tool_pdm: + data["tool"] = {"pdm": tool_pdm} (member_dir / "pyproject.toml").write_text(tomlkit.dumps(data)) return member_dir @@ -136,3 +151,26 @@ def workspace(project: Project, add_member: AddMemberFixture) -> Project: project.pyproject.metadata["dependencies"] = [] project.pyproject.write() return project + + +class MemberFixture(Protocol): + def __call__(self, name: str = ...) -> Project: ... + + +@pytest.fixture +def member(project: Project) -> MemberFixture: + """Build a `Project` for a member of the current workspace. + + The factory is called from the test body, once the workspace + `pyproject.toml` has been written: `Project.pyproject` and + `Project.workspace_project` are both cached properties. + """ + + def factory(name: str = "member-a") -> Project: + member_project = project.core.create_project(project.root / "packages" / name) + # Guard against a fixture ordering regression silently degrading + # the tests to the plain, non-workspace code path + assert member_project.workspace_project is not None + return member_project + + return factory diff --git a/tests/test_installer.py b/tests/test_installer.py index d1fc9f2..d3aad4d 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -290,9 +290,6 @@ def _make_uv_synchronizer( dockerize_settings["include_bins"] = include_bins if exclude_bins is not None: dockerize_settings["exclude_bins"] = exclude_bins - project.pyproject.settings.get.side_effect = lambda key, default=None: ( - dockerize_settings if key == "dockerize" else default - ) project.core.uv_cmd = ["uv"] project.sources = sources or [] project.root = tmp_path @@ -302,6 +299,8 @@ def _make_uv_synchronizer( environment = MagicMock() environment.packages_path = tmp_path / "packages" environment.interpreter.executable = "/usr/bin/python3" + # Settings are sourced from the environment, hence from the targeted member + environment.settings = dockerize_settings return DockerizeUvSynchronizer( project=project, diff --git a/tests/test_pdm_dockerize.py b/tests/test_pdm_dockerize.py index 694ae5c..0d68c13 100644 --- a/tests/test_pdm_dockerize.py +++ b/tests/test_pdm_dockerize.py @@ -28,6 +28,19 @@ def test_expose_version(): assert pdm_dockerize.__version__ +def test_generate_docker_dist_without_dependencies(project: Project, pdm: PDMCallable): + """The output directory is only created by the installers""" + project.pyproject.metadata["dependencies"] = [] + project.pyproject.write() + pdm("lock", obj=project, strict=True) + + pdm("dockerize -v", obj=project, strict=True) + + entrypoint = project.root / "dist/docker/entrypoint" + assert entrypoint.is_file() + assert os.access(entrypoint, os.X_OK) + + def test_generate_docker_dist(project: Project, pdm: PDMCallable, snapshot: SnapshotAssertion): project.pyproject.settings["dockerize"]["include_bins"] = "*" project.pyproject.write() diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 48b83e1..86d9b59 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -140,17 +140,17 @@ def test_no_default_excludes_members(workspace: Project, pdm: PDMCallable) -> No assert not (lib / "member_b").exists() -def test_dockerize_from_a_member_fails(workspace: Project, pdm: PDMCallable) -> None: - """A single image is built from the workspace root, like `pdm install`""" - pdm("lock", obj=workspace, strict=True) +def test_dockerize_from_a_member_targets_the_member( + workspace: Project, pdm: PDMCallable +) -> None: + """Running from a member builds that member image, not the workspace one""" + pdm("lock", obj=workspace, strict=True, cleanup=False) member = workspace.core.create_project(workspace.root / "packages/member-a") - result = pdm("dockerize", obj=member) + pdm("dockerize -v", obj=member, strict=True) - assert result.exit_code != 0 - output = result.stderr + result.output - assert "can only be run from the workspace root" in output - assert str(workspace.root) in output + assert (member.root / "dist/docker/entrypoint").is_file() + assert not (workspace.root / "dist/docker").exists() def test_dockerize_outside_a_workspace_is_unaffected( diff --git a/tests/test_workspace_member.py b/tests/test_workspace_member.py new file mode 100644 index 0000000..3a01e22 --- /dev/null +++ b/tests/test_workspace_member.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import pytest +import tomlkit + +if TYPE_CHECKING: + from pathlib import Path + + from pdm.project import Project + from pdm.pytest import PDMCallable + from syrupy import SnapshotAssertion + + from tests.conftest import AddMemberFixture, MemberFixture, ShellcheckFixture + + +backends = pytest.mark.parametrize("use_uv", [False, True], ids=["pip", "uv"]) +"""Run the same scenario through both the pip based and the uv based installers""" + + +@pytest.fixture +def member_workspace(project: Project, add_member: AddMemberFixture) -> Project: + """A workspace whose `member-a` has a sibling dependency and its own settings""" + add_member( + "member-a", + dependencies=["Faker", "member-b"], + scripts={"member-a": "member_a.cli:main"}, + pdm_scripts={"serve": "echo serving", "_private": "echo hidden"}, + dockerize={"include": "*", "include_bins": "*", "env": {"FROM_MEMBER": "1"}}, + ) + add_member("member-b") + # A member `member-a` does not depend on + add_member("member-z") + project.pyproject.settings["workspace"] = {"members": ["packages/*"]} + project.pyproject.settings["dockerize"] = {"include": "*", "env": {"FROM_ROOT": "1"}} + project.pyproject.settings["scripts"] = {"root-only": "echo root"} + project.pyproject.metadata["requires-python"] = ">=3.10" + project.pyproject.metadata["dependencies"] = [] + project.pyproject.write() + return project + + +def dockerize_member( + pdm: PDMCallable, root: Project, member: Project, args: str = "-v" +) -> Path: + """Lock at the workspace root, then dockerize a single member + + `cleanup=False` keeps the HTTP session alive between both calls: + building the member wheels needs it to fetch their build requirements. + """ + pdm("lock", obj=root, strict=True, cleanup=False) + result = pdm(f"dockerize {args}", obj=member, strict=True) + # Guard against silently falling back to the pip installer in `uv` runs + assert "uv not found" not in result.output + return member.root / "dist/docker" + + +def installed(lib: Path) -> set[str]: + """The distribution names installed in a `lib` directory""" + return {path.name for path in lib.iterdir() if not path.name.endswith(".dist-info")} + + +@backends +def test_member_dependencies_are_installed( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable, use_uv: bool +) -> None: + """Only the member's own dependencies are installed""" + if use_uv: + member_workspace.global_config["use_uv"] = True + + lib = dockerize_member(pdm, member_workspace, member()) / "lib" + + assert (lib / "faker").is_dir() + + +@backends +def test_member_sibling_is_installed( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable, use_uv: bool +) -> None: + """A sibling member depended upon by name is installed, non-editable""" + if use_uv: + member_workspace.global_config["use_uv"] = True + + lib = dockerize_member(pdm, member_workspace, member()) / "lib" + + assert (lib / "member_b").is_dir() + assert not (lib / "member_b").is_symlink() + assert not list(lib.glob("*.pth")) + assert not list(lib.glob("__editable__*")) + + +@backends +def test_unrelated_member_is_not_installed( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable, use_uv: bool +) -> None: + """Members the target does not depend on are left out""" + if use_uv: + member_workspace.global_config["use_uv"] = True + + lib = dockerize_member(pdm, member_workspace, member()) / "lib" + + assert "member_z" not in installed(lib) + + +@backends +def test_member_itself_is_not_installed( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable, use_uv: bool +) -> None: + """The dockerized member is exposed through its sources, not installed""" + if use_uv: + member_workspace.global_config["use_uv"] = True + + lib = dockerize_member(pdm, member_workspace, member()) / "lib" + + assert "member_a" not in installed(lib) + assert not list(lib.glob("member_a-*.dist-info")) + + +def test_transitive_sibling_is_installed( + project: Project, add_member: AddMemberFixture, pdm: PDMCallable +) -> None: + """Sibling members are resolved transitively""" + add_member("member-a", dependencies=["member-b"]) + add_member("member-b", dependencies=["member-c"]) + add_member("member-c", dependencies=["Faker"]) + project.pyproject.settings["workspace"] = {"members": ["packages/*"]} + project.pyproject.settings["dockerize"] = {"include": "*"} + project.pyproject.metadata["requires-python"] = ">=3.10" + project.pyproject.metadata["dependencies"] = [] + project.pyproject.write() + target = project.core.create_project(project.root / "packages/member-a") + + lib = dockerize_member(pdm, project, target) / "lib" + + assert {"member_b", "member_c", "faker"} <= installed(lib) + + +def test_member_sources_are_on_pythonpath( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable +) -> None: + """The member is not installed, so its sources are exposed instead""" + dist = dockerize_member(pdm, member_workspace, member()) + + entrypoint = (dist / "entrypoint").read_text() + assert 'PYTHONPATH="$(pwd)/src":"$(pwd)/lib"' in entrypoint + + +def test_member_scripts_are_used( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable +) -> None: + """Scripts come from the member, the workspace root ones are ignored""" + dist = dockerize_member(pdm, member_workspace, member()) + + entrypoint = (dist / "entrypoint").read_text() + assert "serve)" in entrypoint + assert "root-only" not in entrypoint + assert "_private" not in entrypoint + + +def test_member_settings_are_used( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable +) -> None: + """`tool.pdm.dockerize` comes from the member, the root one does not leak""" + dist = dockerize_member(pdm, member_workspace, member()) + + entrypoint = (dist / "entrypoint").read_text() + assert "FROM_MEMBER" in entrypoint + assert "FROM_ROOT" not in entrypoint + + +@backends +def test_member_bins_filters_are_used( + member_workspace: Project, + member: MemberFixture, + pdm: PDMCallable, + add_member: AddMemberFixture, + use_uv: bool, +) -> None: + """`include_bins`/`exclude_bins` are read from the member, not the root""" + if use_uv: + member_workspace.global_config["use_uv"] = True + add_member( + "member-a", + dependencies=["Faker"], + dockerize={"include": "*", "include_bins": "*", "exclude_bins": ["faker"]}, + ) + + bin = dockerize_member(pdm, member_workspace, member()) / "bin" + + assert not (bin / "faker").exists() + + +@backends +def test_output_is_under_the_member( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable, use_uv: bool +) -> None: + """The image content is generated in the member, not in the workspace root""" + if use_uv: + member_workspace.global_config["use_uv"] = True + + dist = dockerize_member(pdm, member_workspace, member()) + + assert (dist / "entrypoint").is_file() + assert os.access(dist / "entrypoint", os.X_OK) + assert not (member_workspace.root / "dist/docker").exists() + + +def test_member_target_override( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable, tmp_path: Path +) -> None: + """An explicit target wins over the member default output directory""" + target = tmp_path / "target" + + dockerize_member(pdm, member_workspace, member(), f"-v {target}") + + assert (target / "entrypoint").is_file() + assert (target / "lib/faker").is_dir() + assert not (member().root / "dist/docker").exists() + + +@backends +def test_no_interpreter_is_resolved_in_the_member( + member_workspace: Project, member: MemberFixture, pdm: PDMCallable, use_uv: bool +) -> None: + """The interpreter comes from the workspace root + + `Project.python` is not workspace aware and would create a virtualenv + and a `.pdm-python` file inside the member. + """ + if use_uv: + member_workspace.global_config["use_uv"] = True + target = member() + + dockerize_member(pdm, member_workspace, target) + + assert not (target.root / ".venv").exists() + assert not (target.root / ".pdm-python").exists() + assert not (target.root / "__pypackages__").exists() + + +def test_non_distribution_member_warns( + member_workspace: Project, + member: MemberFixture, + pdm: PDMCallable, + add_member: AddMemberFixture, +) -> None: + """A non distribution member is neither installed nor exposed""" + # A non distribution member is not a workspace dependency, so its own + # dependencies are not part of the workspace lock file either + add_member("member-a", dockerize={"include": "*"}, distribution=False) + pdm("lock", obj=member_workspace, strict=True, cleanup=False) + target = member() + + result = pdm("dockerize -v", obj=target, strict=True) + + assert "is not a distribution" in result.output + result.stderr + entrypoint = (target.root / "dist/docker/entrypoint").read_text() + assert '"$(pwd)/src"' not in entrypoint + + +def test_member_entrypoint( + member_workspace: Project, + member: MemberFixture, + pdm: PDMCallable, + snapshot: SnapshotAssertion, + shellcheck: ShellcheckFixture, +) -> None: + """The generated entrypoint is a valid shell script""" + dist = dockerize_member(pdm, member_workspace, member()) + + entrypoint = (dist / "entrypoint").read_text() + assert entrypoint == snapshot + shellcheck(entrypoint) + + +def test_member_only_group_is_rejected( + member_workspace: Project, + member: MemberFixture, + pdm: PDMCallable, + add_member: AddMemberFixture, +) -> None: + """The workspace lock file only knows about the workspace root groups""" + member_dir = add_member("member-a", dependencies=["Faker"]) + pyproject = member_dir / "pyproject.toml" + data = tomlkit.parse(pyproject.read_text()) + data["dependency-groups"] = {"extra": ["Faker"]} + pyproject.write_text(tomlkit.dumps(data)) + pdm("lock", obj=member_workspace, strict=True, cleanup=False) + + result = pdm("dockerize -v -G extra", obj=member()) + + assert result.exit_code != 0 + assert "Requested groups not in lockfile: extra" in result.stderr