From 13cfedfb0d475f1011de0552d736dded5f52dd8a Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 22 Aug 2024 14:24:07 -0400 Subject: [PATCH 1/2] feat(plugins): create base Python plugin (#819) --- craft_parts/plugins/base.py | 170 ++++++++++++++++ craft_parts/plugins/python_plugin.py | 175 +++-------------- .../craft-parts/craft-parts.wordlist.txt | 1 + tests/unit/plugins/test_base.py | 184 ++++++++++++++++++ tests/unit/plugins/test_python_plugin.py | 18 +- 5 files changed, 384 insertions(+), 164 deletions(-) diff --git a/craft_parts/plugins/base.py b/craft_parts/plugins/base.py index 222f46a9..84044541 100644 --- a/craft_parts/plugins/base.py +++ b/craft_parts/plugins/base.py @@ -19,9 +19,12 @@ from __future__ import annotations import abc +import textwrap from copy import deepcopy from typing import TYPE_CHECKING +from overrides import override + from craft_parts.actions import ActionProperties from .properties import PluginProperties @@ -122,3 +125,170 @@ def _get_java_post_build_commands(self) -> list[str]: # pylint: enable=line-too-long return link_java + link_jars + + +class BasePythonPlugin(Plugin): + """A base class for Python plugins. + + Provides common methods for dealing with Python items. + """ + + @override + def get_build_snaps(self) -> set[str]: + """Return a set of required snaps to install in the build environment.""" + return set() + + @override + def get_build_packages(self) -> set[str]: + """Return a set of required packages to install in the build environment. + + Child classes that need to override this should extend the returned set. + """ + return {"findutils", "python3-dev", "python3-venv"} + + @override + def get_build_environment(self) -> dict[str, str]: + """Return a dictionary with the environment to use in the build step. + + Child classes that need to override this should extend the dictionary returned + by this class. + """ + return { + # Add PATH to the python interpreter we always intend to use with + # this plugin. It can be user overridden, but that is an explicit + # choice made by a user. + "PATH": f"{self._part_info.part_install_dir}/bin:${{PATH}}", + "PARTS_PYTHON_INTERPRETER": "python3", + "PARTS_PYTHON_VENV_ARGS": "", + } + + def _get_create_venv_commands(self) -> list[str]: + """Get the commands for setting up the virtual environment.""" + return [ + f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{self._part_info.part_install_dir}"', + f'PARTS_PYTHON_VENV_INTERP_PATH="{self._part_info.part_install_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"', + ] + + def _get_find_python_interpreter_commands(self) -> list[str]: + """Get the commands that find a staged Python interpreter. + + These commands should, in bash, have a side-effect of creating a variable + called ``symlink_target`` containing the path to the relevant Python payload. + """ + python_interpreter = self._get_system_python_interpreter() or "" + return [ + textwrap.dedent( + f"""\ + # look for a provisioned python interpreter + opts_state="$(set +o|grep errexit)" + set +e + install_dir="{self._part_info.part_install_dir}/usr/bin" + stage_dir="{self._part_info.stage_dir}/usr/bin" + + # look for the right Python version - if the venv was created with python3.10, + # look for python3.10 + basename=$(basename $(readlink -f ${{PARTS_PYTHON_VENV_INTERP_PATH}})) + echo Looking for a Python interpreter called \\"${{basename}}\\" in the payload... + payload_python=$(find "$install_dir" "$stage_dir" -type f -executable -name "${{basename}}" -print -quit 2>/dev/null) + + if [ -n "$payload_python" ]; then + # We found a provisioned interpreter, use it. + echo Found interpreter in payload: \\"${{payload_python}}\\" + installed_python="${{payload_python##{self._part_info.part_install_dir}}}" + if [ "$installed_python" = "$payload_python" ]; then + # Found a staged interpreter. + symlink_target="..${{payload_python##{self._part_info.stage_dir}}}" + else + # The interpreter was installed but not staged yet. + symlink_target="..$installed_python" + fi + else + # Otherwise use what _get_system_python_interpreter() told us. + echo "Python interpreter not found in payload." + symlink_target="{python_interpreter}" + fi + + if [ -z "$symlink_target" ]; then + echo "No suitable Python interpreter found, giving up." + exit 1 + fi + + eval "${{opts_state}}" + """ + ) + ] + + def _get_rewrite_shebangs_commands(self) -> list[str]: + """Get the commands used to rewrite shebangs in the install dir. + + This can be overridden by application-specific subclasses to control how Python + shebangs in the final environment should be handled. + """ + script_interpreter = self._get_script_interpreter() + return [ + textwrap.dedent( + f"""\ + find "{self._part_info.part_install_dir}" -type f -executable -print0 | xargs -0 \\ + sed -i "1 s|^#\\!${{PARTS_PYTHON_VENV_INTERP_PATH}}.*$|{script_interpreter}|" + """ + ) + ] + + def _get_handle_symlinks_commands(self) -> list[str]: + """Get commands for handling Python symlinks.""" + if self._should_remove_symlinks(): + return [ + f"echo Removing python symlinks in {self._part_info.part_install_dir}/bin", + f'rm "{self._part_info.part_install_dir}"/bin/python*', + ] + return ['ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}"'] + + def _should_remove_symlinks(self) -> bool: + """Configure executables symlink removal. + + This method can be overridden by application-specific subclasses to control + whether symlinks in the virtual environment should be removed. Default is + False. If True, the venv-created symlinks to python* in bin/ will be + removed and will not be recreated. + """ + return False + + def _get_system_python_interpreter(self) -> str | None: + """Obtain the path to the system-provided python interpreter. + + This method can be overridden by application-specific subclasses. It + returns the path to the Python that bin/python3 should be symlinked to + if Python is not part of the payload. + """ + return '$(readlink -f "$(which "${PARTS_PYTHON_INTERPRETER}")")' + + def _get_script_interpreter(self) -> str: + """Obtain the shebang line to use in Python scripts. + + This method can be overridden by application-specific subclasses. It + returns the script interpreter to use in existing Python scripts. + """ + return "#!/usr/bin/env ${PARTS_PYTHON_INTERPRETER}" + + def _get_pip(self) -> str: + """Get the pip command to use.""" + return f"{self._part_info.part_install_dir}/bin/pip" + + @abc.abstractmethod + def _get_package_install_commands(self) -> list[str]: + """Get the commands for installing the given package in the Python virtualenv. + + A specific Python build system plugin should override this method to provide + the necessary commands. + """ + + @override + def get_build_commands(self) -> list[str]: + """Return a list of commands to run during the build step.""" + return [ + *self._get_create_venv_commands(), + *self._get_package_install_commands(), + *self._get_rewrite_shebangs_commands(), + *self._get_find_python_interpreter_commands(), + *self._get_handle_symlinks_commands(), + ] diff --git a/craft_parts/plugins/python_plugin.py b/craft_parts/plugins/python_plugin.py index d38d3193..b7b17039 100644 --- a/craft_parts/plugins/python_plugin.py +++ b/craft_parts/plugins/python_plugin.py @@ -17,12 +17,9 @@ """The python plugin.""" import shlex -from textwrap import dedent -from typing import Literal, cast +from typing import Literal -from overrides import override - -from .base import Plugin +from .base import BasePythonPlugin from .properties import PluginProperties @@ -39,166 +36,40 @@ class PythonPluginProperties(PluginProperties, frozen=True): source: str # pyright: ignore[reportGeneralTypeIssues] -class PythonPlugin(Plugin): +class PythonPlugin(BasePythonPlugin): """A plugin to build python parts.""" properties_class = PythonPluginProperties + _options: PythonPluginProperties + + def _get_package_install_commands(self) -> list[str]: + commands = [] + + pip = self._get_pip() - @override - def get_build_snaps(self) -> set[str]: - """Return a set of required snaps to install in the build environment.""" - return set() - - @override - def get_build_packages(self) -> set[str]: - """Return a set of required packages to install in the build environment.""" - return {"findutils", "python3-dev", "python3-venv"} - - @override - def get_build_environment(self) -> dict[str, str]: - """Return a dictionary with the environment to use in the build step.""" - return { - # Add PATH to the python interpreter we always intend to use with - # this plugin. It can be user overridden, but that is an explicit - # choice made by a user. - "PATH": f"{self._part_info.part_install_dir}/bin:${{PATH}}", - "PARTS_PYTHON_INTERPRETER": "python3", - "PARTS_PYTHON_VENV_ARGS": "", - } - - # pylint: disable=line-too-long - - @override - def get_build_commands(self) -> list[str]: - """Return a list of commands to run during the build step.""" - build_commands = [ - f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{self._part_info.part_install_dir}"', - f'PARTS_PYTHON_VENV_INTERP_PATH="{self._part_info.part_install_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"', - ] - - options = cast(PythonPluginProperties, self._options) - - pip = f"{self._part_info.part_install_dir}/bin/pip" - - if options.python_constraints: - constraints = " ".join(f"-c {c!r}" for c in options.python_constraints) + if self._options.python_constraints: + constraints = " ".join( + f"-c {c!r}" for c in self._options.python_constraints + ) else: constraints = "" - if options.python_packages: + if self._options.python_packages: python_packages = " ".join( - [shlex.quote(pkg) for pkg in options.python_packages] + [shlex.quote(pkg) for pkg in self._options.python_packages] ) python_packages_cmd = f"{pip} install {constraints} -U {python_packages}" - build_commands.append(python_packages_cmd) + commands.append(python_packages_cmd) - if options.python_requirements: - requirements = " ".join(f"-r {r!r}" for r in options.python_requirements) + if self._options.python_requirements: + requirements = " ".join( + f"-r {r!r}" for r in self._options.python_requirements + ) requirements_cmd = f"{pip} install {constraints} -U {requirements}" - build_commands.append(requirements_cmd) + commands.append(requirements_cmd) - build_commands.append( + commands.append( f"[ -f setup.py ] || [ -f pyproject.toml ] && {pip} install {constraints} -U ." ) - # Now fix shebangs. - script_interpreter = self._get_script_interpreter() - build_commands.append( - dedent( - f"""\ - find "{self._part_info.part_install_dir}" -type f -executable -print0 | xargs -0 \\ - sed -i "1 s|^#\\!${{PARTS_PYTHON_VENV_INTERP_PATH}}.*$|{script_interpreter}|" - """ - ) - ) - # Find the "real" python3 interpreter. - python_interpreter = self._get_system_python_interpreter() or "" - build_commands.append( - dedent( - f"""\ - # look for a provisioned python interpreter - opts_state="$(set +o|grep errexit)" - set +e - install_dir="{self._part_info.part_install_dir}/usr/bin" - stage_dir="{self._part_info.stage_dir}/usr/bin" - - # look for the right Python version - if the venv was created with python3.10, - # look for python3.10 - basename=$(basename $(readlink -f ${{PARTS_PYTHON_VENV_INTERP_PATH}})) - echo Looking for a Python interpreter called \\"${{basename}}\\" in the payload... - payload_python=$(find "$install_dir" "$stage_dir" -type f -executable -name "${{basename}}" -print -quit 2>/dev/null) - - if [ -n "$payload_python" ]; then - # We found a provisioned interpreter, use it. - echo Found interpreter in payload: \\"${{payload_python}}\\" - installed_python="${{payload_python##{self._part_info.part_install_dir}}}" - if [ "$installed_python" = "$payload_python" ]; then - # Found a staged interpreter. - symlink_target="..${{payload_python##{self._part_info.stage_dir}}}" - else - # The interpreter was installed but not staged yet. - symlink_target="..$installed_python" - fi - else - # Otherwise use what _get_system_python_interpreter() told us. - echo "Python interpreter not found in payload." - symlink_target="{python_interpreter}" - fi - - if [ -z "$symlink_target" ]; then - echo "No suitable Python interpreter found, giving up." - exit 1 - fi - - eval "${{opts_state}}" - """ - ) - ) - - # Handle the venv symlink (either remove it or set the final correct target) - if self._should_remove_symlinks(): - build_commands.append( - dedent( - f"""\ - echo Removing python symlinks in {self._part_info.part_install_dir}/bin - rm "{self._part_info.part_install_dir}"/bin/python* - """ - ) - ) - else: - build_commands.append( - dedent( - """\ - ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}" - """ - ) - ) - - return build_commands - - def _should_remove_symlinks(self) -> bool: - """Configure executables symlink removal. - - This method can be overridden by application-specific subclasses to control - whether symlinks in the virtual environment should be removed. Default is - False. If True, the venv-created symlinks to python* in bin/ will be - removed and will not be recreated. - """ - return False - - def _get_system_python_interpreter(self) -> str | None: - """Obtain the path to the system-provided python interpreter. - - This method can be overridden by application-specific subclasses. It - returns the path to the Python that bin/python3 should be symlinked to - if Python is not part of the payload. - """ - return '$(readlink -f "$(which "${PARTS_PYTHON_INTERPRETER}")")' - - def _get_script_interpreter(self) -> str: - """Obtain the shebang line to use in Python scripts. - - This method can be overridden by application-specific subclasses. It - returns the script interpreter to use in existing Python scripts. - """ - return "#!/usr/bin/env ${PARTS_PYTHON_INTERPRETER}" + return commands diff --git a/docs/common/craft-parts/craft-parts.wordlist.txt b/docs/common/craft-parts/craft-parts.wordlist.txt index 1d0d3aae..0b0c9d0b 100644 --- a/docs/common/craft-parts/craft-parts.wordlist.txt +++ b/docs/common/craft-parts/craft-parts.wordlist.txt @@ -14,6 +14,7 @@ AutotoolsPluginProperties Backport BaseSourceModel BaseFileSourceModel +BasePythonPlugin BaseRepository BeforeValidator BuildPackageNotFound diff --git a/tests/unit/plugins/test_base.py b/tests/unit/plugins/test_base.py index 31c822a3..7f33fbf3 100644 --- a/tests/unit/plugins/test_base.py +++ b/tests/unit/plugins/test_base.py @@ -14,12 +14,14 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . +import textwrap from typing import Literal, cast import pytest from craft_parts.infos import PartInfo, ProjectInfo from craft_parts.parts import Part from craft_parts.plugins import Plugin, PluginProperties +from craft_parts.plugins.base import BasePythonPlugin class FooPluginProperties(PluginProperties, frozen=True): @@ -83,3 +85,185 @@ class FaultyPlugin(Plugin): with pytest.raises(TypeError, match=expected): FaultyPlugin(properties=None, part_info=part_info) # type: ignore[reportGeneralTypeIssues] + + +class FooPythonPlugin(BasePythonPlugin): + """A plugin for testing the base Python plugin.""" + + properties_class = FooPluginProperties + + def _get_package_install_commands(self) -> list[str]: + return ["echo 'This is where I put my install commands... if I had any!'"] + + +@pytest.fixture +def python_plugin(new_dir): + properties = FooPythonPlugin.properties_class.unmarshal( + {"source": ".", "foo-name": "testy"} + ) + info = ProjectInfo(application_name="test", cache_dir=new_dir) + part_info = PartInfo(project_info=info, part=Part("p1", {})) + + return FooPythonPlugin(properties=properties, part_info=part_info) + + +def test_python_get_build_packages(python_plugin): + assert python_plugin.get_build_packages() == { + "findutils", + "python3-venv", + "python3-dev", + } + + +def test_python_get_build_environment(new_dir, python_plugin): + assert python_plugin.get_build_environment() == { + "PATH": f"{new_dir}/parts/p1/install/bin:${{PATH}}", + "PARTS_PYTHON_INTERPRETER": "python3", + "PARTS_PYTHON_VENV_ARGS": "", + } + + +def test_python_get_create_venv_commands(new_dir, python_plugin: FooPythonPlugin): + assert python_plugin._get_create_venv_commands() == [ + f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{new_dir}/parts/p1/install"', + f'PARTS_PYTHON_VENV_INTERP_PATH="{new_dir}/parts/p1/install/bin/${{PARTS_PYTHON_INTERPRETER}}"', + ] + + +def test_python_get_find_python_interpreter_commands( + new_dir, python_plugin: FooPythonPlugin +): + assert python_plugin._get_find_python_interpreter_commands() == [ + textwrap.dedent( + f"""\ + # look for a provisioned python interpreter + opts_state="$(set +o|grep errexit)" + set +e + install_dir="{new_dir}/parts/p1/install/usr/bin" + stage_dir="{new_dir}/stage/usr/bin" + + # look for the right Python version - if the venv was created with python3.10, + # look for python3.10 + basename=$(basename $(readlink -f ${{PARTS_PYTHON_VENV_INTERP_PATH}})) + echo Looking for a Python interpreter called \\"${{basename}}\\" in the payload... + payload_python=$(find "$install_dir" "$stage_dir" -type f -executable -name "${{basename}}" -print -quit 2>/dev/null) + + if [ -n "$payload_python" ]; then + # We found a provisioned interpreter, use it. + echo Found interpreter in payload: \\"${{payload_python}}\\" + installed_python="${{payload_python##{new_dir}/parts/p1/install}}" + if [ "$installed_python" = "$payload_python" ]; then + # Found a staged interpreter. + symlink_target="..${{payload_python##{new_dir}/stage}}" + else + # The interpreter was installed but not staged yet. + symlink_target="..$installed_python" + fi + else + # Otherwise use what _get_system_python_interpreter() told us. + echo "Python interpreter not found in payload." + symlink_target="$(readlink -f "$(which "${{PARTS_PYTHON_INTERPRETER}}")")" + fi + + if [ -z "$symlink_target" ]; then + echo "No suitable Python interpreter found, giving up." + exit 1 + fi + + eval "${{opts_state}}" + """ + ), + ] + + +def test_python_get_rewrite_shebangs_commands(new_dir, python_plugin: FooPythonPlugin): + assert python_plugin._get_rewrite_shebangs_commands() == [ + textwrap.dedent( + f"""\ + find "{new_dir}/parts/p1/install" -type f -executable -print0 | xargs -0 \\ + sed -i "1 s|^#\\!${{PARTS_PYTHON_VENV_INTERP_PATH}}.*$|#!/usr/bin/env ${{PARTS_PYTHON_INTERPRETER}}|" + """ + ) + ] + + +@pytest.mark.parametrize( + ("should_remove_symlinks", "expected_template"), + [ + ( + True, + [ + "echo Removing python symlinks in {install_dir}/bin", + 'rm "{install_dir}"/bin/python*', + ], + ), + (False, ['ln -sf "${{symlink_target}}" "${{PARTS_PYTHON_VENV_INTERP_PATH}}"']), + ], +) +def test_python_get_handle_symlinks_commands( + new_dir, + python_plugin: FooPythonPlugin, + should_remove_symlinks, + expected_template: list[str], +): + expected = [ + template.format(install_dir=new_dir / "parts" / "p1" / "install") + for template in expected_template + ] + python_plugin._should_remove_symlinks = lambda: should_remove_symlinks # type: ignore[method-assign] + + assert python_plugin._get_handle_symlinks_commands() == expected + + +def test_python_get_build_commands(new_dir, python_plugin: FooPythonPlugin): + assert python_plugin.get_build_commands() == [ + f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{new_dir}/parts/p1/install"', + f'PARTS_PYTHON_VENV_INTERP_PATH="{new_dir}/parts/p1/install/bin/${{PARTS_PYTHON_INTERPRETER}}"', + "echo 'This is where I put my install commands... if I had any!'", + textwrap.dedent( + f"""\ + find "{new_dir}/parts/p1/install" -type f -executable -print0 | xargs -0 \\ + sed -i "1 s|^#\\!${{PARTS_PYTHON_VENV_INTERP_PATH}}.*$|#!/usr/bin/env ${{PARTS_PYTHON_INTERPRETER}}|" + """ + ), + textwrap.dedent( + f"""\ + # look for a provisioned python interpreter + opts_state="$(set +o|grep errexit)" + set +e + install_dir="{new_dir}/parts/p1/install/usr/bin" + stage_dir="{new_dir}/stage/usr/bin" + + # look for the right Python version - if the venv was created with python3.10, + # look for python3.10 + basename=$(basename $(readlink -f ${{PARTS_PYTHON_VENV_INTERP_PATH}})) + echo Looking for a Python interpreter called \\"${{basename}}\\" in the payload... + payload_python=$(find "$install_dir" "$stage_dir" -type f -executable -name "${{basename}}" -print -quit 2>/dev/null) + + if [ -n "$payload_python" ]; then + # We found a provisioned interpreter, use it. + echo Found interpreter in payload: \\"${{payload_python}}\\" + installed_python="${{payload_python##{new_dir}/parts/p1/install}}" + if [ "$installed_python" = "$payload_python" ]; then + # Found a staged interpreter. + symlink_target="..${{payload_python##{new_dir}/stage}}" + else + # The interpreter was installed but not staged yet. + symlink_target="..$installed_python" + fi + else + # Otherwise use what _get_system_python_interpreter() told us. + echo "Python interpreter not found in payload." + symlink_target="$(readlink -f "$(which "${{PARTS_PYTHON_INTERPRETER}}")")" + fi + + if [ -z "$symlink_target" ]; then + echo "No suitable Python interpreter found, giving up." + exit 1 + fi + + eval "${{opts_state}}" + """ + ), + 'ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}"', + ] diff --git a/tests/unit/plugins/test_python_plugin.py b/tests/unit/plugins/test_python_plugin.py index 0d0d0e4d..68b9faf2 100644 --- a/tests/unit/plugins/test_python_plugin.py +++ b/tests/unit/plugins/test_python_plugin.py @@ -51,18 +51,12 @@ def get_build_commands( new_dir: Path, *, should_remove_symlinks: bool = False ) -> list[str]: if should_remove_symlinks: - postfix = dedent( - f"""\ - echo Removing python symlinks in {new_dir}/parts/p1/install/bin - rm "{new_dir}/parts/p1/install"/bin/python* - """ - ) + postfix = [ + f"echo Removing python symlinks in {new_dir}/parts/p1/install/bin", + f'rm "{new_dir}/parts/p1/install"/bin/python*', + ] else: - postfix = dedent( - """\ - ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}" - """ - ) + postfix = ['ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}"'] return [ dedent( @@ -110,7 +104,7 @@ def get_build_commands( eval "${{opts_state}}" """ ), - postfix, + *postfix, ] From 8b0d76c893ead9301822b3c6ecafef83734f0857 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Mon, 26 Aug 2024 10:17:02 -0400 Subject: [PATCH 2/2] feat(plugin): add poetry plugin (#820) --- .github/workflows/tests.yaml | 19 +- craft_parts/plugins/base.py | 22 +- craft_parts/plugins/plugins.py | 2 + craft_parts/plugins/poetry_plugin.py | 147 +++++++++ .../craft-parts/craft-parts.wordlist.txt | 3 + tests/integration/plugins/test_poetry.py | 292 ++++++++++++++++++ .../integration/plugins/test_poetry/README.md | 0 .../plugins/test_poetry/pyproject.toml | 17 + .../test_poetry/test_poetry/__init__.py | 8 + .../test_poetry/test_poetry/__main__.py | 5 + .../plugins/test_poetry/tests/__init__.py | 0 tests/unit/plugins/test_poetry_plugin.py | 202 ++++++++++++ 12 files changed, 711 insertions(+), 6 deletions(-) create mode 100644 craft_parts/plugins/poetry_plugin.py create mode 100644 tests/integration/plugins/test_poetry.py create mode 100644 tests/integration/plugins/test_poetry/README.md create mode 100644 tests/integration/plugins/test_poetry/pyproject.toml create mode 100644 tests/integration/plugins/test_poetry/test_poetry/__init__.py create mode 100644 tests/integration/plugins/test_poetry/test_poetry/__main__.py create mode 100644 tests/integration/plugins/test_poetry/tests/__init__.py create mode 100644 tests/unit/plugins/test_poetry_plugin.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index ad85b12f..4720f56a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -89,6 +89,13 @@ jobs: sudo apt install -y golang # Install RPM dependencies for RPM tests sudo apt install rpm + # Install poetry. From pipx on focal, from apt on newer systems. + if [[ $(grep VERSION_CODENAME /etc/os-release ) == "VERSION_CODENAME=focal" ]]; then + sudo apt-get install -y pipx + pipx install poetry + else + sudo apt-get install -y python3-poetry + fi # Ensure we don't have dotnet installed, to properly test dotnet-deps # Based on https://github.com/actions/runner-images/blob/main/images/linux/scripts/installers/dotnetcore-sdk.sh sudo apt remove -y dotnet-* || true @@ -156,7 +163,8 @@ jobs: echo "::group::apt install" sudo apt install -y ninja-build cmake scons qt5-qmake p7zip \ autoconf automake autopoint gcc git gperf help2man libtool texinfo \ - curl findutils pkg-config golang rpm + curl findutils pkg-config golang rpm \ + findutils python3-dev python3-venv echo "::endgroup::" echo "::group::dotnet removal" # Ensure we don't have dotnet installed, to properly test dotnet-deps @@ -170,6 +178,15 @@ jobs: echo "::group::Wait for snap to complete" snap watch --last=install echo "::endgroup::" + echo "::group::Poetry" + # Install poetry. From pipx on focal, from apt on newer systems. + if [[ $(grep VERSION_CODENAME /etc/os-release ) == "VERSION_CODENAME=focal" ]]; then + sudo apt-get install -y pipx + pipx install poetry + else + sudo apt-get install -y python3-poetry + fi + echo "::endgroup::" - name: specify node version uses: actions/setup-node@v4 with: diff --git a/craft_parts/plugins/base.py b/craft_parts/plugins/base.py index 84044541..84efff0b 100644 --- a/craft_parts/plugins/base.py +++ b/craft_parts/plugins/base.py @@ -19,6 +19,7 @@ from __future__ import annotations import abc +import pathlib import textwrap from copy import deepcopy from typing import TYPE_CHECKING @@ -162,11 +163,21 @@ def get_build_environment(self) -> dict[str, str]: "PARTS_PYTHON_VENV_ARGS": "", } + def _get_venv_directory(self) -> pathlib.Path: + """Get the directory into which the virtualenv should be placed. + + This method can be overridden by application-specific subclasses to control + the location of the virtual environment if it should be a subdirectory of + the install dir. + """ + return self._part_info.part_install_dir + def _get_create_venv_commands(self) -> list[str]: """Get the commands for setting up the virtual environment.""" + venv_dir = self._get_venv_directory() return [ - f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{self._part_info.part_install_dir}"', - f'PARTS_PYTHON_VENV_INTERP_PATH="{self._part_info.part_install_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"', + f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{venv_dir}"', + f'PARTS_PYTHON_VENV_INTERP_PATH="{venv_dir}/bin/${{PARTS_PYTHON_INTERPRETER}}"', ] def _get_find_python_interpreter_commands(self) -> list[str]: @@ -237,9 +248,10 @@ def _get_rewrite_shebangs_commands(self) -> list[str]: def _get_handle_symlinks_commands(self) -> list[str]: """Get commands for handling Python symlinks.""" if self._should_remove_symlinks(): + venv_dir = self._get_venv_directory() return [ - f"echo Removing python symlinks in {self._part_info.part_install_dir}/bin", - f'rm "{self._part_info.part_install_dir}"/bin/python*', + f"echo Removing python symlinks in {venv_dir}/bin", + f'rm "{venv_dir}"/bin/python*', ] return ['ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}"'] @@ -272,7 +284,7 @@ def _get_script_interpreter(self) -> str: def _get_pip(self) -> str: """Get the pip command to use.""" - return f"{self._part_info.part_install_dir}/bin/pip" + return f"{self._get_venv_directory()}/bin/pip" @abc.abstractmethod def _get_package_install_commands(self) -> list[str]: diff --git a/craft_parts/plugins/plugins.py b/craft_parts/plugins/plugins.py index 4f982a27..5000b781 100644 --- a/craft_parts/plugins/plugins.py +++ b/craft_parts/plugins/plugins.py @@ -31,6 +31,7 @@ from .meson_plugin import MesonPlugin from .nil_plugin import NilPlugin from .npm_plugin import NpmPlugin +from .poetry_plugin import PoetryPlugin from .properties import PluginProperties from .python_plugin import PythonPlugin from .qmake_plugin import QmakePlugin @@ -58,6 +59,7 @@ "meson": MesonPlugin, "nil": NilPlugin, "npm": NpmPlugin, + "poetry": PoetryPlugin, "python": PythonPlugin, "qmake": QmakePlugin, "rust": RustPlugin, diff --git a/craft_parts/plugins/poetry_plugin.py b/craft_parts/plugins/poetry_plugin.py new file mode 100644 index 00000000..9b22ed97 --- /dev/null +++ b/craft_parts/plugins/poetry_plugin.py @@ -0,0 +1,147 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2020-2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""The poetry plugin.""" + +import pathlib +import shlex +import subprocess +from typing import Literal + +import pydantic +from overrides import override + +from craft_parts.plugins import validator + +from .base import BasePythonPlugin +from .properties import PluginProperties + + +class PoetryPluginProperties(PluginProperties, frozen=True): + """The part properties used by the poetry plugin.""" + + plugin: Literal["poetry"] = "poetry" + + poetry_with: set[str] = pydantic.Field( + default_factory=set, + title="Optional dependency groups", + description="optional dependency groups to include when installing.", + ) + + # part properties required by the plugin + source: str # pyright: ignore[reportGeneralTypeIssues] + + +class PoetryPluginEnvironmentValidator(validator.PluginEnvironmentValidator): + """Check the execution environment for the Poetry plugin. + + :param part_name: The part whose build environment is being validated. + :param env: A string containing the build step environment setup. + """ + + _options: PoetryPluginProperties + + @override + def validate_environment( + self, *, part_dependencies: list[str] | None = None + ) -> None: + """Ensure the environment has the dependencies to build Poetry applications. + + :param part_dependencies: A list of the parts this part depends on. + """ + if "poetry-deps" in (part_dependencies or ()): + self.validate_dependency( + dependency="poetry", + plugin_name=self._options.plugin, + part_dependencies=part_dependencies, + ) + + +class PoetryPlugin(BasePythonPlugin): + """A plugin to build python parts.""" + + properties_class = PoetryPluginProperties + validator_class = PoetryPluginEnvironmentValidator + _options: PoetryPluginProperties + + def _system_has_poetry(self) -> bool: + try: + poetry_version = subprocess.check_output(["poetry", "--version"], text=True) + except (subprocess.CalledProcessError, FileNotFoundError): + return False + return "Poetry" in poetry_version + + @override + def get_build_packages(self) -> set[str]: + """Return a set of required packages to install in the build environment.""" + build_packages = super().get_build_packages() + if not self._system_has_poetry(): + build_packages |= {"python3-poetry"} + return build_packages + + def _get_poetry_export_commands(self, requirements_path: pathlib.Path) -> list[str]: + """Get the commands for exporting from poetry. + + Application-specific classes may override this if they need to export from + poetry differently. + + :param requirements_path: The path of the requirements.txt file to write to. + :returns: A list of strings forming the export script. + """ + export_command = [ + "poetry", + "export", + "--format=requirements.txt", + f"--output={requirements_path}", + "--with-credentials", + ] + if self._options.poetry_with: + export_command.append( + f"--with={','.join(sorted(self._options.poetry_with))}", + ) + + return [shlex.join(export_command)] + + def _get_pip_install_commands(self, requirements_path: pathlib.Path) -> list[str]: + """Get the commands for installing with pip. + + Application-specific classes my override this if they need to install + differently. + + :param requirements_path: The path of the requirements.txt file to write to. + :returns: A list of strings forming the install script. + """ + pip = self._get_pip() + return [ + # These steps need to be separate because poetry export defaults to including + # hashes, which don't work with installing from a directory. + f"{pip} install --requirement={requirements_path}", + # All dependencies should be installed through the requirements file made by + # poetry. + f"{pip} install --no-deps .", + # Check that the virtualenv is consistent. + f"{pip} check", + ] + + @override + def _get_package_install_commands(self) -> list[str]: + """Return a list of commands to run during the build step.""" + requirements_path = self._part_info.part_build_dir / "requirements.txt" + + return [ + *self._get_poetry_export_commands(requirements_path), + *self._get_pip_install_commands(requirements_path), + ] diff --git a/docs/common/craft-parts/craft-parts.wordlist.txt b/docs/common/craft-parts/craft-parts.wordlist.txt index 0b0c9d0b..a9aace32 100644 --- a/docs/common/craft-parts/craft-parts.wordlist.txt +++ b/docs/common/craft-parts/craft-parts.wordlist.txt @@ -189,6 +189,9 @@ PluginNotStrict PluginProperties PluginPropertiesModel PluginPullError +PoetryPlugin +PoetryPluginEnvironmentValidator +PoetryPluginProperties Prepend PrimeState ProjectDirs diff --git a/tests/integration/plugins/test_poetry.py b/tests/integration/plugins/test_poetry.py new file mode 100644 index 00000000..42ca2977 --- /dev/null +++ b/tests/integration/plugins/test_poetry.py @@ -0,0 +1,292 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023-2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +import os +import pathlib +import stat +import subprocess +import sys +import textwrap + +import craft_parts.plugins.plugins +import pytest +import pytest_check # type: ignore[import-untyped] +from craft_parts import LifecycleManager, Step, errors, plugins +from overrides import override + + +def setup_function(): + plugins.unregister_all() + + +def teardown_module(): + plugins.unregister_all() + + +@pytest.fixture(params=["test_poetry"]) +def source_directory(request): + return pathlib.Path(__file__).parent / request.param + + +@pytest.fixture +def poetry_part(source_directory): + return {"source": str(source_directory), "plugin": "poetry"} + + +@pytest.fixture +def parts_dict(poetry_part): + return {"parts": {"foo": poetry_part}} + + +def test_poetry_plugin(new_dir, partitions, source_directory, parts_dict): + """Prime a simple python source.""" + lf = LifecycleManager( + parts_dict, + application_name="test_poetry", + cache_dir=new_dir, + partitions=partitions, + ) + actions = lf.plan(Step.PRIME) + + with lf.action_executor() as ctx: + ctx.execute(actions) + + primed_script = pathlib.Path(lf.project_info.prime_dir, "bin", "mytest") + python_link = pathlib.Path(lf.project_info.prime_dir, "bin", "python3") + with pytest_check.check(): + assert primed_script.exists() + assert primed_script.open().readline().rstrip() == "#!/usr/bin/env python3" + assert python_link.is_symlink() + with pytest_check.check(): + assert python_link.readlink().is_absolute() + # This is normally /usr/bin/python3.*, but if running in a venv + # it could be elsewhere. + assert python_link.name.startswith("python3") + assert python_link.stat().st_mode & stat.S_IXOTH + + with pytest_check.check(): + result = subprocess.run( + [python_link, primed_script], text=True, capture_output=True, check=False + ) + assert result.stdout == "Test succeeded!\n" + + with pytest_check.check(): + result = subprocess.run( + [python_link, "-m", "test_poetry"], + text=True, + capture_output=True, + check=False, + ) + assert result.stdout == "Test succeeded!\n" + + +def test_poetry_plugin_override_get_system_interpreter( + new_dir, partitions, source_directory, parts_dict +): + """Override the system interpreter, link should use it.""" + + class MyPoetryPlugin(craft_parts.plugins.plugins.PoetryPlugin): + @override + def _get_system_python_interpreter(self) -> str | None: + return "use-this-python" + + plugins.register({"poetry": MyPoetryPlugin}) + + lf = LifecycleManager( + parts_dict, + application_name="test_poetry", + cache_dir=new_dir, + partitions=partitions, + ) + actions = lf.plan(Step.PRIME) + + with lf.action_executor() as ctx: + ctx.execute(actions) + + python_link = pathlib.Path(lf.project_info.prime_dir, "bin", "python3") + assert python_link.is_symlink() + assert os.readlink(python_link) == "use-this-python" + + +@pytest.mark.parametrize("remove_symlinks", [(True), (False)]) +def test_poetry_plugin_no_system_interpreter( + new_dir, + partitions, + remove_symlinks: bool, # noqa: FBT001 + parts_dict, +): + """Check that the build fails if a payload interpreter is needed but not found.""" + + class MyPoetryPlugin(craft_parts.plugins.plugins.PoetryPlugin): + @override + def _get_system_python_interpreter(self) -> str | None: + return None + + @override + def _should_remove_symlinks(self) -> bool: + # Parametrize this to make sure that the build fails even if the + # venv symlinks will be removed. + return remove_symlinks + + plugins.register({"poetry": MyPoetryPlugin}) + + lf = LifecycleManager( + parts_dict, + application_name="test_poetry", + cache_dir=new_dir, + partitions=partitions, + ) + actions = lf.plan(Step.PRIME) + + with lf.action_executor() as ctx, pytest.raises(errors.PluginBuildError): + ctx.execute(actions) + + +def test_poetry_plugin_remove_symlinks(new_dir, partitions, parts_dict): + """Override symlink removal.""" + + class MyPoetryPlugin(craft_parts.plugins.plugins.PoetryPlugin): + @override + def _should_remove_symlinks(self) -> bool: + return True + + plugins.register({"poetry": MyPoetryPlugin}) + + lf = LifecycleManager( + parts_dict, + application_name="test_poetry", + cache_dir=new_dir, + partitions=partitions, + ) + actions = lf.plan(Step.PRIME) + + with lf.action_executor() as ctx: + ctx.execute(actions) + + python_link = pathlib.Path(lf.project_info.prime_dir, "bin", "python3") + assert python_link.exists() is False + + +def test_poetry_plugin_override_shebangs(new_dir, partitions, parts_dict): + """Override what we want in script shebang lines.""" + + class MyPoetryPlugin(craft_parts.plugins.plugins.PoetryPlugin): + @override + def _get_script_interpreter(self) -> str: + return "#!/my/script/interpreter" + + plugins.register({"poetry": MyPoetryPlugin}) + + lf = LifecycleManager( + parts_dict, + application_name="test_poetry", + cache_dir=new_dir, + partitions=partitions, + ) + actions = lf.plan(Step.PRIME) + + with lf.action_executor() as ctx: + ctx.execute(actions) + + primed_script = pathlib.Path(lf.project_info.prime_dir, "bin/pip") + assert primed_script.open().readline().rstrip() == "#!/my/script/interpreter" + + +def test_find_payload_python_bad_version(new_dir, partitions, parts_dict, poetry_part): + """Test that the build fails if a payload interpreter is needed but it's the + wrong Python version.""" + poetry_part["override-build"] = textwrap.dedent( + f"""\ + # Put a binary called "python3.3" in the payload + mkdir -p ${{CRAFT_PART_INSTALL}}/usr/bin + cp {sys.executable} ${{CRAFT_PART_INSTALL}}/usr/bin/python3.3 + craftctl default + """ + ) + + class MyPoetryPlugin(craft_parts.plugins.plugins.PoetryPlugin): + @override + def _get_system_python_interpreter(self) -> str | None: + # To have the build fail after failing to find the payload interpreter + return None + + plugins.register({"poetry": MyPoetryPlugin}) + + real_python = pathlib.Path(sys.executable).resolve() + real_basename = real_python.name + + lf = LifecycleManager( + parts_dict, + application_name="test_poetry", + cache_dir=new_dir, + partitions=partitions, + ) + actions = lf.plan(Step.PRIME) + + out = pathlib.Path("out.txt") + with out.open(mode="w") as outfile, pytest.raises(errors.ScriptletRunError): + with lf.action_executor() as ctx: + ctx.execute(actions, stdout=outfile) + + output = out.read_text() + expected_text = textwrap.dedent( + f"""\ + Looking for a Python interpreter called "{real_basename}" in the payload... + Python interpreter not found in payload. + No suitable Python interpreter found, giving up. + """ + ) + assert expected_text in output + + +def test_find_payload_python_good_version(new_dir, partitions, parts_dict, poetry_part): + """Test that the build succeeds if a payload interpreter is needed, and it's + the right Python version.""" + + real_python = pathlib.Path(sys.executable).resolve() + real_basename = real_python.name + install_dir = pathlib.Path("parts/foo/install") + # Copy the "real" binary into the payload before calling the plugin's build. + poetry_part["override-build"] = textwrap.dedent( + f"""\ + # Put a binary called "{real_basename}" in the payload + mkdir -p ${{CRAFT_PART_INSTALL}}/usr/bin + cp {sys.executable} ${{CRAFT_PART_INSTALL}}/usr/bin/{real_basename} + craftctl default + """ + ) + + lf = LifecycleManager( + parts_dict, + application_name="test_poetry", + cache_dir=new_dir, + partitions=partitions, + ) + actions = lf.plan(Step.PRIME) + + out = pathlib.Path("out.txt") + with out.open(mode="w") as outfile: + with lf.action_executor() as ctx: + ctx.execute(actions, stdout=outfile) + + output = out.read_text() + payload_python = (install_dir / f"usr/bin/{real_basename}").resolve() + expected_text = textwrap.dedent( + f"""\ + Found interpreter in payload: "{payload_python}" + """ + ) + assert expected_text in output diff --git a/tests/integration/plugins/test_poetry/README.md b/tests/integration/plugins/test_poetry/README.md new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/plugins/test_poetry/pyproject.toml b/tests/integration/plugins/test_poetry/pyproject.toml new file mode 100644 index 00000000..a5f2f65e --- /dev/null +++ b/tests/integration/plugins/test_poetry/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "test-poetry" +version = "0.1.0" +description = "" +authors = ["Alex Lowe "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.10" +distro = "==1.8.0" + +[tool.poetry.scripts] +mytest = "test_poetry:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/integration/plugins/test_poetry/test_poetry/__init__.py b/tests/integration/plugins/test_poetry/test_poetry/__init__.py new file mode 100644 index 00000000..263b51d9 --- /dev/null +++ b/tests/integration/plugins/test_poetry/test_poetry/__init__.py @@ -0,0 +1,8 @@ +import distro # type: ignore[import-not-found] + + +def main() -> int: + # Check that we installed the correct version of `distro`. + assert distro.__version__ == "1.8.0" # pyright: ignore[reportPrivateImportUsage] + print("Test succeeded!") + return 0 diff --git a/tests/integration/plugins/test_poetry/test_poetry/__main__.py b/tests/integration/plugins/test_poetry/test_poetry/__main__.py new file mode 100644 index 00000000..4d8e4833 --- /dev/null +++ b/tests/integration/plugins/test_poetry/test_poetry/__main__.py @@ -0,0 +1,5 @@ +import sys + +import test_poetry # type: ignore[import-not-found] + +sys.exit(test_poetry.main()) diff --git a/tests/integration/plugins/test_poetry/tests/__init__.py b/tests/integration/plugins/test_poetry/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plugins/test_poetry_plugin.py b/tests/unit/plugins/test_poetry_plugin.py new file mode 100644 index 00000000..334c4485 --- /dev/null +++ b/tests/unit/plugins/test_poetry_plugin.py @@ -0,0 +1,202 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +from pathlib import Path +from textwrap import dedent + +import pytest +import pytest_check # type: ignore[import-untyped] +from craft_parts import Part, PartInfo, ProjectInfo +from craft_parts.plugins.poetry_plugin import PoetryPlugin +from pydantic import ValidationError + + +@pytest.fixture +def plugin(new_dir): + properties = PoetryPlugin.properties_class.unmarshal({"source": "."}) + info = ProjectInfo(application_name="test", cache_dir=new_dir) + part_info = PartInfo(project_info=info, part=Part("p1", {})) + + return PoetryPlugin(properties=properties, part_info=part_info) + + +@pytest.mark.parametrize( + ("has_poetry", "expected_packages"), + [ + (False, {"python3-poetry"}), + (True, set()), + ], +) +def test_get_build_packages( + monkeypatch, plugin: PoetryPlugin, has_poetry, expected_packages: set +): + monkeypatch.setattr(plugin, "_system_has_poetry", lambda: has_poetry) + + assert plugin.get_build_packages().issuperset(expected_packages) + + +def test_get_build_environment(plugin, new_dir): + assert plugin.get_build_environment() == { + "PATH": f"{new_dir}/parts/p1/install/bin:${{PATH}}", + "PARTS_PYTHON_INTERPRETER": "python3", + "PARTS_PYTHON_VENV_ARGS": "", + } + + +# pylint: disable=line-too-long + + +def get_build_commands( + new_dir: Path, *, should_remove_symlinks: bool = False +) -> list[str]: + if should_remove_symlinks: + postfix = dedent( + f"""\ + echo Removing python symlinks in {new_dir}/parts/p1/install/bin + rm "{new_dir}/parts/p1/install"/bin/python* + """ + ) + else: + postfix = dedent( + 'ln -sf "${symlink_target}" "${PARTS_PYTHON_VENV_INTERP_PATH}"' + ) + + return [ + dedent( + f"""\ + find "{new_dir}/parts/p1/install" -type f -executable -print0 | xargs -0 \\ + sed -i "1 s|^#\\!${{PARTS_PYTHON_VENV_INTERP_PATH}}.*$|#!/usr/bin/env ${{PARTS_PYTHON_INTERPRETER}}|" + """ + ), + dedent( + f"""\ + # look for a provisioned python interpreter + opts_state="$(set +o|grep errexit)" + set +e + install_dir="{new_dir}/parts/p1/install/usr/bin" + stage_dir="{new_dir}/stage/usr/bin" + + # look for the right Python version - if the venv was created with python3.10, + # look for python3.10 + basename=$(basename $(readlink -f ${{PARTS_PYTHON_VENV_INTERP_PATH}})) + echo Looking for a Python interpreter called \\"${{basename}}\\" in the payload... + payload_python=$(find "$install_dir" "$stage_dir" -type f -executable -name "${{basename}}" -print -quit 2>/dev/null) + + if [ -n "$payload_python" ]; then + # We found a provisioned interpreter, use it. + echo Found interpreter in payload: \\"${{payload_python}}\\" + installed_python="${{payload_python##{new_dir}/parts/p1/install}}" + if [ "$installed_python" = "$payload_python" ]; then + # Found a staged interpreter. + symlink_target="..${{payload_python##{new_dir}/stage}}" + else + # The interpreter was installed but not staged yet. + symlink_target="..$installed_python" + fi + else + # Otherwise use what _get_system_python_interpreter() told us. + echo "Python interpreter not found in payload." + symlink_target="$(readlink -f "$(which "${{PARTS_PYTHON_INTERPRETER}}")")" + fi + + if [ -z "$symlink_target" ]; then + echo "No suitable Python interpreter found, giving up." + exit 1 + fi + + eval "${{opts_state}}" + """ + ), + postfix, + ] + + +@pytest.mark.parametrize( + ("optional_groups", "export_addendum"), + [ + (set(), ""), + ({"dev"}, " --with=dev"), + ({"toml", "yaml", "silly-walks"}, " --with=silly-walks,toml,yaml"), + ], +) +def test_get_build_commands(new_dir, optional_groups, export_addendum): + info = ProjectInfo(application_name="test", cache_dir=new_dir) + part_info = PartInfo(project_info=info, part=Part("p1", {})) + properties = PoetryPlugin.properties_class.unmarshal( + { + "source": ".", + "poetry-with": optional_groups, + } + ) + + plugin = PoetryPlugin(part_info=part_info, properties=properties) + + assert plugin.get_build_commands() == [ + f'"${{PARTS_PYTHON_INTERPRETER}}" -m venv ${{PARTS_PYTHON_VENV_ARGS}} "{new_dir}/parts/p1/install"', + f'PARTS_PYTHON_VENV_INTERP_PATH="{new_dir}/parts/p1/install/bin/${{PARTS_PYTHON_INTERPRETER}}"', + f"poetry export --format=requirements.txt --output={new_dir}/parts/p1/build/requirements.txt --with-credentials" + + export_addendum, + f"{new_dir}/parts/p1/install/bin/pip install --requirement={new_dir}/parts/p1/build/requirements.txt", + f"{new_dir}/parts/p1/install/bin/pip install --no-deps .", + f"{new_dir}/parts/p1/install/bin/pip check", + *get_build_commands(new_dir), + ] + + +def test_missing_properties(): + with pytest.raises(ValidationError) as raised: + PoetryPlugin.properties_class.unmarshal({}) + err = raised.value.errors() + assert len(err) == 1 + assert err[0]["loc"] == ("source",) + assert err[0]["type"] == "missing" + + +def test_get_out_of_source_build(plugin): + assert plugin.get_out_of_source_build() is False + + +def test_should_remove_symlinks(plugin): + assert plugin._should_remove_symlinks() is False + + +def test_get_system_python_interpreter(plugin): + assert plugin._get_system_python_interpreter() == ( + '$(readlink -f "$(which "${PARTS_PYTHON_INTERPRETER}")")' + ) + + +def test_script_interpreter(plugin): + assert plugin._get_script_interpreter() == ( + "#!/usr/bin/env ${PARTS_PYTHON_INTERPRETER}" + ) + + +def test_call_should_remove_symlinks(plugin, new_dir, mocker): + mocker.patch( + "craft_parts.plugins.poetry_plugin.PoetryPlugin._should_remove_symlinks", + return_value=True, + ) + + build_commands = plugin.get_build_commands() + + pytest_check.is_in( + f"echo Removing python symlinks in {plugin._part_info.part_install_dir}/bin", + build_commands, + ) + pytest_check.is_in( + f'rm "{plugin._part_info.part_install_dir}"/bin/python*', build_commands + )