Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/poetry/console/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ def run_script(self, script: str | dict[str, str], args: list[str]) -> int:
Otherwise (when an entry point script does not exist), ``sys.argv[0]`` is the
script name only, i.e. ``poetry run foo`` has ``sys.argv == ['foo']``.
"""
Comment on lines 58 to 60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The run_script docstring now describes file-script fallback behavior incorrectly: it claims that an uninstalled script runs with sys.argv[0] set to only the script name, but run_file_script executes the referenced path directly, so sys.argv[0] is the full reference path.

Triggers: When a file script is run from its project reference because it is not installed.

Suggested fix: Document the distinct sys.argv[0] behavior for file scripts, or execute the fallback in a way that preserves the documented value.

Suggested change
Otherwise (when an entry point script does not exist), ``sys.argv[0]`` is the
script name only, i.e. ``poetry run foo`` has ``sys.argv == ['foo']``.
"""
Otherwise (when an entry point script does not exist), ``sys.argv[0]`` is the
script name only, i.e. ``poetry run foo`` has ``sys.argv == ['foo']``. For a
file script that is not installed, ``sys.argv[0]`` is the full reference path.
"""

if isinstance(script, dict) and script.get("type") == "file":
return self.run_file_script(script, args)

for script_dir in self.env.script_dirs:
script_path = script_dir / args[0]
if WINDOWS:
Expand All @@ -70,7 +73,10 @@ def run_script(self, script: str | dict[str, str], args: list[str]) -> int:
self._warning_not_installed_script(args[0])

if isinstance(script, dict):
script = script["callable"]
# A script can also be specified as a table, either as a legacy
# ``{callable = "module:callable"}`` entry or as a
# ``{reference = "module:callable", type = "console"}`` entry.
script = script.get("callable") or script["reference"]

module, callable_ = script.split(":")

Expand All @@ -89,6 +95,35 @@ def run_script(self, script: str | dict[str, str], args: list[str]) -> int:

return self.env.execute(*cmd)

def run_file_script(self, script: dict[str, str], args: list[str]) -> int:
"""Runs a file script defined in the section ``[tool.poetry.scripts]``.

Unlike entry points, file scripts (``type = "file"``) are copied to the
environment's script directory as-is when the project is installed, so they
are executed directly instead of being resolved to a ``module:callable`` pair.
"""
script_name = args[0]

for script_dir in self.env.script_dirs:
candidates = [script_dir / script_name]
if WINDOWS:
candidates.append(script_dir / f"{script_name}.cmd")
Comment on lines +108 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): On Windows, an installed extensionless file at <scripts>/<name> is selected before <scripts>/<name>.cmd, so env.execute attempts to run the extensionless file instead of using the executable wrapper. The command therefore fails even when a working .cmd wrapper exists.

Triggers: On Windows when both the extensionless installed file and its .cmd wrapper exist.

Suggested fix: Prefer the .cmd candidate on Windows, or skip the extensionless candidate there when a wrapper exists.

Suggested change
candidates = [script_dir / script_name]
if WINDOWS:
candidates.append(script_dir / f"{script_name}.cmd")
candidates = [script_dir / script_name]
if WINDOWS:
candidates.insert(0, script_dir / f"{script_name}.cmd")

for script_path in candidates:
if script_path.exists():
return self.env.execute(str(script_path), *args[1:])

# If we reach this point, the script is not installed, so we fall back to
# the script file referenced in the project.
reference = script["reference"]
script_path = self.poetry.file.path.parent / reference
Comment on lines +117 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): A file-script table without a reference key raises KeyError instead of reporting a command-not-found or malformed-script error. The editable builder explicitly tolerates this configuration and reports a missing reference, so poetry run <name> crashes for a configuration that installation accepts.

Triggers: When a [tool.poetry.scripts] entry has type = "file" but no reference field and no installed script is present.

Suggested fix: Read the reference with .get() and emit the same missing-reference error used by the builder before attempting execution.


if not script_path.exists():
self.line_error(f"<error>Command not found: <c1>{script_name}</c1></error>")
return 1

self._warning_not_installed_script(script_name)
return self.env.execute(str(script_path), *args[1:])

def _warning_not_installed_script(self, script: str) -> None:
message = f"""\
Warning: '{script}' is an entry point defined in pyproject.toml, but it's not \
Expand Down
122 changes: 122 additions & 0 deletions tests/console/commands/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ def poetry_with_scripts(
)


@pytest.fixture
def poetry_with_file_scripts(
project_factory: ProjectFactory, fixture_dir: FixtureDirGetter
) -> Poetry:
source = fixture_dir("file_scripts_project")

return project_factory(
name="file-scripts-project",
pyproject_content=(source / "pyproject.toml").read_text(encoding="utf-8"),
source=source,
)


def test_run_passes_all_args(app_tester: ApplicationTester, env: MockEnv) -> None:
app_tester.execute("run python -V")
assert env.executed == [["python", "-V"]]
Expand Down Expand Up @@ -265,3 +278,112 @@ def test_run_script_sys_argv0(

"""
assert tester.io.fetch_error() == expected_message


def test_run_file_script_from_reference_when_not_installed(
poetry_with_file_scripts: Poetry,
command_tester_factory: CommandTesterFactory,
env: MockEnv,
) -> None:
tester = command_tester_factory("run", poetry=poetry_with_file_scripts)

assert tester.execute("my-script arg1 arg2") == 0

reference = poetry_with_file_scripts.file.path.parent / "bin" / "my-script.sh"
assert env.executed == [[str(reference), "arg1", "arg2"]]
assert "not installed as a script" in tester.io.fetch_error()


def test_run_file_script_from_the_environment(
poetry_with_file_scripts: Poetry,
command_tester_factory: CommandTesterFactory,
env: MockEnv,
) -> None:
script_dir = env.script_dirs[0]
script_dir.mkdir(parents=True, exist_ok=True)
installed_script = script_dir / f"my-script{'.cmd' if WINDOWS else ''}"
installed_script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")

tester = command_tester_factory("run", poetry=poetry_with_file_scripts)

assert tester.execute("my-script") == 0
assert env.executed == [[str(installed_script)]]
assert tester.io.fetch_error() == ""


def test_run_file_script_with_missing_reference(
project_factory: ProjectFactory,
fixture_dir: FixtureDirGetter,
command_tester_factory: CommandTesterFactory,
env: MockEnv,
) -> None:
source = fixture_dir("file_scripts_missing_ref_project")
poetry = project_factory(
name="missing-file-script",
pyproject_content=(source / "pyproject.toml").read_text(encoding="utf-8"),
source=source,
)

tester = command_tester_factory("run", poetry=poetry)

assert tester.execute("missing-script") == 1
assert "Command not found: missing-script" in tester.io.fetch_error()
assert env.executed == []


def test_run_console_script_defined_as_table(
project_factory: ProjectFactory,
fixture_dir: FixtureDirGetter,
command_tester_factory: CommandTesterFactory,
env: MockEnv,
) -> None:
source = fixture_dir("file_scripts_project")
content = (
(source / "pyproject.toml")
.read_text(encoding="utf-8")
.replace(
'console-entry = "file_scripts_project:main"',
'console-entry = { reference = "file_scripts_project:main",'
' type = "console" }',
)
)
poetry = project_factory(
name="file-scripts-project-console-table",
pyproject_content=content,
source=source,
)

tester = command_tester_factory("run", poetry=poetry)

assert tester.execute("console-entry") == 0
(executed,) = env.executed
assert executed[0] == "python"
assert "import_module('file_scripts_project')" in executed[2]


@pytest.mark.skipif(WINDOWS, reason="The file script fixture uses a bash shebang.")
def test_run_file_script_uses_the_installed_file(
poetry_with_file_scripts: Poetry,
command_tester_factory: CommandTesterFactory,
tmp_venv: VirtualEnv,
mocker: MockerFixture,
) -> None:
mocker.patch(
"os.execvpe",
lambda file, args, env: subprocess.call([file, *args[1:]], env=env),
)
install_tester = command_tester_factory(
"install", poetry=poetry_with_file_scripts, environment=tmp_venv
)
assert install_tester.execute() == 0

installed_script = tmp_venv.script_dirs[0] / "my-script"
assert installed_script.exists()
installed_script.write_text("#!/bin/sh\nexit 42\n", encoding="utf-8")
installed_script.chmod(0o755)

tester = command_tester_factory(
"run", poetry=poetry_with_file_scripts, environment=tmp_venv
)
assert tester.execute("my-script") == 42
assert tester.io.fetch_error() == ""
Loading