Skip to content

fix(env): prevent crash when invalid Python or Python 2.x is in PATH#10962

Open
Abiggj wants to merge 4 commits into
python-poetry:mainfrom
Abiggj:bug-fix/issue-10897
Open

fix(env): prevent crash when invalid Python or Python 2.x is in PATH#10962
Abiggj wants to merge 4 commits into
python-poetry:mainfrom
Abiggj:bug-fix/issue-10897

Conversation

@Abiggj

@Abiggj Abiggj commented Jun 24, 2026

Copy link
Copy Markdown

Pull Request Check List

Resolves: #10897

- [ ] Added tests for changed code.
- [x] Updated documentation for changed code.

Description

With Poetry 2.4, system Python discovery checks candidate binaries using findpython. Some older or broken Python interpreters (specifically Python 2.7) do not support the isolated -I option when probed, resulting in a CalledProcessError with exit status 2 and crashing various Poetry commands.

This PR fixes the crash by:

  1. Catching CalledProcessError and ValueError inside python discovery functions (find_all, get_active_python, get_by_name). When encountered, the invalid Python binary is safely skipped.
  2. Converting CalledProcessError exceptions inside python manager properties (executable, version, implementation, etc.) to a standard ValueError.
  3. Adding unit tests in tests/utils/test_python_manager.py that mock the discovery of broken Python installations (such as Python 2.7) to verify that they are cleanly ignored.

The documentation wasn't updates but is prepared, I shall add those once these changes are ensured that they are working and robust.

@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 2 issues, and left some high level feedback:

  • The repeated try/except blocks converting CalledProcessError to ValueError in each property (executable, implementation, free_threaded, major, minor, patch, version) could be centralized via a small helper (e.g., a private method or descriptor) to avoid duplication and keep the error handling logic consistent in one place.
  • Similarly, the explicit interpreter validation logic (touching python.interpreter and catching CalledProcessError/ValueError) is duplicated across find_all, get_active_python, and get_by_name; consider extracting a shared helper like _is_valid_python(python) to make the intent clearer and reduce the chance of divergence in future changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The repeated try/except blocks converting CalledProcessError to ValueError in each property (executable, implementation, free_threaded, major, minor, patch, version) could be centralized via a small helper (e.g., a private method or descriptor) to avoid duplication and keep the error handling logic consistent in one place.
- Similarly, the explicit interpreter validation logic (touching python.interpreter and catching CalledProcessError/ValueError) is duplicated across find_all, get_active_python, and get_by_name; consider extracting a shared helper like `_is_valid_python(python)` to make the intent clearer and reduce the chance of divergence in future changes.

## Individual Comments

### Comment 1
<location path="tests/utils/test_python_manager.py" line_range="203" />
<code_context>
+    assert Python.get_by_name("python") is None
+
+
+def test_python_properties_raise_value_error_on_subprocess_failure(
+    mocker: MockerFixture,
+) -> None:
</code_context>
<issue_to_address>
**suggestion (testing):** Expand this test (or add another) to cover `ValueError` from `interpreter` and multiple properties (version, implementation, etc.), not just `executable`.

Since the implementation now wraps `CalledProcessError` for multiple properties (`implementation`, `free_threaded`, `major`, `minor`, `patch`, `version`) and also catches `ValueError` in other places, this test should exercise those paths as well. Consider parametrizing over these properties to assert they all raise `ValueError` when the underlying `PythonVersion` raises `CalledProcessError`, and add a test where `interpreter` (or another attribute) raises `ValueError` directly to confirm it is surfaced unchanged and with a consistent message.
</issue_to_address>

### Comment 2
<location path="tests/utils/test_python_manager.py" line_range="146-149" />
<code_context>
+    assert Python.get_active_python() is None
+
+
+def test_get_by_name_ignores_broken_installations(mocker: MockerFixture) -> None:
+    from subprocess import CalledProcessError
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a positive-path test for `get_by_name` where a broken primary result is ignored but a valid fallback from `findpython.find` is used.

The existing test only covers the case where both `ShutilWhichPythonProvider.find_python_by_name` and `findpython.find` fail and `get_by_name` returns `None`. Please also add a test where `find_python_by_name` yields a broken `PythonVersion` (raising `CalledProcessError`/`ValueError` on `interpreter`), but `findpython.find(python_name)` returns a valid `PythonVersion`, and assert that `Python.get_by_name(python_name)` returns a non-`None` `Python` to cover the new fallback behavior.

```suggestion
def test_get_by_name_uses_fallback_for_broken_installations(
    mocker: MockerFixture,
) -> None:
    from subprocess import CalledProcessError

    python_name = "python"

    mock_bad_pv = mocker.MagicMock(spec=findpython.PythonVersion)
    mock_bad_pv.executable = Path("/usr/bin/broken-python")
    type(mock_bad_pv).interpreter = mocker.PropertyMock(
        side_effect=CalledProcessError(1, [python_name])
    )

    mock_good_pv = mocker.MagicMock(spec=findpython.PythonVersion)
    mock_good_pv.executable = Path("/usr/bin/python3.12")
    mock_good_pv.interpreter = Path("/usr/bin/python3.12")
    mock_good_pv.version = packaging.version.Version("3.12.0")

    mocker.patch(
        "poetry.utils.env.python.providers.ShutilWhichPythonProvider."
        "find_python_by_name",
        return_value=mock_bad_pv,
    )
    mocker.patch("findpython.find", return_value=mock_good_pv)

    python = Python.get_by_name(python_name)

    assert python is not None


def test_python_ignores_broken_installations(mocker: MockerFixture) -> None:
    from subprocess import CalledProcessError

    mock_good_pv = mocker.MagicMock(spec=findpython.PythonVersion)
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

assert Python.get_by_name("python") is None


def test_python_properties_raise_value_error_on_subprocess_failure(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Expand this test (or add another) to cover ValueError from interpreter and multiple properties (version, implementation, etc.), not just executable.

Since the implementation now wraps CalledProcessError for multiple properties (implementation, free_threaded, major, minor, patch, version) and also catches ValueError in other places, this test should exercise those paths as well. Consider parametrizing over these properties to assert they all raise ValueError when the underlying PythonVersion raises CalledProcessError, and add a test where interpreter (or another attribute) raises ValueError directly to confirm it is surfaced unchanged and with a consistent message.

Comment on lines +146 to +149
def test_python_ignores_broken_installations(mocker: MockerFixture) -> None:
from subprocess import CalledProcessError

mock_good_pv = mocker.MagicMock(spec=findpython.PythonVersion)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add a positive-path test for get_by_name where a broken primary result is ignored but a valid fallback from findpython.find is used.

The existing test only covers the case where both ShutilWhichPythonProvider.find_python_by_name and findpython.find fail and get_by_name returns None. Please also add a test where find_python_by_name yields a broken PythonVersion (raising CalledProcessError/ValueError on interpreter), but findpython.find(python_name) returns a valid PythonVersion, and assert that Python.get_by_name(python_name) returns a non-None Python to cover the new fallback behavior.

Suggested change
def test_python_ignores_broken_installations(mocker: MockerFixture) -> None:
from subprocess import CalledProcessError
mock_good_pv = mocker.MagicMock(spec=findpython.PythonVersion)
def test_get_by_name_uses_fallback_for_broken_installations(
mocker: MockerFixture,
) -> None:
from subprocess import CalledProcessError
python_name = "python"
mock_bad_pv = mocker.MagicMock(spec=findpython.PythonVersion)
mock_bad_pv.executable = Path("/usr/bin/broken-python")
type(mock_bad_pv).interpreter = mocker.PropertyMock(
side_effect=CalledProcessError(1, [python_name])
)
mock_good_pv = mocker.MagicMock(spec=findpython.PythonVersion)
mock_good_pv.executable = Path("/usr/bin/python3.12")
mock_good_pv.interpreter = Path("/usr/bin/python3.12")
mock_good_pv.version = packaging.version.Version("3.12.0")
mocker.patch(
"poetry.utils.env.python.providers.ShutilWhichPythonProvider."
"find_python_by_name",
return_value=mock_bad_pv,
)
mocker.patch("findpython.find", return_value=mock_good_pv)
python = Python.get_by_name(python_name)
assert python is not None
def test_python_ignores_broken_installations(mocker: MockerFixture) -> None:
from subprocess import CalledProcessError
mock_good_pv = mocker.MagicMock(spec=findpython.PythonVersion)

@Abiggj

Abiggj commented Jun 24, 2026

Copy link
Copy Markdown
Author

Documentation Added

  • Added a changelog entry to CHANGELOG.md under [2.4.2] - Unreleased documenting the fix for commands failing when Python 2.7 is present in the PATH.

Refactoring & Centralization

To address code duplication and keep error-handling logic consistent:

  • Extracted a private _get_python_property() helper to wrap the underlying properties and raise a ValueError with uniform messaging on failure.
  • Extracted a _is_valid_python() classmethod to check interpreter validity, which is now reused across find_all(), get_active_python(), and get_by_name().

@Abiggj

Abiggj commented Jun 24, 2026

Copy link
Copy Markdown
Author
  • Imports Any from typing in src/poetry/utils/env/python/manager.py to fix the Name "Any" is not defined mypy type error.
  • Combines nested if statements containing assignment expressions using and to satisfy the Ruff SIM102 lint rule.

@dimbleby

Copy link
Copy Markdown
Contributor

This should be fixed in findpython. IIRC there already is an open pull request there.

@Abiggj

Abiggj commented Jun 24, 2026

Copy link
Copy Markdown
Author

This should be fixed in findpython. IIRC there already is an open pull request there.

That makes sense, but having this fix in Poetry will act as a safety net. Even if findpython is patched in future, Poetry supports a range of older findpython versions; so users on older versions would still experience the crash. Adding this within code makes it resilient to legacy Python environments in a user's PATH, ensuring a raw subprocess error doesn't completely block basic commands like init or install.

@dimbleby

Copy link
Copy Markdown
Contributor

Even if findpython is patched in future, Poetry supports a range of older findpython versions

If and when findpython release a fix, poetry can bump its required findpython version.

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.

Poetry commands fail if python 2.7 is in path

2 participants