Repository: gerbenoostra/poetry-plugin-mono-repo-deps

Issue:
Poetry command fails to run from folders without a pyproject.toml (inc poetry init and poetry --version)
Hello,

Very much appreciating this plugin, although we have found an issue with it.

With the plugin installed, the following commands fail, if run from a folder without a pyproject.toml file:

poetry --version
poetry init
poetry self show plugins

The error message in all cases is:

Poetry could not find a pyproject.toml file in [current folder] or its parents.

If I uninstall the plugin, they succeed.

I've tried installing (and then uninstalling) the plugin via:
 - pip install in the poetry virtual environment (%APPDATA%\pypoetry\venv\Scripts)
 - poetry self add poetry-plugin-mono-repo-deps (which works in a folder with a pyproject.toml file)

Just for comparison and triage I tried installing another plugin and didn't get this behaviour, while colleagues are seeing the same behaviour with this plugin on their machines.

We're on Windows machines and have installed Poetry via PowerShell using the official installer.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 9ed309e..c7ffa47 100644
--- a/README.md
+++ b/README.md
@@ -112,7 +112,7 @@ enabled = true
 commands = ["build", "export"]
 constraint = "~="
 source_types = ["file", "directory"]
-only_develop = false
+only_develop = False
 ```
 
 Possible alternative values can be found in the following section:
diff --git a/poetry_plugin_mono_repo_deps/plugin.py b/poetry_plugin_mono_repo_deps/plugin.py
index 455e420..179153c 100644
--- a/poetry_plugin_mono_repo_deps/plugin.py
+++ b/poetry_plugin_mono_repo_deps/plugin.py
@@ -9,7 +9,6 @@ from cleo.events.console_events import COMMAND
 from cleo.events.event import Event
 from cleo.events.event_dispatcher import EventDispatcher
 from cleo.io.io import IO
-from cleo.io.null_io import NullIO
 from poetry.console.application import Application
 from poetry.core.packages.dependency import Dependency
 from poetry.core.packages.package import Package
@@ -84,22 +83,9 @@ def load_config(poetry: Poetry) -> Config | None:
 class MonoRepoDepsPlugin(ApplicationPlugin):
     def __init__(self) -> None:
         super().__init__()
-        self._config: Config | None = None
 
     def activate(self, application: Application) -> None:
         self._application = application
-        self._poetry = self._application.poetry
-        self._config = load_config(self._poetry)
-        self._is_disabled = self._config is None
-
-        io = self._application._io or NullIO()
-        if self._is_disabled:
-            if io.is_debug():  # pragma: no cover
-                io.write_line(
-                    "<debug>Not replacing path dependencies by named dependencies as no tool configuration section "
-                    "found.</debug>"
-                )
-            return
         dispatcher = application.event_dispatcher
         if dispatcher is not None:
             dispatcher.add_listener(COMMAND, self.handle_command)
@@ -107,12 +93,29 @@ class MonoRepoDepsPlugin(ApplicationPlugin):
             pass
 
     def handle_command(self, event: Event, _event_name: str, _dispatcher: EventDispatcher) -> None:
-        if self._config is None:  # pragma: no cover
+        try:
+            poetry = self._application.poetry
+        except RuntimeError:
+            # should only happen if poetry runs outside poetry folder structure
+            # as we modify poetry lock files, the plugin can only work in poetry folders
+            # and is only relevant for commands that work in poetry folders
+            # thus, either the command is not relevant
+            # or, the command would fail itself
             return
+
+        config = load_config(poetry)
+
         event = cast(ConsoleEvent, event)  # because we listen to COMMANDs
         io = event.io
+        if config is None:  # pragma: no cover
+            if io.is_debug():  # pragma: no cover
+                io.write_line(
+                    "<debug>Not replacing path dependencies by named dependencies as no tool configuration section "
+                    "found.</debug>"
+                )
+            return
         command = event.command
-        if command.name not in self._config.commands:
+        if command.name not in config.commands:
             if io.is_debug():  # pragma: no cover
                 io.write_line(
                     "<debug>Not replacing path dependencies with named dependencies for command "
@@ -124,22 +127,20 @@ class MonoRepoDepsPlugin(ApplicationPlugin):
             io.write_line("<debug>Replacing path dependencies with named dependencies.</debug>")
 
         # for build
-        self.update_locked_repository(io)
+        self.update_locked_repository(io, config)
         # for export
-        self.update_lock_data()
+        self.update_lock_data(config)
         return None
 
-    def update_locked_repository(self, io: IO) -> None:
+    def update_locked_repository(self, io: IO, config: Config) -> None:
         """Updates the lockers locked repository, necessary for commands like `build`"""
-        if self._config is None:  # pragma: no cover
-            return
-        constraint = self._config.constraint
+        constraint = config.constraint
         poetry = self._application.poetry
         locked_repository = poetry._locker.locked_repository()
         for name in poetry.package.dependency_group_names():
             group = poetry.package.dependency_group(name)
             for dep in group.dependencies:
-                if is_to_be_replaced_dependency(self._config, dep):
+                if is_to_be_replaced_dependency(config, dep):
                     name = dep.name
                     # get the locked package to retrieve the current version
                     package = find_package(locked_repository, name)
@@ -155,14 +156,12 @@ class MonoRepoDepsPlugin(ApplicationPlugin):
                     else:  # pragma: no cover
                         io.write_error_line(f"Failed to find version for path dependency {name}")
 
-    def update_lock_data(self) -> None:
+    def update_lock_data(self, config: Config) -> None:
         """Updates the lockers internal lock data, necessary for commands like `export`"""
-        if self._config is None:  # pragma: no cover
-            return
         poetry = self._application.poetry
         locked_packages = cast(List[Dict[str, Any]], poetry._locker.lock_data["package"])
         for info in locked_packages:
-            if is_to_be_replaced_package(self._config, info):
+            if is_to_be_replaced_package(config, info):
                 modify_locked_package_to_named(info)
 
 


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
index 8b16d59..f56f0ef 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -8,6 +8,7 @@ from typing import Any
 
 from cleo.commands.command import Command
 from cleo.io.inputs.argv_input import ArgvInput
+from cleo.io.io import IO
 from cleo.io.outputs.output import Verbosity
 from cleo.io.outputs.stream_output import StreamOutput
 from cleo.testers.command_tester import CommandTester
@@ -178,8 +179,38 @@ def prepare_test_poetry(proj_path: Path) -> Poetry:
 export_warning = "Warning: poetry-plugin-export will not be installed by default in a future version of Poetry.\nIn order to avoid a breaking change and make your automation forward-compatible, please install poetry-plugin-export explicitly. See https://python-poetry.org/docs/plugins/#using-plugins for details on how to install a plugin.\nTo disable this warning run 'poetry config warnings.export false'.\n"  # noqa: E501 Line too long
 
 
+def run_test_app(args: list[str]) -> tuple[str, str]:
+    test_app = Application()
+
+    out = StringIO()
+    err = StringIO()
+    out_stream = StreamOutput(out)
+    out_stream.set_verbosity(Verbosity.DEBUG)
+    err_stream = StreamOutput(err)
+    instr = ArgvInput(args)
+    io = IO(instr, out_stream, err_stream)
+    test_app._load_plugins(io)
+    test_app.auto_exits(False)
+    test_app.run(
+        instr,
+        out_stream,
+        err_stream,
+    )
+    out_stream.flush()
+    err_stream.flush()
+    out_value = out.getvalue()
+    _logger.info(out_value)
+    err_value = err.getvalue()
+    _logger.error(err_value)
+    err_value = err_value.replace(export_warning, "")
+    err_value = os.linesep.join(
+        [line for line in err_value.splitlines() if not line.startswith("Creating virtualenv ")]
+    )
+    return out_value, err_value
+
+
 class AppRunner:
-    def __init__(self, app: PoetryTestApplication) -> None:
+    def __init__(self, app: Application) -> None:
         self._test_app = app
 
     def run(self, args: list[str]) -> tuple[str, str]:
@@ -187,8 +218,10 @@ class AppRunner:
         err = StringIO()
         out_stream = StreamOutput(out)
         err_stream = StreamOutput(err)
+        instr = ArgvInput(args)
+        instr.bind(self._test_app.definition)
         self._test_app.run(
-            ArgvInput(args),
+            instr,
             out_stream,
             err_stream,
         )
diff --git a/tests/test_commands_e2e.py b/tests/test_commands_e2e.py
index d12c639..c2f5e48 100644
--- a/tests/test_commands_e2e.py
+++ b/tests/test_commands_e2e.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import logging
 import os
 import zipfile
 from pathlib import Path
@@ -9,18 +10,19 @@ from zipfile import ZipFile
 import pytest
 
 from tests.fixtures import TestSetup, module_setups, package_name_of
-from tests.helpers import AppRunner, prepare_test_app
+from tests.helpers import run_test_app
+
+_logger = logging.getLogger(__name__)
 
 
 @pytest.mark.parametrize("module_dir", module_setups.keys())
 def test_export_locked(fixture_simple_a: Path, tmp_path: Path, module_dir: str) -> None:
     setup = module_setups[module_dir]
-    test_app = prepare_test_app(fixture_simple_a / module_dir)
-    runner = AppRunner(test_app)
+    os.chdir(fixture_simple_a / module_dir)
 
     # export should contain the dependency as a name
     requirements_path = tmp_path / "reqs.txt"
-    _out, err = runner.run(["poetry", "export", "-vvv", "--output", str(requirements_path)])
+    _out, err = run_test_app(["poetry", "export", "-vvv", "--output", str(requirements_path)])
     assert err == ""
     requirements_content = requirements_path.read_text()
     fixed_str = f"""{setup.dep_name()}==0.0.1 ; python_version >= "3.8" and python_version < "4.0\""""
@@ -41,11 +43,10 @@ def test_export_locked(fixture_simple_a: Path, tmp_path: Path, module_dir: str)
 def test_ignored_command(fixture_simple_a: Path, tmp_path: Path, module_dir: str) -> None:
     """Running on a completely different command."""
     module_setups[module_dir]
-    test_app = prepare_test_app(fixture_simple_a / module_dir)
-    runner = AppRunner(test_app)
+    os.chdir(fixture_simple_a / module_dir)
 
     # should have no effect on lock file
-    _out, err = runner.run(["poetry", "lock"])
+    _out, err = run_test_app(["poetry", "lock"])
     assert err == ""
     text = (tmp_path / "simple_a" / module_dir / "poetry.lock").read_text()
     if module_setups[module_dir].has_dep:
@@ -73,11 +74,10 @@ url = "../lib-a"
 def test_build_artifact(fixture_simple_a: Path, tmp_path: Path, module_dir: str) -> None:
     setup = module_setups[module_dir]
     package_name = package_name_of(module_dir)
-    test_app = prepare_test_app(fixture_simple_a / module_dir)
-    runner = AppRunner(test_app)
+    os.chdir(fixture_simple_a / module_dir)
 
     dist_path = tmp_path / "dist"
-    _out, err = runner.run(["poetry", "build", "-vvv", "--output", str(dist_path)])
+    _out, err = run_test_app(["poetry", "build", "-vvv", "--output", str(dist_path)])
     assert err == ""
 
     with ZipFile(dist_path / f"{package_name}-0.0.1-py3-none-any.whl") as whl:
@@ -107,3 +107,38 @@ def validate_package_metadata(setup: TestSetup, content: list[str]) -> None:
     else:
         assert fixed_str not in content
         assert not any([line.startswith(path_str) for line in content])
+
+
+def test_outside_project_version(tmp_path: Path) -> None:
+    os.chdir(tmp_path)  # run from folder without pyproject.toml
+
+    args = ["poetry", "--version"]
+    out_value, err_value = run_test_app(args)
+    assert err_value == ""
+    assert out_value.startswith("Poetry (version ")
+
+
+def test_outside_project_show_plugins(tmp_path: Path) -> None:
+    os.chdir(tmp_path)  # run from folder without pyproject.toml
+    args = ["poetry", "self", "show", "plugins"]
+    out_value, err_value = run_test_app(args)
+    assert err_value == ""
+    assert "poetry-plugin-mono-repo-deps" in out_value
+    assert (
+        "Poetry plugin to replace path dependencies in mono repos with named dependency specifications at build time"
+        in out_value
+    )
+
+
+def test_outside_project_cache(tmp_path: Path) -> None:
+    os.chdir(tmp_path)  # run from folder without pyproject.toml
+    args = ["poetry", "cache", "list"]
+    _out_value, err_value = run_test_app(args)
+    assert err_value == "No caches found"
+
+
+def test_outside_project_search(tmp_path: Path) -> None:
+    os.chdir(tmp_path)  # run from folder without pyproject.toml
+    args = ["poetry", "search", "requests"]
+    _out_value, err_value = run_test_app(args)
+    assert err_value == ""