Skip to content

Allow user specified python interpreter #320

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 16, 2025
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
8 changes: 8 additions & 0 deletions src/ansible_dev_environment/arg_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"ansi": "NO_COLOR",
"ansible_core_version": "ADE_ANSIBLE_CORE_VERSION",
"isolation_mode": "ADE_ISOLATION_MODE",
"python": "ADE_PYTHON",
"seed": "ADE_SEED",
"uv": "ADE_UV",
"venv": "VIRTUAL_ENV",
Expand Down Expand Up @@ -227,6 +228,13 @@ def parse() -> argparse.Namespace:
help="Install editable.",
)

install.add_argument(
"-p",
"--python",
dest="python",
help="Python interpreter to use for the virtual environment. A version. name or path can be provided. If not provided, the python interpreter for the current process will be used.",
)

install.add_argument(
"--seed",
action=argparse.BooleanOptionalAction,
Expand Down
109 changes: 85 additions & 24 deletions src/ansible_dev_environment/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def __init__(
self._output: Output = output
self.python_path: Path
self.site_pkg_path: Path
self.specified_python: str | Path | None = None
self.venv_interpreter: Path
self.term_features: TermFeatures = term_features

Expand Down Expand Up @@ -164,34 +165,53 @@ def _set_interpreter(
self,
) -> None:
"""Set the interpreter."""
if self.uv_available:
venv_cmd = "uv venv --seed --python-preference=system"
self._locate_python()
if self.specified_python is None:
venv_cmd = (
"uv venv --seed --python-preference=system"
if self.uv_available
else f"{sys.executable} -m venv"
)
else:
venv_cmd = f"{sys.executable} -m venv"
venv_cmd = (
f"uv venv --seed --python-preference=system --python {self.specified_python}"
if self.uv_available
else f"{self.specified_python} -m venv"
)

if self.venv.exists() and self.specified_python:
err = "User specified python cannot be used with an existing virtual environment."
self._output.critical(err)
return # pragma: no cover # critical exits

if not self.venv.exists() and not self._create_venv:
err = f"Cannot find virtual environment: {self.venv}."
self._output.critical(err)
return # pragma: no cover # critical exits

if not self.venv.exists():
if self._create_venv:
msg = f"Creating virtual environment: {self.venv}"
command = f"{venv_cmd} {self.venv}"
if self.args.system_site_packages:
command = f"{command} --system-site-packages"
msg += " with system site packages"
self._output.debug(msg)
try:
subprocess_run(
command=command,
verbose=self.args.verbose,
msg=msg,
output=self._output,
)
msg = f"Created virtual environment: {self.venv}"
self._output.info(msg)
except subprocess.CalledProcessError as exc:
err = f"Failed to create virtual environment: {exc}"
self._output.critical(err)
else:
err = f"Cannot find virtual environment: {self.venv}."
msg = f"Creating virtual environment: {self.venv}"
command = f"{venv_cmd} {self.venv}"
if self.args.system_site_packages:
command = f"{command} --system-site-packages"
msg += " with system site packages"
self._output.debug(msg)
try:
subprocess_run(
command=command,
verbose=self.args.verbose,
msg=msg,
output=self._output,
)
msg = f"Created virtual environment: {self.venv}"
if self.specified_python:
msg += f" using {self.specified_python}"
self._output.note(msg)
except subprocess.CalledProcessError as exc:
err = f"Failed to create virtual environment: {exc.stdout} {exc.stderr}"
self._output.critical(err)
return # pragma: no cover # critical exits

msg = f"Virtual environment: {self.venv}"
self._output.debug(msg)
venv_interpreter = self.venv / "bin" / "python"
Expand Down Expand Up @@ -239,3 +259,44 @@ def _set_site_pkg_path(self) -> None:
self.site_pkg_path = Path(purelib)
msg = f"Found site packages path: {self.site_pkg_path}"
self._output.debug(msg)

def _locate_python(self) -> None:
"""Locate the python interpreter.

1) If not user provided default to system
2) If it is a path and exists, use that
3) If it starts with python and uv, use that
4) If it starts with python and pip, use that
5) If it is a version and uv, use that
6) If it is a version and pip, and found, use that

"""
python_arg = getattr(self.args, "python", None)
if not python_arg:
return

if Path(python_arg).exists():
self.specified_python = Path(python_arg).expanduser().resolve()
elif python_arg.lower().startswith("python"):
if self.uv_available:
self.specified_python = python_arg
elif path := shutil.which(python_arg):
self.specified_python = path
else:
msg = f"Cannot find specified python interpreter. ({python_arg})"
self._output.critical(msg)
return # pragma: no cover # critical exits
else:
possible = f"python{python_arg}"
if self.uv_available:
self.specified_python = possible
elif path := shutil.which(possible):
self.specified_python = path
else:
msg = f"Cannot find specified python interpreter. ({possible})"
self._output.critical(msg)
return # pragma: no cover # critical exits
self._output.debug(
f"Using specified python interpreter: {self.specified_python}",
)
return
111 changes: 111 additions & 0 deletions tests/integration/test_user_python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Test multiple variants of python version."""

from __future__ import annotations

import sys

from pathlib import Path

import pytest

from ansible_dev_environment.cli import main


def generate_pythons_uv() -> list[str]:
"""Generate a list of python versions.

Returns:
List of python versions
"""
pythons = ["python3"]
version = sys.version.split(" ", maxsplit=1)[0]
pythons.append(version)
pythons.append(f"python{version}")
major_minor = version.rsplit(".", 1)[0]
pythons.append(major_minor)
major, minor = major_minor.split(".")
one_less = f"{major}.{int(minor) - 1}"
pythons.append(one_less)
sys_path = str(Path("/usr/bin/python3").resolve())
pythons.append(sys_path)
return pythons


@pytest.mark.parametrize("python", generate_pythons_uv())
def test_specified_python_version_uv(
python: str,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Build the venv with a user specified python version.

Args:
python: Python version
capsys: Capture stdout and stderr
tmp_path: Temporary directory
monkeypatch: Pytest monkeypatch
"""
venv_path = tmp_path / ".venv"
monkeypatch.setattr(
"sys.argv",
[
"ade",
"install",
f"--venv={venv_path}",
f"--python={python}",
],
)
with pytest.raises(SystemExit):
main()

captured = capsys.readouterr()
venv_line = [
line for line in captured.out.splitlines() if "Created virtual environment:" in line
]
assert venv_line[0].endswith(python)


def generate_pythons_pip() -> list[str]:
"""Generate a list of python versions.

Returns:
List of python versions
"""
pythons = ["python3"]
version = sys.version.split(" ", maxsplit=1)[0]
major_minor = version.rsplit(".", 1)[0]
pythons.append(major_minor)
sys_path = str(Path("/usr/bin/python3").resolve())
pythons.append(sys_path)
return pythons


@pytest.mark.parametrize("python", generate_pythons_pip())
def test_specified_python_version_pip(
python: str,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Build the venv with a user specified python version.

Args:
python: Python version
capsys: Capture stdout and stderr
tmp_path: Temporary directory
monkeypatch: Pytest monkeypatch
"""
venv_path = tmp_path / ".venv"
monkeypatch.setattr(
"sys.argv",
["ade", "install", f"--venv={venv_path}", f"--python={python}", "--no-uv"],
)
with pytest.raises(SystemExit):
main()

captured = capsys.readouterr()
venv_line = [
line for line in captured.out.splitlines() if "Created virtual environment:" in line
]
assert venv_line[0].endswith(python)
73 changes: 73 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import shutil
import subprocess
import sys

from pathlib import Path
from typing import TYPE_CHECKING, Any
Expand Down Expand Up @@ -669,3 +670,75 @@ def test_uv_disabled(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixt
output = capsys.readouterr()
assert "uv is disabled" in output.out
assert cli.config.venv_pip_install_cmd.endswith("/bin/python -m pip install")


def test_venv_exist_python_specified(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
"""Test that if the venv exists and python is specified, exit.

Args:
monkeypatch: Pytest fixture.
capsys: Pytest fixture for capturing output.
tmp_path: Pytest fixture for temporary directory.
"""
version = sys.version.split(" ", maxsplit=1)[0]
venv_path = tmp_path / ".venv"
monkeypatch.setattr(
"sys.argv",
["ansible-dev-environment", "install", "--python", version, "--venv", str(venv_path)],
)

venv_path.mkdir(parents=True, exist_ok=True)

cli = Cli()
cli.parse_args()
cli.init_output()
cli.config = Config(
args=cli.args,
output=cli.output,
term_features=cli.term_features,
)
with pytest.raises(SystemExit) as exc:
cli.config.init()
assert exc.value.code == 1

output = capsys.readouterr()
assert "cannot be used" in output.err


@pytest.mark.parametrize("python", ("python1000", "1000", "not_python"))
def test_pip_missing_python(
python: str,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Test if uv is disabled and python cannot be found, exit.

Args:
python: Python version
monkeypatch: Pytest fixture.
capsys: Pytest fixture for capturing output.
"""
monkeypatch.setattr(
"sys.argv",
["ansible-dev-environment", "install", "--python", python, "--no-uv"],
)

cli = Cli()
cli.parse_args()
cli.init_output()
cli.config = Config(
args=cli.args,
output=cli.output,
term_features=cli.term_features,
)
with pytest.raises(SystemExit) as exc:
cli.config.init()
assert exc.value.code == 1

output = capsys.readouterr()
assert "Cannot find specified python interpreter." in output.err
assert python in output.err