diff --git a/poetry_monoranger_plugin/lock_modifier.py b/poetry_monoranger_plugin/lock_modifier.py index 60467c9..289fb50 100644 --- a/poetry_monoranger_plugin/lock_modifier.py +++ b/poetry_monoranger_plugin/lock_modifier.py @@ -50,7 +50,9 @@ def execute(self, event: ConsoleCommandEvent): io.write_line("Running command from monorepo root directory") monorepo_root = (command.poetry.pyproject_path.parent / self.plugin_conf.monorepo_root).resolve() - monorepo_root_poetry = Factory().create_poetry(cwd=monorepo_root, io=io) + monorepo_root_poetry = Factory().create_poetry( + cwd=monorepo_root, io=io, disable_cache=command.poetry.disable_cache + ) installer = Installer( io, diff --git a/poetry_monoranger_plugin/monorepo_adder.py b/poetry_monoranger_plugin/monorepo_adder.py index 2625fdd..7204e10 100644 --- a/poetry_monoranger_plugin/monorepo_adder.py +++ b/poetry_monoranger_plugin/monorepo_adder.py @@ -126,7 +126,7 @@ def post_execute(self, event: ConsoleTerminateEvent): return monorepo_root = (poetry.pyproject_path.parent / self.plugin_conf.monorepo_root).resolve() - monorepo_root_poetry = Factory().create_poetry(cwd=monorepo_root, io=io) + monorepo_root_poetry = Factory().create_poetry(cwd=monorepo_root, io=io, disable_cache=poetry.disable_cache) installer = Installer( io, diff --git a/poetry_monoranger_plugin/path_rewriter.py b/poetry_monoranger_plugin/path_rewriter.py index d424dfe..7d31e4a 100644 --- a/poetry_monoranger_plugin/path_rewriter.py +++ b/poetry_monoranger_plugin/path_rewriter.py @@ -55,6 +55,19 @@ def execute(self, event: ConsoleCommandEvent): main_deps_group.remove_dependency(dependency.name) main_deps_group.add_dependency(pinned) + def _get_dependency_pyproject(self, poetry: Poetry, dependency: DirectoryDependency) -> PyProjectTOML: + pyproject_file = poetry.pyproject_path.parent / dependency.path / "pyproject.toml" + + if not pyproject_file.exists(): + raise RuntimeError(f"Could not find pyproject.toml in {dependency.path}") + + dep_pyproject: PyProjectTOML = PyProjectTOML(pyproject_file) + + if not dep_pyproject.is_poetry_project(): + raise RuntimeError(f"Directory {dependency.path} is not a valid poetry project") + + return dep_pyproject + def _pin_dependency(self, poetry: Poetry, dependency: DirectoryDependency): """Pins a directory dependency to a specific version based on the plugin configuration. @@ -69,15 +82,7 @@ def _pin_dependency(self, poetry: Poetry, dependency: DirectoryDependency): RuntimeError: If the pyproject.toml file is not found or is not a valid Poetry project. ValueError: If the version rewrite rule is invalid. """ - pyproject_file = poetry.pyproject_path.parent / dependency.path / "pyproject.toml" - - if not pyproject_file.exists(): - raise RuntimeError(f"Could not find pyproject.toml in {dependency.path}") - - dep_pyproject: PyProjectTOML = PyProjectTOML(pyproject_file) - - if not dep_pyproject.is_poetry_project(): - raise RuntimeError(f"Directory {dependency.path} is not a valid poetry project") + dep_pyproject: PyProjectTOML = self._get_dependency_pyproject(poetry, dependency) name = cast(str, dep_pyproject.poetry_config["name"]) version = cast(str, dep_pyproject.poetry_config["version"]) diff --git a/poetry_monoranger_plugin/venv_modifier.py b/poetry_monoranger_plugin/venv_modifier.py index bde641f..0fe2f42 100644 --- a/poetry_monoranger_plugin/venv_modifier.py +++ b/poetry_monoranger_plugin/venv_modifier.py @@ -66,7 +66,7 @@ def execute(self, event: ConsoleCommandEvent): poetry = command.poetry monorepo_root = (poetry.pyproject_path.parent / self.plugin_conf.monorepo_root).resolve() - monorepo_root_poetry = Factory().create_poetry(cwd=monorepo_root, io=io) + monorepo_root_poetry = Factory().create_poetry(cwd=monorepo_root, io=io, disable_cache=poetry.disable_cache) io.write_line(f"Using monorepo root venv {monorepo_root.name}\n") env_manager = EnvManager(monorepo_root_poetry, io=io) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fc07718 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Type +from unittest.mock import Mock + +import pytest +from poetry.core.packages.dependency import Dependency +from poetry.core.packages.dependency_group import DependencyGroup +from poetry.core.packages.directory_dependency import DirectoryDependency +from poetry.poetry import Poetry + + +@pytest.fixture +def mock_event_gen(): + from poetry.console.commands.command import Command + + def _factory(command_cls: Type[Command], disable_cache: bool): + from cleo.events.console_command_event import ConsoleCommandEvent + + main_grp = DependencyGroup("main") + main_grp.add_dependency(DirectoryDependency("packageB", Path("../packageB"), develop=True)) + main_grp.add_dependency(Dependency("numpy", "==1.5.0")) + + mock_command = Mock(spec=command_cls) + mock_command.poetry = Mock(spec=Poetry) + mock_command.poetry.pyproject_path = Path("/monorepo_root/packageA/pyproject.toml") + mock_command.poetry.package = Mock() + mock_command.poetry.package.name = "packageA" + mock_command.poetry.package.dependency_group = Mock() + mock_command.poetry.package.dependency_group.return_value = main_grp + mock_command.poetry.locker = Mock() + mock_command.poetry.pool = Mock() + mock_command.poetry.config = Mock() + mock_command.poetry.disable_cache = disable_cache + mock_command.option = Mock(return_value=False) + + mock_io = Mock() + + mock_event = Mock(spec=ConsoleCommandEvent) + mock_event.command = mock_command + mock_event.io = mock_io + + return mock_event + + return _factory + + +@pytest.fixture +def mock_terminate_event_gen(mock_event_gen): + from poetry.console.commands.command import Command + + def _factory(command_cls: Type[Command], disable_cache: bool): + from cleo.events.console_terminate_event import ConsoleTerminateEvent + + mock_event = mock_event_gen(command_cls, disable_cache) + mock_io = mock_event.io + mock_command = mock_event.command + del mock_event + + mock_terminate_event = Mock(spec=ConsoleTerminateEvent) + mock_terminate_event.command = mock_command + mock_terminate_event.io = mock_io + + return mock_terminate_event + + return _factory diff --git a/tests/test_lock_modifier.py b/tests/test_lock_modifier.py new file mode 100644 index 0000000..c879612 --- /dev/null +++ b/tests/test_lock_modifier.py @@ -0,0 +1,47 @@ +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from poetry.console.commands.lock import LockCommand +from poetry.installation.installer import Installer +from poetry.poetry import Poetry + +from poetry_monoranger_plugin.config import MonorangerConfig +from poetry_monoranger_plugin.lock_modifier import LockModifier + + +@pytest.mark.parametrize("disable_cache", [True, False]) +def test_executes_modifications_for_lock_command(mock_event_gen, disable_cache: bool): + mock_event = mock_event_gen(LockCommand, disable_cache=disable_cache) + mock_command = mock_event.command + config = MonorangerConfig(enabled=True, monorepo_root="../") + lock_modifier = LockModifier(config) + + with ( + patch("poetry_monoranger_plugin.lock_modifier.Factory.create_poetry", autospec=True) as mock_create_poetry, + patch("poetry_monoranger_plugin.lock_modifier.Installer", autospec=True) as mock_installer_cls, + ): + mock_create_poetry.return_value = Mock(spec=Poetry) + mock_installer_cls.return_value = Mock(spec=Installer) + + lock_modifier.execute(mock_event) + + # A new poetry project object at the monorepo root should be created + mock_create_poetry.assert_called_once() + assert mock_create_poetry.call_args[1]["cwd"] == Path("/monorepo_root").resolve() + assert mock_create_poetry.call_args[1]["io"] == mock_event.io + assert mock_create_poetry.call_args[1]["disable_cache"] == disable_cache + + # A new installer should be created with the monorepo root poetry project + mock_installer_cls.assert_called_once() + # Env is remained unchanged as it is the responsibility of venv_modifier.py + assert mock_installer_cls.call_args[0][1] == mock_command.env + assert mock_installer_cls.call_args[0][2] == mock_create_poetry.return_value.package + assert mock_installer_cls.call_args[0][3] == mock_create_poetry.return_value.locker + assert mock_installer_cls.call_args[0][4] == mock_create_poetry.return_value.pool + assert mock_installer_cls.call_args[0][5] == mock_create_poetry.return_value.config + assert mock_installer_cls.call_args[1]["disable_cache"] == mock_create_poetry.return_value.disable_cache + + # The new poetry and installer objects should be set on the command + assert mock_command.set_poetry.call_args[0][0] == mock_create_poetry.return_value + assert mock_command.set_installer.call_args[0][0] == mock_installer_cls.return_value diff --git a/tests/test_monorepo_adder.py b/tests/test_monorepo_adder.py new file mode 100644 index 0000000..991e062 --- /dev/null +++ b/tests/test_monorepo_adder.py @@ -0,0 +1,73 @@ +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from poetry.console.commands.add import AddCommand +from poetry.installation.installer import Installer +from poetry.poetry import Poetry + +from poetry_monoranger_plugin.config import MonorangerConfig +from poetry_monoranger_plugin.monorepo_adder import DummyInstaller, MonorepoAdderRemover + + +@pytest.mark.parametrize("disable_cache", [True, False]) +def test_executes_modifications_for_addremove_command(mock_event_gen, disable_cache: bool): + mock_event = mock_event_gen(AddCommand, disable_cache=disable_cache) + mock_command = mock_event.command + config = MonorangerConfig(enabled=True, monorepo_root="../") + adder_remover = MonorepoAdderRemover(config) + + with patch("poetry_monoranger_plugin.monorepo_adder.Poetry.__new__", autospec=True) as mock_poetry: + mock_poetry.return_value = Mock(spec=Poetry) + + adder_remover.execute(mock_event) + + mock_poetry.assert_called_once() + assert mock_command.set_poetry.call_args[0][0] == mock_poetry.return_value + assert isinstance(mock_command.set_installer.call_args[0][0], DummyInstaller) + + +@pytest.mark.parametrize("disable_cache", [True, False]) +def test_executes_modifications_post_addremove_command(mock_terminate_event_gen, disable_cache: bool): + # Here we test the .post_execute command + mock_event = mock_terminate_event_gen(AddCommand, disable_cache=disable_cache) + mock_command = mock_event.command + config = MonorangerConfig(enabled=True, monorepo_root="../") + adder_remover = MonorepoAdderRemover(config) + + with ( + patch("poetry_monoranger_plugin.monorepo_adder.Factory.create_poetry", autospec=True) as mock_create_poetry, + patch("poetry_monoranger_plugin.monorepo_adder.Installer", autospec=True) as mock_installer_cls, + ): + mock_create_poetry.return_value = Mock(spec=Poetry) + mock_installer_cls.return_value = Mock(spec=Installer) + + adder_remover.post_execute(mock_event) + + # A new poetry project object at the monorepo root should be created + mock_create_poetry.assert_called_once() + assert mock_create_poetry.call_args[1]["cwd"] == Path("/monorepo_root").resolve() + assert mock_create_poetry.call_args[1]["io"] == mock_event.io + assert mock_create_poetry.call_args[1]["disable_cache"] == disable_cache + + # A new installer should be created with the monorepo root poetry project + mock_installer_cls.assert_called_once() + # Env is remained unchanged as it is the responsibility of venv_modifier.py + assert mock_installer_cls.call_args[0][1] == mock_command.env + assert mock_installer_cls.call_args[0][2] == mock_create_poetry.return_value.package + assert mock_installer_cls.call_args[0][3] == mock_create_poetry.return_value.locker + assert mock_installer_cls.call_args[0][4] == mock_create_poetry.return_value.pool + assert mock_installer_cls.call_args[0][5] == mock_create_poetry.return_value.config + assert mock_installer_cls.call_args[1]["disable_cache"] == mock_create_poetry.return_value.disable_cache + + # Check settings of installer + assert mock_installer_cls.return_value.dry_run.call_args[0][0] == mock_command.option("dry-run") + assert mock_installer_cls.return_value.verbose.call_args[0][0] == mock_event.io.is_verbose() + assert mock_installer_cls.return_value.update.call_args[0][0] is True + assert mock_installer_cls.return_value.execute_operations.call_args[0][0] is not mock_command.option("lock") + + # The whitelist should contain the package name + assert mock_installer_cls.return_value.whitelist.call_args[0][0] == [mock_command.poetry.package.name] + + # The installer should be run + assert mock_installer_cls.return_value.run.call_count == 1 diff --git a/tests/test_path_rewriter.py b/tests/test_path_rewriter.py new file mode 100644 index 0000000..61e31f9 --- /dev/null +++ b/tests/test_path_rewriter.py @@ -0,0 +1,41 @@ +import copy +from unittest.mock import Mock, patch + +import pytest +from poetry.console.commands.build import BuildCommand +from poetry.core.packages.directory_dependency import DirectoryDependency +from poetry.core.pyproject.toml import PyProjectTOML + +from poetry_monoranger_plugin.config import MonorangerConfig +from poetry_monoranger_plugin.path_rewriter import PathRewriter + + +@pytest.mark.parametrize("disable_cache", [True, False]) +def test_executes_path_rewriting_for_build_command(mock_event_gen, disable_cache: bool): + mock_event = mock_event_gen(BuildCommand, disable_cache=disable_cache) + mock_command = mock_event.command + config = MonorangerConfig(enabled=True, monorepo_root="../", version_rewrite_rule="==") + path_rewriter = PathRewriter(config) + + original_dependencies = copy.deepcopy(mock_command.poetry.package.dependency_group.return_value.dependencies) + + with patch( + "poetry_monoranger_plugin.path_rewriter.PathRewriter._get_dependency_pyproject", autospec=True + ) as mock_get_dep: + mock_get_dep.return_value = Mock(spec=PyProjectTOML) + mock_get_dep.return_value.poetry_config = {"version": "0.1.0", "name": "packageB"} + + path_rewriter.execute(mock_event) + + new_dependencies = mock_command.poetry.package.dependency_group.return_value + + assert len(new_dependencies.dependencies) == len(original_dependencies) + # sort the dependencies by name to ensure they are in the same order + original_dependencies = sorted(original_dependencies, key=lambda x: x.name) + new_dependencies = sorted(new_dependencies.dependencies, key=lambda x: x.name) + for i, dep in enumerate(new_dependencies): + assert dep.name == original_dependencies[i].name + if isinstance(original_dependencies[i], DirectoryDependency): + assert dep.pretty_constraint == "0.1.0" + else: + assert dep.pretty_constraint == original_dependencies[i].pretty_constraint diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..703e2b7 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,69 @@ +from unittest.mock import Mock, patch + +import pytest +from poetry.console.commands.add import AddCommand +from poetry.console.commands.build import BuildCommand +from poetry.console.commands.command import Command +from poetry.console.commands.env_command import EnvCommand +from poetry.console.commands.install import InstallCommand +from poetry.console.commands.lock import LockCommand +from poetry.console.commands.remove import RemoveCommand +from poetry.console.commands.update import UpdateCommand + +from poetry_monoranger_plugin.config import MonorangerConfig +from poetry_monoranger_plugin.plugin import Monoranger + + +def test_activates_plugin_with_valid_config(): + application = Mock() + application.poetry.pyproject.data = { + "tool": {"poetry-monoranger-plugin": {"enabled": True, "monorepo_root": "../"}} + } + application.event_dispatcher = Mock() + plugin = Monoranger() + plugin.activate(application) + + assert plugin.plugin_conf.enabled is True + assert plugin.plugin_conf.monorepo_root == "../" + application.event_dispatcher.add_listener.assert_called() + + +def test_does_not_activate_plugin_with_disabled_config(): + application = Mock() + application.poetry.pyproject.data = {} + application.event_dispatcher = Mock() + plugin = Monoranger() + plugin.activate(application) + + assert plugin.plugin_conf is None + application.event_dispatcher.add_listener.assert_not_called() + + +@pytest.mark.parametrize( + "cmd_type", + [ + AddCommand, + RemoveCommand, + BuildCommand, + EnvCommand, + LockCommand, + InstallCommand, + UpdateCommand, + ], +) +def test_handles_all_command_events(mock_event_gen, cmd_type: type[Command]): + cmd_to_patch: dict[type[Command], str] = { + AddCommand: "poetry_monoranger_plugin.monorepo_adder.MonorepoAdderRemover.execute", + RemoveCommand: "poetry_monoranger_plugin.monorepo_adder.MonorepoAdderRemover.execute", + BuildCommand: "poetry_monoranger_plugin.path_rewriter.PathRewriter.execute", + EnvCommand: "poetry_monoranger_plugin.venv_modifier.VenvModifier.execute", + LockCommand: "poetry_monoranger_plugin.lock_modifier.LockModifier.execute", + InstallCommand: "poetry_monoranger_plugin.lock_modifier.LockModifier.execute", + UpdateCommand: "poetry_monoranger_plugin.lock_modifier.LockModifier.execute", + } + event = mock_event_gen(cmd_type, disable_cache=False) + plugin = Monoranger() + plugin.plugin_conf = MonorangerConfig(enabled=True, monorepo_root="../") + with patch(cmd_to_patch[cmd_type]) as mock_execute: + plugin.console_command_event_listener(event, "", Mock()) + mock_execute.assert_called_once_with(event) diff --git a/tests/test_venv_modifier.py b/tests/test_venv_modifier.py new file mode 100644 index 0000000..deaf003 --- /dev/null +++ b/tests/test_venv_modifier.py @@ -0,0 +1,102 @@ +import os +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from poetry.console.commands.env_command import EnvCommand +from poetry.console.commands.installer_command import InstallerCommand +from poetry.installation.installer import Installer +from poetry.poetry import Poetry + +from poetry_monoranger_plugin.config import MonorangerConfig +from poetry_monoranger_plugin.venv_modifier import VenvModifier + + +@pytest.mark.parametrize("disable_cache", [True, False]) +def test_executes_modifications_for_env_command(mock_event_gen, disable_cache: bool): + mock_event = mock_event_gen(EnvCommand, disable_cache=disable_cache) + mock_command = mock_event.command + config = MonorangerConfig(enabled=True, monorepo_root="../") + venv_modifier = VenvModifier(config) + + environ = os.environ.copy() + environ.pop("VIRTUAL_ENV", None) + with ( + patch("poetry_monoranger_plugin.venv_modifier.Factory.create_poetry", autospec=True) as mock_create_poetry, + patch("poetry_monoranger_plugin.venv_modifier.EnvManager.create_venv", autospec=True) as mock_create_venv, + patch.dict("os.environ", environ, clear=True), + ): + mock_create_poetry.return_value = Mock(spec=Poetry) + mock_create_venv.return_value = Mock() + + venv_modifier.execute(mock_event) + + # create_poetry is called with the correct args + mock_create_poetry.assert_called_once() + assert mock_create_poetry.call_args[1]["cwd"] == Path("/monorepo_root") + assert mock_create_poetry.call_args[1]["io"] == mock_event.io + assert mock_create_poetry.call_args[1]["disable_cache"] == disable_cache + + # create_venv is called and EnvManager was created with the correct args + mock_create_venv.assert_called_once() + # Check if 'EnvManager()._poetry' contains the Mock output of mock_create_poetry. + # Use the 'self' argument to mock_create_venv to access the 'EnvManager' instance. + assert mock_create_venv.call_args[0][0]._poetry == mock_create_poetry.return_value + + # The new venv object is attached to the original command + mock_command.set_env.assert_called_once() + assert mock_command.set_env.call_args[0][0] == mock_create_venv.return_value + + +@pytest.mark.parametrize("disable_cache", [True, False]) +def test_executes_modifications_for_installer_command(mock_event_gen, disable_cache: bool): + mock_event = mock_event_gen(InstallerCommand, disable_cache=disable_cache) + mock_command = mock_event.command + config = MonorangerConfig(enabled=True, monorepo_root="../") + venv_modifier = VenvModifier(config) + + environ = os.environ.copy() + environ.pop("VIRTUAL_ENV", None) + with ( + patch("poetry_monoranger_plugin.venv_modifier.Factory.create_poetry", autospec=True) as mock_create_poetry, + patch("poetry_monoranger_plugin.venv_modifier.EnvManager.create_venv", autospec=True) as mock_create_venv, + patch("poetry_monoranger_plugin.venv_modifier.Installer", autospec=True) as mock_installer_cls, + patch.dict("os.environ", environ, clear=True), + ): + mock_create_poetry.return_value = Mock(spec=Poetry) + mock_create_venv.return_value = Mock() + mock_installer_cls.return_value = Mock(spec=Installer) + + venv_modifier.execute(mock_event) + + # Installer is created with all args from the original command except the env + mock_installer_cls.assert_called_once() + assert mock_installer_cls.call_args[0][1] == mock_create_venv.return_value + assert mock_installer_cls.call_args[0][2] == mock_command.poetry.package + assert mock_installer_cls.call_args[0][3] == mock_command.poetry.locker + assert mock_installer_cls.call_args[0][4] == mock_command.poetry.pool + assert mock_installer_cls.call_args[0][5] == mock_command.poetry.config + assert mock_installer_cls.call_args[1]["disable_cache"] == mock_command.poetry.disable_cache + + mock_command.set_installer.assert_called_once() + assert mock_command.set_installer.call_args[0][0] == mock_installer_cls.return_value + + +@pytest.mark.parametrize("disable_cache", [True, False]) +def test_does_not_activate_venv_if_already_in_venv(mock_event_gen, disable_cache: bool): + mock_event = mock_event_gen(EnvCommand, disable_cache=disable_cache) + config = MonorangerConfig(enabled=True, monorepo_root="../") + venv_modifier = VenvModifier(config) + + environ = os.environ.copy() + environ["VIRTUAL_ENV"] = "/some/venv" + with ( + patch("poetry_monoranger_plugin.venv_modifier.Factory.create_poetry", autospec=True) as mock_create_poetry, + patch("poetry_monoranger_plugin.venv_modifier.EnvManager.create_venv", autospec=True) as mock_create_venv, + patch.dict("os.environ", environ, clear=True), + ): + venv_modifier.execute(mock_event) + + mock_create_poetry.assert_not_called() + mock_create_venv.assert_not_called() + mock_event.command.set_env.assert_not_called()