diff --git a/aws_lambda_builders/workflows/python_pip/actions.py b/aws_lambda_builders/workflows/python_pip/actions.py index 1e9c4d95f..0cf53323c 100644 --- a/aws_lambda_builders/workflows/python_pip/actions.py +++ b/aws_lambda_builders/workflows/python_pip/actions.py @@ -2,12 +2,24 @@ Action to resolve Python dependencies using PIP """ +import logging +from typing import Optional, Tuple + from aws_lambda_builders.actions import ActionFailedError, BaseAction, Purpose from aws_lambda_builders.architecture import X86_64 +from aws_lambda_builders.binary_path import BinaryPath +from aws_lambda_builders.exceptions import MisMatchRuntimeError, RuntimeValidatorError +from aws_lambda_builders.workflows.python_pip.exceptions import MissingPipError +from aws_lambda_builders.workflows.python_pip.packager import ( + DependencyBuilder, + PackagerError, + PipRunner, + PythonPipDependencyBuilder, + SubprocessPip, +) from aws_lambda_builders.workflows.python_pip.utils import OSUtils -from .exceptions import MissingPipError -from .packager import DependencyBuilder, PackagerError, PipRunner, PythonPipDependencyBuilder, SubprocessPip +LOG = logging.getLogger(__name__) class PythonPipBuildAction(BaseAction): @@ -27,20 +39,21 @@ def __init__( self.binaries = binaries self.architecture = architecture - def execute(self): - os_utils = OSUtils() - python_path = self.binaries[self.LANGUAGE].binary_path - try: - pip = SubprocessPip(osutils=os_utils, python_exe=python_path) - except MissingPipError as ex: - raise ActionFailedError(str(ex)) - pip_runner = PipRunner(python_exe=python_path, pip=pip) + self._os_utils = OSUtils() + + def execute(self) -> None: + """ + Executes the build action for Python `pip` workflows. + """ + pip, python_with_pip = self._find_runtime_with_pip() + pip_runner = PipRunner(python_exe=python_with_pip, pip=pip) + dependency_builder = DependencyBuilder( - osutils=os_utils, pip_runner=pip_runner, runtime=self.runtime, architecture=self.architecture + osutils=self._os_utils, pip_runner=pip_runner, runtime=self.runtime, architecture=self.architecture ) package_builder = PythonPipDependencyBuilder( - osutils=os_utils, runtime=self.runtime, dependency_builder=dependency_builder + osutils=self._os_utils, runtime=self.runtime, dependency_builder=dependency_builder ) try: target_artifact_dir = self.artifacts_dir @@ -55,3 +68,43 @@ def execute(self): ) except PackagerError as ex: raise ActionFailedError(str(ex)) + + def _find_runtime_with_pip(self) -> Tuple[SubprocessPip, str]: + """ + Finds a Python runtime that also contains `pip`. + + Returns + ------- + Tuple[SubprocessPip, str] + Returns a tuple of the SubprocessPip object created from + a valid Python runtime and the runtime path itself + + Raises + ------ + ActionFailedError + Raised if the method is not able to find a valid runtime + that has the correct Python and pip installed + """ + binary_object: Optional[BinaryPath] = self.binaries.get(self.LANGUAGE) + + if not binary_object: + raise ActionFailedError("Failed to fetch Python binaries from the PATH.") + + for python_path in binary_object.resolver.exec_paths: + try: + valid_python_path = binary_object.validator.validate(python_path) + + if valid_python_path: + pip = SubprocessPip(osutils=self._os_utils, python_exe=valid_python_path) + + return (pip, valid_python_path) + except (MisMatchRuntimeError, RuntimeValidatorError): + # runtime and mismatch exceptions should have been caught + # during the init phase + + # we can ignore these and let the action fail at the end + LOG.debug(f"Python runtime path '{valid_python_path}' does not match the workflow") + except MissingPipError: + LOG.debug(f"Python runtime path '{valid_python_path}' does not contain pip") + + raise ActionFailedError("Failed to find a Python runtime containing pip on the PATH.") diff --git a/tests/unit/workflows/python_pip/test_actions.py b/tests/unit/workflows/python_pip/test_actions.py index 30bfa1db1..2917fdf42 100644 --- a/tests/unit/workflows/python_pip/test_actions.py +++ b/tests/unit/workflows/python_pip/test_actions.py @@ -9,14 +9,16 @@ from aws_lambda_builders.workflows.python_pip.actions import PythonPipBuildAction from aws_lambda_builders.workflows.python_pip.exceptions import MissingPipError -from aws_lambda_builders.workflows.python_pip.packager import PackagerError +from aws_lambda_builders.workflows.python_pip.packager import PackagerError, SubprocessPip class TestPythonPipBuildAction(TestCase): @patch("aws_lambda_builders.workflows.python_pip.actions.PythonPipDependencyBuilder") @patch("aws_lambda_builders.workflows.python_pip.actions.DependencyBuilder") - def test_action_must_call_builder(self, DependencyBuilderMock, PythonPipDependencyBuilderMock): - builder_instance = PythonPipDependencyBuilderMock.return_value + @patch("aws_lambda_builders.workflows.python_pip.actions.PythonPipBuildAction._find_runtime_with_pip") + def test_action_must_call_builder(self, find_runtime_mock, dependency_builder_mock, pip_dependency_builder_mock): + builder_instance = pip_dependency_builder_mock.return_value + find_runtime_mock.return_value = (Mock(), Mock()) action = PythonPipBuildAction( "artifacts", @@ -28,7 +30,7 @@ def test_action_must_call_builder(self, DependencyBuilderMock, PythonPipDependen ) action.execute() - DependencyBuilderMock.assert_called_with(osutils=ANY, pip_runner=ANY, runtime="runtime", architecture=X86_64) + dependency_builder_mock.assert_called_with(osutils=ANY, pip_runner=ANY, runtime="runtime", architecture=X86_64) builder_instance.build_dependencies.assert_called_with( artifacts_dir_path="artifacts", scratch_dir_path="scratch_dir", requirements_path="manifest" @@ -36,8 +38,12 @@ def test_action_must_call_builder(self, DependencyBuilderMock, PythonPipDependen @patch("aws_lambda_builders.workflows.python_pip.actions.PythonPipDependencyBuilder") @patch("aws_lambda_builders.workflows.python_pip.actions.DependencyBuilder") - def test_action_must_call_builder_with_architecture(self, DependencyBuilderMock, PythonPipDependencyBuilderMock): - builder_instance = PythonPipDependencyBuilderMock.return_value + @patch("aws_lambda_builders.workflows.python_pip.actions.PythonPipBuildAction._find_runtime_with_pip") + def test_action_must_call_builder_with_architecture( + self, find_runtime_mock, dependency_builder_mock, pip_dependency_builder_mock + ): + builder_instance = pip_dependency_builder_mock.return_value + find_runtime_mock.return_value = (Mock(), Mock()) action = PythonPipBuildAction( "artifacts", @@ -50,32 +56,18 @@ def test_action_must_call_builder_with_architecture(self, DependencyBuilderMock, ) action.execute() - DependencyBuilderMock.assert_called_with(osutils=ANY, pip_runner=ANY, runtime="runtime", architecture=ARM64) + dependency_builder_mock.assert_called_with(osutils=ANY, pip_runner=ANY, runtime="runtime", architecture=ARM64) builder_instance.build_dependencies.assert_called_with( artifacts_dir_path="artifacts", scratch_dir_path="scratch_dir", requirements_path="manifest" ) @patch("aws_lambda_builders.workflows.python_pip.actions.PythonPipDependencyBuilder") - def test_must_raise_exception_on_failure(self, PythonPipDependencyBuilderMock): - builder_instance = PythonPipDependencyBuilderMock.return_value + @patch("aws_lambda_builders.workflows.python_pip.actions.PythonPipBuildAction._find_runtime_with_pip") + def test_must_raise_exception_on_failure(self, find_runtime_mock, pip_dependency_builder_mock): + builder_instance = pip_dependency_builder_mock.return_value builder_instance.build_dependencies.side_effect = PackagerError() - - action = PythonPipBuildAction( - "artifacts", - "scratch_dir", - "manifest", - "runtime", - None, - {"python": BinaryPath(resolver=Mock(), validator=Mock(), binary="python", binary_path=sys.executable)}, - ) - - with self.assertRaises(ActionFailedError): - action.execute() - - @patch("aws_lambda_builders.workflows.python_pip.actions.SubprocessPip") - def test_must_raise_exception_on_pip_failure(self, PythonSubProcessPipMock): - PythonSubProcessPipMock.side_effect = MissingPipError(python_path="mockpath") + find_runtime_mock.return_value = (Mock(), Mock()) action = PythonPipBuildAction( "artifacts", @@ -90,8 +82,10 @@ def test_must_raise_exception_on_pip_failure(self, PythonSubProcessPipMock): action.execute() @patch("aws_lambda_builders.workflows.python_pip.actions.PythonPipDependencyBuilder") - def test_action_must_call_builder_with_dependencies_dir(self, PythonPipDependencyBuilderMock): - builder_instance = PythonPipDependencyBuilderMock.return_value + @patch("aws_lambda_builders.workflows.python_pip.actions.PythonPipBuildAction._find_runtime_with_pip") + def test_action_must_call_builder_with_dependencies_dir(self, find_runtime_mock, pip_dependency_builder_mock): + builder_instance = pip_dependency_builder_mock.return_value + find_runtime_mock.return_value = (Mock(), Mock()) action = PythonPipBuildAction( "artifacts", @@ -106,3 +100,84 @@ def test_action_must_call_builder_with_dependencies_dir(self, PythonPipDependenc builder_instance.build_dependencies.assert_called_with( artifacts_dir_path="dependencies_dir", scratch_dir_path="scratch_dir", requirements_path="manifest" ) + + def test_find_runtime_missing_binary_object(self): + mock_binaries = {} + + with self.assertRaises(ActionFailedError) as ex: + PythonPipBuildAction(Mock(), Mock(), Mock(), Mock(), Mock(), mock_binaries)._find_runtime_with_pip() + + self.assertEqual(str(ex.exception), "Failed to fetch Python binaries from the PATH.") + + def test_find_runtime_empty_exec_paths(self): + mock_resolver = Mock() + mock_resolver.resolver = Mock() + mock_resolver.resolver.exec_paths = [] + + mock_binaries = Mock() + mock_binaries.get = Mock(return_value=mock_resolver) + + with self.assertRaises(ActionFailedError) as ex: + PythonPipBuildAction(Mock(), Mock(), Mock(), Mock(), Mock(), mock_binaries)._find_runtime_with_pip() + + self.assertEqual(str(ex.exception), "Failed to fetch Python binaries from the PATH.") + + @patch("aws_lambda_builders.workflows.python_pip.actions.SubprocessPip") + def test_find_runtime_found_pip(self, pip_subprocess_mock): + expected_pip = Mock() + pip_subprocess_mock.return_value = expected_pip + + expected_python_path = "my_python_path" + + mock_binary_path = Mock() + mock_binary_path.resolver = Mock() + mock_binary_path.resolver.exec_paths = [expected_python_path] + mock_binary_path.validator = Mock() + mock_binary_path.validator.validate.return_value = expected_python_path + + mock_binaries = Mock() + mock_binaries.get = Mock(return_value=mock_binary_path) + + pip, runtime_path = PythonPipBuildAction( + Mock(), Mock(), Mock(), Mock(), Mock(), mock_binaries + )._find_runtime_with_pip() + + self.assertEqual(pip, expected_pip) + self.assertEqual(runtime_path, expected_python_path) + + @patch("aws_lambda_builders.workflows.python_pip.actions.SubprocessPip") + def test_find_runtime_no_pip_matches(self, pip_subprocess_mock): + python_path = "my_python_path" + + pip_subprocess_mock.side_effect = [MissingPipError(python_path="message")] + + mock_binary_path = Mock() + mock_binary_path.resolver = Mock() + mock_binary_path.resolver.exec_paths = [python_path] + mock_binary_path.validator = Mock() + mock_binary_path.validator.validate.return_value = python_path + + mock_binaries = Mock() + mock_binaries.get = Mock(return_value=mock_binary_path) + + with self.assertRaises(ActionFailedError) as ex: + PythonPipBuildAction(Mock(), Mock(), Mock(), Mock(), Mock(), mock_binaries)._find_runtime_with_pip() + + self.assertEqual(str(ex.exception), "Failed to find a Python runtime containing pip on the PATH.") + + def test_find_runtime_no_python_matches(self): + python_path = "my_python_path" + + mock_binary_path = Mock() + mock_binary_path.resolver = Mock() + mock_binary_path.resolver.exec_paths = [python_path] + mock_binary_path.validator = Mock() + mock_binary_path.validator.validate.return_value = None + + mock_binaries = Mock() + mock_binaries.get = Mock(return_value=mock_binary_path) + + with self.assertRaises(ActionFailedError) as ex: + PythonPipBuildAction(Mock(), Mock(), Mock(), Mock(), Mock(), mock_binaries)._find_runtime_with_pip() + + self.assertEqual(str(ex.exception), "Failed to find a Python runtime containing pip on the PATH.")