Repository: python-poetry/poetry
Pull request: fix(application): handle global options correctly
Previously, the application implementation did not handle global options correctly when positioned after command options. This lead to incorrect cached working directory values prior to switching application context.

Resolves: #9978 python-poetry/poetry-plugin-shell#5

## Summary by Sourcery

Bug Fixes:
- Fixed an issue where global options were not handled correctly when positioned after command options, leading to incorrect cached working directory values.

Problem statement:
poetry 2 install command can not find pyproject.toml with --only and --directory parameters
### Description

I have a pyproject.toml with `package-mode = false` and I'm copying it into a docker image. I installed the dependencies like this in previous versions of poetry:
```
poetry install --only main --no-cache -C /tmp
```
/tmp contains the pyproject.toml and poetry.lock files.

The command fails with poetry 2. Error message:
```
Poetry could not find a pyproject.toml file in / or its parents
```
The current directory during docker build is /. Poetry should change the directory to /tmp because of the  `-C /tmp` parameter.

If I change the order of the parameters like this:
```
poetry install -C /tmp --only main --no-cache
```
then poetry successfully installs the dependencies.

### Workarounds

Specify -C parameter before the --only parameter

### Poetry Installation Method

pip

### Operating System

Alpine 3.21

### Poetry Version

2.0.0

### Poetry Configuration

```bash session
cache-dir = "/home/user/.cache/pypoetry"
installer.max-workers = null
installer.no-binary = null
installer.only-binary = null
installer.parallel = true
installer.re-resolve = true
keyring.enabled = true
requests.max-retries = 0
solver.lazy-wheel = true
system-git-client = false
virtualenvs.create = true
virtualenvs.in-project = null
virtualenvs.options.always-copy = false
virtualenvs.options.no-pip = false
virtualenvs.options.system-site-packages = false
virtualenvs.path = "{cache-dir}/virtualenvs"  # /home/user/.cache/pypoetry/virtualenvs
virtualenvs.prompt = "{project_name}-py{python_version}"
virtualenvs.use-poetry-python = false
```


### Python Sysconfig

_No response_

### Example pyproject.toml

_No response_

### Poetry Runtime Logs

```bash session
Stack trace:

  12  /opt/doc_venv/lib/python3.13/site-packages/cleo/application.py:327 in run
       325│ 
       326│             try:
     → 327│                 exit_code = self._run(io)
       328│             except BrokenPipeError:
       329│                 # If we are piped to another process, it may close early and send a

  11  /opt/doc_venv/lib/python3.13/site-packages/poetry/console/application.py:236 in _run
       234│ 
       235│         with directory(self._working_directory):
     → 236│             exit_code: int = super()._run(io)
       237│ 
       238│         return exit_code

  10  /opt/doc_venv/lib/python3.13/site-packages/cleo/application.py:431 in _run
       429│             io.input.interactive(interactive)
       430│ 
     → 431│         exit_code = self._run_command(command, io)
       432│         self._running_command = None
       433│ 

   9  /opt/doc_venv/lib/python3.13/site-packages/cleo/application.py:473 in _run_command
       471│ 
       472│         if error is not None:
     → 473│             raise error
       474│ 
       475│         return terminate_event.exit_code

   8  /opt/doc_venv/lib/python3.13/site-packages/cleo/application.py:454 in _run_command
       452│ 
       453│         try:
     → 454│             self._event_dispatcher.dispatch(command_event, COMMAND)
       455│ 
       456│             if command_event.command_should_run():

   7  /opt/doc_venv/lib/python3.13/site-packages/cleo/events/event_dispatcher.py:26 in dispatch
        24│ 
        25│         if listeners:
     →  26│             self._do_dispatch(listeners, event_name, event)
        27│ 
        28│         return event

   6  /opt/doc_venv/lib/python3.13/site-packages/cleo/events/event_dispatcher.py:85 in _do_dispatch
        83│                 break
        84│ 
     →  85│             listener(event, event_name, self)
        86│ 
        87│     def _sort_listeners(self, event_name: str) -> None:

   5  /opt/doc_venv/lib/python3.13/site-packages/poetry/console/application.py:343 in configure_env
       341│ 
       342│         io = event.io
     → 343│         poetry = command.poetry
       344│ 
       345│         env_manager = EnvManager(poetry, io=io)

   4  /opt/doc_venv/lib/python3.13/site-packages/poetry/console/commands/command.py:24 in poetry
        22│     def poetry(self) -> Poetry:
        23│         if self._poetry is None:
     →  24│             return self.get_application().poetry
        25│ 
        26│         return self._poetry

   3  /opt/doc_venv/lib/python3.13/site-packages/poetry/console/application.py:181 in poetry
       179│             return self._poetry
       180│ 
     → 181│         self._poetry = Factory().create_poetry(
       182│             cwd=self._project_directory,
       183│             io=self._io,

   2  /opt/doc_venv/lib/python3.13/site-packages/poetry/factory.py:60 in create_poetry
        58│             io = NullIO()
        59│ 
     →  60│         base_poetry = super().create_poetry(cwd=cwd, with_groups=with_groups)
        61│ 
        62│         if version_str := base_poetry.local_config.get("requires-poetry"):

   1  /opt/doc_venv/lib/python3.13/site-packages/poetry/core/factory.py:48 in create_poetry
        46│         from poetry.core.pyproject.toml import PyProjectTOML
        47│ 
     →  48│         poetry_file = self.locate(cwd)
        49│         pyproject = PyProjectTOML(path=poetry_file)
        50│ 

  RuntimeError

  Poetry could not find a pyproject.toml file in / or its parents

  at /opt/doc_venv/lib/python3.13/site-packages/poetry/core/factory.py:802 in locate
      798│             if poetry_file.exists():
      799│                 return poetry_file
      800│ 
      801│         else:
    → 802│             raise RuntimeError(
      803│                 f"Poetry could not find a pyproject.toml file in {cwd} or its parents"
      804│             )
      805│
```

Hints from issue discussion:
Probably belongs in cleo,  not here
The root cause of the issue might be cleo indeed, but it might be a "missing feature" for cleo or lack of proper way to register custom application level options. 

CC: @Secrus for visibility.

For folks who want to test the fix out you can do so as described in https://github.com/python-poetry/poetry/pull/10021#issuecomment-2585476914.

Commit message: chore(test): refactor app context tests

Patch to review:
diff --git a/src/poetry/console/application.py b/src/poetry/console/application.py
index dc17735770b..1dfe64eadd4 100644
--- a/src/poetry/console/application.py
+++ b/src/poetry/console/application.py
@@ -4,7 +4,6 @@
 import re
 
 from contextlib import suppress
-from functools import cached_property
 from importlib import import_module
 from pathlib import Path
 from typing import TYPE_CHECKING
@@ -25,6 +24,7 @@
 
 if TYPE_CHECKING:
     from collections.abc import Callable
+    from typing import Any
 
     from cleo.events.event import Event
     from cleo.io.inputs.argv_input import ArgvInput
@@ -103,6 +103,8 @@ def __init__(self) -> None:
         self._disable_plugins = False
         self._disable_cache = False
         self._plugins_loaded = False
+        self._working_directory = Path.cwd()
+        self._project_directory = self._working_directory
 
         dispatcher = EventDispatcher()
         dispatcher.add_listener(COMMAND, self.register_command_loggers)
@@ -156,21 +158,6 @@ def _default_definition(self) -> Definition:
 
         return definition
 
-    @cached_property
-    def _project_directory(self) -> Path:
-        if self._io and self._io.input.option("project"):
-            with directory(self._working_directory):
-                return Path(self._io.input.option("project")).absolute()
-
-        return self._working_directory
-
-    @cached_property
-    def _working_directory(self) -> Path:
-        if self._io and self._io.input.option("directory"):
-            return Path(self._io.input.option("directory")).absolute()
-
-        return Path.cwd()
-
     @property
     def poetry(self) -> Poetry:
         from poetry.factory import Factory
@@ -227,9 +214,6 @@ def create_io(
         return io
 
     def _run(self, io: IO) -> int:
-        self._disable_plugins = io.input.parameter_option("--no-plugins")
-        self._disable_cache = io.input.has_parameter_option("--no-cache")
-
         self._load_plugins(io)
 
         with directory(self._working_directory):
@@ -237,6 +221,44 @@ def _run(self, io: IO) -> int:
 
         return exit_code
 
+    def _option_get_value(self, io: IO, name: str, default: Any) -> Any:
+        option = self.definition.option(name)
+
+        if option is None:
+            return default
+
+        values = [f"--{option.name}"]
+
+        if option.shortcut:
+            values.append(f"-{option.shortcut}")
+
+        if not io.input.has_parameter_option(values):
+            return default
+
+        if option.is_flag():
+            return True
+
+        return io.input.parameter_option(values=values, default=default)
+
+    def _configure_custom_application_options(self, io: IO) -> None:
+        self._disable_plugins = self._option_get_value(
+            io, "no-plugins", self._disable_plugins
+        )
+        self._disable_cache = self._option_get_value(
+            io, "no-cache", self._disable_cache
+        )
+        self._working_directory = self._project_directory = Path(
+            self._option_get_value(io, "directory", Path.cwd())
+        )
+
+        self._project_directory = Path(
+            self._option_get_value(io, "project", self._working_directory)
+        )
+
+        if self._project_directory != self._working_directory:
+            with directory(self._working_directory):
+                self._project_directory = self._project_directory.absolute()
+
     def _configure_io(self, io: IO) -> None:
         # We need to check if the command being run
         # is the "run" command.
@@ -273,6 +295,8 @@ def _configure_io(self, io: IO) -> None:
 
         super()._configure_io(io)
 
+        self._configure_custom_application_options(io)
+
     def register_command_loggers(
         self, event: Event, event_name: str, _: EventDispatcher
     ) -> None:
diff --git a/tests/console/commands/test_version.py b/tests/console/commands/test_version.py
index 4df35505c99..5741cf3d9f5 100644
--- a/tests/console/commands/test_version.py
+++ b/tests/console/commands/test_version.py
@@ -1,22 +1,14 @@
 from __future__ import annotations
 
-import os
-import textwrap
-
-from pathlib import Path
 from typing import TYPE_CHECKING
 
 import pytest
 
-from cleo.testers.application_tester import ApplicationTester
-
-from poetry.console.application import Application
 from poetry.console.commands.version import VersionCommand
 
 
 if TYPE_CHECKING:
     from cleo.testers.command_tester import CommandTester
-    from pytest_mock import MockerFixture
 
     from poetry.poetry import Poetry
     from tests.types import CommandTesterFactory
@@ -140,103 +132,3 @@ def test_dry_run(tester: CommandTester) -> None:
     new_pyproject = tester.command.poetry.file.path.read_text(encoding="utf-8")
     assert tester.io.fetch_output() == "Bumping version from 1.2.3 to 2.0.0\n"
     assert old_pyproject == new_pyproject
-
-
-def test_version_with_project_parameter(
-    fixture_dir: FixtureDirGetter, mocker: MockerFixture
-) -> None:
-    app = Application()
-    tester = ApplicationTester(app)
-
-    orig_version_command = VersionCommand.handle
-
-    def mock_handle(command: VersionCommand) -> int:
-        exit_code = orig_version_command(command)
-
-        command.io.write_line(f"ProjectPath: {command.poetry.pyproject_path.parent}")
-        command.io.write_line(f"WorkingDirectory: {os.getcwd()}")
-
-        return exit_code
-
-    mocker.patch("poetry.console.commands.version.VersionCommand.handle", mock_handle)
-
-    source_dir = fixture_dir("scripts")
-    tester.execute(f"--project {source_dir} version")
-
-    output = tester.io.fetch_output()
-    expected = textwrap.dedent(f"""\
-    scripts 0.1.0
-    ProjectPath: {source_dir}
-    WorkingDirectory: {os.getcwd()}
-    """)
-
-    assert source_dir != Path(os.getcwd())
-    assert output == expected
-
-
-def test_version_with_directory_parameter(
-    fixture_dir: FixtureDirGetter, mocker: MockerFixture
-) -> None:
-    app = Application()
-    tester = ApplicationTester(app)
-
-    orig_version_command = VersionCommand.handle
-
-    def mock_handle(command: VersionCommand) -> int:
-        exit_code = orig_version_command(command)
-
-        command.io.write_line(f"ProjectPath: {command.poetry.pyproject_path.parent}")
-        command.io.write_line(f"WorkingDirectory: {os.getcwd()}")
-
-        return exit_code
-
-    mocker.patch("poetry.console.commands.version.VersionCommand.handle", mock_handle)
-
-    source_dir = fixture_dir("scripts")
-    tester.execute(f"--directory {source_dir} version")
-
-    output = tester.io.fetch_output()
-    expected = textwrap.dedent(f"""\
-    scripts 0.1.0
-    ProjectPath: {source_dir}
-    WorkingDirectory: {source_dir}
-    """)
-
-    assert source_dir != Path(os.getcwd())
-    assert output == expected
-
-
-def test_version_with_directory_and_project_parameter(
-    fixture_dir: FixtureDirGetter, mocker: MockerFixture
-) -> None:
-    app = Application()
-    tester = ApplicationTester(app)
-
-    orig_version_command = VersionCommand.handle
-
-    def mock_handle(command: VersionCommand) -> int:
-        exit_code = orig_version_command(command)
-
-        command.io.write_line(f"ProjectPath: {command.poetry.pyproject_path.parent}")
-        command.io.write_line(f"WorkingDirectory: {os.getcwd()}")
-
-        return exit_code
-
-    mocker.patch("poetry.console.commands.version.VersionCommand.handle", mock_handle)
-
-    source_dir = fixture_dir("scripts")
-    working_directory = source_dir.parent
-    project_path = "./scripts"
-
-    tester.execute(f"--directory {working_directory} --project {project_path} version")
-
-    output = tester.io.fetch_output()
-
-    expected = textwrap.dedent(f"""\
-    scripts 0.1.0
-    ProjectPath: {source_dir}
-    WorkingDirectory: {working_directory}
-    """)
-
-    assert source_dir != working_directory
-    assert output == expected
diff --git a/tests/console/test_application_global_options.py b/tests/console/test_application_global_options.py
new file mode 100644
index 00000000000..70f9d143a91
--- /dev/null
+++ b/tests/console/test_application_global_options.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+import textwrap
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+from cleo.testers.application_tester import ApplicationTester
+
+from poetry.console.application import Application
+from poetry.console.commands.version import VersionCommand
+from tests.helpers import switch_working_directory
+
+
+if TYPE_CHECKING:
+    from pytest import TempPathFactory
+    from pytest_mock import MockerFixture
+
+    from tests.types import FixtureCopier
+
+
+NO_PYPROJECT_TOML_ERROR = "Poetry could not find a pyproject.toml file in"
+
+
+@pytest.fixture
+def project_source_directory(fixture_copier: FixtureCopier) -> Path:
+    return fixture_copier("up_to_date_lock")
+
+
+@pytest.fixture
+def tester() -> ApplicationTester:
+    return ApplicationTester(Application())
+
+
+@pytest.fixture
+def with_mocked_version_command(mocker: MockerFixture) -> None:
+    orig_version_command = VersionCommand.handle
+
+    def mock_handle(command: VersionCommand) -> int:
+        exit_code = orig_version_command(command)
+
+        command.io.write_line(f"ProjectPath: {command.poetry.pyproject_path.parent}")
+        command.io.write_line(f"WorkingDirectory: {Path.cwd()}")
+
+        return exit_code
+
+    mocker.patch("poetry.console.commands.version.VersionCommand.handle", mock_handle)
+
+
+def test_application_global_option_ensure_error_when_context_invalid(
+    tester: ApplicationTester,
+) -> None:
+    # command fails due to lack of pyproject.toml file in cwd
+    tester.execute("show --only main")
+    assert tester.status_code != 0
+
+    stderr = tester.io.fetch_error()
+    assert NO_PYPROJECT_TOML_ERROR in stderr
+
+
+@pytest.mark.parametrize("parameter", ["-C", "--directory", "-P", "--project"])
+@pytest.mark.parametrize(
+    "command_args",
+    [
+        "{option} show --only main",
+        "show {option} --only main",
+        "show --only main {option}",
+    ],
+)
+def test_application_global_option_position_does_not_matter(
+    parameter: str,
+    command_args: str,
+    tester: ApplicationTester,
+    project_source_directory: Path,
+) -> None:
+    cwd = Path.cwd()
+    assert cwd != project_source_directory
+
+    option = f"{parameter} {project_source_directory.as_posix()}"
+    tester.execute(command_args.format(option=option))
+    assert tester.status_code == 0
+
+    stdout = tester.io.fetch_output()
+    stderr = tester.io.fetch_error()
+
+    assert NO_PYPROJECT_TOML_ERROR not in stderr
+    assert NO_PYPROJECT_TOML_ERROR not in stdout
+
+    assert "certifi" in stdout
+    assert len(stdout.splitlines()) == 8
+
+
+@pytest.mark.parametrize("parameter", ["project", "directory"])
+def test_application_with_context_parameters(
+    parameter: str,
+    tester: ApplicationTester,
+    project_source_directory: Path,
+    with_mocked_version_command: None,
+) -> None:
+    # ensure pre-conditions are met
+    assert project_source_directory != Path.cwd()
+
+    is_directory_param = parameter == "directory"
+
+    tester.execute(f"--{parameter} {project_source_directory} version")
+    assert tester.io.fetch_error() == ""
+    assert tester.status_code == 0
+
+    output = tester.io.fetch_output()
+    assert output == textwrap.dedent(f"""\
+    foobar 0.1.0
+    ProjectPath: {project_source_directory}
+    WorkingDirectory: {project_source_directory if is_directory_param else Path.cwd()}
+    """)
+
+
+def test_application_with_relative_project_parameter(
+    tester: ApplicationTester,
+    project_source_directory: Path,
+    with_mocked_version_command: None,
+    tmp_path_factory: TempPathFactory,
+) -> None:
+    # ensure pre-conditions are met
+    cwd = Path.cwd()
+    assert project_source_directory.is_relative_to(cwd)
+
+    # construct relative path
+    relative_source_directory = project_source_directory.relative_to(cwd)
+    assert relative_source_directory.as_posix() != project_source_directory.as_posix()
+    assert not relative_source_directory.is_absolute()
+
+    # we expect application run to be executed within current cwd but project to be a subdirectory
+    args = f"--directory '{cwd}' --project {relative_source_directory} version"
+
+    # we switch cwd to a new temporary directory unrelated to the project directory
+    new_working_dir = tmp_path_factory.mktemp("unrelated-working-directory")
+    with switch_working_directory(new_working_dir):
+        assert Path.cwd() == new_working_dir
+
+        tester.execute(args)
+        assert tester.io.fetch_error() == ""
+        assert tester.status_code == 0
+
+        output = tester.io.fetch_output()
+        assert output == textwrap.dedent(f"""\
+        foobar 0.1.0
+        ProjectPath: {project_source_directory}
+        WorkingDirectory: {cwd}
+        """)


Code review comments:
diff_hunk: @@ -237,6 +228,52 @@ def _run(self, io: IO) -> int:
 
         return exit_code
 
+    def _option_get_value(self, io: IO, name: str, default: Any) -> Any:
+        option = self.definition.option(name)
+
+        if option is None:
+            return default
+
+        values = [f"--{option.name}"]
+
+        if option.shortcut:
+            values.append(f"-{option.shortcut}")
+
+        if not io.input.has_parameter_option(values):
+            return default
+
+        if option.is_flag():
+            return True
+
+        return io.input.parameter_option(values=values, default=default)
+
+    def _configure_custom_application_options(self, io: IO | None) -> None:
path: src/poetry/console/application.py
text: @author:
Fair. I think I instinctively did a `if io is None` check in the code since the `self._io` is `IO | None`. Then sourcery complained about it, so I added this. Now removing it and the `if io is None` check.