Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/changelog/2659.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Stop `--try-first-with` overriding absolute `--python` paths.
Contributed by :user:`esafak`.
45 changes: 45 additions & 0 deletions docs/cli_interface.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,51 @@ The options that can be passed to virtualenv, along with their default values an
:module: virtualenv.run
:func: build_parser_only

Discovery options
~~~~~~~~~~~~~~~~~

Understanding Interpreter Discovery: ``--python`` vs. ``--try-first-with``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

You can control which Python interpreter ``virtualenv`` selects using the ``--python`` and ``--try-first-with`` flags.
To avoid confusion, it's best to think of them as the "rule" and the "hint".

**``--python <spec>``: The Rule**

This flag sets the mandatory requirements for the interpreter. The ``<spec>`` can be:

- **A version string** (e.g., ``python3.8``, ``pypy3``). ``virtualenv`` will search for any interpreter that matches this version.
- **An absolute path** (e.g., ``/usr/bin/python3.8``). This is a *strict* requirement. Only the interpreter at this exact path will be used. If it does not exist or is not a valid interpreter, creation will fail.

**``--try-first-with <path>``: The Hint**

This flag provides a path to a Python executable to check *before* ``virtualenv`` performs its standard search. This can speed up discovery or help select a specific interpreter when multiple versions exist on your system.

**How They Work Together**

``virtualenv`` will only use an interpreter from ``--try-first-with`` if it **satisfies the rule** from the ``--python`` flag. The ``--python`` rule always wins.

**Examples:**

1. **Hint does not match the rule:**

.. code-block:: bash

virtualenv --python python3.8 --try-first-with /usr/bin/python3.10 my-env

- **Result:** ``virtualenv`` first inspects ``/usr/bin/python3.10``. It sees this does not match the ``python3.8`` rule and **rejects it**. It then proceeds with its normal search to find a ``python3.8`` interpreter elsewhere.

2. **Hint does not match a strict path rule:**

.. code-block:: bash

virtualenv --python /usr/bin/python3.8 --try-first-with /usr/bin/python3.10 my-env

- **Result:** The rule is strictly ``/usr/bin/python3.8``. ``virtualenv`` checks the ``/usr/bin/python3.10`` hint, sees the path doesn't match, and **rejects it**. It then moves on to test ``/usr/bin/python3.8`` and successfully creates the environment.

This approach ensures that the behavior is predictable and that ``--python`` remains the definitive source of truth for the user's intent.


Defaults
~~~~~~~~

Expand Down
16 changes: 15 additions & 1 deletion src/virtualenv/discovery/builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,23 @@ def propose_interpreters( # noqa: C901, PLR0912, PLR0915
app_data: AppData | None = None,
env: Mapping[str, str] | None = None,
) -> Generator[tuple[PythonInfo, bool], None, None]:
# 0. try with first
# 0. if it's a path and exists, and is absolute path, this is the only option we consider
env = os.environ if env is None else env
tested_exes: set[str] = set()
if spec.is_abs:
try:
os.lstat(spec.path) # Windows Store Python does not work with os.path.exists, but does for os.lstat
except OSError:
pass
else:
exe_raw = os.path.abspath(spec.path)
exe_id = fs_path_id(exe_raw)
if exe_id not in tested_exes:
tested_exes.add(exe_id)
yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
return

# 1. try with first
for py_exe in try_first_with:
path = os.path.abspath(py_exe)
try:
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/discovery/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,32 @@ def test_returns_second_python_specified_when_more_than_one_is_specified_and_env
assert result == mocker.sentinel.python_from_cli


def test_discovery_absolute_path_with_try_first(tmp_path, session_app_data):
good_env = tmp_path / "good"
bad_env = tmp_path / "bad"

# Create two real virtual environments
subprocess.check_call([sys.executable, "-m", "virtualenv", str(good_env)])
subprocess.check_call([sys.executable, "-m", "virtualenv", str(bad_env)])

# On Windows, the executable is in Scripts/python.exe
scripts_dir = "Scripts" if IS_WIN else "bin"
exe_name = "python.exe" if IS_WIN else "python"
good_exe = good_env / scripts_dir / exe_name
bad_exe = bad_env / scripts_dir / exe_name

# The spec is an absolute path, this should be a hard requirement.
# The --try-first-with option should be rejected as it does not match the spec.
interpreter = get_interpreter(
str(good_exe),
try_first_with=[str(bad_exe)],
app_data=session_app_data,
)

assert interpreter is not None
assert Path(interpreter.executable) == good_exe


def test_discovery_via_path_with_file(tmp_path, monkeypatch):
a_file = tmp_path / "a_file"
a_file.touch()
Expand Down