diff --git a/src/ansible_dev_environment/arg_parser.py b/src/ansible_dev_environment/arg_parser.py index de469dd..2060ce7 100644 --- a/src/ansible_dev_environment/arg_parser.py +++ b/src/ansible_dev_environment/arg_parser.py @@ -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", @@ -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, diff --git a/src/ansible_dev_environment/config.py b/src/ansible_dev_environment/config.py index c3b6190..3b1df7f 100644 --- a/src/ansible_dev_environment/config.py +++ b/src/ansible_dev_environment/config.py @@ -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 @@ -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" @@ -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 diff --git a/tests/integration/test_user_python.py b/tests/integration/test_user_python.py new file mode 100644 index 0000000..7975da2 --- /dev/null +++ b/tests/integration/test_user_python.py @@ -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) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 054a6fc..e8d4118 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -7,6 +7,7 @@ import json import shutil import subprocess +import sys from pathlib import Path from typing import TYPE_CHECKING, Any @@ -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