Skip to content

fix(run): execute file scripts instead of looking for a callable - #11113

Open
kokokoXUY wants to merge 1 commit into
python-poetry:mainfrom
kokokoXUY:fix/run-file-scripts
Open

kokokoXUY wants to merge 1 commit into
python-poetry:mainfrom
kokokoXUY:fix/run-file-scripts

Conversation

@kokokoXUY

@kokokoXUY kokokoXUY commented Sep 29, 2026 •

Copy link
Copy Markdown

Description

Fixes #11090.

poetry run <script> fails with KeyError: 'callable' when the script is declared as a table in [tool.poetry.scripts]:

[tool.poetry.scripts]
my-script = { reference = "bin/my-script.sh", type = "file" }

Poetry already copies bin/my-script.sh into the environment's script directory as an executable when the project is installed, but poetry run my-script then tried to resolve the entry as a module:callable pair, so file scripts could not be executed by name.

Root cause

src/poetry/console/commands/run.py unconditionally read the callable key for table entries:

if isinstance(script, dict):
    script = script["callable"]

{ reference = ..., type = "file" } (and type = "console") has no callable key, so the lookup raised KeyError: 'callable'.

Changes

  • run.py now dispatches table entries with type == "file" to a new RunCommand.run_file_script() method, which executes the script file directly. The script is looked up in the environment's script directories (<scripts>/<name>, or <scripts>/<name>.cmd on Windows) and falls back to the referenced file relative to the project root, printing the existing "not installed as a script" warning. If the reference does not exist, it reports Command not found and returns 1.
  • Other table entries are resolved via callable (legacy form) or reference (type = "console"), which fixes the same KeyError for those entries.

Tests

Five tests added to tests/console/commands/test_run.py:

  • test_run_file_script_from_reference_when_not_installed – the referenced file is executed with the extra arguments when the script is not installed.
  • test_run_file_script_from_the_environment – the installed file in the environment's script directory takes precedence.
  • test_run_file_script_with_missing_reference – Command not found: missing-script and exit code 1.
  • test_run_console_script_defined_as_table – { reference = "pkg:main", type = "console" } is resolved to an entry point.
  • test_run_file_script_uses_the_installed_file – end-to-end install with tmp_venv: the installed file is executed and its exit code is propagated (skipped on Windows because the fixture uses a bash shebang).

The four non-skipped new tests fail on main with KeyError: 'callable' in run.py and pass with this change.

Verification

$ python -m pytest tests/console/commands/test_run.py -q -n0
23 passed, 1 skipped

ruff check, ruff format --check and mypy are clean for both files.

Known limitation

File scripts are still not runnable on Windows: the editable builder copies the file without a .cmd wrapper, and Windows cannot execute an extension-less script. This change does not make that worse (it was a KeyError before) and a <name>.cmd wrapper is used if one exists. Happy to address that separately if it should be in scope here.

Disclosure: this change was prepared with an AI coding assistant (DeepSeek) under my direction; I reviewed the diff and the commands above before pushing.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/poetry/console/commands/run.py" line_range="108-110" />
<code_context>
+        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:
</code_context>
<issue_to_address>
**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.

```suggestion
            candidates = [script_dir / script_name]
            if WINDOWS:
                candidates.insert(0, script_dir / f"{script_name}.cmd")
```
</issue_to_address>

### Comment 2
<location path="src/poetry/console/commands/run.py" line_range="117-118" />
<code_context>
+
+        # 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
+
+        if not script_path.exists():
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 3
<location path="src/poetry/console/commands/run.py" line_range="58-60" />
<code_context>
         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']``.
         """
+        if isinstance(script, dict) and script.get("type") == "file":
+            return self.run_file_script(script, args)
+
         for script_dir in self.env.script_dirs:
</code_context>
<issue_to_address>
**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.

```suggestion
        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.
        """
```
</issue_to_address>

Sourcery assessment

Approval pending. 2 findings to address first.

Blocking findings: src/poetry/console/commands/run.py:110, src/poetry/console/commands/run.py:118


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

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

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")

Comment on lines +117 to +118
reference = script["reference"]
script_path = self.poetry.file.path.parent / reference

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.

Comment on lines 58 to 60
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']``.
"""

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.
"""

@dimbleby

Copy link
Copy Markdown
Contributor

Explain why not #11092, #11108

`poetry run <script>` raised `KeyError: 'callable'` for scripts declared as
tables in `[tool.poetry.scripts]`, e.g.
`my-script = { reference = "bin/my-script.sh", type = "file" }`, even though
the referenced file is copied to the environment's script directory when the
project is installed.

File scripts are now executed directly. They are looked up in the
environment's script directories first and fall back to the referenced file
in the project, with the usual "not installed as a script" warning. Scripts
declared with `type = "console"` (or with the legacy `callable` key) keep
being resolved to a `module:callable` entry point, which also fixes the same
`KeyError` for that table form.
@kokokoXUY
kokokoXUY force-pushed the fix/run-file-scripts branch from 33508f1 to 6e1ff6b Compare October 5, 2026 05:40
@kokokoXUY

Copy link
Copy Markdown
Author

Here is the comparison as I see it.

#11108 (closed by its author in favour of #11092) stops at detecting the file script: when the script is not installed it executes args[0] unchanged, and it has no coverage for a missing reference.

#11092 (open) fixes the same crash with a wider surface: besides run.py it also changes Env._bin() in src/poetry/utils/env/base_env.py, which every env.execute() / env.run() caller goes through. It keeps the original script name on Windows and detects file scripts before the callable lookup.

What this PR does

  • the change is confined to RunCommand (src/poetry/console/commands/run.py): run_script() dispatches type = "file" to a new run_file_script() before any callable lookup;
  • the installed script is looked up under the script name and, additionally on Windows, as <name>.cmd in each env.script_dirs entry — the console path rewrites to .cmd unconditionally, which is wrong for file scripts, but a wrapper is still worth trying;
  • when the script is not installed it falls back to script["reference"] relative to pyproject.toml, after the same "not installed as a script" warning the console path emits; if the referenced file does not exist it reports Command not found: <name> and returns 1 instead of executing a bare name;
  • while in that function, script["callable"] became script.get("callable") or script["reference"], so an entry written as { reference = "pkg:main", type = "console" } no longer raises KeyError: 'callable' (test_run_console_script_defined_as_table).

Tests in tests/console/commands/test_run.py: test_run_file_script_from_the_environment, test_run_file_script_from_reference_when_not_installed, test_run_file_script_with_missing_reference, and an end-to-end test_run_file_script_uses_the_installed_file that installs the project into a tmp venv and asserts the installed file is executed and its exit code returned. Result on the rebased branch: 23 passed, 1 skipped — the skip is the end-to-end test on Windows, because the fixture script uses a bash shebang.

Limitation I am not claiming to fix: on Windows, an extension-less script copied verbatim into the script directory cannot be started by cmd.exe (Env.execute() uses shell=True there), which is the same reason #11092's test is skipif(WINDOWS). Making type = "file" scripts work on Windows needs a generated, shebang-aware wrapper — a separate change.

So: if you would rather land #11092 and drop mine, that is fine with me; I would only ask that the "not installed / missing reference" handling ends up somewhere, since that is the part #11108 lacked. Otherwise this is the minimal change to run.py that keeps Env._bin() untouched.

Rebased onto main (3ea141d) just now.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make file scripts executable via poetry run

2 participants