diff --git a/lint b/lint index f8ab2b6..cf21d5a 100755 --- a/lint +++ b/lint @@ -4,9 +4,9 @@ set -e cd $( dirname ${BASH_SOURCE[0]}) . ./activate.sh echo Running ruff src -ruff --fix src +ruff check --fix src echo Running ruff tests -ruff --fix tests +ruff check --fix tests echo Running black src tests black src tests echo Running isort src tests diff --git a/pyproject.toml b/pyproject.toml index 6fe1d9b..bbb37bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "wheel", "filelock", "semver", - "setuptools" + "setuptools", ] # Change this with the version number bump. version = "1.3.1" diff --git a/src/isolated_environment/__init__.py b/src/isolated_environment/__init__.py index 0e18775..6632cf5 100644 --- a/src/isolated_environment/__init__.py +++ b/src/isolated_environment/__init__.py @@ -10,14 +10,18 @@ # the python interpreter. On linux it will drop you into the # python interpreter and you will not be able to exit. def isolated_environment( - env_path: Union[Path, str], requirements: list[str] | None = None + env_path: Union[Path, str], + requirements: list[str] | None = None, + full_isolation: bool = False, ) -> dict[str, Any]: """Creates an isolated environment.""" if isinstance(env_path, str): env_path = Path(env_path) # type: ignore requirements = requirements or [] reqs = Requirements(requirements) - iso_env = IsolatedEnvironment(env_path, reqs) + iso_env = IsolatedEnvironment( + env_path=env_path, requirements=reqs, full_isolation=full_isolation + ) env = iso_env.environment() return env @@ -26,6 +30,7 @@ def isolated_environment_run( env_path: Union[Path, str], requirements: list[str] | None, cmd_list: list[str], + full_isolation: bool = False, **kwargs: Any, ) -> CompletedProcess: """ @@ -40,7 +45,9 @@ def isolated_environment_run( env_path = Path(env_path) # type: ignore requirements = requirements or [] reqs = Requirements(requirements) - iso_env = IsolatedEnvironment(env_path, reqs) + iso_env = IsolatedEnvironment( + env_path=env_path, requirements=reqs, full_isolation=full_isolation + ) iso_env.ensure_installed(reqs) cp = iso_env.run(cmd_list, **kwargs) return cp diff --git a/src/isolated_environment/api.py b/src/isolated_environment/api.py index 8b88a52..054e71a 100644 --- a/src/isolated_environment/api.py +++ b/src/isolated_environment/api.py @@ -14,6 +14,7 @@ import warnings from contextlib import contextmanager from pathlib import Path +from shutil import which from typing import Any, Iterator from filelock import FileLock @@ -43,9 +44,34 @@ def _create_virtual_env(env_path: Path) -> Path: return env_path -def _get_activated_environment(env_path: Path) -> dict[str, str]: +def has_python_or_pip(path: str) -> bool: + """Returns True if python or pip is in the path.""" + python = which("python", path=path) or which("python3", path=path) + pip = which("pip", path=path) or which("pip3", path=path) + return (python is not None) or (pip is not None) + + +def _remove_python_paths_from_env(env: dict[str, str]) -> dict[str, str]: + """Removes PYTHONPATH from the environment.""" + out_env = env.copy() + if "PYTHONPATH" in out_env: + del out_env["PYTHONPATH"] + path_list = out_env["PATH"].split(os.pathsep) + + exported_path_list: list[str] = [] + for p in path_list: + if not has_python_or_pip(p): + exported_path_list.append(p) + exported_path_list = [os.path.basename(sys.executable)] + exported_path_list + out_env["PATH"] = os.pathsep.join(exported_path_list) + return out_env + + +def _get_activated_environment(env_path: Path, full_isolation: bool) -> dict[str, str]: """Gets the activate environment for the environment.""" out_env = os.environ.copy() + if full_isolation: + out_env = _remove_python_paths_from_env(out_env) if sys.platform == "win32": out_env["PATH"] = str(env_path / "Scripts") + ";" + out_env["PATH"] else: @@ -60,11 +86,11 @@ def _get_activated_environment(env_path: Path) -> dict[str, str]: def _pip_install( - env_path: Path, package: str, build_options: str | None = None + env_path: Path, package: str, build_options: str | None, full_isolation: bool ) -> None: """Installs a package in the virtual environment.""" # Activate the environment and install packages - env = _get_activated_environment(env_path) + env = _get_activated_environment(env_path, full_isolation) cmd_list = ["pip", "install", package] if build_options: cmd_list.extend(build_options.split(" ")) @@ -85,15 +111,18 @@ class IsolatedEnvironment: """An isolated environment.""" def __init__( - self, env_path: Path, requirements: Requirements | None = None + self, + env_path: Path, + requirements: Requirements | None = None, + full_isolation: bool = False, # For absolute isolation, set to False ) -> None: self.env_path = env_path + self.full_isolation = full_isolation self.env_path.mkdir(parents=True, exist_ok=True) # file_lock is side-by-side with the environment. self.file_lock = FileLock(str(env_path) + ".lock") self.packages_json = env_path / "packages.json" - if requirements is not None: - self.ensure_installed(requirements) + self.ensure_installed(requirements or Requirements([])) def install_environment(self) -> None: """Installs the environment.""" @@ -141,7 +170,10 @@ def lock(self) -> Iterator[None]: self.file_lock.release() def pip_install( - self, package: str | list[str], build_options: str | None = None + self, + package: str | list[str], + build_options: str | None, + full_isolation: bool, ) -> None: """Installs a package in the virtual environment.""" assert ( @@ -150,10 +182,10 @@ def pip_install( reqs = self._read_reqs() if isinstance(package, list): for p in package: - _pip_install(self.env_path, p, build_options) + _pip_install(self.env_path, p, build_options, full_isolation) reqs.add(package) elif isinstance(package, str): - _pip_install(self.env_path, package, build_options) + _pip_install(self.env_path, package, build_options, full_isolation) reqs.add(package) else: raise TypeError(f"Unknown type for package: {type(package)}") @@ -161,7 +193,7 @@ def pip_install( def environment(self) -> dict[str, str]: """Gets the activated environment, which should be applied to subprocess environments.""" - return _get_activated_environment(self.env_path) + return _get_activated_environment(self.env_path, self.full_isolation) def run(self, cmd_list: list[str], **kwargs) -> subprocess.CompletedProcess: """Runs a command in the environment.""" @@ -186,6 +218,14 @@ def run(self, cmd_list: list[str], **kwargs) -> subprocess.CompletedProcess: text = kwargs.get("text", universal_newlines) if "text" in kwargs: del kwargs["text"] + scripts = "Scripts" if sys.platform == "win32" else "bin" + python_name = "python.exe" if sys.platform == "win32" else "python" + if cmd_list and ( + cmd_list[0] == "python" + or cmd_list[0] == "python.exe" + or cmd_list[0] == "python3" + ): + cmd_list[0] = str(self.env_path / scripts / python_name) cp = subprocess.run( cmd_list, env=env, @@ -248,7 +288,11 @@ def ensure_installed(self, reqs: Requirements) -> dict[str, Any]: if req not in prev_reqs: package_str = req.get_package_str() build_options = req.build_options - self.pip_install(package=package_str, build_options=build_options) + self.pip_install( + package=package_str, + build_options=build_options, + full_isolation=self.full_isolation, + ) self._write_reqs(reqs) return self.environment() diff --git a/test b/test index 66bf098..24de54b 100755 --- a/test +++ b/test @@ -3,4 +3,4 @@ set -e # cd to self bash script directory cd $( dirname ${BASH_SOURCE[0]}) . ./activate.sh -pytest \ No newline at end of file +pytest -n auto -v tests \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 70ab63a..2cf80a9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ """ Unit test file. """ + import os import unittest diff --git a/tests/test_data/inner.py b/tests/test_data/inner.py new file mode 100644 index 0000000..d76fb3f --- /dev/null +++ b/tests/test_data/inner.py @@ -0,0 +1,18 @@ +import sys + +try: + from isolated_environment import api + + print(api) + print("Successfully imported IsolatedEnvironment") + # print the path to find the isolated_environment module + # print(api.__file__) + print(f"IsolatedEnvironment path: {api.__file__}") + # print out the python path + # print(sys.path) + # print out the python executable + print(f"Python executable: {sys.executable}") + sys.exit(0) +except ImportError: + print("Failed to import IsolatedEnvironment") + sys.exit(1) diff --git a/tests/test_full_isolation.py b/tests/test_full_isolation.py new file mode 100644 index 0000000..8e82153 --- /dev/null +++ b/tests/test_full_isolation.py @@ -0,0 +1,49 @@ +""" +Unit test file. +""" + +import json +import os +import shutil +import subprocess +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any + +from isolated_environment.api import IsolatedEnvironment + +HERE = Path(__file__).parent.absolute() +TEST_DATA = HERE / "test_data" +INNER_PY = TEST_DATA / "inner.py" + +assert INNER_PY.exists(), f"Missing: {INNER_PY}" + + +def pretty(data: Any) -> str: + """Make JSON beautiful.""" + return json.dumps(data, indent=4, sort_keys=True) + + +class FullIoslationTester(unittest.TestCase): + """Main tester class.""" + + def test_ensure_installed(self) -> None: + """Tests that ensure_installed works.""" + with TemporaryDirectory() as tmp_dir: + prev_dir = os.getcwd() + os.chdir(tmp_dir) + shutil.copy(INNER_PY, tmp_dir) + try: + iso_env = IsolatedEnvironment( + Path(tmp_dir) / "venv", requirements=None, full_isolation=True + ) + # now create an inner environment without the static-ffmpeg + cp: subprocess.CompletedProcess = iso_env.run(["python", "inner.py"]) + self.assertEqual(1, cp.returncode) + finally: + os.chdir(prev_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_iso_environment.py b/tests/test_iso_environment.py index ca795b6..19dc90f 100644 --- a/tests/test_iso_environment.py +++ b/tests/test_iso_environment.py @@ -46,8 +46,9 @@ def test_ensure_installed(self) -> None: self.assertEqual(installed_reqs, reqs) try: subprocess.check_output( - ["static_ffmpeg", "--help"], env=env, - shell=True # shell=True is allowed only when NOT running python. + ["static_ffmpeg", "--help"], + env=env, + shell=True, # shell=True is allowed only when NOT running python. ) except subprocess.CalledProcessError as exc: # doesn't fail on Windows, but it does on other platforms diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 6582fd3..5a28d46 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -1,6 +1,7 @@ """ Unit test file. """ + import unittest from isolated_environment.requirements import Requirements diff --git a/tests/test_run_py_file.py b/tests/test_run_py_file.py index 538f341..c1c6496 100644 --- a/tests/test_run_py_file.py +++ b/tests/test_run_py_file.py @@ -51,8 +51,10 @@ def test_isolated_environment_run(self) -> None: with TemporaryDirectory() as tmp_dir: venv_path = Path(tmp_dir) / "venv" cp = isolated_environment_run( - env_path=venv_path, requirements=[], cmd_list=["python", str(RUN_PY)], - capture_output=True + env_path=venv_path, + requirements=[], + cmd_list=["python", str(RUN_PY)], + capture_output=True, ) self.assertEqual(0, cp.returncode) self.assertEqual("Hello World!\n", cp.stdout)