diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3cb28ed..65b2455 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -22,21 +22,22 @@ jobs: # This information is repeated in tox.ini # (see https://github.com/fedora-python/tox-github-action/issues/8) # Generate it by: tox -l | sed "s/^/- /" - - py36-toxrelease - - py36-toxmaster - - py36-tox315 - - py37-toxrelease - - py37-toxmaster - - py37-tox315 - - py38-toxrelease - - py38-toxmaster - - py38-tox315 - - py39-toxrelease - - py39-toxmaster - - py39-tox315 - - py310-toxrelease - - py310-toxmaster - - py310-tox315 - + - py36-tox324 + - py36-tox3 + - py37-tox324 + - py37-tox3 + - py37-tox4 + - py38-tox324 + - py38-tox3 + - py38-tox4 + - py39-tox324 + - py39-tox3 + - py39-tox4 + - py310-tox324 + - py310-tox3 + - py310-tox4 + - py311-tox324 + - py311-tox3 + - py311-tox4 # Use GitHub's Linux Docker host runs-on: ubuntu-latest diff --git a/README.rst b/README.rst index 2ccec46..560a5b7 100644 --- a/README.rst +++ b/README.rst @@ -157,6 +157,17 @@ To get a list of names of extras, run: Caveats, warnings and limitations --------------------------------- +tox 4 +~~~~~ + +The plugin is available also for tox 4. Differences in behavior between tox 3 and 4 are these: + +- ``--recreate`` is no longer needed when you switch from the plugin back to standard tox. Tox +detects it and handles the recreation automatically. +- The plugin does not check the requested Python version nor the environment name. If you let +it run for multiple environments they'll all use the same Python. +- Deprecated ``--print-deps-only`` option is no longer available. + Use an isolated environment ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -197,15 +208,13 @@ forcefully killing it before it finished, uninstalling the plugin, and running ``tox``), you will get undefined results (such as installing packages from PyPI into your current environment). -Environment variables are not passed by default -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Environment variables are passed by default +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Although the plugin name suggests that current environment is used for tests, -it means the Python environment, not Shell. -If you want the tests to see environment variables of the calling process, -use the ``TOX_TESTENV_PASSENV`` environment variable. -Read `the documentation for passing environment variables to tox -`_. +Since 0.0.9, all Shell environment variables are passed by default when using +this plugin. The `passenv` tox configuration is set to `*`. +Read `the documentation for more information about passing environment variables to tox +`_. tox provisioning ~~~~~~~~~~~~~~~~ @@ -221,20 +230,20 @@ installs (a newer version of) ``tox`` and the missing packages into that environment and proxies all ``tox`` invocations trough that. Unfortunately, this is undesired for ``tox-current-env``. - 1. Starting with ``tox`` 3.23, it is possible to invoke it as - ``tox --no-provision`` to prevent the provision entirely. + 1. It is possible to invoke ``tox`` with ``--no-provision`` + to prevent the provision entirely. When requirements are missing, ``tox`` fails instead of provisioning. If a path is passed as a value for ``--no-provision``, the requirements will be serialized to the file, as JSON. - 2. Starting with ``tox`` 3.22, the requires, if specified, are included in the + 2. The requires, if specified, are included in the results of ``tox --print-deps-to``. This only works when they are installed (otherwise see the first point). 3. The minimal tox version, if specified, is included in the results of - ``tox --print-deps-to`` (as ``tox >= X.Y.Z``). + ``tox --print-deps-to``. This only works when the version requirement is satisfied (otherwise see the first point). -With ``tox >= 3.23``, the recommend way to handle this is: +The recommend way to handle this is: 1. Run ``tox --no-provision provision.json --print-deps-to=...`` or similar. 2. If the command fails, install requirements from ``provision.json`` to the diff --git a/setup.py b/setup.py index a976155..b0ba8ce 100644 --- a/setup.py +++ b/setup.py @@ -14,12 +14,12 @@ def long_description(): author_email="miro@hroncok.cz", url="https://github.com/fedora-python/tox-current-env", license="MIT", - version="0.0.8", + version="0.0.9", package_dir={"": "src"}, packages=find_packages("src"), entry_points={"tox": ["current-env = tox_current_env.hooks"]}, install_requires=[ - "tox>=3.15,<4", + "tox>=3.24", "importlib_metadata; python_version < '3.8'" ], extras_require={ @@ -42,6 +42,7 @@ def long_description(): "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Testing", ], diff --git a/src/tox_current_env/hooks.py b/src/tox_current_env/hooks.py index d52b680..96bc8be 100644 --- a/src/tox_current_env/hooks.py +++ b/src/tox_current_env/hooks.py @@ -1,284 +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 - - -def _allow_all_externals(envconfig): - for option in ["allowlist_externals", "whitelist_externals"]: - # If either is set, we change it, as we cannot have both set at the same time: - if getattr(envconfig, option, None): - setattr(envconfig, option, "*") - break - else: - # If none was set, we set one of them, preferably the new one: - if hasattr(envconfig, "allowlist_externals"): - envconfig.allowlist_externals = "*" - else: - # unless we need to fallback to the old and deprecated - # TODO, drop this when we drop support for tox < 3.18 - envconfig.whitelist_externals = "*" - -@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].usedevelop = False - _allow_all_externals(config.envconfigs[testenv]) - - # When printing dependencies/extras we don't run any commands. - # Unfortunately tox_runtest_pre/tox_runtest_post hooks don't use firstresult=True, - # so we cannot override running commands_pre/commands_post. - # We empty the lists of commands instead. - if config.option.print_deps_to or config.option.print_extras_to: - for testenv in config.envconfigs: - config.envconfigs[testenv].commands_pre = [] - config.envconfigs[testenv].commands_post = [] - - 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..eca79f8 --- /dev/null +++ b/src/tox_current_env/hooks3.py @@ -0,0 +1,284 @@ +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 -`. Not available with tox 4.", + ) + 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 + + +def _allow_all_externals(envconfig): + for option in ["allowlist_externals", "whitelist_externals"]: + # If either is set, we change it, as we cannot have both set at the same time: + if getattr(envconfig, option, None): + setattr(envconfig, option, "*") + break + else: + # If none was set, we set the new one + envconfig.allowlist_externals = "*" + + +@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].usedevelop = False + _allow_all_externals(config.envconfigs[testenv]) + # Because tox 4 no longer reads $TOX_TESTENV_PASSENV, + # this plugin always passes all environment variables by default, + # even on tox 3. + # Unfortunately at this point the set contains actual values, not globs: + config.envconfigs[testenv].passenv |= set(os.environ.keys()) + + # When printing dependencies/extras we don't run any commands. + # Unfortunately tox_runtest_pre/tox_runtest_post hooks don't use firstresult=True, + # so we cannot override running commands_pre/commands_post. + # We empty the lists of commands instead. + if config.option.print_deps_to or config.option.print_extras_to: + for testenv in config.envconfigs: + config.envconfigs[testenv].commands_pre = [] + config.envconfigs[testenv].commands_post = [] + + 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}" + yield from 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..40a263b --- /dev/null +++ b/src/tox_current_env/hooks4.py @@ -0,0 +1,241 @@ +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 + + +@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 + + +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" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6efd787 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +import os +import shutil + +import pytest +from utils import FIXTURES_DIR, TOX4 + + +@pytest.fixture(autouse=True) +def projdir(tmp_path, monkeypatch, worker_id): + pwd = tmp_path / "projdir" + pwd.mkdir() + for fname in "tox.ini", "setup.py": + shutil.copy(FIXTURES_DIR / fname, pwd) + monkeypatch.chdir(pwd) + # https://github.com/pypa/pip/issues/5345#issuecomment-386424455 + monkeypatch.setenv("XDG_CACHE_HOME", + os.path.expanduser(f"~/.cache/pytest-xdist-{worker_id}")) + return pwd + + +if TOX4: + available_options = ("--print-deps-to-file=-", "--print-deps-to=-") +else: + available_options = ( + "--print-deps-only", + "--print-deps-to-file=-", + "--print-deps-to=-", + ) + + +@pytest.fixture(params=available_options) +def print_deps_stdout_arg(request): + """Argument for printing deps to stdout""" + return request.param + + +@pytest.fixture(params=("--print-extras-to-file=-", "--print-extras-to=-")) +def print_extras_stdout_arg(request): + """Argument for printing extras to stdout""" + return request.param diff --git a/tests/fixtures/tox.ini b/tests/fixtures/tox.ini index 0055b26..6d3f448 100644 --- a/tests/fixtures/tox.ini +++ b/tests/fixtures/tox.ini @@ -1,7 +1,9 @@ [tox] -envlist = py36,py37,py38,py39,py310 +envlist = py36,py37,py38,py39,py310,py311 [testenv] +passenv = + XDG_CACHE_HOME deps = six py diff --git a/tests/test_integration.py b/tests/test_integration_tox3.py similarity index 62% rename from tests/test_integration.py rename to tests/test_integration_tox3.py index f802403..0f5eb35 100644 --- a/tests/test_integration.py +++ b/tests/test_integration_tox3.py @@ -1,112 +1,31 @@ -import functools import os -import pathlib import re import shutil import subprocess import sys import textwrap -import warnings -import configparser -import contextlib - -from packaging.version import parse as ver import pytest - -NATIVE_TOXENV = f"py{sys.version_info[0]}{sys.version_info[1]}" -NATIVE_SITE_PACKAGES = f"lib/python{sys.version_info[0]}.{sys.version_info[1]}/site-packages" -NATIVE_EXECUTABLE = str(pathlib.Path(sys.executable).resolve()) -FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" -DOT_TOX = pathlib.Path("./.tox") - - -def _exec_prefix(executable): - """Returns sys.exec_prefix for the given executable""" - cmd = (executable, "-c", "import sys; print(sys.exec_prefix)") - return subprocess.check_output(cmd, encoding="utf-8").strip() - - -NATIVE_EXEC_PREFIX = _exec_prefix(NATIVE_EXECUTABLE) -NATIVE_EXEC_PREFIX_MSG = f"{NATIVE_EXEC_PREFIX} is the exec_prefix" - - -@pytest.fixture(autouse=True) -def projdir(tmp_path, monkeypatch): - pwd = tmp_path / "projdir" - pwd.mkdir() - for fname in "tox.ini", "setup.py": - shutil.copy(FIXTURES_DIR / fname, pwd) - monkeypatch.chdir(pwd) - return pwd - - -@pytest.fixture(params=('--print-deps-only', '--print-deps-to-file=-', '--print-deps-to=-')) -def print_deps_stdout_arg(request): - """Argument for printing deps to stdout""" - return request.param - - -@pytest.fixture(params=('--print-extras-to-file=-', '--print-extras-to=-')) -def print_extras_stdout_arg(request): - """Argument for printing extras to stdout""" - return request.param - - -@contextlib.contextmanager -def modify_config(tox_ini_path): - """Context manager that allows modifying the given tox config file - - A statement like:: - - with prepare_config(projdir) as config: - - will make `config` a ConfigParser instance that is saved at the end - of the `with` block. - """ - config = configparser.ConfigParser() - config.read(tox_ini_path) - yield config - with open(tox_ini_path, 'w') as tox_ini_file: - config.write(tox_ini_file) - - -def tox(*args, quiet=True, **kwargs): - kwargs.setdefault("encoding", "utf-8") - kwargs.setdefault("stdout", subprocess.PIPE) - kwargs.setdefault("stderr", subprocess.PIPE) - kwargs.setdefault("check", True) - q = ("-q",) if quiet else () - try: - cp = subprocess.run((sys.executable, "-m", "tox") + q + args, **kwargs) - except subprocess.CalledProcessError as e: - print(e.stdout, file=sys.stdout) - print(e.stderr, file=sys.stderr) - raise - print(cp.stdout, file=sys.stdout) - print(cp.stderr, file=sys.stderr) - return cp - - -TOX_VERSION = ver(tox("--version").stdout.split(" ")[0]) - - -@functools.lru_cache(maxsize=8) -def is_available(python): - try: - subprocess.run((python, "--version")) - except FileNotFoundError: - return False - return True - - -needs_py3678910 = pytest.mark.skipif( - not all((is_available(f"python3.{x}") for x in range(6, 12))), - reason="This test needs python3.6, 3.7, 3.8, 3.9 and 3.10 available in $PATH", +from utils import ( + DOT_TOX, + NATIVE_EXEC_PREFIX_MSG, + NATIVE_EXECUTABLE, + NATIVE_SITE_PACKAGES, + NATIVE_TOXENV, + TOX_VERSION, + envs_from_tox_ini, + is_available, + modify_config, + needs_all_pythons, + tox, + tox_footer, ) +if TOX_VERSION.major != 3: + pytest.skip("skipping tests for tox 3", allow_module_level=True) + def test_native_toxenv_current_env(): result = tox("-e", NATIVE_TOXENV, "--current-env") @@ -114,7 +33,7 @@ def test_native_toxenv_current_env(): assert not (DOT_TOX / NATIVE_TOXENV / "lib").is_dir() -@needs_py3678910 +@needs_all_pythons def test_all_toxenv_current_env(): result = tox("--current-env", check=False) assert NATIVE_EXEC_PREFIX_MSG in result.stdout.splitlines() @@ -133,7 +52,7 @@ def test_missing_toxenv_current_env(python): assert result.returncode > 0 -@needs_py3678910 +@needs_all_pythons def test_all_toxenv_current_env_skip_missing(): result = tox("--current-env", "--skip-missing-interpreters", check=False) assert "InterpreterMismatch:" in result.stdout @@ -141,22 +60,20 @@ def test_all_toxenv_current_env_skip_missing(): assert result.returncode == 0 -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) def test_print_deps(toxenv, print_deps_stdout_arg): result = tox("-e", toxenv, print_deps_stdout_arg) expected = textwrap.dedent( f""" six py - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) @pytest.mark.parametrize("pre_post", ["pre", "post", "both"]) def test_print_deps_with_commands_pre_post(projdir, toxenv, pre_post, print_deps_stdout_arg): with modify_config(projdir / 'tox.ini') as config: @@ -170,18 +87,16 @@ def test_print_deps_with_commands_pre_post(projdir, toxenv, pre_post, print_deps f""" six py - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected assert result.stderr == "" -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) def test_print_deps_with_tox_minversion(projdir, toxenv, print_deps_stdout_arg): - with modify_config(projdir / 'tox.ini') as config: + with modify_config(projdir / "tox.ini") as config: config["tox"]["minversion"] = "3.13" result = tox("-e", toxenv, print_deps_stdout_arg) expected = textwrap.dedent( @@ -189,18 +104,15 @@ def test_print_deps_with_tox_minversion(projdir, toxenv, print_deps_stdout_arg): tox >= 3.13 six py - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected -@pytest.mark.xfail(TOX_VERSION < ver("3.22"), reason="No support in old tox") -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) def test_print_deps_with_tox_requires(projdir, toxenv, print_deps_stdout_arg): - with modify_config(projdir / 'tox.ini') as config: + with modify_config(projdir / "tox.ini") as config: config["tox"]["requires"] = "\n setuptools > 30\n pluggy" result = tox("-e", toxenv, print_deps_stdout_arg) expected = textwrap.dedent( @@ -209,18 +121,17 @@ def test_print_deps_with_tox_requires(projdir, toxenv, print_deps_stdout_arg): pluggy six py - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected -@pytest.mark.xfail(TOX_VERSION < ver("3.22"), reason="No support in old tox") -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39"]) -def test_print_deps_with_tox_minversion_and_requires(projdir, toxenv, print_deps_stdout_arg): - with modify_config(projdir / 'tox.ini') as config: +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +def test_print_deps_with_tox_minversion_and_requires( + projdir, toxenv, print_deps_stdout_arg +): + with modify_config(projdir / "tox.ini") as config: config["tox"]["minversion"] = "3.13" config["tox"]["requires"] = "\n setuptools > 30\n pluggy" result = tox("-e", toxenv, print_deps_stdout_arg) @@ -231,30 +142,26 @@ def test_print_deps_with_tox_minversion_and_requires(projdir, toxenv, print_deps pluggy six py - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) def test_print_extras(toxenv, print_extras_stdout_arg): result = tox("-e", toxenv, print_extras_stdout_arg) expected = textwrap.dedent( f""" dev full - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) @pytest.mark.parametrize("pre_post", ["pre", "post", "both"]) def test_print_extras_with_commands_pre_post(projdir, toxenv, pre_post, print_extras_stdout_arg): with modify_config(projdir / 'tox.ini') as config: @@ -268,20 +175,20 @@ def test_print_extras_with_commands_pre_post(projdir, toxenv, pre_post, print_ex f""" dev full - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected assert result.stderr == "" -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) def test_print_deps_only_deprecated(toxenv): result = tox( - "-e", toxenv, '--print-deps-only', - env={**os.environ, 'PYTHONWARNINGS': 'always'}, + "-e", + toxenv, + "--print-deps-only", + env={**os.environ, "PYTHONWARNINGS": "always"}, ) waring_text = ( "DeprecationWarning: --print-deps-only is deprecated; " @@ -292,119 +199,69 @@ def test_print_deps_only_deprecated(toxenv): def test_allenvs_print_deps(print_deps_stdout_arg): result = tox(print_deps_stdout_arg) - expected = textwrap.dedent( - """ - six - py - six - py - six - py - six - py - six - py - ___________________________________ summary ____________________________________ - py36: commands succeeded - py37: commands succeeded - py38: commands succeeded - py39: commands succeeded - py310: commands succeeded - congratulations :) - """ - ).lstrip() + expected = "" + for env in envs_from_tox_ini(): + expected += "six\npy\n" + expected += tox_footer(spaces=0) + "\n" assert result.stdout == expected def test_allenvs_print_extras(print_extras_stdout_arg): result = tox(print_extras_stdout_arg) - expected = textwrap.dedent( - """ - dev - full - dev - full - dev - full - dev - full - dev - full - ___________________________________ summary ____________________________________ - py36: commands succeeded - py37: commands succeeded - py38: commands succeeded - py39: commands succeeded - py310: commands succeeded - congratulations :) - """ - ).lstrip() + expected = "" + for env in envs_from_tox_ini(): + expected += "dev\nfull\n" + expected += tox_footer(spaces=0) + "\n" assert result.stdout == expected -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39", "py310"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) def test_print_deps_to_file(toxenv, tmp_path): depspath = tmp_path / "deps" result = tox("-e", toxenv, "--print-deps-to", str(depspath)) assert depspath.read_text().splitlines() == ["six", "py"] expected = textwrap.dedent( f""" - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected -@pytest.mark.parametrize("toxenv", ["py36", "py37", "py38", "py39", "py310"]) +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) def test_print_extras_to_file(toxenv, tmp_path): extraspath = tmp_path / "extras" result = tox("-e", toxenv, "--print-extras-to", str(extraspath)) assert extraspath.read_text().splitlines() == ["dev", "full"] expected = textwrap.dedent( f""" - ___________________________________ summary ____________________________________ - {toxenv}: commands succeeded - congratulations :) + {tox_footer(toxenv)} """ ).lstrip() assert result.stdout == expected -@pytest.mark.parametrize('option', ('--print-deps-to', '--print-deps-to-file')) +@pytest.mark.parametrize("option", ("--print-deps-to", "--print-deps-to-file")) def test_allenvs_print_deps_to_file(tmp_path, option): depspath = tmp_path / "deps" result = tox(option, str(depspath)) - assert depspath.read_text().splitlines() == ["six", "py"] * 5 + assert depspath.read_text().splitlines() == ["six", "py"] * len(envs_from_tox_ini()) expected = textwrap.dedent( - """ - ___________________________________ summary ____________________________________ - py36: commands succeeded - py37: commands succeeded - py38: commands succeeded - py39: commands succeeded - py310: commands succeeded - congratulations :) + f""" + {tox_footer()} """ ).lstrip() assert result.stdout == expected -@pytest.mark.parametrize('option', ('--print-extras-to', '--print-extras-to-file')) +@pytest.mark.parametrize("option", ("--print-extras-to", "--print-extras-to-file")) def test_allenvs_print_extras_to_file(tmp_path, option): extraspath = tmp_path / "extras" result = tox(option, str(extraspath)) - assert extraspath.read_text().splitlines() == ["dev", "full"] * 5 + assert extraspath.read_text().splitlines() == ["dev", "full"] * len(envs_from_tox_ini()) expected = textwrap.dedent( - """ - ___________________________________ summary ____________________________________ - py36: commands succeeded - py37: commands succeeded - py38: commands succeeded - py39: commands succeeded - py310: commands succeeded - congratulations :) + f""" + {tox_footer()} """ ).lstrip() assert result.stdout == expected @@ -413,7 +270,7 @@ def test_allenvs_print_extras_to_file(tmp_path, option): def test_allenvs_print_deps_to_existing_file(tmp_path): depspath = tmp_path / "deps" depspath.write_text("nada") - result = tox("--print-deps-to", str(depspath)) + _ = tox("--print-deps-to", str(depspath)) lines = depspath.read_text().splitlines() assert "nada" not in lines assert "six" in lines @@ -423,7 +280,7 @@ def test_allenvs_print_deps_to_existing_file(tmp_path): def test_allenvs_print_extras_to_existing_file(tmp_path): extraspath = tmp_path / "extras" extraspath.write_text("nada") - result = tox("--print-extras-to", str(extraspath)) + _ = tox("--print-extras-to", str(extraspath)) lines = extraspath.read_text().splitlines() assert "nada" not in lines assert "dev" in lines @@ -432,14 +289,15 @@ def test_allenvs_print_extras_to_existing_file(tmp_path): @pytest.mark.parametrize("deps_stdout", [True, False]) @pytest.mark.parametrize("extras_stdout", [True, False]) -def test_allenvs_print_deps_to_file_print_extras_to_other_file(tmp_path, deps_stdout, extras_stdout): +def test_allenvs_print_deps_to_file_print_extras_to_other_file( + tmp_path, deps_stdout, extras_stdout +): if deps_stdout and extras_stdout: pytest.xfail("Unsupported combination of parameters") depspath = "-" if deps_stdout else tmp_path / "deps" extraspath = "-" if extras_stdout else tmp_path / "extras" - result = tox("--print-deps-to", str(depspath), - "--print-extras-to", str(extraspath)) + result = tox("--print-deps-to", str(depspath), "--print-extras-to", str(extraspath)) if deps_stdout: depslines = result.stdout.splitlines() extraslines = extraspath.read_text().splitlines() @@ -466,8 +324,10 @@ def test_print_deps_extras_to_same_file_is_not_possible(tmp_path): result = tox( "-e", NATIVE_TOXENV, - "--print-deps-to", str(depsextraspath), - "--print-extras-to", str(depsextraspath), + "--print-deps-to", + str(depsextraspath), + "--print-extras-to", + str(depsextraspath), check=False, ) assert result.returncode > 0 @@ -475,9 +335,10 @@ def test_print_deps_extras_to_same_file_is_not_possible(tmp_path): def test_print_deps_extras_to_stdout_is_not_possible( - tmp_path, - print_deps_stdout_arg, - print_extras_stdout_arg,): + tmp_path, + print_deps_stdout_arg, + print_extras_stdout_arg, +): result = tox( "-e", NATIVE_TOXENV, @@ -502,19 +363,17 @@ def test_print_deps_only_print_deps_to_file_are_mutually_exclusive(): assert "cannot be used together" in result.stderr -@needs_py3678910 +@needs_all_pythons def test_regular_run(): result = tox() lines = result.stdout.splitlines()[:5] - assert "/.tox/py36 is the exec_prefix" in lines[0] - assert "/.tox/py37 is the exec_prefix" in lines[1] - assert "/.tox/py38 is the exec_prefix" in lines[2] - assert "/.tox/py39 is the exec_prefix" in lines[3] - assert "/.tox/py310 is the exec_prefix" in lines[4] + for line, env in zip(lines, envs_from_tox_ini()): + assert f"/.tox/{env} is the exec_prefix" in line assert "congratulations" in result.stdout - for y in 6, 7, 8, 9, 10: + for env in envs_from_tox_ini(): + major, minor = re.match(r"py(\d)(\d+)", env).groups() for pkg in "py", "six", "test": - sitelib = DOT_TOX / f"py3{y}/lib/python3.{y}/site-packages" + sitelib = DOT_TOX / f"{env}/lib/python{major}.{minor}/site-packages" assert sitelib.is_dir() assert len(list(sitelib.glob(f"{pkg}-*.dist-info"))) == 1 @@ -525,9 +384,7 @@ def test_regular_run_native_toxenv(): assert f"/.tox/{NATIVE_TOXENV} is the exec_prefix" in lines[0] assert "congratulations" in result.stdout for pkg in "py", "six", "test": - sitelib = ( - DOT_TOX / f"{NATIVE_TOXENV}/{NATIVE_SITE_PACKAGES}" - ) + sitelib = DOT_TOX / f"{NATIVE_TOXENV}/{NATIVE_SITE_PACKAGES}" assert sitelib.is_dir() assert len(list(sitelib.glob(f"{pkg}-*.dist-info"))) == 1 @@ -643,9 +500,7 @@ def test_print_deps_without_python_command(tmp_path, print_deps_stdout_arg): f""" six py - ___________________________________ summary ____________________________________ - {NATIVE_TOXENV}: commands succeeded - congratulations :) + {tox_footer(NATIVE_TOXENV)} """ ).lstrip() assert result.stdout == expected @@ -679,20 +534,20 @@ def test_noquiet_installed_packages(flag): assert all(re.match(r"\S+==\S+", p) for p in packages) -@pytest.mark.parametrize("flag", ["--print-deps-to=-", "--print-extras-to=-", "--current-env"]) +@pytest.mark.parametrize( + "flag", ["--print-deps-to=-", "--print-extras-to=-", "--current-env"] +) @pytest.mark.parametrize("usedevelop", [True, False]) def test_self_is_not_installed(projdir, flag, usedevelop): - with modify_config(projdir / 'tox.ini') as config: - config['testenv']['usedevelop'] = str(usedevelop) + with modify_config(projdir / "tox.ini") as config: + config["testenv"]["usedevelop"] = str(usedevelop) result = tox("-e", NATIVE_TOXENV, flag, quiet=False) - assert 'test==0.0.0' not in result.stdout - assert 'test @ file://' not in result.stdout + assert "test==0.0.0" not in result.stdout + assert "test @ file://" not in result.stdout @pytest.mark.parametrize("externals", [None, "allowlist_externals", "whitelist_externals"]) def test_externals(projdir, externals): - if externals == "allowlist_externals" and TOX_VERSION < ver("3.18"): - pytest.xfail("No support in old tox") with modify_config(projdir / 'tox.ini') as config: config['testenv']['commands'] = "echo assertme" if externals is not None: @@ -705,8 +560,21 @@ def test_externals(projdir, externals): @pytest.mark.parametrize("usedevelop", [True, False]) def test_self_is_installed_with_regular_tox(projdir, usedevelop): - with modify_config(projdir / 'tox.ini') as config: - config['testenv']['usedevelop'] = str(usedevelop) + with modify_config(projdir / "tox.ini") as config: + config["testenv"]["usedevelop"] = str(usedevelop) result = tox("-e", NATIVE_TOXENV, quiet=False) - assert ('test==0.0.0' in result.stdout or - 'test @ file://' in result.stdout) + assert "test==0.0.0" in result.stdout or "test @ file://" in result.stdout + + +@pytest.mark.parametrize("passenv", [None, "different list", "__var", "*"]) +def test_passenv(projdir, passenv): + with modify_config(projdir / "tox.ini") as config: + config["testenv"]["commands"] = """python -c 'import os; print(os.getenv("__var"))'""" + if passenv is not None: + existing = config["testenv"].get("passenv", "") + " " + config["testenv"]["passenv"] = existing + passenv + env = {"__var": "assertme"} + result = tox("-e", NATIVE_TOXENV, "--current-env", env=env, quiet=False) + assert result.returncode == 0 + assert "\nassertme\n" in result.stdout + assert "\nNone\n" not in result.stdout diff --git a/tests/test_integration_tox4.py b/tests/test_integration_tox4.py new file mode 100644 index 0000000..3ab9fbd --- /dev/null +++ b/tests/test_integration_tox4.py @@ -0,0 +1,424 @@ +import os +import re +import shutil +import textwrap + +import pytest + +from utils import ( + DOT_TOX, + NATIVE_EXEC_PREFIX_MSG, + NATIVE_SITE_PACKAGES, + NATIVE_TOXENV, + TOX_VERSION, + TOX_MIN_VERSION, + envs_from_tox_ini, + modify_config, + needs_all_pythons, + prep_tox_output, + tox, + tox_footer, +) + + +if TOX_VERSION.major != 4: + pytest.skip("skipping tests for tox 4", allow_module_level=True) + + +def test_native_toxenv_current_env(): + result = tox("-e", NATIVE_TOXENV, "--current-env") + assert result.stdout.splitlines()[0] == NATIVE_EXEC_PREFIX_MSG + assert not (DOT_TOX / NATIVE_TOXENV / "lib").is_dir() + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +def test_print_deps(toxenv, print_deps_stdout_arg): + result = tox("-e", toxenv, print_deps_stdout_arg) + expected = textwrap.dedent( + f""" + tox>={TOX_MIN_VERSION} + six + py + {tox_footer(toxenv)} + """ + ).lstrip() + assert prep_tox_output(result.stdout) == expected + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +@pytest.mark.parametrize("pre_post", ["pre", "post", "both"]) +def test_print_deps_with_commands_pre_post(projdir, toxenv, pre_post, print_deps_stdout_arg): + with modify_config(projdir / 'tox.ini') as config: + if pre_post == "both": + config["testenv"]["commands_pre"] = "echo unexpected" + config["testenv"]["commands_post"] = "echo unexpected" + else: + config["testenv"][f"commands_{pre_post}"] = "echo unexpected" + result = tox("-e", toxenv, print_deps_stdout_arg) + expected = textwrap.dedent( + f""" + tox>={TOX_MIN_VERSION} + six + py + {tox_footer(toxenv)} + """ + ).lstrip() + assert sorted(prep_tox_output(result.stdout).splitlines()) == sorted( + expected.splitlines() + ) + assert result.stderr == "" + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +def test_print_deps_with_tox_minversion(projdir, toxenv, print_deps_stdout_arg): + with modify_config(projdir / "tox.ini") as config: + config["tox"]["minversion"] = "3.13" + result = tox("-e", toxenv, print_deps_stdout_arg) + expected = textwrap.dedent( + f""" + tox>=3.13 + six + py + {tox_footer(toxenv)} + """ + ).lstrip() + assert prep_tox_output(result.stdout) == expected + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +def test_print_deps_with_tox_requires(projdir, toxenv, print_deps_stdout_arg): + with modify_config(projdir / "tox.ini") as config: + config["tox"]["requires"] = "\n setuptools > 30\n pluggy" + result = tox("-e", toxenv, print_deps_stdout_arg) + expected = textwrap.dedent( + f""" + setuptools>30 + pluggy + tox>={TOX_MIN_VERSION} + six + py + {tox_footer(toxenv)} + """ + ).lstrip() + assert prep_tox_output(result.stdout) == expected + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +def test_print_deps_with_tox_minversion_and_requires( + projdir, toxenv, print_deps_stdout_arg +): + with modify_config(projdir / "tox.ini") as config: + config["tox"]["minversion"] = "3.13" + config["tox"]["requires"] = "\n setuptools > 30\n pluggy" + result = tox("-e", toxenv, print_deps_stdout_arg) + expected = textwrap.dedent( + f""" + setuptools>30 + pluggy + tox>=3.13 + six + py + {tox_footer(toxenv)} + """ + ).lstrip() + assert prep_tox_output(result.stdout) == expected + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +def test_print_extras(toxenv, print_extras_stdout_arg): + result = tox("-e", toxenv, print_extras_stdout_arg) + expected = textwrap.dedent( + f""" + dev + full + {tox_footer(toxenv)} + """ + ).lstrip() + assert sorted(prep_tox_output(result.stdout).splitlines()) == sorted( + expected.splitlines() + ) + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +@pytest.mark.parametrize("pre_post", ["pre", "post", "both"]) +def test_print_extras_with_commands_pre_post(projdir, toxenv, pre_post, print_extras_stdout_arg): + with modify_config(projdir / 'tox.ini') as config: + if pre_post == "both": + config["testenv"]["commands_pre"] = "echo unexpected" + config["testenv"]["commands_post"] = "echo unexpected" + else: + config["testenv"][f"commands_{pre_post}"] = "echo unexpected" + result = tox("-e", toxenv, print_extras_stdout_arg) + expected = textwrap.dedent( + f""" + dev + full + {tox_footer(toxenv)} + """ + ).lstrip() + assert sorted(prep_tox_output(result.stdout).splitlines()) == sorted( + expected.splitlines() + ) + assert result.stderr == "" + + +def test_allenvs_print_deps(print_deps_stdout_arg): + result = tox(print_deps_stdout_arg) + expected = [] + for env in envs_from_tox_ini(): + expected.extend((f"tox>={TOX_MIN_VERSION}", "six", "py", f"{env}: OK")) + expected.pop() # The last "py310: OK" is not there + expected.append(tox_footer(spaces=0)) + expected = ("\n".join(expected)).splitlines() + assert sorted(prep_tox_output(result.stdout).splitlines()) == sorted(expected) + + +def test_allenvs_print_extras(print_extras_stdout_arg): + result = tox(print_extras_stdout_arg) + expected = [] + for env in envs_from_tox_ini(): + expected.extend(("dev", "full", f"{env}: OK")) + expected.pop() # The last "py310: OK" is not there + expected.append(tox_footer(spaces=0)) + expected = ("\n".join(expected)).splitlines() + assert sorted(prep_tox_output(result.stdout).splitlines()) == sorted(expected) + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +def test_print_deps_to_file(toxenv, tmp_path): + depspath = tmp_path / "deps" + result = tox("-e", toxenv, "--print-deps-to", str(depspath)) + assert sorted(depspath.read_text().splitlines()) == sorted( + [f"tox>={TOX_MIN_VERSION}", "six", "py"] + ) + expected = tox_footer(toxenv, spaces=0) + "\n" + assert prep_tox_output(result.stdout) == expected + + +@pytest.mark.parametrize("toxenv", envs_from_tox_ini()) +def test_print_extras_to_file(toxenv, tmp_path): + extraspath = tmp_path / "extras" + result = tox("-e", toxenv, "--print-extras-to", str(extraspath)) + assert sorted(extraspath.read_text().splitlines()) == sorted(["dev", "full"]) + expected = tox_footer(toxenv, spaces=0) + "\n" + assert prep_tox_output(result.stdout) == expected + + +@pytest.mark.parametrize("option", ("--print-deps-to", "--print-deps-to-file")) +def test_allenvs_print_deps_to_file(tmp_path, option): + depspath = tmp_path / "deps" + result = tox(option, str(depspath)) + assert sorted(depspath.read_text().splitlines()) == sorted( + [f"tox>={TOX_MIN_VERSION}", "six", "py"] * len(envs_from_tox_ini()) + ) + expected = "" + for env in envs_from_tox_ini()[:-1]: + expected += f"{env}: OK\n" + expected += tox_footer(spaces=0) + "\n" + assert prep_tox_output(result.stdout) == expected + + +@pytest.mark.parametrize("option", ("--print-extras-to", "--print-extras-to-file")) +def test_allenvs_print_extras_to_file(tmp_path, option): + extraspath = tmp_path / "extras" + result = tox(option, str(extraspath)) + assert sorted(extraspath.read_text().splitlines()) == sorted( + ["dev", "full"] * len(envs_from_tox_ini()) + ) + expected = "" + for env in envs_from_tox_ini()[:-1]: + expected += f"{env}: OK\n" + expected += tox_footer(spaces=0) + "\n" + assert prep_tox_output(result.stdout) == expected + + +def test_allenvs_print_deps_to_existing_file(tmp_path): + depspath = tmp_path / "deps" + depspath.write_text("nada") + _ = tox("--print-deps-to", str(depspath)) + lines = depspath.read_text().splitlines() + assert "nada" not in lines + assert "six" in lines + assert "py" in lines + + +def test_allenvs_print_extras_to_existing_file(tmp_path): + extraspath = tmp_path / "extras" + extraspath.write_text("nada") + _ = tox("--print-extras-to", str(extraspath)) + lines = extraspath.read_text().splitlines() + assert "nada" not in lines + assert "dev" in lines + assert "full" in lines + + +@pytest.mark.parametrize("deps_stdout", [True, False]) +@pytest.mark.parametrize("extras_stdout", [True, False]) +def test_allenvs_print_deps_to_file_print_extras_to_other_file( + tmp_path, deps_stdout, extras_stdout +): + if deps_stdout and extras_stdout: + pytest.xfail("Unsupported combination of parameters") + + depspath = "-" if deps_stdout else tmp_path / "deps" + extraspath = "-" if extras_stdout else tmp_path / "extras" + result = tox("--print-deps-to", str(depspath), "--print-extras-to", str(extraspath)) + if deps_stdout: + depslines = result.stdout.splitlines() + extraslines = extraspath.read_text().splitlines() + elif extras_stdout: + depslines = depspath.read_text().splitlines() + extraslines = result.stdout.splitlines() + else: + extraslines = extraspath.read_text().splitlines() + depslines = depspath.read_text().splitlines() + + assert "six" in depslines + assert "py" in depslines + assert "full" in extraslines + assert "dev" in extraslines + + assert "six" not in extraslines + assert "py" not in extraslines + assert "full" not in depslines + assert "dev" not in depslines + + +def test_print_deps_extras_to_same_file_is_not_possible(tmp_path): + depsextraspath = tmp_path / "depsextras" + result = tox( + "-e", + NATIVE_TOXENV, + "--print-deps-to", + str(depsextraspath), + "--print-extras-to", + str(depsextraspath), + check=False, + ) + assert result.returncode > 0 + assert "cannot be identical" in result.stderr + + +def test_print_deps_extras_to_stdout_is_not_possible( + tmp_path, + print_deps_stdout_arg, + print_extras_stdout_arg, +): + result = tox( + "-e", + NATIVE_TOXENV, + print_deps_stdout_arg, + print_extras_stdout_arg, + check=False, + ) + assert result.returncode > 0 + assert "cannot be identical" in result.stderr + + +@needs_all_pythons +def test_regular_run(): + result = tox() + lines = result.stdout.splitlines()[:5] + for line, env in zip(lines, envs_from_tox_ini()): + assert f"/.tox/{env} is the exec_prefix" in line + assert "congratulations" in result.stdout + for env in envs_from_tox_ini(): + major, minor = re.match(r"py(\d)(\d+)", env).groups() + for pkg in "py", "six", "test": + sitelib = DOT_TOX / f"{env}/lib/python{major}.{minor}/site-packages" + assert sitelib.is_dir() + assert len(list(sitelib.glob(f"{pkg}-*.dist-info"))) == 1 + + +def test_regular_run_native_toxenv(): + result = tox("-e", NATIVE_TOXENV) + lines = sorted(result.stdout.splitlines()[:1]) + assert f"/.tox/{NATIVE_TOXENV} is the exec_prefix" in lines[0] + assert "congratulations" in result.stdout + for pkg in "py", "six", "test": + sitelib = DOT_TOX / f"{NATIVE_TOXENV}/{NATIVE_SITE_PACKAGES}" + assert sitelib.is_dir() + assert len(list(sitelib.glob(f"{pkg}-*.dist-info"))) == 1 + + +def test_print_deps_without_python_command(tmp_path, print_deps_stdout_arg): + bin = tmp_path / "bin" + bin.mkdir() + tox_link = bin / "tox" + tox_path = shutil.which("tox") + tox_link.symlink_to(tox_path) + env = {**os.environ, "PATH": str(bin)} + + result = tox("-e", NATIVE_TOXENV, print_deps_stdout_arg, env=env) + expected = textwrap.dedent( + f""" + tox>={TOX_MIN_VERSION} + six + py + {tox_footer(NATIVE_TOXENV)} + """ + ).lstrip() + assert prep_tox_output(result.stdout) == expected + + +@pytest.mark.parametrize("flag", ["--print-deps-to=-", "--current-env"]) +def test_recreate_environment(flag): + flags = (flag,) if flag else () + _ = tox("-e", NATIVE_TOXENV, check=False) + result = tox("-e", NATIVE_TOXENV, *flags, quiet=False, check=False) + assert f"{NATIVE_TOXENV}: recreate env because env type changed" in prep_tox_output( + result.stdout + ) + + +@pytest.mark.parametrize( + "flag", ["--print-deps-to=-", "--print-extras-to=-", "--current-env"] +) +@pytest.mark.parametrize("usedevelop", [True, False]) +def test_self_is_not_installed(projdir, flag, usedevelop): + with modify_config(projdir / "tox.ini") as config: + config["testenv"]["usedevelop"] = str(usedevelop) + _ = tox("-e", NATIVE_TOXENV, flag, quiet=False) + egg_link = DOT_TOX / f"{NATIVE_TOXENV}/{NATIVE_SITE_PACKAGES}" / "test.egg-link" + dist_info = ( + DOT_TOX / f"{NATIVE_TOXENV}/{NATIVE_SITE_PACKAGES}" / "test-0.0.0.dist-info" + ) + assert not egg_link.exists() + assert not dist_info.exists() + + +@pytest.mark.parametrize("usedevelop", [True, False]) +def test_self_is_installed_with_regular_tox(projdir, usedevelop): + with modify_config(projdir / "tox.ini") as config: + config["testenv"]["usedevelop"] = str(usedevelop) + result = tox("-e", NATIVE_TOXENV, "-v", quiet=False) + assert "test-0.0.0" in result.stdout + if usedevelop: + assert "test-0.0.0-0.editable" in result.stdout + + +@pytest.mark.parametrize("passenv", [None, "different list", "__var", "*"]) +def test_passenv(projdir, passenv): + with modify_config(projdir / "tox.ini") as config: + config["testenv"]["commands"] = """python -c 'import os; print(os.getenv("__var"))'""" + if passenv is not None: + existing = config["testenv"].get("passenv", "") + " " + config["testenv"]["passenv"] = existing + passenv + env = {"__var": "assertme"} + result = tox("-e", NATIVE_TOXENV, "--current-env", env=env, quiet=False) + assert result.returncode == 0 + assert "\nassertme\n" in result.stdout + assert "\nNone\n" not in result.stdout + + +@pytest.mark.parametrize("pass_env", [None, "different\nlist", "__var", "*"]) +def test_pass_env(projdir, pass_env): + with modify_config(projdir / "tox.ini") as config: + config["testenv"]["commands"] = """python -c 'import os; print(os.getenv("__var"))'""" + if pass_env is not None: + config["testenv"]["pass_env"] = pass_env + env = {"__var": "assertme"} + result = tox("-e", NATIVE_TOXENV, "--current-env", env=env, quiet=False) + assert result.returncode == 0 + assert "\nassertme\n" in result.stdout + assert "\nNone\n" not in result.stdout diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..377b2b3 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,130 @@ +import configparser +import contextlib +import functools +import os +import pathlib +import re +import subprocess +import sys +from configparser import ConfigParser + +import pytest +from packaging.version import parse as ver + +PYTHON_VERSION_DOT = f"{sys.version_info[0]}.{sys.version_info[1]}" +PYTHON_VERSION_NODOT = f"{sys.version_info[0]}{sys.version_info[1]}" +NATIVE_TOXENV = f"py{PYTHON_VERSION_NODOT}" +NATIVE_SITE_PACKAGES = f"lib/python{PYTHON_VERSION_DOT}/site-packages" +NATIVE_EXECUTABLE = str(pathlib.Path(sys.executable).resolve()) +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" +DOT_TOX = pathlib.Path("./.tox") + + +def _exec_prefix(executable): + """Returns sys.exec_prefix for the given executable""" + cmd = (executable, "-c", "import sys; print(sys.exec_prefix)") + return subprocess.check_output(cmd, encoding="utf-8").strip() + + +NATIVE_EXEC_PREFIX = _exec_prefix(NATIVE_EXECUTABLE) +NATIVE_EXEC_PREFIX_MSG = f"{NATIVE_EXEC_PREFIX} is the exec_prefix" + + +def tox(*args, quiet=True, **kwargs): + kwargs.setdefault("encoding", "utf-8") + kwargs.setdefault("stdout", subprocess.PIPE) + kwargs.setdefault("stderr", subprocess.PIPE) + kwargs.setdefault("check", True) + kwargs.setdefault("cwd", os.getcwd()) + q = ("-q",) if quiet else () + env = dict(os.environ) + env.pop("TOX_WORK_DIR", None) + kwargs["env"] = {**env, **kwargs.get("env", {})} + try: + print("current", os.getcwd(), "running in", kwargs["cwd"]) + cp = subprocess.run((sys.executable, "-m", "tox") + q + args, **kwargs) + except subprocess.CalledProcessError as e: + print(e.stdout, file=sys.stdout) + print(e.stderr, file=sys.stderr) + raise + print(cp.stdout, file=sys.stdout) + print(cp.stderr, file=sys.stderr) + return cp + + +TOX_VERSION = ver(tox("--version").stdout.split(" ")[0].split("+")[0]) +TOX_MIN_VERSION = ver(f"{TOX_VERSION.major}.{TOX_VERSION.minor}") +TOX4 = TOX_VERSION.major == 4 + + +@contextlib.contextmanager +def modify_config(tox_ini_path): + """Context manager that allows modifying the given Tox config file + + A statement like:: + + with prepare_config(projdir) as config: + + will make `config` a ConfigParser instance that is saved at the end + of the `with` block. + """ + config = configparser.ConfigParser() + config.read(tox_ini_path) + yield config + with open(tox_ini_path, "w") as tox_ini_file: + config.write(tox_ini_file) + + +@functools.lru_cache(maxsize=8) +def is_available(python): + try: + subprocess.run((python, "--version")) + except FileNotFoundError: + return False + return True + + +@functools.lru_cache() +def envs_from_tox_ini(): + cp = ConfigParser() + cp.read(FIXTURES_DIR / "tox.ini") + return cp["tox"]["envlist"].split(",") + + +def tox_footer(envs=None, spaces=8): + if envs is None: + envs = envs_from_tox_ini() + elif isinstance(envs, str): + envs = [envs] + + default_indent = " " * spaces + + if TOX4: + result = "" + else: + result = "___________________________________ summary ____________________________________\n" + + for i, env in enumerate(envs): + if TOX4: + # Skip indentation for the first line + indent = default_indent if i > 0 else "" + result += f"{indent} {env}: OK\n" + else: + result += f"{default_indent} {env}: commands succeeded\n" + + result += f"{default_indent} congratulations :)" + + return result + + +def prep_tox_output(output): + """Remove time info from tox output""" + result = re.sub(r" \((\d+\.\d+|\d+) seconds\)", "", output) + result = re.sub(r" ✔ in (\d+\.\d+|\d+) seconds", "", result) + return result + + +needs_all_pythons = pytest.mark.skipif( + not all((is_available(f"python3.{x}") for x in range(6, 12))), + reason="This test needs all pythons from 3.6 to 3.11 available in $PATH", +) diff --git a/tox.ini b/tox.ini index ac9136d..d8463de 100644 --- a/tox.ini +++ b/tox.ini @@ -2,15 +2,15 @@ # This information is repeated in .github/workflows/main.yaml # (see https://github.com/fedora-python/tox-github-action/issues/8) -envlist = {py36,py37,py38,py39,py310}-tox{release,master,315} +envlist = py36-tox{324,3},{py37,py38,py39,py310,py311}-tox{324,3,4} [testenv] extras = tests deps= - tox315: tox >=3.15,<3.16 - toxrelease: tox < 4 - toxmaster: git+https://github.com/tox-dev/tox.git@legacy + tox324: tox >=3.24,<3.25 + tox3: tox < 4 + tox4: tox >=4,< 5 commands = pytest -v {posargs} tests