diff --git a/src/tox_current_env/hooks.py b/src/tox_current_env/hooks.py index 80de07d..96bc8be 100644 --- a/src/tox_current_env/hooks.py +++ b/src/tox_current_env/hooks.py @@ -1,260 +1,6 @@ -import os -import shutil -import subprocess -import sys -import tox -import warnings -import argparse +from tox import __version__ as TOX_VERSION -try: - import importlib.metadata as importlib_metadata -except ImportError: - import importlib_metadata - - -@tox.hookimpl -def tox_addoption(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-only", - action="store_true", - dest="print_deps_only", - default=False, - help="Deprecated, equivalent to `--print-deps-to -`", - ) - parser.add_argument( - "--print-deps-to", - "--print-deps-to-file", - action="store", - type=argparse.FileType('w'), - metavar="FILE", - default=None, - 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=None, - help="Don't run tests, only print the names of the required extras to the given file " - + "(use `-` for stdout)", - ) - - -def _plugin_active(option): - return option.current_env or option.print_deps_to or option.print_extras_to - - -@tox.hookimpl -def tox_configure(config): - """Stores options in the config. Makes all commands external and skips sdist""" - if config.option.print_deps_only: - warnings.warn( - "--print-deps-only is deprecated; use `--print-deps-to -`", - DeprecationWarning, - ) - if not config.option.print_deps_to: - config.option.print_deps_to = sys.stdout - else: - raise tox.exception.ConfigError( - "--print-deps-only cannot be used together " - + "with --print-deps-to" - ) - if _plugin_active(config.option): - config.skipsdist = True - for testenv in config.envconfigs: - config.envconfigs[testenv].whitelist_externals = "*" - config.envconfigs[testenv].usedevelop = False - - if (getattr(config.option.print_deps_to, "name", object()) == - getattr(config.option.print_extras_to, "name", object())): - raise tox.exception.ConfigError( - "The paths given to --print-deps-to and --print-extras-to cannot be identical." - ) - - return config - - -class InterpreterMismatch(tox.exception.InterpreterNotFound): - """Interpreter version in current env does not match requested version""" - - -def _python_activate_exists(venv): - python = venv.envconfig.get_envpython() - bindir = os.path.dirname(python) - activate = os.path.join(bindir, "activate") - return os.path.exists(python), os.path.exists(activate) - - -def is_current_env_link(venv): - python, activate = _python_activate_exists(venv) - return python and not activate - - -def is_proper_venv(venv): - python, activate = _python_activate_exists(venv) - return python and activate - - -def is_any_env(venv): - python, activate = _python_activate_exists(venv) - return python - - -def rm_venv(venv): - link = venv.envconfig.get_envpython() - shutil.rmtree(os.path.dirname(os.path.dirname(link)), ignore_errors=True) - - -def unsupported_raise(config, venv): - if config.option.recreate: - return - if not _plugin_active(config.option) and is_current_env_link(venv): - if hasattr(tox.hookspecs, "tox_cleanup"): - raise tox.exception.ConfigError( - "Looks like previous --current-env, --print-deps-to or --print-extras-to tox run didn't finish the cleanup. " - "Run tox run with --recreate (-r) or manually remove the environment in .tox." - ) - else: - raise tox.exception.ConfigError( - "Regular tox run after --current-env, --print-deps-to or --print-extras-to tox run " - "is not supported without --recreate (-r)." - ) - elif config.option.current_env and is_proper_venv(venv): - raise tox.exception.ConfigError( - "--current-env after regular tox run is not supported without --recreate (-r)." - ) - - -@tox.hookimpl -def tox_testenv_create(venv, action): - """We create a fake virtualenv with just the symbolic link""" - config = venv.envconfig.config - create_fake_env = check_version = config.option.current_env - if config.option.print_deps_to or config.option.print_extras_to: - if is_any_env(venv): - # We don't need anything - return True - else: - # We need at least some kind of environment, - # or tox fails without a python command - # We fallback to --current-env behavior, - # because it's cheaper, faster and won't install stuff - create_fake_env = True - if check_version: - # With real --current-env, we check this, but not with --print-deps/extras-to only - version_info = venv.envconfig.python_info.version_info - if version_info is None: - raise tox.exception.InterpreterNotFound(venv.envconfig.basepython) - if version_info[:2] != sys.version_info[:2]: - raise InterpreterMismatch( - f"tox_current_env: interpreter versions do not match:\n" - + f" in current env: {tuple(sys.version_info)}\n" - + f" requested: {version_info}" - ) - if create_fake_env: - # Make sure the `python` command on path is sys.executable. - # (We might have e.g. /usr/bin/python3, not `python`.) - # Remove the rest of the virtualenv. - link = venv.envconfig.get_envpython() - target = sys.executable - rm_venv(venv) - os.makedirs(os.path.dirname(link)) - if sys.platform == "win32": - # Avoid requiring admin rights on Windows - subprocess.check_call(f'mklink /J "{link}" "{target}"', shell=True) - else: - os.symlink(target, link) - # prevent tox from creating the venv - return True - if not is_proper_venv(venv): - rm_venv(venv) - return None # let tox handle the rest - - -@tox.hookimpl -def tox_package(session, venv): - """Fail early when unsupported""" - config = venv.envconfig.config - unsupported_raise(config, venv) - - -@tox.hookimpl -def tox_testenv_install_deps(venv, action): - """We don't install anything""" - config = venv.envconfig.config - unsupported_raise(config, venv) - if _plugin_active(config.option): - return True - - -def tox_dependencies(config): - """Get dependencies of tox itself, 'minversion' and 'requires' config options""" - if config.minversion is not None: - yield f"tox >= {config.minversion}" - # config does not have the "requires" attribute until tox 3.22: - yield from getattr(config, "requires", []) - - -@tox.hookimpl -def tox_runtest(venv, redirect): - """If --print-deps-to, prints deps instead of running tests. - If --print-extras-to, prints extras instead of running tests. - Both options can be used together.""" - config = venv.envconfig.config - unsupported_raise(config, venv) - ret = None - - if config.option.print_deps_to: - print( - *tox_dependencies(config), - *venv.get_resolved_dependencies(), - sep="\n", - file=config.option.print_deps_to, - ) - config.option.print_deps_to.flush() - ret = True - - if config.option.print_extras_to: - print( - *venv.envconfig.extras, - sep="\n", - file=config.option.print_extras_to, - ) - config.option.print_extras_to.flush() - ret = True - - return ret - - -@tox.hookimpl -def tox_cleanup(session): - """Remove the fake virtualenv not to collide with regular tox - Collisions can happen anyway (when tox is killed forcefully before this happens) - Note that we don't remove real venvs, as recreating them is expensive""" - for venv in session.venv_dict.values(): - if is_current_env_link(venv): - rm_venv(venv) - - -@tox.hookimpl -def tox_runenvreport(venv, action): - """Prevent using pip to display installed packages, - use importlib.metadata instead, but fallback to default without our flags.""" - if not _plugin_active(venv.envconfig.config.option): - return None - return ( - "{}=={}".format(d.metadata.get("name"), d.version) - for d in sorted( - importlib_metadata.distributions(), key=lambda d: d.metadata.get("name") - ) - ) +if TOX_VERSION[0] == "4": + from tox_current_env.hooks4 import * +else: + from tox_current_env.hooks3 import * diff --git a/src/tox_current_env/hooks3.py b/src/tox_current_env/hooks3.py new file mode 100644 index 0000000..80de07d --- /dev/null +++ b/src/tox_current_env/hooks3.py @@ -0,0 +1,260 @@ +import os +import shutil +import subprocess +import sys +import tox +import warnings +import argparse + +try: + import importlib.metadata as importlib_metadata +except ImportError: + import importlib_metadata + + +@tox.hookimpl +def tox_addoption(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-only", + action="store_true", + dest="print_deps_only", + default=False, + help="Deprecated, equivalent to `--print-deps-to -`", + ) + parser.add_argument( + "--print-deps-to", + "--print-deps-to-file", + action="store", + type=argparse.FileType('w'), + metavar="FILE", + default=None, + 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=None, + help="Don't run tests, only print the names of the required extras to the given file " + + "(use `-` for stdout)", + ) + + +def _plugin_active(option): + return option.current_env or option.print_deps_to or option.print_extras_to + + +@tox.hookimpl +def tox_configure(config): + """Stores options in the config. Makes all commands external and skips sdist""" + if config.option.print_deps_only: + warnings.warn( + "--print-deps-only is deprecated; use `--print-deps-to -`", + DeprecationWarning, + ) + if not config.option.print_deps_to: + config.option.print_deps_to = sys.stdout + else: + raise tox.exception.ConfigError( + "--print-deps-only cannot be used together " + + "with --print-deps-to" + ) + if _plugin_active(config.option): + config.skipsdist = True + for testenv in config.envconfigs: + config.envconfigs[testenv].whitelist_externals = "*" + config.envconfigs[testenv].usedevelop = False + + if (getattr(config.option.print_deps_to, "name", object()) == + getattr(config.option.print_extras_to, "name", object())): + raise tox.exception.ConfigError( + "The paths given to --print-deps-to and --print-extras-to cannot be identical." + ) + + return config + + +class InterpreterMismatch(tox.exception.InterpreterNotFound): + """Interpreter version in current env does not match requested version""" + + +def _python_activate_exists(venv): + python = venv.envconfig.get_envpython() + bindir = os.path.dirname(python) + activate = os.path.join(bindir, "activate") + return os.path.exists(python), os.path.exists(activate) + + +def is_current_env_link(venv): + python, activate = _python_activate_exists(venv) + return python and not activate + + +def is_proper_venv(venv): + python, activate = _python_activate_exists(venv) + return python and activate + + +def is_any_env(venv): + python, activate = _python_activate_exists(venv) + return python + + +def rm_venv(venv): + link = venv.envconfig.get_envpython() + shutil.rmtree(os.path.dirname(os.path.dirname(link)), ignore_errors=True) + + +def unsupported_raise(config, venv): + if config.option.recreate: + return + if not _plugin_active(config.option) and is_current_env_link(venv): + if hasattr(tox.hookspecs, "tox_cleanup"): + raise tox.exception.ConfigError( + "Looks like previous --current-env, --print-deps-to or --print-extras-to tox run didn't finish the cleanup. " + "Run tox run with --recreate (-r) or manually remove the environment in .tox." + ) + else: + raise tox.exception.ConfigError( + "Regular tox run after --current-env, --print-deps-to or --print-extras-to tox run " + "is not supported without --recreate (-r)." + ) + elif config.option.current_env and is_proper_venv(venv): + raise tox.exception.ConfigError( + "--current-env after regular tox run is not supported without --recreate (-r)." + ) + + +@tox.hookimpl +def tox_testenv_create(venv, action): + """We create a fake virtualenv with just the symbolic link""" + config = venv.envconfig.config + create_fake_env = check_version = config.option.current_env + if config.option.print_deps_to or config.option.print_extras_to: + if is_any_env(venv): + # We don't need anything + return True + else: + # We need at least some kind of environment, + # or tox fails without a python command + # We fallback to --current-env behavior, + # because it's cheaper, faster and won't install stuff + create_fake_env = True + if check_version: + # With real --current-env, we check this, but not with --print-deps/extras-to only + version_info = venv.envconfig.python_info.version_info + if version_info is None: + raise tox.exception.InterpreterNotFound(venv.envconfig.basepython) + if version_info[:2] != sys.version_info[:2]: + raise InterpreterMismatch( + f"tox_current_env: interpreter versions do not match:\n" + + f" in current env: {tuple(sys.version_info)}\n" + + f" requested: {version_info}" + ) + if create_fake_env: + # Make sure the `python` command on path is sys.executable. + # (We might have e.g. /usr/bin/python3, not `python`.) + # Remove the rest of the virtualenv. + link = venv.envconfig.get_envpython() + target = sys.executable + rm_venv(venv) + os.makedirs(os.path.dirname(link)) + if sys.platform == "win32": + # Avoid requiring admin rights on Windows + subprocess.check_call(f'mklink /J "{link}" "{target}"', shell=True) + else: + os.symlink(target, link) + # prevent tox from creating the venv + return True + if not is_proper_venv(venv): + rm_venv(venv) + return None # let tox handle the rest + + +@tox.hookimpl +def tox_package(session, venv): + """Fail early when unsupported""" + config = venv.envconfig.config + unsupported_raise(config, venv) + + +@tox.hookimpl +def tox_testenv_install_deps(venv, action): + """We don't install anything""" + config = venv.envconfig.config + unsupported_raise(config, venv) + if _plugin_active(config.option): + return True + + +def tox_dependencies(config): + """Get dependencies of tox itself, 'minversion' and 'requires' config options""" + if config.minversion is not None: + yield f"tox >= {config.minversion}" + # config does not have the "requires" attribute until tox 3.22: + yield from getattr(config, "requires", []) + + +@tox.hookimpl +def tox_runtest(venv, redirect): + """If --print-deps-to, prints deps instead of running tests. + If --print-extras-to, prints extras instead of running tests. + Both options can be used together.""" + config = venv.envconfig.config + unsupported_raise(config, venv) + ret = None + + if config.option.print_deps_to: + print( + *tox_dependencies(config), + *venv.get_resolved_dependencies(), + sep="\n", + file=config.option.print_deps_to, + ) + config.option.print_deps_to.flush() + ret = True + + if config.option.print_extras_to: + print( + *venv.envconfig.extras, + sep="\n", + file=config.option.print_extras_to, + ) + config.option.print_extras_to.flush() + ret = True + + return ret + + +@tox.hookimpl +def tox_cleanup(session): + """Remove the fake virtualenv not to collide with regular tox + Collisions can happen anyway (when tox is killed forcefully before this happens) + Note that we don't remove real venvs, as recreating them is expensive""" + for venv in session.venv_dict.values(): + if is_current_env_link(venv): + rm_venv(venv) + + +@tox.hookimpl +def tox_runenvreport(venv, action): + """Prevent using pip to display installed packages, + use importlib.metadata instead, but fallback to default without our flags.""" + if not _plugin_active(venv.envconfig.config.option): + return None + return ( + "{}=={}".format(d.metadata.get("name"), d.version) + for d in sorted( + importlib_metadata.distributions(), key=lambda d: d.metadata.get("name") + ) + ) diff --git a/src/tox_current_env/hooks4.py b/src/tox_current_env/hooks4.py new file mode 100644 index 0000000..c80b097 --- /dev/null +++ b/src/tox_current_env/hooks4.py @@ -0,0 +1,226 @@ +import argparse +import platform +import sys +import warnings +from pathlib import Path +from typing import Set + +from tox.execute.api import Execute +from tox.execute.local_sub_process import LocalSubProcessExecuteInstance +from tox.plugin import impl +from tox.tox_env.python.api import PythonInfo +from tox.tox_env.python.runner import PythonRun + + +@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-only", + action="store_true", + dest="print_deps_only", + default=False, + help="Deprecated, equivalent to `--print-deps-to -`", + ) + 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_register_tox_env(register): + register.add_run_env(CurrentEnv) + register.add_run_env(PrintEnv) + + +@impl +def tox_add_core_config(core_conf, config): + if config.options.current_env: + config.options.default_runner = "current-env" + return + + if config.options.print_deps_only: + warnings.warn( + "--print-deps-only is deprecated; use `--print-deps-to -`", + DeprecationWarning, + ) + if not config.options.print_deps_to: + config.options.print_deps_to = sys.stdout + else: + raise RuntimeError( + "--print-deps-only cannot be used together " "with --print-deps-to" + ) + + if getattr(config.options.print_deps_to, "name", object()) == getattr( + config.options.print_extras_to, "name", object() + ): + raise RuntimeError( + "The paths given to --print-deps-to and --print-extras-to cannot be identical." + ) + + if config.options.print_deps_to or config.options.print_extras_to: + config.options.default_runner = "print-env" + return + + # No options used - switch back to the standard runner + # Workaround for: https://github.com/tox-dev/tox/issues/2264 + config.options.default_runner = "virtualenv" + + +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 = CurrentEnvRunExecutor(self.options.is_colored) + return self._executor + + def _get_python(self, base_python): + # TODO: Improve version check and error message + version_nodot = "".join(str(p) for p in sys.version_info[:2]) + base_python = base_python[0] + if not base_python.endswith(version_nodot): + raise RuntimeError( + "Python version mismatch. " + f"Current version: {sys.version_info[:2]}, " + f"Requested environment: {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): + return None + + def env_bin_dir(self): + return Path(sys.prefix) / "bin" + + def env_python(self): + return sys.executable + + def env_site_package_dir(self): + return Path(sys.prefix) / "lib" + + @property + def installer(self): + if self._installer is None: + self._installer = DummyInstaller() + return self._installer + + def prepend_env_var_path(self): + return [self.env_bin_dir()] + + @property + def runs_on_platform(self): + return sys.platform + + +class CurrentEnvRunExecutor(Execute): + def build_instance( + self, + request, + options, + out, + err, + ): + # Disable check for the external commands, + # all of them are external for us. + request.allow = None + return LocalSubProcessExecuteInstance(request, options, out, err) + + +class DummyInstaller: + def install(self, *args): + return + + +class PrintEnv(CurrentEnv): + def __init__(self, create_args): + super().__init__(create_args) + + # As soon as this environment has enough info to do its job, + # do it and nothing more. + + 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: + 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", + ) + print( + *self.conf["extras"], + sep="\n", + file=self.options.print_extras_to, + ) + self.options.print_extras_to.flush() + + # We are done + sys.exit(0) + + @staticmethod + def id(): + return "print-env"