diff --git a/src/poetry/console/application.py b/src/poetry/console/application.py index 1ff189bd819..e217ec56f2f 100644 --- a/src/poetry/console/application.py +++ b/src/poetry/console/application.py @@ -1,7 +1,7 @@ from __future__ import annotations +import argparse import logging -import re from contextlib import suppress from importlib import import_module @@ -16,6 +16,7 @@ from cleo.exceptions import CleoCommandNotFoundError from cleo.exceptions import CleoError from cleo.formatters.style import Style +from cleo.io.inputs.argv_input import ArgvInput from poetry.__version__ import __version__ from poetry.console.command_loader import CommandLoader @@ -26,10 +27,8 @@ 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 from cleo.io.inputs.definition import Definition from cleo.io.inputs.input import Input from cleo.io.io import IO @@ -243,7 +242,7 @@ def _run(self, io: IO) -> int: # to ensure the users are not exposed to a stack trace for providing invalid values to # the options --directory or --project, configuring the options here allow cleo to trap and # display the error cleanly unless the user uses verbose or debug - self._configure_custom_application_options(io) + self._configure_global_options(io) self._load_plugins(io) @@ -265,40 +264,29 @@ 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) + def _configure_global_options(self, io: IO) -> None: + """ + Configures global options for the application by setting up the relevant + directories, disabling plugins or cache, and managing the working and + project directories. This method ensures that all directories are valid + paths and handles the resolution of the project directory relative to the + working directory if necessary. - if option is None: - return default + :param io: The IO instance whose input and options are being read. + :return: Nothing. + """ + self._sort_global_options(io) - 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._disable_plugins = io.input.option("no-plugins") + self._disable_cache = io.input.option("no-cache") # we use ensure_path for the directories to make sure these are valid paths # this will raise an exception if the path is invalid self._working_directory = ensure_path( - self._option_get_value(io, "directory", Path.cwd()), is_directory=True + io.input.option("directory") or Path.cwd(), is_directory=True ) - self._project_directory = self._option_get_value(io, "project", None) + self._project_directory = io.input.option("project") if self._project_directory is not None: self._project_directory = Path(self._project_directory) self._project_directory = ensure_path( @@ -310,40 +298,151 @@ def _configure_custom_application_options(self, io: IO) -> None: is_directory=True, ) - def _configure_io(self, io: IO) -> None: - # We need to check if the command being run - # is the "run" command. - definition = self.definition + def _sort_global_options(self, io: IO) -> None: + """ + Sorts global options of the provided IO instance according to the + definition of the available options, reordering and parsing arguments + to ensure consistency in input handling. + + The function interprets the options and their corresponding values + using an argument parser, constructs a sorted list of tokens, and + recreates the input with the rearranged sequence while maintaining + compatibility with the initially provided input stream. + + If using in conjunction with `_configure_run_command`, it is recommended that + it be called first in order to correctly handling cases like + `poetry run -V python -V`. + + :param io: The IO instance whose input and options are being processed + and reordered. + :return: Nothing. + """ + original_input = cast(ArgvInput, io.input) + tokens: list[str] = original_input._tokens + + parser = argparse.ArgumentParser(add_help=False) + + for option in self.definition.options: + parser.add_argument( + f"--{option.name}", + *([f"-{option.shortcut}"] if option.shortcut else []), + action="store_true" if option.is_flag() else "store", + ) + + args, remaining_args = parser.parse_known_args(tokens) + + tokens = [] + for option in self.definition.options: + key = option.name.replace("-", "_") + value = getattr(args, key, None) + + if value is not None: + if value: # is truthy + tokens.append(f"--{option.name}") + + if option.accepts_value(): + tokens.append(str(value)) + + sorted_input = ArgvInput([self._name or "", *tokens, *remaining_args]) + sorted_input.set_stream(original_input.stream) + + with suppress(CleoError): + sorted_input.bind(self.definition) + + io.set_input(sorted_input) + + def _configure_run_command(self, io: IO) -> None: + """ + Configures the input for the "run" command to properly handle cases where the user + executes commands such as "poetry run -- ". This involves reorganizing + input tokens to ensure correct parsing and execution of the run command. + """ with suppress(CleoError): - io.input.bind(definition) - - name = io.input.first_argument - if name == "run": - from poetry.console.io.inputs.run_argv_input import RunArgvInput - - input = cast("ArgvInput", io.input) - run_input = RunArgvInput([self._name or "", *input._tokens]) - # For the run command reset the definition - # with only the set options (i.e. the options given before the command) - for option_name, value in input.options.items(): - if value: - option = definition.option(option_name) - run_input.add_parameter_option("--" + option.name) - if option.shortcut: - shortcuts = re.split(r"\|-?", option.shortcut.lstrip("-")) - shortcuts = [s for s in shortcuts if s] - for shortcut in shortcuts: - run_input.add_parameter_option("-" + shortcut.lstrip("-")) + io.input.bind(self.definition) + + command_name = io.input.first_argument + + if command_name == "run": + original_input = cast(ArgvInput, io.input) + tokens: list[str] = original_input._tokens + + if "--" in tokens: + # this means the user has done the right thing and used "poetry run -- echo hello" + # in this case there is not much we need to do, we can skip the rest + return + + # find the correct command index, in some cases this might not be first occurrence + # eg: poetry -C run run echo + command_index = tokens.index(command_name) + + while command_index < (len(tokens) - 1): + try: + # try parsing the tokens so far + _ = ArgvInput( + [self._name or "", *tokens[: command_index + 1]], + definition=self.definition, + ) + break + except CleoError: + # parsing failed, try finding the next "run" token + try: + command_index += ( + tokens[command_index + 1 :].index(command_name) + 1 + ) + except ValueError: + command_index = len(tokens) + else: + # looks like we reached the end of the road, let cleo deal with it + return + + # fetch tokens after the "run" command + tokens_without_command = tokens[command_index + 1 :] + + # we create a new input for parsing the subcommand pretending + # it is poetry command + without_command = ArgvInput( + [self._name or "", *tokens_without_command], None + ) with suppress(CleoError): - run_input.bind(definition) + # we want to bind the definition here so that cleo knows what should be + # parsed, and how + without_command.bind(self.definition) + + # the first argument here is the subcommand + subcommand = without_command.first_argument + subcommand_index = ( + (tokens_without_command.index(subcommand) if subcommand else 0) + + command_index + + 1 + ) + + # recreate the original input reordering in the following order + # - all tokens before "run" command + # - all tokens after "run" command but before the subcommand + # - the "run" command token + # - the "--" token to normalise the form + # - all remaining tokens starting with the subcommand + run_input = ArgvInput( + [ + self._name or "", + *tokens[:command_index], + *tokens[command_index + 1 : subcommand_index], + command_name, + "--", + *tokens[subcommand_index:], + ] + ) + run_input.set_stream(original_input.stream) - for option_name, value in input.options.items(): - if value: - run_input.set_option(option_name, value) + with suppress(CleoError): + run_input.bind(self.definition) + # reset the input to our constructed form io.set_input(run_input) + def _configure_io(self, io: IO) -> None: + self._configure_run_command(io) super()._configure_io(io) def register_command_loggers( diff --git a/src/poetry/console/io/__init__.py b/src/poetry/console/io/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/poetry/console/io/inputs/__init__.py b/src/poetry/console/io/inputs/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/poetry/console/io/inputs/run_argv_input.py b/src/poetry/console/io/inputs/run_argv_input.py deleted file mode 100644 index 36735202118..00000000000 --- a/src/poetry/console/io/inputs/run_argv_input.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from cleo.io.inputs.argv_input import ArgvInput - - -if TYPE_CHECKING: - from cleo.io.inputs.definition import Definition - - -class RunArgvInput(ArgvInput): - def __init__( - self, - argv: list[str] | None = None, - definition: Definition | None = None, - ) -> None: - super().__init__(argv, definition=definition) - - self._parameter_options: list[str] = [] - - @property - def first_argument(self) -> str | None: - return "run" - - def add_parameter_option(self, name: str) -> None: - self._parameter_options.append(name) - - def has_parameter_option( - self, values: str | list[str], only_params: bool = False - ) -> bool: - if not isinstance(values, list): - values = [values] - - for token in self._tokens: - if only_params and token == "--": - return False - - for value in values: - if value not in self._parameter_options: - continue - - # Options with values: - # For long options, test for '--option=' at beginning - # For short options, test for '-o' at beginning - leading = value + "=" if value.startswith("--") else value - - if token == value or leading != "" and token.startswith(leading): - return True - - return False - - def _parse(self) -> None: - parse_options = True - self._parsed = self._tokens[:] - - try: - token = self._parsed.pop(0) - except IndexError: - token = None - - while token is not None: - if parse_options and token == "": - self._parse_argument(token) - elif parse_options and token == "--": - parse_options = False - elif parse_options and token.find("--") == 0: - if token in self._parameter_options: - self._parse_long_option(token) - else: - self._parse_argument(token) - elif parse_options and token[0] == "-" and token != "-": - if token in self._parameter_options: - self._parse_short_option(token) - else: - self._parse_argument(token) - else: - self._parse_argument(token) - - try: - token = self._parsed.pop(0) - except IndexError: - token = None diff --git a/tests/console/commands/test_run.py b/tests/console/commands/test_run.py index 4f07205db6e..5fc067f0eff 100644 --- a/tests/console/commands/test_run.py +++ b/tests/console/commands/test_run.py @@ -51,6 +51,74 @@ def test_run_passes_all_args(app_tester: ApplicationTester, env: MockEnv) -> Non assert env.executed == [["python", "-V"]] +def test_run_is_not_eager(app_tester: ApplicationTester, env: MockEnv) -> None: + app_tester.execute("--no-ansi -C run -install", decorated=True) + assert ( + app_tester.io.fetch_error().strip() + == "Specified path 'run' is not a valid directory." + ) + assert env.executed == [] + + +def test_run_passes_args_after_run_before_command( + app_tester: ApplicationTester, env: MockEnv +) -> None: + app_tester.execute("run -P. python -V", decorated=True) + assert env.executed == [["python", "-V"]] + + +@pytest.mark.parametrize( + "args", + [ + "-vP run run", + "run -vP run", + "-vPrun run", + "run -vPrun ", + "-v --project=run run", + "-v run --project=run", + "-v --directory=run run", + "run -v --directory=run", + ], +) +def test_run_passes_args_after_run_before_command_name_conflict( + args: str, + app_tester: ApplicationTester, + env: MockEnv, + project_factory: ProjectFactory, +) -> None: + poetry = project_factory("run") + path = poetry.file.path.parent + path.rename(path.parent / "run") + + app_tester.execute(f"{args} python -V", decorated=True) + assert app_tester.io.fetch_error() == "" + assert env.executed == [["python", "-V"]] + + +def test_run_keeps_options_passed_before_command_args_combined_short_opts( + app_tester: ApplicationTester, env: MockEnv +) -> None: + app_tester.execute("run -VP. --no-ansi python", decorated=True) + + assert not app_tester.io.is_decorated() + assert app_tester.io.fetch_output() == app_tester.io.remove_format( + app_tester.application.long_version + "\n" + ) + assert env.executed == [] + + +def test_run_keeps_options_passed_before_command_args( + app_tester: ApplicationTester, env: MockEnv +) -> None: + app_tester.execute("run -V --no-ansi python", decorated=True) + + assert not app_tester.io.is_decorated() + assert app_tester.io.fetch_output() == app_tester.io.remove_format( + app_tester.application.long_version + "\n" + ) + assert env.executed == [] + + def test_run_keeps_options_passed_before_command( app_tester: ApplicationTester, env: MockEnv ) -> None: diff --git a/tests/console/test_application.py b/tests/console/test_application.py index 8fbc6d7dc67..2e2348490de 100644 --- a/tests/console/test_application.py +++ b/tests/console/test_application.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from typing import ClassVar +from typing import cast import pytest @@ -24,8 +25,10 @@ if TYPE_CHECKING: from pathlib import Path + from cleo.io.inputs.argv_input import ArgvInput from pytest_mock import MockerFixture + from tests.helpers import PoetryTestApplication from tests.types import FixtureDirGetter from tests.types import SetProjectContext @@ -185,3 +188,60 @@ def test_application_verify_cache_flag_at_install( (name, args, kwargs) = call assert "disable_cache" in kwargs assert disable_cache is kwargs["disable_cache"] + + +@pytest.mark.parametrize( + ("tokens", "result"), + [ + ( + ["-C", "/path/working/dir", "env", "list"], + ["--directory", "/path/working/dir", "env", "list"], + ), + ( + ["-P", "/path/project/dir", "env", "list"], + ["--project", "/path/project/dir", "env", "list"], + ), + ( + ["-P/path/project/dir", "env", "list"], + ["--project", "/path/project/dir", "env", "list"], + ), + ( + ["-P/path/project/dir", "env", "list"], + ["--project", "/path/project/dir", "env", "list"], + ), + ( + ["-v", "run", "-P/path/project/dir", "echo", "--help"], + [ + "--verbose", + "--project", + "/path/project/dir", + "run", + "--", + "echo", + "--help", + ], + ), + ( + ["--no-ansi", "run", "-V", "python", "-V"], + ["--version", "--no-ansi", "run", "--", "python", "-V"], + ), + ( + ["--no-ansi", "run", "-V", "--", "python", "-V"], + ["--version", "--no-ansi", "run", "--", "python", "-V"], + ), + ], +) +def test_application_input_configuration_and_sorting( + tokens: list[str], result: list[str], app: PoetryTestApplication +) -> None: + app.create_io() + assert app._io is not None + + io_input = cast("ArgvInput", app._io.input) + io_input._tokens = tokens + + app._configure_io(app._io) + app._sort_global_options(app._io) + + io_input = cast("ArgvInput", app._io.input) + assert io_input._tokens == result