# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. import ast import functools import os import subprocess import sys import tempfile from pathlib import Path from subprocess import CompletedProcess from typing import List import buildconfig import mozunit import pkg_resources import pytest from mach.site import MozSiteMetadata, PythonVirtualenv, activate_virtualenv class ActivationContext: def __init__( self, topsrcdir: Path, work_dir: Path, original_python_path: str, stdlib_paths: List[Path], system_paths: List[Path], required_mach_sys_paths: List[Path], mach_requirement_paths: List[Path], command_requirement_path: Path, ): self.topsrcdir = topsrcdir self.work_dir = work_dir self.original_python_path = original_python_path self.stdlib_paths = stdlib_paths self.system_paths = system_paths self.required_moz_init_sys_paths = required_mach_sys_paths self.mach_requirement_paths = mach_requirement_paths self.command_requirement_path = command_requirement_path def virtualenv(self, name: str) -> PythonVirtualenv: base_path = self.work_dir if name == "mach": base_path = base_path / "_virtualenvs" return PythonVirtualenv(str(base_path / name)) def test_new_package_appears_in_pkg_resources(): try: # "carrot" was chosen as the package to use because: # * It has to be a package that doesn't exist in-scope at the start (so, # all vendored modules included in the test virtualenv aren't usage). # * It must be on our internal PyPI mirror. # Of the options, "carrot" is a small install that fits these requirements. pkg_resources.get_distribution("carrot") assert False, "Expected to not find 'carrot' as the initial state of the test" except pkg_resources.DistributionNotFound: pass with tempfile.TemporaryDirectory() as venv_dir: subprocess.check_call( [ sys.executable, "-m", "venv", venv_dir, ] ) venv = PythonVirtualenv(venv_dir) venv.pip_install(["carrot==0.10.7"]) initial_metadata = MozSiteMetadata.from_runtime() try: metadata = MozSiteMetadata(None, None, None, None, venv.prefix) with metadata.update_current_site(venv.python_path): activate_virtualenv(venv) assert pkg_resources.get_distribution("carrot").version == "0.10.7" finally: MozSiteMetadata.current = initial_metadata def test_sys_path_source_none_build(context): original, mach, command = _run_activation_script_for_paths(context, "none", "build") _assert_original_python_sys_path(context, original) assert not os.path.exists(context.virtualenv("mach").prefix) assert mach == [ *context.stdlib_paths, *context.mach_requirement_paths, ] expected_command_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, context.command_requirement_path, ] assert command == expected_command_paths command_venv = _sys_path_of_virtualenv(context.virtualenv("build")) assert command_venv == [Path(""), *expected_command_paths] def test_sys_path_source_none_other(context): original, mach, command = _run_activation_script_for_paths(context, "none", "other") _assert_original_python_sys_path(context, original) assert not os.path.exists(context.virtualenv("mach").prefix) assert mach == [ *context.stdlib_paths, *context.mach_requirement_paths, ] command_virtualenv = PythonVirtualenv(str(context.work_dir / "other")) expected_command_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, context.command_requirement_path, *(Path(p) for p in command_virtualenv.site_packages_dirs()), ] assert command == expected_command_paths command_venv = _sys_path_of_virtualenv(context.virtualenv("other")) assert command_venv == [ Path(""), *expected_command_paths, ] def test_sys_path_source_venv_build(context): original, mach, command = _run_activation_script_for_paths(context, "pip", "build") _assert_original_python_sys_path(context, original) mach_virtualenv = context.virtualenv("mach") expected_mach_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, *(Path(p) for p in mach_virtualenv.site_packages_dirs()), ] assert mach == expected_mach_paths command_virtualenv = context.virtualenv("build") expected_command_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, *(Path(p) for p in mach_virtualenv.site_packages_dirs()), context.command_requirement_path, *(Path(p) for p in command_virtualenv.site_packages_dirs()), ] assert command == expected_command_paths mach_venv = _sys_path_of_virtualenv(mach_virtualenv) assert mach_venv == [ Path(""), *expected_mach_paths, ] command_venv = _sys_path_of_virtualenv(command_virtualenv) assert command_venv == [ Path(""), *expected_command_paths, ] def test_sys_path_source_venv_other(context): original, mach, command = _run_activation_script_for_paths(context, "pip", "other") _assert_original_python_sys_path(context, original) mach_virtualenv = context.virtualenv("mach") expected_mach_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, *(Path(p) for p in mach_virtualenv.site_packages_dirs()), ] assert mach == expected_mach_paths command_virtualenv = context.virtualenv("other") expected_command_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, *(Path(p) for p in mach_virtualenv.site_packages_dirs()), context.command_requirement_path, *(Path(p) for p in command_virtualenv.site_packages_dirs()), ] assert command == expected_command_paths mach_venv = _sys_path_of_virtualenv(mach_virtualenv) assert mach_venv == [ Path(""), *expected_mach_paths, ] command_venv = _sys_path_of_virtualenv(command_virtualenv) assert command_venv == [ Path(""), *expected_command_paths, ] def test_sys_path_source_system_build(context): original, mach, command = _run_activation_script_for_paths( context, "system", "build" ) _assert_original_python_sys_path(context, original) assert not os.path.exists(context.virtualenv("mach").prefix) expected_mach_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, *context.system_paths, ] assert mach == expected_mach_paths command_virtualenv = context.virtualenv("build") expected_command_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, *context.system_paths, context.command_requirement_path, ] assert command == expected_command_paths command_venv = _sys_path_of_virtualenv(command_virtualenv) assert command_venv == [ Path(""), *expected_command_paths, ] def test_sys_path_source_system_other(context): result = _run_activation_script( context, "system", "other", context.original_python_path, stderr=subprocess.PIPE, ) assert result.returncode != 0 assert ( 'Cannot use MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE="system" for any sites ' "other than" in result.stderr ) def test_sys_path_source_venvsystem_build(context): venv_system_python = _create_venv_system_python( context.work_dir, context.original_python_path ) venv_system_site_packages_dirs = [ Path(p) for p in venv_system_python.site_packages_dirs() ] original, mach, command = _run_activation_script_for_paths( context, "system", "build", venv_system_python.python_path ) assert original == [ Path(__file__).parent, *context.required_moz_init_sys_paths, *context.stdlib_paths, *venv_system_site_packages_dirs, ] assert not os.path.exists(context.virtualenv("mach").prefix) expected_mach_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, *venv_system_site_packages_dirs, ] assert mach == expected_mach_paths command_virtualenv = context.virtualenv("build") expected_command_paths = [ *context.stdlib_paths, *context.mach_requirement_paths, *venv_system_site_packages_dirs, context.command_requirement_path, ] assert command == expected_command_paths command_venv = _sys_path_of_virtualenv(command_virtualenv) assert command_venv == [ Path(""), *expected_command_paths, ] def test_sys_path_source_venvsystem_other(context): venv_system_python = _create_venv_system_python( context.work_dir, context.original_python_path ) result = _run_activation_script( context, "system", "other", venv_system_python.python_path, stderr=subprocess.PIPE, ) assert result.returncode != 0 assert ( 'Cannot use MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE="system" for any sites ' "other than" in result.stderr ) @pytest.fixture(name="context") def _activation_context(): original_python_path, stdlib_paths, system_paths = _original_python() topsrcdir = Path(buildconfig.topsrcdir) required_mach_sys_paths = [ topsrcdir / "python" / "mach", topsrcdir / "third_party" / "python" / "packaging", topsrcdir / "third_party" / "python" / "pyparsing", topsrcdir / "third_party" / "python" / "pip", ] with tempfile.TemporaryDirectory() as work_dir: # Get "resolved" version of path to ease comparison against "site"-added sys.path # entries, as "site" calculates the realpath of provided locations. work_dir = Path(work_dir).resolve() mach_requirement_paths = [ *required_mach_sys_paths, work_dir / "mach_site_path", ] command_requirement_path = work_dir / "command_site_path" (work_dir / "mach_site_path").touch() command_requirement_path.touch() yield ActivationContext( topsrcdir, work_dir, original_python_path, stdlib_paths, system_paths, required_mach_sys_paths, mach_requirement_paths, command_requirement_path, ) @functools.lru_cache(maxsize=None) def _original_python(): current_site = MozSiteMetadata.from_runtime() stdlib_paths, system_paths = current_site.original_python.sys_path() stdlib_paths = [Path(path) for path in _filter_pydev_from_paths(stdlib_paths)] system_paths = [Path(path) for path in system_paths] return current_site.original_python.python_path, stdlib_paths, system_paths def _run_activation_script( context: ActivationContext, source: str, site_name: str, invoking_python: str, **kwargs ) -> CompletedProcess: return subprocess.run( [ invoking_python, str(Path(__file__).parent / "script_site_activation.py"), ], stdout=subprocess.PIPE, universal_newlines=True, env={ "TOPSRCDIR": str(context.topsrcdir), "COMMAND_SITE": site_name, "PYTHONPATH": os.pathsep.join( str(p) for p in context.required_moz_init_sys_paths ), "MACH_SITE_PTH_REQUIREMENTS": os.pathsep.join( str(p) for p in context.mach_requirement_paths ), "COMMAND_SITE_PTH_REQUIREMENTS": str(context.command_requirement_path), "MACH_BUILD_PYTHON_NATIVE_PACKAGE_SOURCE": source, "WORK_DIR": str(context.work_dir), # These two variables are needed on Windows so that Python initializes # properly and adds the "user site packages" to the sys.path like normal. "SYSTEMROOT": os.environ.get("SYSTEMROOT", ""), "APPDATA": os.environ.get("APPDATA", ""), }, **kwargs, ) def _run_activation_script_for_paths( context: ActivationContext, source: str, site_name: str, invoking_python: str = None ) -> List[List[Path]]: """Return the states of the sys.path when activating Mach-managed sites Three sys.path states are returned: * The initial sys.path, equivalent to "path_to_python -c "import sys; print(sys.path)" * The sys.path after activating the Mach site * The sys.path after activating the command site """ output = _run_activation_script( context, source, site_name, invoking_python or context.original_python_path, check=True, ).stdout # Filter to the last line, which will have our nested list that we want to # parse. This will avoid unrelated output, such as from virtualenv creation output = output.splitlines()[-1] return [ [Path(path) for path in _filter_pydev_from_paths(paths)] for paths in ast.literal_eval(output) ] def _assert_original_python_sys_path(context: ActivationContext, original: List[Path]): # Assert that initial sys.path (prior to any activations) matches expectations. assert original == [ Path(__file__).parent, *context.required_moz_init_sys_paths, *context.stdlib_paths, *context.system_paths, ] def _sys_path_of_virtualenv(virtualenv: PythonVirtualenv) -> List[Path]: output = subprocess.run( [virtualenv.python_path, "-c", "import sys; print(sys.path)"], stdout=subprocess.PIPE, universal_newlines=True, env={ # Needed for python to initialize properly "SYSTEMROOT": os.environ.get("SYSTEMROOT", ""), }, check=True, ).stdout return [Path(path) for path in _filter_pydev_from_paths(ast.literal_eval(output))] def _filter_pydev_from_paths(paths: List[str]) -> List[str]: # Filter out injected "pydev" debugging tool if running within a JetBrains # debugging context. return [path for path in paths if "pydev" not in path and "JetBrains" not in path] def _create_venv_system_python( work_dir: Path, invoking_python: str ) -> PythonVirtualenv: virtualenv = PythonVirtualenv(str(work_dir / "system_python")) subprocess.run( [ invoking_python, "-m", "venv", virtualenv.prefix, "--without-pip", ], check=True, ) return virtualenv if __name__ == "__main__": mozunit.main()