Skip to content

Commit c272cd2

Browse files
authored
Allow user specified python interpreter (#320)
* Allow user specified python interpreter * Add tests * Additional test * Additional tests * Remove unreachable code * Add environment variable
1 parent 10454d1 commit c272cd2

File tree

4 files changed

+277
-24
lines changed

4 files changed

+277
-24
lines changed

src/ansible_dev_environment/arg_parser.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"ansi": "NO_COLOR",
3535
"ansible_core_version": "ADE_ANSIBLE_CORE_VERSION",
3636
"isolation_mode": "ADE_ISOLATION_MODE",
37+
"python": "ADE_PYTHON",
3738
"seed": "ADE_SEED",
3839
"uv": "ADE_UV",
3940
"venv": "VIRTUAL_ENV",
@@ -227,6 +228,13 @@ def parse() -> argparse.Namespace:
227228
help="Install editable.",
228229
)
229230

231+
install.add_argument(
232+
"-p",
233+
"--python",
234+
dest="python",
235+
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.",
236+
)
237+
230238
install.add_argument(
231239
"--seed",
232240
action=argparse.BooleanOptionalAction,

src/ansible_dev_environment/config.py

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def __init__(
4343
self._output: Output = output
4444
self.python_path: Path
4545
self.site_pkg_path: Path
46+
self.specified_python: str | Path | None = None
4647
self.venv_interpreter: Path
4748
self.term_features: TermFeatures = term_features
4849

@@ -164,34 +165,53 @@ def _set_interpreter(
164165
self,
165166
) -> None:
166167
"""Set the interpreter."""
167-
if self.uv_available:
168-
venv_cmd = "uv venv --seed --python-preference=system"
168+
self._locate_python()
169+
if self.specified_python is None:
170+
venv_cmd = (
171+
"uv venv --seed --python-preference=system"
172+
if self.uv_available
173+
else f"{sys.executable} -m venv"
174+
)
169175
else:
170-
venv_cmd = f"{sys.executable} -m venv"
176+
venv_cmd = (
177+
f"uv venv --seed --python-preference=system --python {self.specified_python}"
178+
if self.uv_available
179+
else f"{self.specified_python} -m venv"
180+
)
181+
182+
if self.venv.exists() and self.specified_python:
183+
err = "User specified python cannot be used with an existing virtual environment."
184+
self._output.critical(err)
185+
return # pragma: no cover # critical exits
186+
187+
if not self.venv.exists() and not self._create_venv:
188+
err = f"Cannot find virtual environment: {self.venv}."
189+
self._output.critical(err)
190+
return # pragma: no cover # critical exits
171191

172192
if not self.venv.exists():
173-
if self._create_venv:
174-
msg = f"Creating virtual environment: {self.venv}"
175-
command = f"{venv_cmd} {self.venv}"
176-
if self.args.system_site_packages:
177-
command = f"{command} --system-site-packages"
178-
msg += " with system site packages"
179-
self._output.debug(msg)
180-
try:
181-
subprocess_run(
182-
command=command,
183-
verbose=self.args.verbose,
184-
msg=msg,
185-
output=self._output,
186-
)
187-
msg = f"Created virtual environment: {self.venv}"
188-
self._output.info(msg)
189-
except subprocess.CalledProcessError as exc:
190-
err = f"Failed to create virtual environment: {exc}"
191-
self._output.critical(err)
192-
else:
193-
err = f"Cannot find virtual environment: {self.venv}."
193+
msg = f"Creating virtual environment: {self.venv}"
194+
command = f"{venv_cmd} {self.venv}"
195+
if self.args.system_site_packages:
196+
command = f"{command} --system-site-packages"
197+
msg += " with system site packages"
198+
self._output.debug(msg)
199+
try:
200+
subprocess_run(
201+
command=command,
202+
verbose=self.args.verbose,
203+
msg=msg,
204+
output=self._output,
205+
)
206+
msg = f"Created virtual environment: {self.venv}"
207+
if self.specified_python:
208+
msg += f" using {self.specified_python}"
209+
self._output.note(msg)
210+
except subprocess.CalledProcessError as exc:
211+
err = f"Failed to create virtual environment: {exc.stdout} {exc.stderr}"
194212
self._output.critical(err)
213+
return # pragma: no cover # critical exits
214+
195215
msg = f"Virtual environment: {self.venv}"
196216
self._output.debug(msg)
197217
venv_interpreter = self.venv / "bin" / "python"
@@ -239,3 +259,44 @@ def _set_site_pkg_path(self) -> None:
239259
self.site_pkg_path = Path(purelib)
240260
msg = f"Found site packages path: {self.site_pkg_path}"
241261
self._output.debug(msg)
262+
263+
def _locate_python(self) -> None:
264+
"""Locate the python interpreter.
265+
266+
1) If not user provided default to system
267+
2) If it is a path and exists, use that
268+
3) If it starts with python and uv, use that
269+
4) If it starts with python and pip, use that
270+
5) If it is a version and uv, use that
271+
6) If it is a version and pip, and found, use that
272+
273+
"""
274+
python_arg = getattr(self.args, "python", None)
275+
if not python_arg:
276+
return
277+
278+
if Path(python_arg).exists():
279+
self.specified_python = Path(python_arg).expanduser().resolve()
280+
elif python_arg.lower().startswith("python"):
281+
if self.uv_available:
282+
self.specified_python = python_arg
283+
elif path := shutil.which(python_arg):
284+
self.specified_python = path
285+
else:
286+
msg = f"Cannot find specified python interpreter. ({python_arg})"
287+
self._output.critical(msg)
288+
return # pragma: no cover # critical exits
289+
else:
290+
possible = f"python{python_arg}"
291+
if self.uv_available:
292+
self.specified_python = possible
293+
elif path := shutil.which(possible):
294+
self.specified_python = path
295+
else:
296+
msg = f"Cannot find specified python interpreter. ({possible})"
297+
self._output.critical(msg)
298+
return # pragma: no cover # critical exits
299+
self._output.debug(
300+
f"Using specified python interpreter: {self.specified_python}",
301+
)
302+
return

tests/integration/test_user_python.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Test multiple variants of python version."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from ansible_dev_environment.cli import main
12+
13+
14+
def generate_pythons_uv() -> list[str]:
15+
"""Generate a list of python versions.
16+
17+
Returns:
18+
List of python versions
19+
"""
20+
pythons = ["python3"]
21+
version = sys.version.split(" ", maxsplit=1)[0]
22+
pythons.append(version)
23+
pythons.append(f"python{version}")
24+
major_minor = version.rsplit(".", 1)[0]
25+
pythons.append(major_minor)
26+
major, minor = major_minor.split(".")
27+
one_less = f"{major}.{int(minor) - 1}"
28+
pythons.append(one_less)
29+
sys_path = str(Path("/usr/bin/python3").resolve())
30+
pythons.append(sys_path)
31+
return pythons
32+
33+
34+
@pytest.mark.parametrize("python", generate_pythons_uv())
35+
def test_specified_python_version_uv(
36+
python: str,
37+
capsys: pytest.CaptureFixture[str],
38+
tmp_path: Path,
39+
monkeypatch: pytest.MonkeyPatch,
40+
) -> None:
41+
"""Build the venv with a user specified python version.
42+
43+
Args:
44+
python: Python version
45+
capsys: Capture stdout and stderr
46+
tmp_path: Temporary directory
47+
monkeypatch: Pytest monkeypatch
48+
"""
49+
venv_path = tmp_path / ".venv"
50+
monkeypatch.setattr(
51+
"sys.argv",
52+
[
53+
"ade",
54+
"install",
55+
f"--venv={venv_path}",
56+
f"--python={python}",
57+
],
58+
)
59+
with pytest.raises(SystemExit):
60+
main()
61+
62+
captured = capsys.readouterr()
63+
venv_line = [
64+
line for line in captured.out.splitlines() if "Created virtual environment:" in line
65+
]
66+
assert venv_line[0].endswith(python)
67+
68+
69+
def generate_pythons_pip() -> list[str]:
70+
"""Generate a list of python versions.
71+
72+
Returns:
73+
List of python versions
74+
"""
75+
pythons = ["python3"]
76+
version = sys.version.split(" ", maxsplit=1)[0]
77+
major_minor = version.rsplit(".", 1)[0]
78+
pythons.append(major_minor)
79+
sys_path = str(Path("/usr/bin/python3").resolve())
80+
pythons.append(sys_path)
81+
return pythons
82+
83+
84+
@pytest.mark.parametrize("python", generate_pythons_pip())
85+
def test_specified_python_version_pip(
86+
python: str,
87+
capsys: pytest.CaptureFixture[str],
88+
tmp_path: Path,
89+
monkeypatch: pytest.MonkeyPatch,
90+
) -> None:
91+
"""Build the venv with a user specified python version.
92+
93+
Args:
94+
python: Python version
95+
capsys: Capture stdout and stderr
96+
tmp_path: Temporary directory
97+
monkeypatch: Pytest monkeypatch
98+
"""
99+
venv_path = tmp_path / ".venv"
100+
monkeypatch.setattr(
101+
"sys.argv",
102+
["ade", "install", f"--venv={venv_path}", f"--python={python}", "--no-uv"],
103+
)
104+
with pytest.raises(SystemExit):
105+
main()
106+
107+
captured = capsys.readouterr()
108+
venv_line = [
109+
line for line in captured.out.splitlines() if "Created virtual environment:" in line
110+
]
111+
assert venv_line[0].endswith(python)

tests/unit/test_config.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import shutil
99
import subprocess
10+
import sys
1011

1112
from pathlib import Path
1213
from typing import TYPE_CHECKING, Any
@@ -669,3 +670,75 @@ def test_uv_disabled(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixt
669670
output = capsys.readouterr()
670671
assert "uv is disabled" in output.out
671672
assert cli.config.venv_pip_install_cmd.endswith("/bin/python -m pip install")
673+
674+
675+
def test_venv_exist_python_specified(
676+
monkeypatch: pytest.MonkeyPatch,
677+
capsys: pytest.CaptureFixture[str],
678+
tmp_path: Path,
679+
) -> None:
680+
"""Test that if the venv exists and python is specified, exit.
681+
682+
Args:
683+
monkeypatch: Pytest fixture.
684+
capsys: Pytest fixture for capturing output.
685+
tmp_path: Pytest fixture for temporary directory.
686+
"""
687+
version = sys.version.split(" ", maxsplit=1)[0]
688+
venv_path = tmp_path / ".venv"
689+
monkeypatch.setattr(
690+
"sys.argv",
691+
["ansible-dev-environment", "install", "--python", version, "--venv", str(venv_path)],
692+
)
693+
694+
venv_path.mkdir(parents=True, exist_ok=True)
695+
696+
cli = Cli()
697+
cli.parse_args()
698+
cli.init_output()
699+
cli.config = Config(
700+
args=cli.args,
701+
output=cli.output,
702+
term_features=cli.term_features,
703+
)
704+
with pytest.raises(SystemExit) as exc:
705+
cli.config.init()
706+
assert exc.value.code == 1
707+
708+
output = capsys.readouterr()
709+
assert "cannot be used" in output.err
710+
711+
712+
@pytest.mark.parametrize("python", ("python1000", "1000", "not_python"))
713+
def test_pip_missing_python(
714+
python: str,
715+
monkeypatch: pytest.MonkeyPatch,
716+
capsys: pytest.CaptureFixture[str],
717+
) -> None:
718+
"""Test if uv is disabled and python cannot be found, exit.
719+
720+
Args:
721+
python: Python version
722+
monkeypatch: Pytest fixture.
723+
capsys: Pytest fixture for capturing output.
724+
"""
725+
monkeypatch.setattr(
726+
"sys.argv",
727+
["ansible-dev-environment", "install", "--python", python, "--no-uv"],
728+
)
729+
730+
cli = Cli()
731+
cli.parse_args()
732+
cli.init_output()
733+
cli.config = Config(
734+
args=cli.args,
735+
output=cli.output,
736+
term_features=cli.term_features,
737+
)
738+
with pytest.raises(SystemExit) as exc:
739+
cli.config.init()
740+
assert exc.value.code == 1
741+
742+
output = capsys.readouterr()
743+
assert "Cannot find specified python interpreter." in output.err
744+
assert python in output.err

0 commit comments

Comments
 (0)