Source code for craft_parts.plugins.python_plugin

# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# Copyright 2020-2023 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 <http://www.gnu.org/licenses/>.

"""The python plugin."""

import shlex
from textwrap import dedent
from typing import Any, Dict, List, Optional, Set, cast

from overrides import override

from .base import Plugin, PluginModel, extract_plugin_properties
from .properties import PluginProperties


[docs]class PythonPluginProperties(PluginProperties, PluginModel): """The part properties used by the python plugin.""" python_requirements: List[str] = [] python_constraints: List[str] = [] python_packages: List[str] = ["pip", "setuptools", "wheel"] # part properties required by the plugin source: str
[docs] @classmethod @override def unmarshal(cls, data: Dict[str, Any]) -> "PythonPluginProperties": """Populate make properties from the part specification. :param data: A dictionary containing part properties. :return: The populated plugin properties data object. :raise pydantic.ValidationError: If validation fails. """ plugin_data = extract_plugin_properties( data, plugin_name="python", required=["source"] ) return cls(**plugin_data)
[docs]class PythonPlugin(Plugin): """A plugin to build python parts.""" properties_class = PythonPluginProperties
[docs] @override def get_build_snaps(self) -> Set[str]: """Return a set of required snaps to install in the build environment.""" return set()
[docs] @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"}
[docs] @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
[docs] @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) else: constraints = "" if options.python_packages: python_packages = " ".join( [shlex.quote(pkg) for pkg in options.python_packages] ) python_packages_cmd = f"{pip} install {constraints} -U {python_packages}" build_commands.append(python_packages_cmd) if options.python_requirements: requirements = " ".join(f"-r {r!r}" for r in options.python_requirements) requirements_cmd = f"{pip} install {constraints} -U {requirements}" build_commands.append(requirements_cmd) build_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) -> Optional[str]: """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}"