import argparse import os import platform import sys import sysconfig from pathlib import Path from typing import Set from tox.config.loader.memory import MemoryLoader from tox.execute.local_sub_process import ( Execute, LocalSubProcessExecuteInstance, ) from tox.plugin import impl from tox.tox_env.python.api import PythonInfo from tox.tox_env.python.runner import PythonRun try: import importlib.metadata as importlib_metadata except ImportError: import importlib_metadata @impl def tox_register_tox_env(register): register.add_run_env(CurrentEnv) register.add_run_env(PrintEnv) @impl def tox_add_option(parser): parser.add_argument( "--current-env", action="store_true", dest="current_env", default=False, help="Run tests in current environment, not creating any virtual environment", ) parser.add_argument( "--print-deps-to", "--print-deps-to-file", action="store", type=argparse.FileType("w"), metavar="FILE", default=False, help="Don't run tests, only print the dependencies to the given file " + "(use `-` for stdout)", ) parser.add_argument( "--print-extras-to", "--print-extras-to-file", action="store", type=argparse.FileType("w"), metavar="FILE", default=False, help="Don't run tests, only print the names of the required extras to the given file " + "(use `-` for stdout)", ) @impl def tox_add_core_config(core_conf, state): opt = state.conf.options if opt.current_env or opt.print_deps_to or opt.print_extras_to: # We do not want to install the main package. # no_package is the same as skipsdist. loader = MemoryLoader(no_package=True) core_conf.loaders.insert(0, loader) if opt.current_env: opt.default_runner = "current-env" return if getattr(opt.print_deps_to, "name", object()) == getattr( opt.print_extras_to, "name", object() ): raise RuntimeError( "The paths given to --print-deps-to and --print-extras-to cannot be identical." ) if opt.print_deps_to or opt.print_extras_to: opt.default_runner = "print-env" return # No options used - switch back to the standard runner # Workaround for: https://github.com/tox-dev/tox/issues/2264 opt.default_runner = "virtualenv" @impl def tox_add_env_config(env_conf, state): opt = state.conf.options # This allows all external commands. # All of them are external for us. # Because tox 4 no longer reads $TOX_TESTENV_PASSENV, # this plugin always passes all environment variables by default. if opt.current_env: allow_external_cmds = MemoryLoader(allowlist_externals=["*"], pass_env=["*"]) env_conf.loaders.insert(0, allow_external_cmds) # For print-deps-to and print-extras-to, use empty # list of commands so the tox does nothing. if opt.print_deps_to or opt.print_extras_to: empty_commands = MemoryLoader(commands=[], commands_pre=[], commands_post=[]) env_conf.loaders.insert(0, empty_commands) class Installer: """Noop installer""" def install(self, *args, **kwargs): return None def installed(self): """Return list of installed packages like `pip freeze`.""" return [ "{}=={}".format(d.metadata.get("name"), d.version) for d in sorted( importlib_metadata.distributions(), key=lambda d: d.metadata.get("name") ) ] class CurrentEnvLocalSubProcessExecutor(Execute): def build_instance( self, request, options, out, err, ): request.env["PATH"] = ":".join( (str(options._env.env_dir / "bin"), request.env.get("PATH", "")) ) return LocalSubProcessExecuteInstance(request, options, out, err) class CurrentEnv(PythonRun): def __init__(self, create_args): self._executor = None self._installer = None self._path = [] super().__init__(create_args) @staticmethod def id(): return "current-env" @property def _default_package_tox_env_type(self): return None @property def _external_pkg_tox_env_type(self): return None @property def _package_tox_env_type(self): return None @property def executor(self): if self._executor is None: self._executor = CurrentEnvLocalSubProcessExecutor(self.options.is_colored) return self._executor def _get_python(self, base_python): return PythonInfo( implementation=sys.implementation, version_info=sys.version_info, version=sys.version, is_64=(platform.architecture()[0] == "64bit"), platform=platform.platform(), extra={"executable": Path(sys.executable)}, ) def create_python_env(self): """Fake Python environment just to make sure all possible commands like python or python3 works.""" bindir = self.env_dir / "bin" if not bindir.exists(): os.mkdir(bindir) for suffix in ( "", f"{sys.version_info.major}", f"{sys.version_info.major}.{sys.version_info.minor}", ): symlink = bindir / f"python{suffix}" if not symlink.exists(): os.symlink(sys.executable, symlink) def env_bin_dir(self): return Path(sysconfig.get_path("scripts")) def env_python(self): return sys.executable def env_site_package_dir(self): return Path(sysconfig.get_path("purelib")) @property def installer(self): return Installer() def prepend_env_var_path(self): return [self.env_bin_dir()] @property def runs_on_platform(self): return sys.platform class PrintEnv(CurrentEnv): def __init__(self, create_args): super().__init__(create_args) if self.options.print_extras_to: if "extras" not in self.conf: # Unfortunately, if there is skipsdist/no_package or skip_install # in the config, this section is not parsed at all so we have to # do it here manually to be able to read its content. self.conf.add_config( keys=["extras"], of_type=Set[str], default=set(), desc="extras to install of the target package", ) def create_python_env(self): """We don't need any environment for this plugin""" return None def prepend_env_var_path(self): """Usage of this method for the core of this plugin is far from perfect but this method is called every time even without recreated environment""" if self.options.print_deps_to: print( *self.core["requires"], *self.conf["deps"].lines(), sep="\n", file=self.options.print_deps_to, ) self.options.print_deps_to.flush() if self.options.print_extras_to: print( *self.conf["extras"], sep="\n", file=self.options.print_extras_to, ) self.options.print_extras_to.flush() @staticmethod def id(): return "print-env"