diff --git a/.gitignore b/.gitignore index 8e754eaaf..125b5d408 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ __pycache__ credentials.json pip-wheel-metadata +.DS_Store diff --git a/MANIFEST.in b/MANIFEST.in index ed0088a1b..eb61289e4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,3 @@ -include CHANGELOG.rst -include README.rst include CONTRIBUTORS include LICENSE include setup.py @@ -7,5 +5,4 @@ include tox.ini graft docs graft tests -global-exclude __pycache__ global-exclude *.py[cod] diff --git a/docs/conf.py b/docs/conf.py index 02849a76e..463366d11 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,14 +47,14 @@ def generate_draft_news(): generate_draft_news() -project = u"tox" +project = "tox" _full_version = tox.__version__ release = _full_version.split("+", 1)[0] version = ".".join(release.split(".")[:2]) author = "holger krekel and others" year = date.today().year -copyright = u"2010-{}, {}".format(year, author) +copyright = "2010-{}, {}".format(year, author) master_doc = "index" source_suffix = ".rst" @@ -82,8 +82,8 @@ def generate_draft_news(): html_show_sourcelink = False html_static_path = ["_static"] htmlhelp_basename = "{}doc".format(project) -latex_documents = [("index", "tox.tex", u"{} Documentation".format(project), author, "manual")] -man_pages = [("index", project, u"{} Documentation".format(project), [author], 1)] +latex_documents = [("index", "tox.tex", "{} Documentation".format(project), author, "manual")] +man_pages = [("index", project, "{} Documentation".format(project), [author], 1)] epub_title = project epub_author = author epub_publisher = author diff --git a/setup.cfg b/setup.cfg index 943b56b1a..7ff81a09f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,36 +41,34 @@ install_requires = importlib-metadata >= 0.12, <1;python_version<"3.8" packaging >= 14 pluggy >= 0.12.0, <1 - py >= 1.4.17, <2 - six >= 1.0.0, <2 - virtualenv >= 14.0.0 - toml >=0.9.4 - filelock >= 3.0.0, <4 + appdirs >= 1.4.3, <2 + virtualenv + colorama + toml [options.packages.find] where = src [options.entry_points] console_scripts = - tox=tox:cmdline - tox-quickstart=tox._quickstart:main + tox = tox.run:run [options.extras_require] testing = - freezegun >= 0.3.11, <1 - pathlib2 >= 2.3.3, <3 pytest >= 4.0.0, <6 pytest-cov >= 2.5.1, <3 pytest-mock >= 1.10.0, <2 - pytest-xdist >= 1.22.2, <2 - pytest-randomly >= 1.2.3, <2 - psutil >= 5.6.1, < 6; python_version != "3.4" - flaky >= 3.4.0, < 4 + pytest-timeout + pytest-xdist + psutil + setuptools >= 41.0.0 + wheel >= 0.30.0 docs = sphinx >= 2.0.0, < 3 towncrier >= 18.5.0 pygments-github-lexers >= 0.0.5 sphinxcontrib-autoprogram >= 0.1.5 + importlib-metadata >= 0.12, <1;python_version<"3.8" [bdist_wheel] universal = 1 diff --git a/src/new.md b/src/new.md new file mode 100644 index 000000000..da096246d --- /dev/null +++ b/src/new.md @@ -0,0 +1,101 @@ +# External facing +0. ``Python 3.5+`` only. +1. Lazy configuration - everything is materialized only when needed (don't ever generate data that will not be used - general speed improvement) +2. built-in wheel build support - no longer generates sdist only +3. library dependency changes are now detected (no longer need to recreate tox-env when adding a new dependency to your library) - use PEP-517 meta data generation to acquire these +4. CLI arguments rewrite - all defaults now are override-able either via global ini, or env var +5. allow overriding all configuration values from the cli +6. tox now supports sub-commands - still defaults to run sequential the envs (we plan to add additional commands later - e.g. configuration validation): + - the list envs has migrated to the `list` sub-command from -a (`l` shortcut) + - the show config has migrated to the `config` sub-command form `--showconfig` (`c` shortcut) + - the run parallel has migrated to `run-parallel` sub-command form `-p` (`p` shortcut) + - the run sequential has migrated to `run` sub-command form non other commands (`r` shortcut) +7. while executing subprocess calls the standard error no longer gets forwarded to the standard output but correctly to the standard error (previously this was only true for +non captured commands) +8. ``basepython`` is now a list, the first successfully detected python will be used to generate python environment + +# Internal +0. ``Python 3.5+`` only with type annotated code. +1. Separate core configuration concepts from the ini system (to allow introduction of new configuration) +2. so long `py` my good old friend, use `pathlib` always +3. Introduce the executor concept - replaces action, generalize to avoid ease of replacement with +4. Generalize tox environment concept to make it python agnostic +5. Separate the packaging environments versus run environments +6. Package environments are tied directly to run environments (multiple run environments may share the same packaging environment) +7. Use the logging framework to log - drop our custom reporter - default log level is `INFO` +8. Python discovery delegated to virtualenv - due to exposing that in virtualenv is WIP, and dependent on our release we vendor it for now +9. rewrite the internal cache log (log more, structured, phased) + +```json +{ + "ToxEnv": { + "name": "py37", + "type": "VirtualEnvRunner" + }, + "Python": { + "version_info": [ + 3, + 7, + 4, + "final", + 0 + ], + "executable": "/Users/bgabor8/git/github/tox/.tox/dev/bin/python" + }, + "PythonRun": { + "deps": [ + "pip==19.2.1" + ], + "package_deps": [ + "packaging>=14", + "pluggy<1,>=0.12.0", + "appdirs<2,>=1.4.3", + "virtualenv", + "importlib-metadata<1,>=0.12; python_version < \"3.8\"", + "freezegun<1,>=0.3.11", + "pytest<6,>=4.0.0", + "pytest-cov<3,>=2.5.1", + "pytest-mock<2,>=1.10.0" + ] + } +}⏎ +{ + "ToxEnv": { + "name": ".package", + "type": "Pep517VirtualEnvPackageWheel" + }, + "Python": { + "version_info": [ + 3, + 7, + 4, + "final", + 0 + ], + "executable": "/Users/bgabor8/git/github/tox/.tox/dev/bin/python" + }, + "PythonPackage": { + "requires": [ + "setuptools >= 40.0.4", + "setuptools_scm >= 2.0.0, <4", + "wheel >= 0.29.0" + ], + "build-requires": [] + } +} +``` + +# TODO +* index url support for python pip +* introduce the run log concept +* handle provisioning +* make it parallel safe (packaging + logs) +* Make sure we're config compliant with tox 3 (excluding deprecated features) - CLI compliant is best effort +* Allow plugins generating new tox-environments (this will probably require a in-memory config) +* Rewrite documentation (generate configuration from code) + +## Validate rewrite +* provide a pre-commit env generator plugin +* provide a sphinx doc env generator plugin +* Provide a tox environment that uses Docker images instead of virtual environments (this will validate the internal refactor) +* migrate some popular tox plugins to the new system (`tox-travis` + `tox-pipenv` + `tox-conda` + `tox-pyenv` + `tox-current-env`) diff --git a/src/tox/__init__.py b/src/tox/__init__.py index b3df3d5f8..e69de29bb 100644 --- a/src/tox/__init__.py +++ b/src/tox/__init__.py @@ -1,32 +0,0 @@ -"""Everything made explicitly available via `__all__` can be considered as part of the tox API. - -We will emit deprecation warnings for one minor release before making changes to these objects. - -If objects are marked experimental they might change between minor versions. - -To override/modify tox behaviour via plugins see `tox.hookspec` and its use with pluggy. -""" -import pluggy - -from . import exception -from .constants import INFO, PIP, PYTHON -from .hookspecs import hookspec -from .version import __version__ - -__all__ = ( - "__version__", # tox version - "cmdline", # run tox as part of another program/IDE (same behaviour as called standalone) - "hookimpl", # Hook implementation marker to be imported by plugins - "exception", # tox specific exceptions - # EXPERIMENTAL CONSTANTS API - "PYTHON", - "INFO", - "PIP", - # DEPRECATED - will be removed from API in tox 4 - "hookspec", -) - -hookimpl = pluggy.HookimplMarker("tox") - -# NOTE: must come last due to circular import -from .session import cmdline # isort:skip diff --git a/src/tox/__main__.py b/src/tox/__main__.py index 821fa4800..5d458d48d 100644 --- a/src/tox/__main__.py +++ b/src/tox/__main__.py @@ -1,4 +1,4 @@ -import tox +from tox.run import run if __name__ == "__main__": - tox.cmdline() + run() diff --git a/src/tox/_pytestplugin.py b/src/tox/_pytestplugin.py deleted file mode 100644 index 5d34258e3..000000000 --- a/src/tox/_pytestplugin.py +++ /dev/null @@ -1,598 +0,0 @@ -from __future__ import print_function, unicode_literals - -import os -import subprocess -import sys -import textwrap -import time -import traceback -from collections import OrderedDict -from fnmatch import fnmatch - -import py -import pytest -import six - -import tox -import tox.session -from tox import venv -from tox.config import parseconfig -from tox.config.parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY -from tox.reporter import update_default_reporter -from tox.venv import CreationConfig, VirtualEnv, getdigest - -mark_dont_run_on_windows = pytest.mark.skipif(os.name == "nt", reason="non windows test") -mark_dont_run_on_posix = pytest.mark.skipif(os.name == "posix", reason="non posix test") - - -def pytest_configure(): - if "TOXENV" in os.environ: - del os.environ["TOXENV"] - if "HUDSON_URL" in os.environ: - del os.environ["HUDSON_URL"] - - -def pytest_addoption(parser): - parser.addoption( - "--no-network", - action="store_true", - dest="no_network", - help="don't run tests requiring network", - ) - - -def pytest_report_header(): - return "tox comes from: {!r}".format(tox.__file__) - - -@pytest.fixture -def work_in_clean_dir(tmpdir): - with tmpdir.as_cwd(): - yield - - -@pytest.fixture(autouse=True) -def check_cwd_not_changed_by_test(): - old = os.getcwd() - yield - new = os.getcwd() - if old != new: - pytest.fail("test changed cwd: {!r} => {!r}".format(old, new)) - - -@pytest.fixture(autouse=True) -def check_os_environ_stable(): - old = os.environ.copy() - - to_clean = { - k: os.environ.pop(k, None) - for k in {PARALLEL_ENV_VAR_KEY, str("TOX_WORK_DIR"), str("PYTHONPATH")} - } - - yield - - for key, value in to_clean.items(): - if value is not None: - os.environ[key] = value - - new = os.environ - extra = {k: new[k] for k in set(new) - set(old)} - miss = {k: old[k] for k in set(old) - set(new)} - diff = { - "{} = {} vs {}".format(k, old[k], new[k]) - for k in set(old) & set(new) - if old[k] != new[k] and not k.startswith("PYTEST_") - } - if extra or miss or diff: - msg = "test changed environ" - if extra: - msg += " extra {}".format(extra) - if miss: - msg += " miss {}".format(miss) - if diff: - msg += " diff {}".format(diff) - pytest.fail(msg) - - -@pytest.fixture(name="newconfig") -def create_new_config_file(tmpdir): - def create_new_config_file_(args, source=None, plugins=()): - if source is None: - source = args - args = [] - s = textwrap.dedent(source) - p = tmpdir.join("tox.ini") - p.write(s) - tox.session.setup_reporter(args) - with tmpdir.as_cwd(): - return parseconfig(args, plugins=plugins) - - return create_new_config_file_ - - -@pytest.fixture -def cmd(request, monkeypatch, capfd): - if request.config.option.no_network: - pytest.skip("--no-network was specified, test cannot run") - request.addfinalizer(py.path.local().chdir) - - def run(*argv): - reset_report() - with RunResult(argv, capfd) as result: - _collect_session(result) - - # noinspection PyBroadException - try: - tox.session.main([str(x) for x in argv]) - assert False # this should always exist with SystemExit - except SystemExit as exception: - result.ret = exception.code - except OSError as e: - traceback.print_exc() - result.ret = e.errno - except Exception: - traceback.print_exc() - result.ret = 1 - return result - - def _collect_session(result): - prev_build = tox.session.build_session - - def build_session(config): - result.session = prev_build(config) - return result.session - - monkeypatch.setattr(tox.session, "build_session", build_session) - - yield run - - -class RunResult: - def __init__(self, args, capfd): - self.args = args - self.ret = None - self.duration = None - self.out = None - self.err = None - self.session = None - self.capfd = capfd - - def __enter__(self): - self._start = time.time() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.duration = time.time() - self._start - self.out, self.err = self.capfd.readouterr() - - def _read(self, out, pos): - out.buffer.seek(pos) - return out.buffer.read().decode(out.encoding, errors=out.errors) - - @property - def outlines(self): - out = [] if self.out is None else self.out.splitlines() - err = [] if self.err is None else self.err.splitlines() - return err + out - - def __repr__(self): - res = "RunResult(ret={}, args={!r}, out=\n{}\n, err=\n{})".format( - self.ret, self.args, self.out, self.err - ) - if six.PY2: - return res.encode("UTF-8") - else: - return res - - def output(self): - return "{}\n{}\n{}".format(self.ret, self.err, self.out) - - def assert_success(self, is_run_test_env=True): - msg = self.output() - assert self.ret == 0, msg - if is_run_test_env: - assert any(" congratulations :)" == l for l in reversed(self.outlines)), msg - - def assert_fail(self, is_run_test_env=True): - msg = self.output() - assert self.ret, msg - if is_run_test_env: - assert not any(" congratulations :)" == l for l in reversed(self.outlines)), msg - - -class ReportExpectMock: - def __init__(self): - from tox import reporter - - self.instance = reporter._INSTANCE - self.clear() - self._index = -1 - - def clear(self): - self._index = -1 - if six.PY3: - self.instance.reported_lines.clear() - else: - del self.instance.reported_lines[:] - - def getnext(self, cat): - __tracebackhide__ = True - newindex = self._index + 1 - while newindex < len(self.instance.reported_lines): - call = self.instance.reported_lines[newindex] - lcat = call[0] - if fnmatch(lcat, cat): - self._index = newindex - return call - newindex += 1 - raise LookupError( - "looking for {!r}, no reports found at >={:d} in {!r}".format( - cat, self._index + 1, self.instance.reported_lines - ) - ) - - def expect(self, cat, messagepattern="*", invert=False): - __tracebackhide__ = True - if not messagepattern.startswith("*"): - messagepattern = "*{}".format(messagepattern) - while self._index < len(self.instance.reported_lines): - try: - call = self.getnext(cat) - except LookupError: - break - for lmsg in call[1:]: - lmsg = str(lmsg).replace("\n", " ") - if fnmatch(lmsg, messagepattern): - if invert: - raise AssertionError( - "found {}({!r}), didn't expect it".format(cat, messagepattern) - ) - return - if not invert: - raise AssertionError( - "looking for {}({!r}), no reports found at >={:d} in {!r}".format( - cat, messagepattern, self._index + 1, self.instance.reported_lines - ) - ) - - def not_expect(self, cat, messagepattern="*"): - return self.expect(cat, messagepattern, invert=True) - - -class pcallMock: - def __init__(self, args, cwd, env, stdout, stderr, shell): - self.arg0 = args[0] - self.args = args - self.cwd = cwd - self.env = env - self.stdout = stdout - self.stderr = stderr - self.shell = shell - self.pid = os.getpid() - self.returncode = 0 - - @staticmethod - def communicate(): - return "", "" - - def wait(self): - pass - - -@pytest.fixture(name="mocksession") -def create_mocksession(request): - config = request.getfixturevalue("newconfig")([], "") - - class MockSession(tox.session.Session): - def __init__(self, config): - self.logging_levels(config.option.quiet_level, config.option.verbose_level) - super(MockSession, self).__init__(config, popen=self.popen) - self._pcalls = [] - self.report = ReportExpectMock() - - def _clearmocks(self): - if six.PY3: - self._pcalls.clear() - else: - del self._pcalls[:] - self.report.clear() - - def popen(self, args, cwd, shell=None, stdout=None, stderr=None, env=None, **_): - process_call_mock = pcallMock(args, cwd, env, stdout, stderr, shell) - self._pcalls.append(process_call_mock) - return process_call_mock - - def new_config(self, config): - self.logging_levels(config.option.quiet_level, config.option.verbose_level) - self.config = config - self.venv_dict.clear() - self.existing_venvs.clear() - - def logging_levels(self, quiet, verbose): - update_default_reporter(quiet, verbose) - if hasattr(self, "config"): - self.config.option.quiet_level = quiet - self.config.option.verbose_level = verbose - - return MockSession(config) - - -@pytest.fixture -def newmocksession(mocksession, newconfig): - def newmocksession_(args, source, plugins=()): - config = newconfig(args, source, plugins=plugins) - mocksession._reset(config, mocksession.popen) - return mocksession - - return newmocksession_ - - -def getdecoded(out): - try: - return out.decode("utf-8") - except UnicodeDecodeError: - return "INTERNAL not-utf8-decodeable, truncated string:\n{}".format(py.io.saferepr(out)) - - -@pytest.fixture -def initproj(tmpdir): - """Create a factory function for creating example projects. - - Constructed folder/file hierarchy examples: - - with `src_root` other than `.`: - - tmpdir/ - name/ # base - src_root/ # src_root - name/ # package_dir - __init__.py - name.egg-info/ # created later on package build - setup.py - - with `src_root` given as `.`: - - tmpdir/ - name/ # base, src_root - name/ # package_dir - __init__.py - name.egg-info/ # created later on package build - setup.py - """ - - def initproj_(nameversion, filedefs=None, src_root=".", add_missing_setup_py=True): - if filedefs is None: - filedefs = {} - if not src_root: - src_root = "." - if isinstance(nameversion, six.string_types): - parts = nameversion.rsplit(str("-"), 1) - if len(parts) == 1: - parts.append("0.1") - name, version = parts - else: - name, version = nameversion - base = tmpdir.join(name) - src_root_path = _path_join(base, src_root) - assert base == src_root_path or src_root_path.relto( - base - ), "`src_root` must be the constructed project folder or its direct or indirect subfolder" - - base.ensure(dir=1) - create_files(base, filedefs) - if not _filedefs_contains(base, filedefs, "setup.py") and add_missing_setup_py: - create_files( - base, - { - "setup.py": """ - from setuptools import setup, find_packages - setup( - name='{name}', - description='{name} project', - version='{version}', - license='MIT', - platforms=['unix', 'win32'], - packages=find_packages('{src_root}'), - package_dir={{'':'{src_root}'}}, - ) - """.format( - **locals() - ) - }, - ) - if not _filedefs_contains(base, filedefs, src_root_path.join(name)): - create_files( - src_root_path, - { - name: { - "__init__.py": textwrap.dedent( - ''' - """ module {} """ - __version__ = {!r}''' - ) - .strip() - .format(name, version) - } - }, - ) - manifestlines = [ - "include {}".format(p.relto(base)) for p in base.visit(lambda x: x.check(file=1)) - ] - create_files(base, {"MANIFEST.in": "\n".join(manifestlines)}) - base.chdir() - return base - - with py.path.local().as_cwd(): - yield initproj_ - - -def _path_parts(path): - path = path and str(path) # py.path.local support - parts = [] - while path: - folder, name = os.path.split(path) - if folder == path: # root folder - folder, name = name, folder - if name: - parts.append(name) - path = folder - parts.reverse() - return parts - - -def _path_join(base, *args): - # workaround for a py.path.local bug on Windows (`path.join('/x', abs=1)` - # should be py.path.local('X:\\x') where `X` is the current drive, when in - # fact it comes out as py.path.local('\\x')) - return py.path.local(base.join(*args, abs=1)) - - -def _filedefs_contains(base, filedefs, path): - """ - whether `filedefs` defines a file/folder with the given `path` - - `path`, if relative, will be interpreted relative to the `base` folder, and - whether relative or not, must refer to either the `base` folder or one of - its direct or indirect children. The base folder itself is considered - created if the filedefs structure is not empty. - - """ - unknown = object() - base = py.path.local(base) - path = _path_join(base, path) - - path_rel_parts = _path_parts(path.relto(base)) - for part in path_rel_parts: - if not isinstance(filedefs, dict): - return False - filedefs = filedefs.get(part, unknown) - if filedefs is unknown: - return False - return path_rel_parts or path == base and filedefs - - -def create_files(base, filedefs): - for key, value in filedefs.items(): - if isinstance(value, dict): - create_files(base.ensure(key, dir=1), value) - elif isinstance(value, six.string_types): - s = textwrap.dedent(value) - base.join(key).write(s) - - -@pytest.fixture() -def mock_venv(monkeypatch): - """This creates a mock virtual environment (e.g. will inherit the current interpreter). - Note: because we inherit, to keep things sane you must call the py environment and only that; - and cannot install any packages. """ - - # first ensure we have a clean python path - monkeypatch.delenv(str("PYTHONPATH"), raising=False) - - # object to collect some data during the execution - class Result(object): - def __init__(self, session): - self.popens = popen_list - self.session = session - - res = OrderedDict() - - # convince tox that the current running virtual environment is already the env we would create - class ProxyCurrentPython: - @classmethod - def readconfig(cls, path): - if path.dirname.endswith("{}py".format(os.sep)): - return CreationConfig( - base_resolved_python_sha256=getdigest(sys.executable), - base_resolved_python_path=sys.executable, - tox_version=tox.__version__, - sitepackages=False, - usedevelop=False, - deps=[], - alwayscopy=False, - ) - elif path.dirname.endswith("{}.package".format(os.sep)): - return CreationConfig( - base_resolved_python_sha256=getdigest(sys.executable), - base_resolved_python_path=sys.executable, - tox_version=tox.__version__, - sitepackages=False, - usedevelop=False, - deps=[(getdigest(""), "setuptools >= 35.0.2"), (getdigest(""), "wheel")], - alwayscopy=False, - ) - assert False # pragma: no cover - - monkeypatch.setattr(CreationConfig, "readconfig", ProxyCurrentPython.readconfig) - - # provide as Python the current python executable - def venv_lookup(venv, name): - assert name == "python" - venv.envconfig.envdir = py.path.local(sys.executable).join("..", "..") - return sys.executable - - monkeypatch.setattr(VirtualEnv, "_venv_lookup", venv_lookup) - - # don't allow overriding the tox config data for the host Python - def finish_venv(self): - return - - monkeypatch.setattr(VirtualEnv, "finish", finish_venv) - - # we lie that it's an environment with no packages in it - @tox.hookimpl - def tox_runenvreport(venv, action): - return [] - - monkeypatch.setattr(venv, "tox_runenvreport", tox_runenvreport) - - # intercept the build session to save it and we intercept the popen invocations - # collect all popen calls - popen_list = [] - - def popen(cmd, **kwargs): - # we don't want to perform installation of new packages, - # just replace with an always ok cmd - if "pip" in cmd and "install" in cmd: - cmd = ["python", "-c", "print({!r})".format(cmd)] - ret = None - try: - ret = subprocess.Popen(cmd, **kwargs) - except tox.exception.InvocationError as exception: # pragma: no cover - ret = exception # pragma: no cover - finally: - popen_list.append((kwargs.get("env"), ret, cmd)) - return ret - - def build_session(config): - session = tox.session.Session(config, popen=popen) - res[id(session)] = Result(session) - return session - - monkeypatch.setattr(tox.session, "build_session", build_session) - return res - - -@pytest.fixture(scope="session") -def current_tox_py(): - """generate the current (test runners) python versions key - e.g. py37 when running under Python 3.7""" - return "{}{}{}".format("pypy" if tox.INFO.IS_PYPY else "py", *sys.version_info) - - -def pytest_runtest_setup(item): - reset_report() - - -def pytest_runtest_teardown(item): - reset_report() - - -def pytest_pyfunc_call(pyfuncitem): - reset_report() - - -def reset_report(quiet=0, verbose=0): - from tox.reporter import _INSTANCE - - _INSTANCE._reset(quiet_level=quiet, verbose_level=verbose) diff --git a/src/tox/_quickstart.py b/src/tox/_quickstart.py deleted file mode 100644 index 74f082c66..000000000 --- a/src/tox/_quickstart.py +++ /dev/null @@ -1,283 +0,0 @@ -# -*- coding: utf-8 -*- -""" - tox._quickstart - ~~~~~~~~~~~~~~~~~ - - Command-line script to quickly setup a configuration for a Python project - - This file was heavily inspired by and uses code from ``sphinx-quickstart`` - in the BSD-licensed `Sphinx project`_. - - .. Sphinx project_: http://sphinx.pocoo.org/ - - License for Sphinx - ================== - - Copyright (c) 2007-2011 by the Sphinx team (see AUTHORS file). - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -""" -import argparse -import codecs -import os -import sys -import textwrap - -import six - -import tox - -ALTERNATIVE_CONFIG_NAME = "tox-generated.ini" -QUICKSTART_CONF = """\ -# tox (https://tox.readthedocs.io/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - -[tox] -envlist = {envlist} - -[testenv] -deps = - {deps} -commands = - {commands} -""" - - -class ValidationError(Exception): - """Raised for validation errors.""" - - -def nonempty(x): - if not x: - raise ValidationError("Please enter some text.") - return x - - -def choice(*l): - def val(x): - if x not in l: - raise ValidationError("Please enter one of {}.".format(", ".join(l))) - return x - - return val - - -def boolean(x): - if x.upper() not in ("Y", "YES", "N", "NO"): - raise ValidationError("Please enter either 'y' or 'n'.") - return x.upper() in ("Y", "YES") - - -def list_modificator(answer, existing=None): - if not existing: - existing = [] - if not isinstance(existing, list): - existing = [existing] - if not answer: - return existing - existing.extend([t.strip() for t in answer.split(",") if t.strip()]) - return existing - - -def do_prompt(map_, key, text, default=None, validator=nonempty, modificator=None): - while True: - prompt = "> {} [{}]: ".format(text, default) if default else "> {}: ".format(text) - answer = six.moves.input(prompt) - if default and not answer: - answer = default - # FIXME use six instead of self baked solution - # noinspection PyUnresolvedReferences - if sys.version_info < (3,) and not isinstance(answer, unicode): # noqa - # for Python 2.x, try to get a Unicode string out of it - if answer.decode("ascii", "replace").encode("ascii", "replace") != answer: - term_encoding = getattr(sys.stdin, "encoding", None) - if term_encoding: - answer = answer.decode(term_encoding) - else: - print( - "* Note: non-ASCII characters entered but terminal encoding unknown" - " -> assuming UTF-8 or Latin-1." - ) - try: - answer = answer.decode("utf-8") - except UnicodeDecodeError: - answer = answer.decode("latin1") - if validator: - try: - answer = validator(answer) - except ValidationError as exception: - print("* {}".format(exception)) - continue - break - map_[key] = modificator(answer, map_.get(key)) if modificator else answer - - -def ask_user(map_): - """modify *map_* in place by getting info from the user.""" - print("Welcome to the tox {} quickstart utility.".format(tox.__version__)) - print( - "This utility will ask you a few questions and then generate a simple configuration " - "file to help get you started using tox.\n" - "Please enter values for the following settings (just press Enter to accept a " - "default value, if one is given in brackets).\n" - ) - print( - textwrap.dedent( - """What Python versions do you want to test against? - [1] {} - [2] py27, {} - [3] (All versions) {} - [4] Choose each one-by-one""" - ).format( - tox.PYTHON.CURRENT_RELEASE_ENV, - tox.PYTHON.CURRENT_RELEASE_ENV, - ", ".join(tox.PYTHON.QUICKSTART_PY_ENVS), - ) - ) - do_prompt( - map_, - "canned_pyenvs", - "Enter the number of your choice", - default="3", - validator=choice("1", "2", "3", "4"), - ) - if map_["canned_pyenvs"] == "1": - map_[tox.PYTHON.CURRENT_RELEASE_ENV] = True - elif map_["canned_pyenvs"] == "2": - for pyenv in ("py27", tox.PYTHON.CURRENT_RELEASE_ENV): - map_[pyenv] = True - elif map_["canned_pyenvs"] == "3": - for pyenv in tox.PYTHON.QUICKSTART_PY_ENVS: - map_[pyenv] = True - elif map_["canned_pyenvs"] == "4": - for pyenv in tox.PYTHON.QUICKSTART_PY_ENVS: - if pyenv not in map_: - do_prompt( - map_, - pyenv, - "Test your project with {} (Y/n)".format(pyenv), - "Y", - validator=boolean, - ) - print( - textwrap.dedent( - """What command should be used to test your project? Examples:\ - - pytest\n" - - python -m unittest discover - - python setup.py test - - trial package.module""" - ) - ) - do_prompt( - map_, - "commands", - "Type the command to run your tests", - default="pytest", - modificator=list_modificator, - ) - print("What extra dependencies do your tests have?") - map_["deps"] = get_default_deps(map_["commands"]) - if map_["deps"]: - print("default dependencies are: {}".format(map_["deps"])) - do_prompt( - map_, - "deps", - "Comma-separated list of dependencies", - validator=None, - modificator=list_modificator, - ) - - -def get_default_deps(commands): - if commands and any(c in str(commands) for c in ["pytest", "py.test"]): - return ["pytest"] - if "trial" in commands: - return ["twisted"] - return [] - - -def post_process_input(map_): - envlist = [env for env in tox.PYTHON.QUICKSTART_PY_ENVS if map_.get(env) is True] - map_["envlist"] = ", ".join(envlist) - map_["commands"] = "\n ".join([cmd.strip() for cmd in map_["commands"]]) - map_["deps"] = "\n ".join([dep.strip() for dep in set(map_["deps"])]) - - -def generate(map_): - """Generate project based on values in *d*.""" - dpath = map_.get("path", os.getcwd()) - altpath = os.path.join(dpath, ALTERNATIVE_CONFIG_NAME) - while True: - name = map_.get("name", tox.INFO.DEFAULT_CONFIG_NAME) - targetpath = os.path.join(dpath, name) - if not os.path.isfile(targetpath): - break - do_prompt(map_, "name", "{} exists - choose an alternative".format(targetpath), altpath) - with codecs.open(targetpath, "w", encoding="utf-8") as f: - f.write(prepare_content(QUICKSTART_CONF.format(**map_))) - print( - "Finished: {} has been created. For information on this file, " - "see https://tox.readthedocs.io/en/latest/config.html\n" - "Execute `tox` to test your project.".format(targetpath) - ) - - -def prepare_content(content): - return "\n".join([line.rstrip() for line in content.split("\n")]) - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Command-line script to quickly tox config file for a Python project." - ) - parser.add_argument( - "root", - type=str, - nargs="?", - default=".", - help="Custom root directory to write config to. Defaults to current directory.", - ) - parser.add_argument( - "--version", action="version", version="%(prog)s {}".format(tox.__version__) - ) - return parser.parse_args() - - -def main(): - args = parse_args() - map_ = {"path": args.root} - try: - ask_user(map_) - except (KeyboardInterrupt, EOFError): - print("\n[Interrupted.]") - return 1 - post_process_input(map_) - generate(map_) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/tox/action.py b/src/tox/action.py deleted file mode 100644 index 10707b48f..000000000 --- a/src/tox/action.py +++ /dev/null @@ -1,263 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import os -import pipes -import signal -import subprocess -import sys -import time -from contextlib import contextmanager -from threading import Thread - -import py - -from tox import reporter -from tox.constants import INFO -from tox.exception import InvocationError -from tox.reporter import Verbosity -from tox.util.lock import get_unique_file -from tox.util.stdlib import is_main_thread - -WAIT_INTERRUPT = 0.3 -WAIT_TERMINATE = 0.2 - - -class Action(object): - """Action is an effort to group operations with the same goal (within reporting)""" - - def __init__(self, name, msg, args, log_dir, generate_tox_log, command_log, popen, python): - self.name = name - self.args = args - self.msg = msg - self.activity = self.msg.split(" ", 1)[0] - self.log_dir = log_dir - self.generate_tox_log = generate_tox_log - self.via_popen = popen - self.command_log = command_log - self._timed_report = None - self.python = python - - def __enter__(self): - msg = "{} {}".format(self.msg, " ".join(map(str, self.args))) - self._timed_report = reporter.timed_operation(self.name, msg) - self._timed_report.__enter__() - - return self - - def __exit__(self, type, value, traceback): - self._timed_report.__exit__(type, value, traceback) - - def setactivity(self, name, msg): - self.activity = name - if msg: - reporter.verbosity0("{} {}: {}".format(self.name, name, msg), bold=True) - else: - reporter.verbosity1("{} {}: {}".format(self.name, name, msg), bold=True) - - def info(self, name, msg): - reporter.verbosity1("{} {}: {}".format(self.name, name, msg), bold=True) - - def popen( - self, - args, - cwd=None, - env=None, - redirect=True, - returnout=False, - ignore_ret=False, - capture_err=True, - callback=None, - report_fail=True, - ): - """this drives an interaction with a subprocess""" - cwd = py.path.local() if cwd is None else cwd - cmd_args = [str(x) for x in self._rewrite_args(cwd, args)] - cmd_args_shell = " ".join(pipes.quote(i) for i in cmd_args) - stream_getter = self._get_standard_streams( - capture_err, cmd_args_shell, redirect, returnout, cwd - ) - exit_code, output = None, None - with stream_getter as (fin, out_path, stderr, stdout): - try: - process = self.via_popen( - cmd_args, - stdout=stdout, - stderr=stderr, - cwd=str(cwd), - env=os.environ.copy() if env is None else env, - universal_newlines=True, - shell=False, - creationflags=( - subprocess.CREATE_NEW_PROCESS_GROUP - if sys.platform == "win32" - else 0 - # needed for Windows signal send ability (CTRL+C) - ), - ) - except OSError as exception: - exit_code = exception.errno - else: - if callback is not None: - callback(process) - reporter.log_popen(cwd, out_path, cmd_args_shell, process.pid) - output = self.evaluate_cmd(fin, process, redirect) - exit_code = process.returncode - finally: - if out_path is not None and out_path.exists(): - lines = out_path.read_text("UTF-8").split("\n") - # first three lines are the action, cwd, and cmd - remove it - output = "\n".join(lines[3:]) - try: - if exit_code and not ignore_ret: - if report_fail: - msg = "invocation failed (exit code {:d})".format(exit_code) - if out_path is not None: - msg += ", logfile: {}".format(out_path) - if not out_path.exists(): - msg += " warning log file missing" - reporter.error(msg) - if out_path is not None and out_path.exists(): - reporter.separator("=", "log start", Verbosity.QUIET) - reporter.quiet(output) - reporter.separator("=", "log end", Verbosity.QUIET) - raise InvocationError(cmd_args_shell, exit_code, output) - finally: - self.command_log.add_command(cmd_args, output, exit_code) - return output - - def evaluate_cmd(self, input_file_handler, process, redirect): - try: - if self.generate_tox_log and not redirect: - if process.stderr is not None: - # prevent deadlock - raise ValueError("stderr must not be piped here") - # we read binary from the process and must write using a binary stream - buf = getattr(sys.stdout, "buffer", sys.stdout) - last_time = time.time() - while True: - # we have to read one byte at a time, otherwise there - # might be no output for a long time with slow tests - data = input_file_handler.read(1) - if data: - buf.write(data) - if b"\n" in data or (time.time() - last_time) > 1: - # we flush on newlines or after 1 second to - # provide quick enough feedback to the user - # when printing a dot per test - buf.flush() - last_time = time.time() - elif process.poll() is not None: - if process.stdout is not None: - process.stdout.close() - break - else: - time.sleep(0.1) - # the seek updates internal read buffers - input_file_handler.seek(0, 1) - input_file_handler.close() - out, _ = process.communicate() # wait to finish - except KeyboardInterrupt as exception: - reporter.error("got KeyboardInterrupt signal") - main_thread = is_main_thread() - while True: - try: - if main_thread: - # spin up a new thread to disable further interrupt on main thread - stopper = Thread(target=self.handle_interrupt, args=(process,)) - stopper.start() - stopper.join() - else: - self.handle_interrupt(process) - except KeyboardInterrupt: - continue - break - raise exception - return out - - def handle_interrupt(self, process): - """A three level stop mechanism for children - INT -> TERM -> KILL""" - msg = "from {} {{}} pid {}".format(os.getpid(), process.pid) - if process.poll() is None: - self.info("KeyboardInterrupt", msg.format("SIGINT")) - process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT) - if self._wait(process, WAIT_INTERRUPT) is None: - self.info("KeyboardInterrupt", msg.format("SIGTERM")) - process.terminate() - if self._wait(process, WAIT_TERMINATE) is None: - self.info("KeyboardInterrupt", msg.format("SIGKILL")) - process.kill() - process.communicate() - - @staticmethod - def _wait(process, timeout): - if sys.version_info >= (3, 3): - # python 3 has timeout feature built-in - try: - process.communicate(timeout=WAIT_INTERRUPT) - except subprocess.TimeoutExpired: - pass - else: - # on Python 2 we need to simulate it - delay = 0.01 - while process.poll() is None and timeout > 0: - time.sleep(delay) - timeout -= delay - return process.poll() - - @contextmanager - def _get_standard_streams(self, capture_err, cmd_args_shell, redirect, returnout, cwd): - stdout = out_path = input_file_handler = None - stderr = subprocess.STDOUT if capture_err else None - - if self.generate_tox_log or redirect: - out_path = self.get_log_path(self.name) - with out_path.open("wt") as stdout, out_path.open("rb") as input_file_handler: - msg = "action: {}, msg: {}\ncwd: {}\ncmd: {}\n".format( - self.name.replace("\n", " "), - self.msg.replace("\n", " "), - str(cwd).replace("\n", " "), - cmd_args_shell.replace("\n", " "), - ) - stdout.write(msg) - stdout.flush() - input_file_handler.read() # read the header, so it won't be written to stdout - yield input_file_handler, out_path, stderr, stdout - return - - if returnout: - stdout = subprocess.PIPE - - yield input_file_handler, out_path, stderr, stdout - - def get_log_path(self, actionid): - log_file = get_unique_file(self.log_dir, prefix=actionid, suffix=".log") - return log_file - - def _rewrite_args(self, cwd, args): - - executable = None - if INFO.IS_WIN: - # shebang lines are not adhered on Windows so if it's a python script - # pre-pend the interpreter - ext = os.path.splitext(str(args[0]))[1].lower() - if ext == ".py": - executable = str(self.python) - if executable is None: - executable = args[0] - args = args[1:] - - new_args = [executable] - - # to make the command shorter try to use relative paths for all subsequent arguments - # note the executable cannot be relative as the Windows applies cwd after invocation - for arg in args: - if arg and os.path.isabs(str(arg)): - arg_path = py.path.local(arg) - if arg_path.exists() and arg_path.common(cwd) is not None: - potential_arg = cwd.bestrelpath(arg_path) - if len(potential_arg.split("..")) < 2: - # just one parent directory accepted as relative path - arg = potential_arg - new_args.append(str(arg)) - - return new_args diff --git a/src/tox/cli.py b/src/tox/cli.py deleted file mode 100644 index 2fe755cf1..000000000 --- a/src/tox/cli.py +++ /dev/null @@ -1,11 +0,0 @@ -from tox.config import Parser, get_plugin_manager - - -def cli_parser(): - parser = Parser() - pm = get_plugin_manager(tuple()) - pm.hook.tox_addoption(parser=parser) - return parser.argparser - - -cli = cli_parser() diff --git a/src/tox/config/__init__.py b/src/tox/config/__init__.py index 1d66ed7ae..e69de29bb 100644 --- a/src/tox/config/__init__.py +++ b/src/tox/config/__init__.py @@ -1,1796 +0,0 @@ -from __future__ import print_function - -import argparse -import itertools -import os -import random -import re -import shlex -import string -import sys -import traceback -import warnings -from collections import OrderedDict -from fnmatch import fnmatchcase -from subprocess import list2cmdline -from threading import Thread - -import pluggy -import py -import toml -from packaging import requirements -from packaging.utils import canonicalize_name - -import tox -from tox.constants import INFO -from tox.exception import MissingDependency -from tox.interpreters import Interpreters, NoInterpreterInfo -from tox.reporter import ( - REPORTER_TIMESTAMP_ON_ENV, - error, - update_default_reporter, - using, - verbosity1, -) -from tox.util.path import ensure_empty_dir -from tox.util.stdlib import importlib_metadata - -from .parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY -from .parallel import add_parallel_config, add_parallel_flags -from .reporter import add_verbosity_commands - -try: - from shlex import quote as shlex_quote -except ImportError: - from pipes import quote as shlex_quote - - -hookimpl = tox.hookimpl -"""DEPRECATED - REMOVE - left for compatibility with plugins importing from here. - -Import hookimpl directly from tox instead. -""" - -WITHIN_PROVISION = os.environ.get(str("TOX_PROVISION")) == "1" - - -def get_plugin_manager(plugins=()): - # initialize plugin manager - import tox.venv - - pm = pluggy.PluginManager("tox") - pm.add_hookspecs(tox.hookspecs) - pm.register(tox.config) - pm.register(tox.interpreters) - pm.register(tox.venv) - pm.register(tox.session) - from tox import package - - pm.register(package) - pm.load_setuptools_entrypoints("tox") - for plugin in plugins: - pm.register(plugin) - pm.check_pending() - return pm - - -class Parser: - """Command line and ini-parser control object.""" - - def __init__(self): - class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter): - def __init__(self, prog): - super(HelpFormatter, self).__init__(prog, max_help_position=35, width=190) - - self.argparser = argparse.ArgumentParser( - description="tox options", add_help=False, prog="tox", formatter_class=HelpFormatter - ) - self._testenv_attr = [] - - def add_argument(self, *args, **kwargs): - """ add argument to command line parser. This takes the - same arguments that ``argparse.ArgumentParser.add_argument``. - """ - return self.argparser.add_argument(*args, **kwargs) - - def add_testenv_attribute(self, name, type, help, default=None, postprocess=None): - """ add an ini-file variable for "testenv" section. - - Types are specified as strings like "bool", "line-list", "string", "argv", "path", - "argvlist". - - The ``postprocess`` function will be called for each testenv - like ``postprocess(testenv_config=testenv_config, value=value)`` - where ``value`` is the value as read from the ini (or the default value) - and ``testenv_config`` is a :py:class:`tox.config.TestenvConfig` instance - which will receive all ini-variables as object attributes. - - Any postprocess function must return a value which will then be set - as the final value in the testenv section. - """ - self._testenv_attr.append(VenvAttribute(name, type, default, help, postprocess)) - - def add_testenv_attribute_obj(self, obj): - """ add an ini-file variable as an object. - - This works as the ``add_testenv_attribute`` function but expects - "name", "type", "help", and "postprocess" attributes on the object. - """ - assert hasattr(obj, "name") - assert hasattr(obj, "type") - assert hasattr(obj, "help") - assert hasattr(obj, "postprocess") - self._testenv_attr.append(obj) - - def parse_cli(self, args, strict=False): - args, argv = self.argparser.parse_known_args(args) - if argv and (strict or WITHIN_PROVISION): - self.argparser.error("unrecognized arguments: {}".format(" ".join(argv))) - return args - - def _format_help(self): - return self.argparser.format_help() - - -class VenvAttribute: - def __init__(self, name, type, default, help, postprocess): - self.name = name - self.type = type - self.default = default - self.help = help - self.postprocess = postprocess - - -class DepOption: - name = "deps" - type = "line-list" - help = "each line specifies a dependency in pip/setuptools format." - default = () - - def postprocess(self, testenv_config, value): - deps = [] - config = testenv_config.config - for depline in value: - m = re.match(r":(\w+):\s*(\S+)", depline) - if m: - iname, name = m.groups() - ixserver = config.indexserver[iname] - else: - name = depline.strip() - ixserver = None - # we need to process options, in case they contain a space, - # as the subprocess call to pip install will otherwise fail. - # in case of a short option, we remove the space - for option in tox.PIP.INSTALL_SHORT_OPTIONS_ARGUMENT: - if name.startswith(option): - name = "{}{}".format(option, name[len(option) :].strip()) - # in case of a long option, we add an equal sign - for option in tox.PIP.INSTALL_LONG_OPTIONS_ARGUMENT: - name_start = "{} ".format(option) - if name.startswith(name_start): - name = "{}={}".format(option, name[len(option) :].strip()) - name = self._cut_off_dep_comment(name) - name = self._replace_forced_dep(name, config) - deps.append(DepConfig(name, ixserver)) - return deps - - def _replace_forced_dep(self, name, config): - """Override given dependency config name. Take ``--force-dep-version`` option into account. - - :param name: dep config, for example ["pkg==1.0", "other==2.0"]. - :param config: ``Config`` instance - :return: the new dependency that should be used for virtual environments - """ - if not config.option.force_dep: - return name - for forced_dep in config.option.force_dep: - if self._is_same_dep(forced_dep, name): - return forced_dep - return name - - @staticmethod - def _cut_off_dep_comment(name): - return re.sub(r"\s+#.*", "", name).strip() - - @classmethod - def _is_same_dep(cls, dep1, dep2): - """Definitions are the same if they refer to the same package, even if versions differ.""" - dep1_name = canonicalize_name(requirements.Requirement(dep1).name) - try: - dep2_name = canonicalize_name(requirements.Requirement(dep2).name) - except requirements.InvalidRequirement: - # we couldn't parse a version, probably a URL - return False - return dep1_name == dep2_name - - -class PosargsOption: - name = "args_are_paths" - type = "bool" - default = True - help = "treat positional args in commands as paths" - - def postprocess(self, testenv_config, value): - config = testenv_config.config - args = config.option.args - if args: - if value: - args = [] - for arg in config.option.args: - if arg and not os.path.isabs(arg): - origpath = os.path.join(config.invocationcwd.strpath, arg) - if os.path.exists(origpath): - arg = os.path.relpath(origpath, testenv_config.changedir.strpath) - args.append(arg) - testenv_config._reader.addsubstitutions(args) - return value - - -class InstallcmdOption: - name = "install_command" - type = "argv" - default = "python -m pip install {opts} {packages}" - help = "install command for dependencies and package under test." - - def postprocess(self, testenv_config, value): - if "{packages}" not in value: - raise tox.exception.ConfigError( - "'install_command' must contain '{packages}' substitution" - ) - return value - - -def parseconfig(args, plugins=()): - """Parse the configuration file and create a Config object. - - :param plugins: - :param list[str] args: list of arguments. - :rtype: :class:`Config` - :raise SystemExit: toxinit file is not found - """ - pm = get_plugin_manager(plugins) - config, option = parse_cli(args, pm) - update_default_reporter(config.option.quiet_level, config.option.verbose_level) - - for config_file in propose_configs(option.configfile): - config_type = config_file.basename - - content = None - if config_type == "pyproject.toml": - toml_content = get_py_project_toml(config_file) - try: - content = toml_content["tool"]["tox"]["legacy_tox_ini"] - except KeyError: - continue - ParseIni(config, config_file, content) - pm.hook.tox_configure(config=config) # post process config object - break - else: - msg = "tox config file (either {}) not found" - candidates = ", ".join(INFO.CONFIG_CANDIDATES) - feedback(msg.format(candidates), sysexit=not (option.help or option.helpini)) - return config - - -def get_py_project_toml(path): - with open(str(path)) as file_handler: - config_data = toml.load(file_handler) - return config_data - - -def propose_configs(cli_config_file): - from_folder = py.path.local() - if cli_config_file is not None: - if os.path.isfile(cli_config_file): - yield py.path.local(cli_config_file) - return - if os.path.isdir(cli_config_file): - from_folder = py.path.local(cli_config_file) - else: - print( - "ERROR: {} is neither file or directory".format(cli_config_file), file=sys.stderr - ) - return - for basename in INFO.CONFIG_CANDIDATES: - if from_folder.join(basename).isfile(): - yield from_folder.join(basename) - for path in from_folder.parts(reverse=True): - ini_path = path.join(basename) - if ini_path.check(): - yield ini_path - - -def parse_cli(args, pm): - parser = Parser() - pm.hook.tox_addoption(parser=parser) - option = parser.parse_cli(args) - if option.version: - print(get_version_info(pm)) - raise SystemExit(0) - interpreters = Interpreters(hook=pm.hook) - config = Config( - pluginmanager=pm, option=option, interpreters=interpreters, parser=parser, args=args - ) - return config, option - - -def feedback(msg, sysexit=False): - print("ERROR: {}".format(msg), file=sys.stderr) - if sysexit: - raise SystemExit(1) - - -def get_version_info(pm): - out = ["{} imported from {}".format(tox.__version__, tox.__file__)] - plugin_dist_info = pm.list_plugin_distinfo() - if plugin_dist_info: - out.append("registered plugins:") - for mod, egg_info in plugin_dist_info: - source = getattr(mod, "__file__", repr(mod)) - out.append(" {}-{} at {}".format(egg_info.project_name, egg_info.version, source)) - return "\n".join(out) - - -class SetenvDict(object): - _DUMMY = object() - - def __init__(self, definitions, reader): - self.definitions = definitions - self.reader = reader - self.resolved = {} - self._lookupstack = [] - - def __repr__(self): - return "{}: {}".format(self.__class__.__name__, self.definitions) - - def __contains__(self, name): - return name in self.definitions - - def get(self, name, default=None): - try: - return self.resolved[name] - except KeyError: - try: - if name in self._lookupstack: - raise KeyError(name) - val = self.definitions[name] - except KeyError: - return os.environ.get(name, default) - self._lookupstack.append(name) - try: - self.resolved[name] = res = self.reader._replace(val) - finally: - self._lookupstack.pop() - return res - - def __getitem__(self, name): - x = self.get(name, self._DUMMY) - if x is self._DUMMY: - raise KeyError(name) - return x - - def keys(self): - return self.definitions.keys() - - def __setitem__(self, name, value): - self.definitions[name] = value - self.resolved[name] = value - - -@tox.hookimpl -def tox_addoption(parser): - parser.add_argument( - "--version", action="store_true", help="report version information to stdout." - ) - parser.add_argument("-h", "--help", action="store_true", help="show help about options") - parser.add_argument( - "--help-ini", "--hi", action="store_true", dest="helpini", help="show help about ini-names" - ) - add_verbosity_commands(parser) - parser.add_argument( - "--showconfig", - action="store_true", - help="show live configuration (by default all env, with -l only default targets," - " specific via TOXENV/-e)", - ) - parser.add_argument( - "-l", - "--listenvs", - action="store_true", - help="show list of test environments (with description if verbose)", - ) - parser.add_argument( - "-a", - "--listenvs-all", - action="store_true", - help="show list of all defined environments (with description if verbose)", - ) - parser.add_argument( - "-c", dest="configfile", help="config file name or directory with 'tox.ini' file." - ) - parser.add_argument( - "-e", - action="append", - dest="env", - metavar="envlist", - help="work against specified environments (ALL selects all).", - ) - parser.add_argument( - "--devenv", - metavar="ENVDIR", - help=( - "sets up a development environment at ENVDIR based on the env's tox " - "configuration specified by `-e` (-e defaults to py)." - ), - ) - parser.add_argument("--notest", action="store_true", help="skip invoking test commands.") - parser.add_argument( - "--sdistonly", action="store_true", help="only perform the sdist packaging activity." - ) - add_parallel_flags(parser) - parser.add_argument( - "--parallel--safe-build", - action="store_true", - dest="parallel_safe_build", - help="(deprecated) ensure two tox builds can run in parallel " - "(uses a lock file in the tox workdir with .lock extension)", - ) - parser.add_argument( - "--installpkg", - metavar="PATH", - help="use specified package for installation into venv, instead of creating an sdist.", - ) - parser.add_argument( - "--develop", - action="store_true", - help="install package in the venv using 'setup.py develop' via 'pip -e .'", - ) - parser.add_argument( - "-i", - "--index-url", - action="append", - dest="indexurl", - metavar="URL", - help="set indexserver url (if URL is of form name=url set the " - "url for the 'name' indexserver, specifically)", - ) - parser.add_argument( - "--pre", - action="store_true", - help="install pre-releases and development versions of dependencies. " - "This will pass the --pre option to install_command " - "(pip by default).", - ) - parser.add_argument( - "-r", "--recreate", action="store_true", help="force recreation of virtual environments" - ) - parser.add_argument( - "--result-json", - dest="resultjson", - metavar="PATH", - help="write a json file with detailed information " - "about all commands and results involved.", - ) - - # We choose 1 to 4294967295 because it is the range of PYTHONHASHSEED. - parser.add_argument( - "--hashseed", - metavar="SEED", - help="set PYTHONHASHSEED to SEED before running commands. " - "Defaults to a random integer in the range [1, 4294967295] " - "([1, 1024] on Windows). " - "Passing 'noset' suppresses this behavior.", - ) - parser.add_argument( - "--force-dep", - action="append", - metavar="REQ", - help="Forces a certain version of one of the dependencies " - "when configuring the virtual environment. REQ Examples " - "'pytest<2.7' or 'django>=1.6'.", - ) - parser.add_argument( - "--sitepackages", - action="store_true", - help="override sitepackages setting to True in all envs", - ) - parser.add_argument( - "--alwayscopy", action="store_true", help="override alwayscopy setting to True in all envs" - ) - - cli_skip_missing_interpreter(parser) - parser.add_argument("--workdir", metavar="PATH", help="tox working directory") - - parser.add_argument( - "args", nargs="*", help="additional arguments available to command positional substitution" - ) - - def _set_envdir_from_devenv(testenv_config, value): - if testenv_config.config.option.devenv is not None: - return py.path.local(testenv_config.config.option.devenv) - else: - return value - - parser.add_testenv_attribute( - name="envdir", - type="path", - default="{toxworkdir}/{envname}", - help="set venv directory -- be very careful when changing this as tox " - "will remove this directory when recreating an environment", - postprocess=_set_envdir_from_devenv, - ) - - # add various core venv interpreter attributes - def setenv(testenv_config, value): - setenv = value - config = testenv_config.config - if "PYTHONHASHSEED" not in setenv and config.hashseed is not None: - setenv["PYTHONHASHSEED"] = config.hashseed - - setenv["TOX_ENV_NAME"] = str(testenv_config.envname) - setenv["TOX_ENV_DIR"] = str(testenv_config.envdir) - return setenv - - parser.add_testenv_attribute( - name="setenv", - type="dict_setenv", - postprocess=setenv, - help="list of X=Y lines with environment variable settings", - ) - - def basepython_default(testenv_config, value): - """either user set or proposed from the factor name - - in both cases we check that the factor name implied python version and the resolved - python interpreter version match up; if they don't we warn, unless ignore base - python conflict is set in which case the factor name implied version if forced - """ - for factor in testenv_config.factors: - match = tox.PYTHON.PY_FACTORS_RE.match(factor) - if match: - base_exe = {"py": "python"}.get(match.group(1), match.group(1)) - version_s = match.group(2) - if not version_s: - version_info = () - elif len(version_s) == 1: - version_info = (version_s,) - else: - version_info = (version_s[0], version_s[1:]) - implied_version = ".".join(version_info) - implied_python = "{}{}".format(base_exe, implied_version) - break - else: - implied_python, version_info, implied_version = None, (), "" - - if testenv_config.config.ignore_basepython_conflict and implied_python is not None: - return implied_python - - proposed_python = (implied_python or sys.executable) if value is None else str(value) - if implied_python is not None and implied_python != proposed_python: - testenv_config.basepython = proposed_python - python_info_for_proposed = testenv_config.python_info - if not isinstance(python_info_for_proposed, NoInterpreterInfo): - proposed_version = ".".join( - str(x) for x in python_info_for_proposed.version_info[: len(version_info)] - ) - if proposed_version != implied_version: - # TODO(stephenfin): Raise an exception here in tox 4.0 - warnings.warn( - "conflicting basepython version (set {}, should be {}) for env '{}';" - "resolve conflict or set ignore_basepython_conflict".format( - proposed_version, implied_version, testenv_config.envname - ) - ) - - return proposed_python - - parser.add_testenv_attribute( - name="basepython", - type="basepython", - default=None, - postprocess=basepython_default, - help="executable name or path of interpreter used to create a virtual test environment.", - ) - - def merge_description(testenv_config, value): - """the reader by default joins generated description with new line, - replace new line with space""" - return value.replace("\n", " ") - - parser.add_testenv_attribute( - name="description", - type="string", - default="", - postprocess=merge_description, - help="short description of this environment", - ) - - parser.add_testenv_attribute( - name="envtmpdir", type="path", default="{envdir}/tmp", help="venv temporary directory" - ) - - parser.add_testenv_attribute( - name="envlogdir", type="path", default="{envdir}/log", help="venv log directory" - ) - - parser.add_testenv_attribute( - name="downloadcache", - type="string", - default=None, - help="(ignored) has no effect anymore, pip-8 uses local caching by default", - ) - - parser.add_testenv_attribute( - name="changedir", - type="path", - default="{toxinidir}", - help="directory to change to when running commands", - ) - - parser.add_testenv_attribute_obj(PosargsOption()) - - parser.add_testenv_attribute( - name="skip_install", - type="bool", - default=False, - help="Do not install the current package. This can be used when you need the virtualenv " - "management but do not want to install the current package", - ) - - parser.add_testenv_attribute( - name="ignore_errors", - type="bool", - default=False, - help="if set to True all commands will be executed irrespective of their result error " - "status.", - ) - - def recreate(testenv_config, value): - if testenv_config.config.option.recreate: - return True - return value - - parser.add_testenv_attribute( - name="recreate", - type="bool", - default=False, - postprocess=recreate, - help="always recreate this test environment.", - ) - - def passenv(testenv_config, value): - # Flatten the list to deal with space-separated values. - value = list(itertools.chain.from_iterable([x.split(" ") for x in value])) - - passenv = { - "PATH", - "PIP_INDEX_URL", - "LANG", - "LANGUAGE", - "LD_LIBRARY_PATH", - "TOX_WORK_DIR", - str(REPORTER_TIMESTAMP_ON_ENV), - str(PARALLEL_ENV_VAR_KEY), - } - - # read in global passenv settings - p = os.environ.get("TOX_TESTENV_PASSENV", None) - if p is not None: - env_values = [x for x in p.split() if x] - value.extend(env_values) - - # we ensure that tmp directory settings are passed on - # we could also set it to the per-venv "envtmpdir" - # but this leads to very long paths when run with jenkins - # so we just pass it on by default for now. - if tox.INFO.IS_WIN: - passenv.add("SYSTEMDRIVE") # needed for pip6 - passenv.add("SYSTEMROOT") # needed for python's crypto module - passenv.add("PATHEXT") # needed for discovering executables - passenv.add("COMSPEC") # needed for distutils cygwincompiler - passenv.add("TEMP") - passenv.add("TMP") - # for `multiprocessing.cpu_count()` on Windows (prior to Python 3.4). - passenv.add("NUMBER_OF_PROCESSORS") - passenv.add("PROCESSOR_ARCHITECTURE") # platform.machine() - passenv.add("USERPROFILE") # needed for `os.path.expanduser()` - passenv.add("MSYSTEM") # fixes #429 - else: - passenv.add("TMPDIR") - for spec in value: - for name in os.environ: - if fnmatchcase(name.upper(), spec.upper()): - passenv.add(name) - return passenv - - parser.add_testenv_attribute( - name="passenv", - type="line-list", - postprocess=passenv, - help="environment variables needed during executing test commands (taken from invocation " - "environment). Note that tox always passes through some basic environment variables " - "which are needed for basic functioning of the Python system. See --showconfig for the " - "eventual passenv setting.", - ) - - parser.add_testenv_attribute( - name="whitelist_externals", - type="line-list", - help="each lines specifies a path or basename for which tox will not warn " - "about it coming from outside the test environment.", - ) - - parser.add_testenv_attribute( - name="platform", - type="string", - default=".*", - help="regular expression which must match against ``sys.platform``. " - "otherwise testenv will be skipped.", - ) - - def sitepackages(testenv_config, value): - return testenv_config.config.option.sitepackages or value - - def alwayscopy(testenv_config, value): - return testenv_config.config.option.alwayscopy or value - - parser.add_testenv_attribute( - name="sitepackages", - type="bool", - default=False, - postprocess=sitepackages, - help="Set to ``True`` if you want to create virtual environments that also " - "have access to globally installed packages.", - ) - - parser.add_testenv_attribute( - "download", - type="bool", - default=False, - help="download the latest pip, setuptools and wheel when creating the virtual" - "environment (default is to use the one bundled in virtualenv)", - ) - - parser.add_testenv_attribute( - name="alwayscopy", - type="bool", - default=False, - postprocess=alwayscopy, - help="Set to ``True`` if you want virtualenv to always copy files rather " - "than symlinking.", - ) - - def pip_pre(testenv_config, value): - return testenv_config.config.option.pre or value - - parser.add_testenv_attribute( - name="pip_pre", - type="bool", - default=False, - postprocess=pip_pre, - help="If ``True``, adds ``--pre`` to the ``opts`` passed to the install command. ", - ) - - def develop(testenv_config, value): - option = testenv_config.config.option - return not option.installpkg and (value or option.develop or option.devenv is not None) - - parser.add_testenv_attribute( - name="usedevelop", - type="bool", - postprocess=develop, - default=False, - help="install package in develop/editable mode", - ) - - parser.add_testenv_attribute_obj(InstallcmdOption()) - - parser.add_testenv_attribute( - name="list_dependencies_command", - type="argv", - default="python -m pip freeze", - help="list dependencies for a virtual environment", - ) - - parser.add_testenv_attribute_obj(DepOption()) - - parser.add_testenv_attribute( - name="commands", - type="argvlist", - default="", - help="each line specifies a test command and can use substitution.", - ) - - parser.add_testenv_attribute( - name="commands_pre", - type="argvlist", - default="", - help="each line specifies a setup command action and can use substitution.", - ) - - parser.add_testenv_attribute( - name="commands_post", - type="argvlist", - default="", - help="each line specifies a teardown command and can use substitution.", - ) - - parser.add_testenv_attribute( - "ignore_outcome", - type="bool", - default=False, - help="if set to True a failing result of this testenv will not make " - "tox fail, only a warning will be produced", - ) - - parser.add_testenv_attribute( - "extras", - type="line-list", - help="list of extras to install with the source distribution or develop install", - ) - - add_parallel_config(parser) - - -def cli_skip_missing_interpreter(parser): - class SkipMissingInterpreterAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - value = "true" if values is None else values - if value not in ("config", "true", "false"): - raise argparse.ArgumentTypeError("value must be config, true or false") - setattr(namespace, self.dest, value) - - parser.add_argument( - "-s", - "--skip-missing-interpreters", - default="config", - metavar="val", - nargs="?", - action=SkipMissingInterpreterAction, - help="don't fail tests for missing interpreters: {config,true,false} choice", - ) - - -class Config(object): - """Global Tox config object.""" - - def __init__(self, pluginmanager, option, interpreters, parser, args): - self.envconfigs = OrderedDict() - """Mapping envname -> envconfig""" - self.invocationcwd = py.path.local() - self.interpreters = interpreters - self.pluginmanager = pluginmanager - self.option = option - self._parser = parser - self._testenv_attr = parser._testenv_attr - self.args = args - - """option namespace containing all parsed command line options""" - - @property - def homedir(self): - homedir = get_homedir() - if homedir is None: - homedir = self.toxinidir # FIXME XXX good idea? - return homedir - - -class TestenvConfig: - """Testenv Configuration object. - - In addition to some core attributes/properties this config object holds all - per-testenv ini attributes as attributes, see "tox --help-ini" for an overview. - """ - - def __init__(self, envname, config, factors, reader): - #: test environment name - self.envname = envname - #: global tox config object - self.config = config - #: set of factors - self.factors = factors - self._reader = reader - self._missing_subs = [] - """Holds substitutions that could not be resolved. - - Pre 2.8.1 missing substitutions crashed with a ConfigError although this would not be a - problem if the env is not part of the current testrun. So we need to remember this and - check later when the testenv is actually run and crash only then. - """ - - def get_envbindir(self): - """Path to directory where scripts/binaries reside.""" - if tox.INFO.IS_WIN and "jython" not in self.basepython and "pypy" not in self.basepython: - return self.envdir.join("Scripts") - else: - return self.envdir.join("bin") - - @property - def envbindir(self): - return self.get_envbindir() - - @property - def envpython(self): - """Path to python executable.""" - return self.get_envpython() - - def get_envpython(self): - """ path to python/jython executable. """ - if "jython" in str(self.basepython): - name = "jython" - else: - name = "python" - return self.envbindir.join(name) - - def get_envsitepackagesdir(self): - """Return sitepackagesdir of the virtualenv environment. - - NOTE: Only available during execution, not during parsing. - """ - x = self.config.interpreters.get_sitepackagesdir(info=self.python_info, envdir=self.envdir) - return x - - @property - def python_info(self): - """Return sitepackagesdir of the virtualenv environment.""" - return self.config.interpreters.get_info(envconfig=self) - - def getsupportedinterpreter(self): - if tox.INFO.IS_WIN and self.basepython and "jython" in self.basepython: - raise tox.exception.UnsupportedInterpreter( - "Jython/Windows does not support installing scripts" - ) - info = self.config.interpreters.get_info(envconfig=self) - if not info.executable: - raise tox.exception.InterpreterNotFound(self.basepython) - if not info.version_info: - raise tox.exception.InvocationError( - "Failed to get version_info for {}: {}".format(info.name, info.err) - ) - return info.executable - - -testenvprefix = "testenv:" - - -def get_homedir(): - try: - return py.path.local._gethomedir() - except Exception: - return None - - -def make_hashseed(): - max_seed = 4294967295 - if tox.INFO.IS_WIN: - max_seed = 1024 - return str(random.randint(1, max_seed)) - - -class ParseIni(object): - def __init__(self, config, ini_path, ini_data): # noqa - config.toxinipath = ini_path - using("tox.ini: {} (pid {})".format(config.toxinipath, os.getpid())) - config.toxinidir = config.toxinipath.dirpath() - - self._cfg = py.iniconfig.IniConfig(config.toxinipath, ini_data) - previous_line_of = self._cfg.lineof - - def line_of_default_to_zero(section, name=None): - at = previous_line_of(section, name=name) - if at is None: - at = 0 - return at - - self._cfg.lineof = line_of_default_to_zero - config._cfg = self._cfg - self.config = config - - prefix = "tox" if ini_path.basename == "setup.cfg" else None - - context_name = getcontextname() - if context_name == "jenkins": - reader = SectionReader( - "tox:jenkins", self._cfg, prefix=prefix, fallbacksections=["tox"] - ) - dist_share_default = "{toxworkdir}/distshare" - elif not context_name: - reader = SectionReader("tox", self._cfg, prefix=prefix) - dist_share_default = "{homedir}/.tox/distshare" - else: - raise ValueError("invalid context") - - if config.option.hashseed is None: - hash_seed = make_hashseed() - elif config.option.hashseed == "noset": - hash_seed = None - else: - hash_seed = config.option.hashseed - config.hashseed = hash_seed - - reader.addsubstitutions(toxinidir=config.toxinidir, homedir=config.homedir) - - if config.option.workdir is None: - config.toxworkdir = reader.getpath("toxworkdir", "{toxinidir}/.tox") - else: - config.toxworkdir = config.toxinidir.join(config.option.workdir, abs=True) - - if os.path.exists(str(config.toxworkdir)): - config.toxworkdir = config.toxworkdir.realpath() - - reader.addsubstitutions(toxworkdir=config.toxworkdir) - config.ignore_basepython_conflict = reader.getbool("ignore_basepython_conflict", False) - - config.distdir = reader.getpath("distdir", "{toxworkdir}/dist") - - reader.addsubstitutions(distdir=config.distdir) - config.distshare = reader.getpath("distshare", dist_share_default) - config.temp_dir = reader.getpath("temp_dir", "{toxworkdir}/.tmp") - reader.addsubstitutions(distshare=config.distshare) - config.sdistsrc = reader.getpath("sdistsrc", None) - config.setupdir = reader.getpath("setupdir", "{toxinidir}") - config.logdir = config.toxworkdir.join("log") - within_parallel = PARALLEL_ENV_VAR_KEY in os.environ - if not within_parallel and not WITHIN_PROVISION: - ensure_empty_dir(config.logdir) - - # determine indexserver dictionary - config.indexserver = {"default": IndexServerConfig("default")} - prefix = "indexserver" - for line in reader.getlist(prefix): - name, url = map(lambda x: x.strip(), line.split("=", 1)) - config.indexserver[name] = IndexServerConfig(name, url) - - if config.option.skip_missing_interpreters == "config": - val = reader.getbool("skip_missing_interpreters", False) - config.option.skip_missing_interpreters = "true" if val else "false" - - override = False - if config.option.indexurl: - for url_def in config.option.indexurl: - m = re.match(r"\W*(\w+)=(\S+)", url_def) - if m is None: - url = url_def - name = "default" - else: - name, url = m.groups() - if not url: - url = None - if name != "ALL": - config.indexserver[name].url = url - else: - override = url - # let ALL override all existing entries - if override: - for name in config.indexserver: - config.indexserver[name] = IndexServerConfig(name, override) - - self.handle_provision(config, reader) - - self.parse_build_isolation(config, reader) - res = self._getenvdata(reader, config) - config.envlist, all_envs, config.envlist_default, config.envlist_explicit = res - - # factors used in config or predefined - known_factors = self._list_section_factors("testenv") - known_factors.update({"py", "python"}) - - # factors stated in config envlist - stated_envlist = reader.getstring("envlist", replace=False) - if stated_envlist: - for env in _split_env(stated_envlist): - known_factors.update(env.split("-")) - - # configure testenvs - to_do = [] - failures = OrderedDict() - results = {} - cur_self = self - - def run(name, section, subs, config): - try: - results[name] = cur_self.make_envconfig(name, section, subs, config) - except Exception as exception: - failures[name] = (exception, traceback.format_exc()) - - order = [] - for name in all_envs: - section = "{}{}".format(testenvprefix, name) - factors = set(name.split("-")) - if ( - section in self._cfg - or factors <= known_factors - or all( - tox.PYTHON.PY_FACTORS_RE.match(factor) for factor in factors - known_factors - ) - ): - order.append(name) - thread = Thread(target=run, args=(name, section, reader._subs, config)) - thread.daemon = True - thread.start() - to_do.append(thread) - for thread in to_do: - while thread.is_alive(): - thread.join(timeout=20) - if failures: - raise tox.exception.ConfigError( - "\n".join( - "{} failed with {} at {}".format(key, exc, trace) - for key, (exc, trace) in failures.items() - ) - ) - for name in order: - config.envconfigs[name] = results[name] - all_develop = all( - name in config.envconfigs and config.envconfigs[name].usedevelop - for name in config.envlist - ) - - config.skipsdist = reader.getbool("skipsdist", all_develop) - - if config.option.devenv is not None: - config.option.notest = True - - if config.option.devenv is not None and len(config.envlist) != 1: - feedback("--devenv requires only a single -e", sysexit=True) - - def handle_provision(self, config, reader): - requires_list = reader.getlist("requires") - config.minversion = reader.getstring("minversion", None) - config.provision_tox_env = name = reader.getstring("provision_tox_env", ".tox") - min_version = "tox >= {}".format(config.minversion or tox.__version__) - deps = self.ensure_requires_satisfied(config, requires_list, min_version) - if config.run_provision: - section_name = "testenv:{}".format(name) - if section_name not in self._cfg.sections: - self._cfg.sections[section_name] = {} - self._cfg.sections[section_name]["description"] = "meta tox" - env_config = self.make_envconfig( - name, "{}{}".format(testenvprefix, name), reader._subs, config - ) - env_config.deps = deps - config.envconfigs[config.provision_tox_env] = env_config - raise tox.exception.MissingRequirement(config) - # if provisioning is not on, now we need do a strict argument evaluation - # raise on unknown args - self.config._parser.parse_cli(args=self.config.args, strict=True) - - @staticmethod - def ensure_requires_satisfied(config, requires, min_version): - missing_requirements = [] - failed_to_parse = False - deps = [] - exists = set() - for require in requires + [min_version]: - # noinspection PyBroadException - try: - package = requirements.Requirement(require) - # check if the package even applies - if package.marker and not package.marker.evaluate({"extra": ""}): - continue - package_name = canonicalize_name(package.name) - if package_name not in exists: - deps.append(DepConfig(require, None)) - exists.add(package_name) - dist = importlib_metadata.distribution(package.name) - if not package.specifier.contains(dist.version, prereleases=True): - raise MissingDependency(package) - except requirements.InvalidRequirement as exception: - failed_to_parse = True - error("failed to parse {!r}".format(exception)) - except Exception as exception: - verbosity1("could not satisfy requires {!r}".format(exception)) - missing_requirements.append(str(requirements.Requirement(require))) - if failed_to_parse: - raise tox.exception.BadRequirement() - if WITHIN_PROVISION and missing_requirements: - msg = "break infinite loop provisioning within {} missing {}" - raise tox.exception.Error(msg.format(sys.executable, missing_requirements)) - config.run_provision = bool(len(missing_requirements)) - return deps - - def parse_build_isolation(self, config, reader): - config.isolated_build = reader.getbool("isolated_build", False) - config.isolated_build_env = reader.getstring("isolated_build_env", ".package") - if config.isolated_build is True: - name = config.isolated_build_env - section_name = "testenv:{}".format(name) - if section_name not in self._cfg.sections: - self._cfg.sections[section_name] = {} - self._cfg.sections[section_name]["deps"] = "" - self._cfg.sections[section_name]["sitepackages"] = "False" - self._cfg.sections[section_name]["description"] = "isolated packaging environment" - config.envconfigs[name] = self.make_envconfig( - name, "{}{}".format(testenvprefix, name), reader._subs, config - ) - - def _list_section_factors(self, section): - factors = set() - if section in self._cfg: - for _, value in self._cfg[section].items(): - exprs = re.findall(r"^([\w{}\.!,-]+)\:\s+", value, re.M) - factors.update(*mapcat(_split_factor_expr_all, exprs)) - return factors - - def make_envconfig(self, name, section, subs, config, replace=True): - factors = set(name.split("-")) - reader = SectionReader(section, self._cfg, fallbacksections=["testenv"], factors=factors) - tc = TestenvConfig(name, config, factors, reader) - reader.addsubstitutions( - envname=name, - envbindir=tc.get_envbindir, - envsitepackagesdir=tc.get_envsitepackagesdir, - envpython=tc.get_envpython, - **subs - ) - for env_attr in config._testenv_attr: - atype = env_attr.type - try: - if atype in ("bool", "path", "string", "dict", "dict_setenv", "argv", "argvlist"): - meth = getattr(reader, "get{}".format(atype)) - res = meth(env_attr.name, env_attr.default, replace=replace) - elif atype == "basepython": - no_fallback = name in (config.provision_tox_env,) - res = reader.getstring( - env_attr.name, env_attr.default, replace=replace, no_fallback=no_fallback - ) - elif atype == "space-separated-list": - res = reader.getlist(env_attr.name, sep=" ") - elif atype == "line-list": - res = reader.getlist(env_attr.name, sep="\n") - elif atype == "env-list": - res = reader.getstring(env_attr.name, replace=False) - res = tuple(_split_env(res)) - else: - raise ValueError("unknown type {!r}".format(atype)) - if env_attr.postprocess: - res = env_attr.postprocess(testenv_config=tc, value=res) - except tox.exception.MissingSubstitution as e: - tc._missing_subs.append(e.name) - res = e.FLAG - setattr(tc, env_attr.name, res) - if atype in ("path", "string", "basepython"): - reader.addsubstitutions(**{env_attr.name: res}) - return tc - - def _getallenvs(self, reader, extra_env_list=None): - extra_env_list = extra_env_list or [] - env_str = reader.getstring("envlist", replace=False) - env_list = _split_env(env_str) - for env in extra_env_list: - if env not in env_list: - env_list.append(env) - - all_envs = OrderedDict((i, None) for i in env_list) - for section in self._cfg: - if section.name.startswith(testenvprefix): - all_envs[section.name[len(testenvprefix) :]] = None - if not all_envs: - all_envs["python"] = None - return list(all_envs.keys()) - - def _getenvdata(self, reader, config): - from_option = self.config.option.env - from_environ = os.environ.get("TOXENV") - from_config = reader.getstring("envlist", replace=False) - - env_list = [] - envlist_explicit = False - if (from_option and "ALL" in from_option) or ( - not from_option and from_environ and "ALL" in from_environ.split(",") - ): - all_envs = self._getallenvs(reader) - else: - candidates = ( - (os.environ.get(PARALLEL_ENV_VAR_KEY), True), - (from_option, True), - (from_environ, True), - ("py" if self.config.option.devenv is not None else None, False), - (from_config, False), - ) - env_str, envlist_explicit = next(((i, e) for i, e in candidates if i), ([], False)) - env_list = _split_env(env_str) - all_envs = self._getallenvs(reader, env_list) - - if not env_list: - env_list = all_envs - - package_env = config.isolated_build_env - if config.isolated_build is True and package_env in all_envs: - all_envs.remove(package_env) - - if config.isolated_build is True and package_env in env_list: - msg = "isolated_build_env {} cannot be part of envlist".format(package_env) - raise tox.exception.ConfigError(msg) - return env_list, all_envs, _split_env(from_config), envlist_explicit - - -def _split_env(env): - """if handed a list, action="append" was used for -e """ - if env is None: - return [] - if not isinstance(env, list): - env = [e.split("#", 1)[0].strip() for e in env.split("\n")] - env = ",".join([e for e in env if e]) - env = [env] - return mapcat(_expand_envstr, env) - - -def _is_negated_factor(factor): - return factor.startswith("!") - - -def _base_factor_name(factor): - return factor[1:] if _is_negated_factor(factor) else factor - - -def _split_factor_expr(expr): - def split_single(e): - raw = e.split("-") - included = {_base_factor_name(factor) for factor in raw if not _is_negated_factor(factor)} - excluded = {_base_factor_name(factor) for factor in raw if _is_negated_factor(factor)} - return included, excluded - - partial_envs = _expand_envstr(expr) - return [split_single(e) for e in partial_envs] - - -def _split_factor_expr_all(expr): - partial_envs = _expand_envstr(expr) - return [{_base_factor_name(factor) for factor in e.split("-")} for e in partial_envs] - - -def _expand_envstr(envstr): - # split by commas not in groups - tokens = re.split(r"((?:\{[^}]+\})+)|,", envstr) - envlist = ["".join(g).strip() for k, g in itertools.groupby(tokens, key=bool) if k] - - def expand(env): - tokens = re.split(r"\{([^}]+)\}", env) - parts = [re.sub(r"\s+", "", token).split(",") for token in tokens] - return ["".join(variant) for variant in itertools.product(*parts)] - - return mapcat(expand, envlist) - - -def mapcat(f, seq): - return list(itertools.chain.from_iterable(map(f, seq))) - - -class DepConfig: - def __init__(self, name, indexserver=None): - self.name = name - self.indexserver = indexserver - - def __repr__(self): - if self.indexserver: - if self.indexserver.name == "default": - return self.name - return ":{}:{}".format(self.indexserver.name, self.name) - return str(self.name) - - -class IndexServerConfig: - def __init__(self, name, url=None): - self.name = name - self.url = url - - def __repr__(self): - return "IndexServerConfig(name={}, url={})".format(self.name, self.url) - - -is_section_substitution = re.compile(r"{\[[^{}\s]+\]\S+?}").match -"""Check value matches substitution form of referencing value from other section. - -E.g. {[base]commands} -""" - - -class SectionReader: - def __init__(self, section_name, cfgparser, fallbacksections=None, factors=(), prefix=None): - if prefix is None: - self.section_name = section_name - else: - self.section_name = "{}:{}".format(prefix, section_name) - self._cfg = cfgparser - self.fallbacksections = fallbacksections or [] - self.factors = factors - self._subs = {} - self._subststack = [] - self._setenv = None - - def get_environ_value(self, name): - if self._setenv is None: - return os.environ.get(name) - return self._setenv.get(name) - - def addsubstitutions(self, _posargs=None, **kw): - self._subs.update(kw) - if _posargs: - self.posargs = _posargs - - def getpath(self, name, defaultpath, replace=True): - path = self.getstring(name, defaultpath, replace=replace) - if path is not None: - toxinidir = self._subs["toxinidir"] - return toxinidir.join(path, abs=True) - - def getlist(self, name, sep="\n"): - s = self.getstring(name, None) - if s is None: - return [] - return [x.strip() for x in s.split(sep) if x.strip()] - - def getdict(self, name, default=None, sep="\n", replace=True): - value = self.getstring(name, None, replace=replace) - return self._getdict(value, default=default, sep=sep, replace=replace) - - def getdict_setenv(self, name, default=None, sep="\n", replace=True): - value = self.getstring(name, None, replace=replace, crossonly=True) - definitions = self._getdict(value, default=default, sep=sep, replace=replace) - self._setenv = SetenvDict(definitions, reader=self) - return self._setenv - - def _getdict(self, value, default, sep, replace=True): - if value is None or not replace: - return default or {} - - d = {} - for line in value.split(sep): - if line.strip(): - name, rest = line.split("=", 1) - d[name.strip()] = rest.strip() - - return d - - def getbool(self, name, default=None, replace=True): - s = self.getstring(name, default, replace=replace) - if not s or not replace: - s = default - if s is None: - raise KeyError("no config value [{}] {} found".format(self.section_name, name)) - - if not isinstance(s, bool): - if s.lower() == "true": - s = True - elif s.lower() == "false": - s = False - else: - raise tox.exception.ConfigError( - "{}: boolean value {!r} needs to be 'True' or 'False'".format(name, s) - ) - return s - - def getargvlist(self, name, default="", replace=True): - s = self.getstring(name, default, replace=False) - return _ArgvlistReader.getargvlist(self, s, replace=replace) - - def getargv(self, name, default="", replace=True): - return self.getargvlist(name, default, replace=replace)[0] - - def getstring(self, name, default=None, replace=True, crossonly=False, no_fallback=False): - x = None - sections = [self.section_name] + ([] if no_fallback else self.fallbacksections) - for s in sections: - try: - x = self._cfg[s][name] - break - except KeyError: - continue - - if x is None: - x = default - else: - # It is needed to apply factors before unwrapping - # dependencies, otherwise it can break the substitution - # process. Once they are unwrapped, we call apply factors - # again for those new dependencies. - x = self._apply_factors(x) - x = self._replace_if_needed(x, name, replace, crossonly) - x = self._apply_factors(x) - - x = self._replace_if_needed(x, name, replace, crossonly) - return x - - def _replace_if_needed(self, x, name, replace, crossonly): - if replace and x and hasattr(x, "replace"): - x = self._replace(x, name=name, crossonly=crossonly) - return x - - def _apply_factors(self, s): - def factor_line(line): - m = re.search(r"^([\w{}\.!,-]+)\:\s+(.+)", line) - if not m: - return line - - expr, line = m.groups() - if any( - included <= self.factors and not any(x in self.factors for x in excluded) - for included, excluded in _split_factor_expr(expr) - ): - return line - - lines = s.strip().splitlines() - return "\n".join(filter(None, map(factor_line, lines))) - - def _replace(self, value, name=None, section_name=None, crossonly=False): - if "{" not in value: - return value - - section_name = section_name if section_name else self.section_name - self._subststack.append((section_name, name)) - try: - replaced = Replacer(self, crossonly=crossonly).do_replace(value) - assert self._subststack.pop() == (section_name, name) - except tox.exception.MissingSubstitution: - if not section_name.startswith(testenvprefix): - raise tox.exception.ConfigError( - "substitution env:{!r}: unknown or recursive definition in" - " section {!r}.".format(value, section_name) - ) - raise - return replaced - - -class Replacer: - RE_ITEM_REF = re.compile( - r""" - (?[^[:{}]+):)? # optional sub_type for special rules - (?P(?:\[[^,{}]*\])?[^:,{}]*) # substitution key - (?::(?P[^{}]*))? # default value - [}] - """, - re.VERBOSE, - ) - - def __init__(self, reader, crossonly=False): - self.reader = reader - self.crossonly = crossonly - - def do_replace(self, value): - """ - Recursively expand substitutions starting from the innermost expression - """ - - def substitute_once(x): - return self.RE_ITEM_REF.sub(self._replace_match, x) - - expanded = substitute_once(value) - - while expanded != value: # substitution found - value = expanded - expanded = substitute_once(value) - - return expanded - - def _replace_match(self, match): - g = match.groupdict() - sub_value = g["substitution_value"] - if self.crossonly: - if sub_value.startswith("["): - return self._substitute_from_other_section(sub_value) - # in crossonly we return all other hits verbatim - start, end = match.span() - return match.string[start:end] - - # special case: all empty values means ":" which is os.pathsep - if not any(g.values()): - return os.pathsep - - # special case: opts and packages. Leave {opts} and - # {packages} intact, they are replaced manually in - # _venv.VirtualEnv.run_install_command. - if sub_value in ("opts", "packages"): - return "{{{}}}".format(sub_value) - - try: - sub_type = g["sub_type"] - except KeyError: - raise tox.exception.ConfigError( - "Malformed substitution; no substitution type provided" - ) - - if sub_type == "env": - return self._replace_env(match) - if sub_type == "tty": - if is_interactive(): - return match.group("substitution_value") - return match.group("default_value") - if sub_type is not None: - raise tox.exception.ConfigError( - "No support for the {} substitution type".format(sub_type) - ) - return self._replace_substitution(match) - - def _replace_env(self, match): - key = match.group("substitution_value") - if not key: - raise tox.exception.ConfigError("env: requires an environment variable name") - default = match.group("default_value") - value = self.reader.get_environ_value(key) - if value is not None: - return value - if default is not None: - return default - raise tox.exception.MissingSubstitution(key) - - def _substitute_from_other_section(self, key): - if key.startswith("[") and "]" in key: - i = key.find("]") - section, item = key[1:i], key[i + 1 :] - cfg = self.reader._cfg - if section in cfg and item in cfg[section]: - if (section, item) in self.reader._subststack: - raise ValueError( - "{} already in {}".format((section, item), self.reader._subststack) - ) - x = str(cfg[section][item]) - return self.reader._replace( - x, name=item, section_name=section, crossonly=self.crossonly - ) - - raise tox.exception.ConfigError("substitution key {!r} not found".format(key)) - - def _replace_substitution(self, match): - sub_key = match.group("substitution_value") - val = self.reader._subs.get(sub_key, None) - if val is None: - val = self._substitute_from_other_section(sub_key) - if callable(val): - val = val() - return str(val) - - -def is_interactive(): - return sys.stdin.isatty() - - -class _ArgvlistReader: - @classmethod - def getargvlist(cls, reader, value, replace=True): - """Parse ``commands`` argvlist multiline string. - - :param SectionReader reader: reader to be used. - :param str value: Content stored by key. - - :rtype: list[list[str]] - :raise :class:`tox.exception.ConfigError`: - line-continuation ends nowhere while resolving for specified section - """ - commands = [] - current_command = "" - for line in value.splitlines(): - line = line.rstrip() - if not line: - continue - if line.endswith("\\"): - current_command += " {}".format(line[:-1]) - continue - current_command += line - - if is_section_substitution(current_command): - replaced = reader._replace(current_command, crossonly=True) - commands.extend(cls.getargvlist(reader, replaced)) - else: - commands.append(cls.processcommand(reader, current_command, replace)) - current_command = "" - else: - if current_command: - raise tox.exception.ConfigError( - "line-continuation ends nowhere while resolving for [{}] {}".format( - reader.section_name, "commands" - ) - ) - return commands - - @classmethod - def processcommand(cls, reader, command, replace=True): - posargs = getattr(reader, "posargs", "") - if sys.platform.startswith("win"): - posargs_string = list2cmdline([x for x in posargs if x]) - else: - posargs_string = " ".join([shlex_quote(x) for x in posargs if x]) - - # Iterate through each word of the command substituting as - # appropriate to construct the new command string. This - # string is then broken up into exec argv components using - # shlex. - if replace: - newcommand = "" - for word in CommandParser(command).words(): - if word == "{posargs}" or word == "[]": - newcommand += posargs_string - continue - elif word.startswith("{posargs:") and word.endswith("}"): - if posargs: - newcommand += posargs_string - continue - else: - word = word[9:-1] - new_arg = "" - new_word = reader._replace(word) - new_word = reader._replace(new_word) - new_word = new_word.replace("\\{", "{").replace("\\}", "}") - new_arg += new_word - newcommand += new_arg - else: - newcommand = command - - # Construct shlex object that will not escape any values, - # use all values as is in argv. - shlexer = shlex.shlex(newcommand, posix=True) - shlexer.whitespace_split = True - shlexer.escape = "" - return list(shlexer) - - -class CommandParser(object): - class State(object): - def __init__(self): - self.word = "" - self.depth = 0 - self.yield_words = [] - - def __init__(self, command): - self.command = command - - def words(self): - ps = CommandParser.State() - - def word_has_ended(): - return ( - ( - cur_char in string.whitespace - and ps.word - and ps.word[-1] not in string.whitespace - ) - or (cur_char == "{" and ps.depth == 0 and not ps.word.endswith("\\")) - or (ps.depth == 0 and ps.word and ps.word[-1] == "}") - or (cur_char not in string.whitespace and ps.word and ps.word.strip() == "") - ) - - def yield_this_word(): - yieldword = ps.word - ps.word = "" - if yieldword: - ps.yield_words.append(yieldword) - - def yield_if_word_ended(): - if word_has_ended(): - yield_this_word() - - def accumulate(): - ps.word += cur_char - - def push_substitution(): - ps.depth += 1 - - def pop_substitution(): - ps.depth -= 1 - - for cur_char in self.command: - if cur_char in string.whitespace: - if ps.depth == 0: - yield_if_word_ended() - accumulate() - elif cur_char == "{": - yield_if_word_ended() - accumulate() - push_substitution() - elif cur_char == "}": - accumulate() - pop_substitution() - else: - yield_if_word_ended() - accumulate() - - if ps.word.strip(): - yield_this_word() - return ps.yield_words - - -def getcontextname(): - if any(env in os.environ for env in ["JENKINS_URL", "HUDSON_URL"]): - return "jenkins" - return None diff --git a/src/tox/session/commands/__init__.py b/src/tox/config/cli/__init__.py similarity index 100% rename from src/tox/session/commands/__init__.py rename to src/tox/config/cli/__init__.py diff --git a/src/tox/config/cli/env_var.py b/src/tox/config/cli/env_var.py new file mode 100644 index 000000000..5461935a0 --- /dev/null +++ b/src/tox/config/cli/env_var.py @@ -0,0 +1,36 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os + +from tox.config.source.ini import StrConvert + +CONVERT = StrConvert() + + +def get_env_var(key, of_type): + """Get the environment variable option. + + :param key: the config key requested + :param of_type: the type we would like to convert it to + :return: + """ + environ_key = "TOX_{}".format(key.upper()) + if environ_key in os.environ: + value = os.environ[environ_key] + # noinspection PyBroadException + try: + source = "env var {}".format(environ_key) + of_type = CONVERT.to(raw=value, of_type=of_type) + return of_type, source + except Exception as exception: + logging.warning( + "env var %s=%r cannot be transformed to %r because %r", + environ_key, + value, + of_type, + exception, + ) + + +__all__ = ("get_env_var",) diff --git a/src/tox/config/cli/ini.py b/src/tox/config/cli/ini.py new file mode 100644 index 000000000..62dc071f9 --- /dev/null +++ b/src/tox/config/cli/ini.py @@ -0,0 +1,74 @@ +from __future__ import absolute_import, unicode_literals + +import logging +import os +from pathlib import Path +from typing import cast + +from appdirs import user_config_dir + +from tox.config.source.ini import Ini, IniLoader + +DEFAULT_CONFIG_FILE = Path(user_config_dir("tox")) / "config.ini" + + +class IniConfig(object): + TOX_CONFIG_FILE_ENV_VAR = "TOX_CONFIG_FILE" + STATE = {None: "failed to parse", True: "active", False: "missing"} + + section = "tox" + + def __init__(self): + config_file = os.environ.get(self.TOX_CONFIG_FILE_ENV_VAR, None) + self.is_env_var = config_file is not None + self.config_file = Path(config_file if config_file is not None else DEFAULT_CONFIG_FILE) + self._cache = {} + + self.has_config_file = self.config_file.exists() + if self.has_config_file: + self.config_file = self.config_file.absolute() + try: + self.ini = Ini(self.config_file) + # noinspection PyProtectedMember + self.has_tox_section = cast(IniLoader, self.ini.core)._section is not None + except Exception as exception: + logging.error("failed to read config file %s because %r", config_file, exception) + self.has_config_file = None + + def get(self, key, of_type): + # noinspection PyBroadException + cache_key = key, of_type + if cache_key in self._cache: + result = self._cache[cache_key] + else: + try: + source = "file" + value = self.ini.core.load(key, of_type=of_type, conf=None) + result = value, source + except KeyError: # just not found + result = None + except Exception as exception: + logging.warning( + "%s key %s as type %r failed with %r", + self.config_file, + key, + of_type, + exception, + ) + result = None + self._cache[cache_key] = result + return result + + def __bool__(self): + return bool(self.has_config_file) and bool(self.has_tox_section) + + @property + def epilog(self): + msg = "{}config file {!r} {} (change{} via env var {})" + return msg.format( + os.linesep, + str(self.config_file), + self.STATE[self.has_config_file], + "d" if self.is_env_var else "", + self.TOX_CONFIG_FILE_ENV_VAR, + ) diff --git a/src/tox/config/cli/parse.py b/src/tox/config/cli/parse.py new file mode 100644 index 000000000..cc9d43d5b --- /dev/null +++ b/src/tox/config/cli/parse.py @@ -0,0 +1,23 @@ +from typing import Dict, List, Tuple + +from tox.report import setup_report + +from .parser import Handler, Parsed, ToxParser + + +def get_options(*args) -> Tuple[Parsed, List[str], Dict[str, Handler]]: + parsed, unknown = ToxParser.base().parse(args) + guess_verbosity = parsed.verbosity + setup_report(guess_verbosity) + + tox_parser = ToxParser.core() + # noinspection PyUnresolvedReferences + from tox.plugin.manager import MANAGER + + MANAGER.tox_add_option(tox_parser) + tox_parser.fix_defaults() + parsed, unknown = tox_parser.parse(args) + if guess_verbosity != parsed.verbosity: + setup_report(parsed.verbosity) # pragma: no cover + handlers = {k: p for k, (_, p) in tox_parser.handlers.items()} + return parsed, unknown, handlers diff --git a/src/tox/config/cli/parser.py b/src/tox/config/cli/parser.py new file mode 100644 index 000000000..66c56e25a --- /dev/null +++ b/src/tox/config/cli/parser.py @@ -0,0 +1,161 @@ +import argparse +import logging +from argparse import SUPPRESS, Action, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace +from itertools import chain +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, TypeVar, cast + +from tox.plugin.util import NAME +from tox.session.state import State + +from .env_var import get_env_var +from .ini import IniConfig + + +class ArgumentParserWithEnvAndConfig(ArgumentParser): + """ + Custom option parser which updates its defaults by checking the configuration files and environmental variables + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.file_config = IniConfig() + kwargs["epilog"] = self.file_config.epilog + super(ArgumentParserWithEnvAndConfig, self).__init__(*args, **kwargs) + + def fix_defaults(self) -> None: + for action in self._actions: + self.fix_default(action) + + def fix_default(self, action: Action) -> None: + if hasattr(action, "default") and hasattr(action, "dest") and action.default != SUPPRESS: + of_type = self.get_type(action) + key = action.dest + outcome = get_env_var(key, of_type=of_type) + if outcome is None and self.file_config: + outcome = self.file_config.get(key, of_type=of_type) + if outcome is not None: + action.default, action.default_source = outcome + if isinstance(action, argparse._SubParsersAction): + for values in action.choices.values(): + values.fix_defaults() + + @staticmethod + def get_type(action): + of_type = getattr(action, "of_type", None) + if of_type is None: + if action.default is not None: + of_type = type(action.default) + elif isinstance(action, argparse._StoreConstAction) and action.const is not None: + of_type = type(action.const) + else: # pragma: no cover + raise TypeError(action) # pragma: no cover + return of_type + + +class HelpFormatter(ArgumentDefaultsHelpFormatter): + def __init__(self, prog: str) -> None: + super(HelpFormatter, self).__init__(prog, max_help_position=42, width=240) + + def _get_help_string(self, action: Action) -> str: + text = super()._get_help_string(action) + if hasattr(action, "default_source"): + default = " (default: %(default)s)" + if text.endswith(default): + text = "{} (default: %(default)s -> from %(default_source)s)".format( + text[: -len(default)] + ) + return text + + +class Parsed(Namespace): + @property + def verbosity(self) -> int: + return max(self.verbose - self.quiet, 0) + + +Handler = Callable[[State], Optional[int]] + + +ToxParserT = TypeVar("ToxParserT", bound="ToxParser") + + +class ToxParser(ArgumentParserWithEnvAndConfig): + def __init__( + self, *args: Any, root: bool = False, add_cmd: bool = False, **kwargs: Any + ) -> None: + super().__init__(*args, **kwargs) + if root is True: + self._add_base_options() + self.handlers: Dict[str, Tuple[Any, Handler]] = {} + if add_cmd is True: + self._cmd = self.add_subparsers( + title="command", help="tox command to execute", dest="command", required=False + ) + self._cmd.default = "run" + + else: + self._cmd = None + + def add_command(self, cmd: str, aliases: Sequence[str], help_msg: str, handler: Handler): + sub_parser = self._cmd.add_parser( + cmd, help=help_msg, aliases=aliases, formatter_class=HelpFormatter + ) + content = sub_parser, handler + self.handlers[cmd] = content + for alias in aliases: + self.handlers[alias] = content + return sub_parser + + def add_argument(self, *args, of_type=None, **kwargs) -> Action: + result = super().add_argument(*args, **kwargs) + if of_type is not None: + result.of_type = of_type + return result + + @classmethod + def base(cls: Type[ToxParserT]) -> ToxParserT: + return cls(add_help=False, root=True) + + @classmethod + def core(cls: Type[ToxParserT]) -> ToxParserT: + return cls(prog=NAME, formatter_class=HelpFormatter, add_cmd=True, root=True) + + def _add_base_options(self) -> None: + from tox.report import LEVELS + + level_map = "|".join( + "{} - {}".format(c, logging.getLevelName(l)) for c, l in sorted(list(LEVELS.items())) + ) + verbosity_group = self.add_argument_group( + "verbosity=verbose-quiet, default {}, map {}".format( + logging.getLevelName(LEVELS[3]), level_map + ) + ) + verbosity_exclusive = verbosity_group.add_mutually_exclusive_group() + verbosity_exclusive.add_argument( + "-v", "--verbose", action="count", dest="verbose", help="increase verbosity", default=2 + ) + verbosity_exclusive.add_argument( + "-q", "--quiet", action="count", dest="quiet", help="decrease verbosity", default=0 + ) + self.fix_defaults() + + def parse(self, args: Sequence[str]) -> Tuple[Parsed, List[str]]: + arg_set = set(args) + if ( + "-h" not in arg_set + and "--help" not in arg_set + and self._cmd is not None + and not (arg_set & set(self.handlers.keys())) + ): + global_args = set( + chain.from_iterable( + i.option_strings for i in self._actions if hasattr(i, "option_strings") + ) + ) + first_differ = next((i for i, j in enumerate(args) if j not in global_args), 0) + new_args = list(args[:first_differ]) + new_args.append(self._cmd.default or "run") + new_args.extend(args[first_differ:]) + args = new_args + parsed, unknown = super().parse_known_args(args, namespace=Parsed()) + return cast(Parsed, parsed), unknown diff --git a/src/tox/config/core.py b/src/tox/config/core.py new file mode 100644 index 000000000..236deaa4b --- /dev/null +++ b/src/tox/config/core.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from tox.config.sets import ConfigSet +from tox.config.source.api import EnvList +from tox.plugin.impl import impl + + +@impl +def tox_add_core_config(core: ConfigSet): + core.add_config( + keys=["work_dir", "toxworkdir"], + of_type=Path, + default=lambda conf, _: conf.core["tox_root"] / ".tox", + desc="working directory", + ) + core.add_config( + keys=["temp_dir"], + of_type=Path, + default=lambda conf, _: conf.core["tox_root"] / ".temp", + desc="temporary directory cleaned at start", + ) + core.add_config( + keys=["env_list", "envlist"], + of_type=EnvList, + default=[], + desc="define environments to automatically run", + ) + core.add_config( + keys=["skip_missing_interpreters"], + of_type=bool, + default=True, + desc="skip missing interpreters", + ) diff --git a/src/tox/config/main.py b/src/tox/config/main.py new file mode 100644 index 000000000..12ad121ff --- /dev/null +++ b/src/tox/config/main.py @@ -0,0 +1,40 @@ +from collections import OrderedDict +from pathlib import Path + +from .sets import ConfigSet +from .source.api import Source + + +class Config: + def __init__(self, config_source: Source) -> None: + self._src = config_source + self.core = self._setup_core(self._src) + self._env_names = list(self._src.envs(self.core)) + self._envs = OrderedDict() + + def _setup_core(self, config_source): + core = ConfigSet(config_source.core, self) + core.add_config( + keys=["tox_root", "toxinidir"], + of_type=Path, + default=config_source.tox_root, + desc="the root directory (where the configuration file is found)", + ) + from tox.plugin.manager import MANAGER + + MANAGER.tox_add_core_config(core) + return core + + def __getitem__(self, item): + try: + return self._envs[item] + except KeyError: + env = ConfigSet(self._src[item], self) + self._envs[item] = env + return env + + def __iter__(self): + return iter(self._env_names) + + def __repr__(self): + return "{}(config_source={!r})".format(type(self).__name__, self._src) diff --git a/src/tox/config/parallel.py b/src/tox/config/parallel.py deleted file mode 100644 index 5cc859a2c..000000000 --- a/src/tox/config/parallel.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -from argparse import ArgumentTypeError - -ENV_VAR_KEY = "TOX_PARALLEL_ENV" -OFF_VALUE = 0 -DEFAULT_PARALLEL = OFF_VALUE - - -def auto_detect_cpus(): - try: - from os import sched_getaffinity # python 3 only - - def cpu_count(): - return len(sched_getaffinity(0)) - - except ImportError: - # python 2 options - try: - from os import cpu_count - except ImportError: - from multiprocessing import cpu_count - - try: - n = cpu_count() - except NotImplementedError: # pragma: no cov - n = None # pragma: no cov - return n if n else 1 - - -def parse_num_processes(s): - if s == "all": - return None - if s == "auto": - return auto_detect_cpus() - else: - value = int(s) - if value < 0: - raise ArgumentTypeError("value must be positive") - return value - - -def add_parallel_flags(parser): - parser.add_argument( - "-p", - "--parallel", - dest="parallel", - help="run tox environments in parallel, the argument controls limit: all," - " auto - cpu count, some positive number, zero is turn off", - action="store", - type=parse_num_processes, - default=DEFAULT_PARALLEL, - metavar="VAL", - ) - parser.add_argument( - "-o", - "--parallel-live", - action="store_true", - dest="parallel_live", - help="connect to stdout while running environments", - ) - - -def add_parallel_config(parser): - parser.add_testenv_attribute( - "depends", - type="env-list", - help="tox environments that this environment depends on (must be run after those)", - ) - - parser.add_testenv_attribute( - "parallel_show_output", - type="bool", - default=False, - help="if set to True the content of the output will always be shown " - "when running in parallel mode", - ) diff --git a/src/tox/config/reporter.py b/src/tox/config/reporter.py deleted file mode 100644 index 3acdf107f..000000000 --- a/src/tox/config/reporter.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import absolute_import, unicode_literals - - -def add_verbosity_commands(parser): - parser.add_argument( - "-v", - action="count", - dest="verbose_level", - default=0, - help="increase verbosity of reporting output." - "-vv mode turns off output redirection for package installation, " - "above level two verbosity flags are passed through to pip (with two less level)", - ) - parser.add_argument( - "-q", - action="count", - dest="quiet_level", - default=0, - help="progressively silence reporting output.", - ) diff --git a/src/tox/config/sets.py b/src/tox/config/sets.py new file mode 100644 index 000000000..b43e9b435 --- /dev/null +++ b/src/tox/config/sets.py @@ -0,0 +1,154 @@ +from abc import ABC, abstractmethod +from collections import OrderedDict +from copy import deepcopy +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterable, + Optional, + Sequence, + Set, + Type, + Union, +) + +from tox.config.source.api import Loader + +if TYPE_CHECKING: + from tox.config.main import Config # pragma: no cover + + +class ConfigDefinition(ABC): + def __init__(self, keys: Iterable[str], desc: str) -> None: + self.keys = keys + self.desc = desc + + @abstractmethod + def __call__(self, src: Loader, conf: "Config"): + raise NotImplementedError + + +class ConfigConstantDefinition(ConfigDefinition): + def __init__(self, keys: Iterable[str], desc: str, value: Any) -> None: + super().__init__(keys, desc) + self.value = value + + def __call__(self, src: Loader, conf: "Config"): + if callable(self.value): + value = self.value() + else: + value = self.value + return value + + +_PLACE_HOLDER = object() + + +class ConfigDynamicDefinition(ConfigDefinition): + def __init__( + self, + keys: Iterable[str], + of_type: Union[Type, str], + default: Any, + desc: str, + post_process: Optional[Callable[[str], Any]] = None, + ) -> None: + super().__init__(keys, desc) + self.of_type = of_type + self.default = default + self.post_process = post_process + self._cache = _PLACE_HOLDER + + def __call__(self, src: Loader, conf: "Config"): + if self._cache is _PLACE_HOLDER: + for key in self.keys: + try: + value = src.load(key, self.of_type, conf) + except KeyError: + continue + break + else: + value = self.default(conf, src.name) if callable(self.default) else self.default + if self.post_process is not None: + value = self.post_process(value, conf) + self._cache = value + return self._cache + + def __deepcopy__(self, memo): + result = type(self)(None, None, None, None, None) + orig_value = self._cache + result.__dict__ = deepcopy(self.__dict__) + if orig_value is _PLACE_HOLDER: + result._cache = _PLACE_HOLDER + return result + + def __repr__(self): + return "{}(keys={!r}, of_type={!r}, default={!r}, desc={!r}, post_process={!r})".format( + type(self).__name__, + self.keys, + self.of_type, + self.default, + self.desc, + self.post_process, + ) + + +class ConfigSet: + def __init__(self, raw: Loader, conf: "Config"): + self._raw = raw + self._defined: Dict[str, ConfigDefinition] = {} + self._conf = conf + self._keys = OrderedDict() + self._raw.setup_with_conf(self) + + def add_config( + self, + keys: Union[str, Sequence[str]], + of_type: Union[Type, str], + default: Any, + desc: str, + post_process=None, + overwrite=False, + ): + keys_ = self._make_keys(keys) + for key in keys_: + if key in self._defined and overwrite is False: + # already added + return + definition = ConfigDynamicDefinition(keys_, of_type, default, desc, post_process) + self._add_conf(keys_, definition) + + def add_constant(self, keys, desc, value): + keys_ = self._make_keys(keys) + definition = ConfigConstantDefinition(keys_, desc, value) + self._add_conf(keys, definition) + + def make_package_conf(self): + self._raw.make_package_conf() + + @staticmethod + def _make_keys(keys): + return (keys,) if isinstance(keys, str) else keys + + def _add_conf(self, keys: Union[str, Sequence[str]], definition: ConfigDefinition): + self._keys[keys[0]] = None + for key in keys: + self._defined[key] = definition + + @property + def name(self): + return self._raw.name + + def __getitem__(self, item): + return self._defined[item](self._raw, self._conf) + + def __repr__(self): + return "{}(raw={!r}, conf={!r})".format(type(self).__name__, self._raw, self._conf) + + def __iter__(self): + return iter(self._keys.keys()) + + def unused(self) -> Set[str]: + return self._raw.found_keys() - set(self._defined.keys()) diff --git a/src/tox/session/commands/run/__init__.py b/src/tox/config/source/__init__.py similarity index 100% rename from src/tox/session/commands/run/__init__.py rename to src/tox/config/source/__init__.py diff --git a/src/tox/config/source/api.py b/src/tox/config/source/api.py new file mode 100644 index 000000000..830f620d7 --- /dev/null +++ b/src/tox/config/source/api.py @@ -0,0 +1,157 @@ +from abc import ABC, abstractmethod +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, List, Sequence, Set, Union + +_NO_MAPPING = object() + + +class Command: + def __init__(self, args): + self.args: List[str] = args + + def __repr__(self): + return "{}(args={!r})".format(type(self).__name__, self.args) + + def __eq__(self, other): + return type(self) == type(other) and self.args == other.args + + +class EnvList: + def __init__(self, envs: Sequence[str]): + self.envs = list(OrderedDict((e, None) for e in envs).keys()) + + def __repr__(self): + return "{}(envs={!r})".format(type(self).__name__, ",".join(self.envs)) + + def __eq__(self, other): + return type(self) == type(other) and self.envs == other.envs + + def __iter__(self): + return iter(self.envs) + + +class Convert(ABC): + def to(self, raw, of_type): + if getattr(of_type, "__module__", None) == "typing": + return self._to_typing(raw, of_type) + elif issubclass(of_type, Path): + return self.to_path(raw) + elif issubclass(of_type, bool): + return self.to_bool(raw) + elif issubclass(of_type, Command): + return self.to_command(raw) + elif issubclass(of_type, EnvList): + return self.to_env_list(raw) + elif issubclass(of_type, str): + return self.to_str(raw) + return of_type(raw) + + def _to_typing(self, raw, of_type): + origin = getattr(of_type, "__origin__", None) + if origin is not None: + result: Any = _NO_MAPPING + if origin == list: + result = [self.to(i, of_type.__args__[0]) for i in self.to_list(raw)] + elif origin == set: + result = {self.to(i, of_type.__args__[0]) for i in self.to_set(raw)} + elif origin == dict: + result = OrderedDict( + (self.to(k, of_type.__args__[0]), self.to(v, of_type.__args__[1])) + for k, v in self.to_dict(raw) + ) + elif origin == Union: + if len(of_type.__args__) == 2 and type(None) in of_type.__args__: + if not raw.strip(): + result = None + else: + new_type = next(i for i in of_type.__args__ if i != type(None)) # noqa + result = self._to_typing(raw, new_type) + if result is not _NO_MAPPING: + return result + raise TypeError("{} cannot cast to {}".format(raw, of_type)) + + @staticmethod + def to_str(value): + return value.strip() + + @staticmethod + @abstractmethod + def to_list(value): + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_set(value): + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_dict(value): + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_path(value): + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_command(value): + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_env_list(value): + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_bool(value): + raise NotImplementedError + + +class Loader(Convert, ABC): + """Loader is able to load a given key of given type from a source. Each source will have its own loader.""" + + def __init__(self, name): + self.name = name + + def load(self, key, of_type, conf): + raw = self._load_raw(key, conf) + converted = self.to(raw, of_type) + return converted + + @abstractmethod + def setup_with_conf(self, conf): + raise NotImplementedError + + def make_package_conf(self): + """""" + + @abstractmethod + def _load_raw(self, key, conf): + raise NotImplementedError + + @abstractmethod + def found_keys(self) -> Set[str]: + raise NotImplementedError + + +class Source(ABC): + def __init__(self, core: Loader) -> None: + self.core: Loader = core + self._envs: Dict[str, Loader] = {} + + @abstractmethod + def envs(self, core_conf): + raise NotImplementedError + + @abstractmethod + def __getitem__(self, item): + raise NotImplementedError + + @property + @abstractmethod + def tox_root(self): + raise NotImplementedError diff --git a/src/tox/config/source/ini/__init__.py b/src/tox/config/source/ini/__init__.py new file mode 100644 index 000000000..2263ca44f --- /dev/null +++ b/src/tox/config/source/ini/__init__.py @@ -0,0 +1,161 @@ +from configparser import ConfigParser, SectionProxy +from itertools import chain +from pathlib import Path +from typing import Dict, List, Optional, Set + +from tox.config.sets import ConfigSet + +from ..api import EnvList, Loader, Source +from .convert import StrConvert +from .factor import filter_for_env, find_envs +from .replace import replace + +BASE_TEST_ENV = "testenv" +TEST_ENV_PREFIX = "{}:".format(BASE_TEST_ENV) + + +class Ini(Source): + CORE_PREFIX = "tox" + + def __init__(self, path: Path) -> None: + self._parser = ConfigParser() + with path.open() as file_handler: + self._parser.read_file(file_handler) + self._path = path + core = IniLoader( + section=self._get_section(self.CORE_PREFIX), + src=self, + name=None, + default_base=EnvList([]), + ) + super().__init__(core) + self._envs: Dict[str, IniLoader] = {} + + def _get_section(self, key): + if self._parser.has_section(key): + return self._parser[key] + return None + + @property + def tox_root(self): + return self._path.parent.absolute() + + def envs(self, core_config): + seen = set() + for name in self._discover_tox_envs(core_config): + if name not in seen: + seen.add(name) + yield name + + BASE_ENV_LIST = EnvList([BASE_TEST_ENV]) + + def __getitem__(self, item): + key = "{}{}".format(TEST_ENV_PREFIX, item) + return self.get_section(key, item) + + def get_section(self, item, name): + try: + return self._envs[item] + except KeyError: + loader = IniLoader(self._get_section(item), self, name, self.BASE_ENV_LIST) + self._envs[item] = loader + return loader + + def _discover_tox_envs(self, core_config): + explicit = list(core_config["env_list"]) + yield from explicit + known_factors = None + for section in self._parser.sections(): + if section.startswith(BASE_TEST_ENV): + is_base_section = section == BASE_TEST_ENV + name = BASE_TEST_ENV if is_base_section else section[len(TEST_ENV_PREFIX) :] + if not is_base_section: + yield name + if known_factors is None: + known_factors = set(chain.from_iterable(e.split("-") for e in explicit)) + yield from self._discover_from_section(section, known_factors) + + def _discover_from_section(self, section, known_factors): + for key in self._parser[section]: + value = self._parser[section].get(key) + if value: + for env in find_envs(value): + if env not in known_factors: + yield env + + def __repr__(self): + return "{}(path={})".format(type(self).__name__, self._path) + + +class IniLoader(Loader, StrConvert): + """Load from a ini section""" + + def __init__( + self, section: Optional[SectionProxy], src: Ini, name: Optional[str], default_base: EnvList + ) -> None: + super().__init__(name) + self._section: Optional[SectionProxy] = section + self._src: Ini = src + self._default_base: EnvList = default_base + self._base: List[IniLoader] = [] + + def setup_with_conf(self, conf: ConfigSet): + def load_bases(values, conf): + result = [] + for value in values: + name = value.lstrip(TEST_ENV_PREFIX) + result.append(self._src.get_section(value, name)) + return result + + conf.add_config( + keys="base", + of_type=EnvList, + default=self._default_base, + desc="inherit missing keys from these sections", + post_process=load_bases, + ) + self._base = conf["base"] + + def make_package_conf(self): + """no inheritance please if this is a packaging env""" + self._base = [] + + def __repr__(self): + return "{}(section={}, src={!r})".format( + type(self).__name__, self._section.name if self._section else self.name, self._src + ) + + def _load_raw(self, key, conf, as_name=None): + for candidate in self.loaders: + if as_name is None and candidate.name == "": + as_name = self.name + try: + return candidate._load_raw_from(as_name, conf, key) + except KeyError: + continue + else: + raise KeyError + + def _load_raw_from(self, as_name, conf, key): + if as_name is None: + as_name = self.name + if self._section is None: + raise KeyError(key) + value = self._section[key] + collapsed_newlines = value.replace("\\\n", "") # collapse explicit line splits + replace_executed = replace(collapsed_newlines, conf, as_name) # do replacements + factor_selected = filter_for_env(replace_executed, as_name) # select matching factors + # extend factors + return factor_selected + + @property + def loaders(self): + yield self + yield from self._base + + def found_keys(self) -> Set[str]: + result = set() + for candidate in self.loaders: + if candidate._section is not None: + result.update(candidate._section.keys()) + return result diff --git a/src/tox/config/source/ini/convert.py b/src/tox/config/source/ini/convert.py new file mode 100644 index 000000000..012a665f5 --- /dev/null +++ b/src/tox/config/source/ini/convert.py @@ -0,0 +1,68 @@ +import shlex +from itertools import chain +from pathlib import Path + +from tox.config.source.api import Command, Convert, EnvList + + +class StrConvert(Convert): + @staticmethod + def to_path(value): + return Path(value) + + @staticmethod + def to_list(value): + splitter = "\n" if "\n" in value else "," + for token in value.split(splitter): + value = token.strip() + if value: + yield value + + @staticmethod + def to_set(value): + return set(StrConvert.to_list(value)) + + @staticmethod + def to_dict(value): + for row in value.split("\n"): + row = row.strip() + if row: + try: + at = row.index("=") + except ValueError: + raise TypeError( + "dictionary lines must be of form key=value, found {}".format(row) + ) + else: + key = row[:at].strip() + value = row[at + 1 :].strip() + yield key, value + + @staticmethod + def to_command(value): + return Command(shlex.split(value)) + + @staticmethod + def to_env_list(value): + from tox.config.source.ini.factor import extend_factors + + elements = list(chain.from_iterable(extend_factors(expr) for expr in value.split("\n"))) + return EnvList(elements) + + TRUTHFUL_VALUES = {"true", "1", "yes", "on"} + FALSY_VALUES = {"false", "0", "no", "off"} + VALID_BOOL = list(sorted(TRUTHFUL_VALUES | FALSY_VALUES)) + + @staticmethod + def to_bool(value): + norm = value.strip().lower() + if norm in StrConvert.TRUTHFUL_VALUES: + return True + elif norm in StrConvert.FALSY_VALUES: + return False + else: + raise TypeError( + "value {} cannot be transformed to bool, valid: {}".format( + value, ", ".join(StrConvert.VALID_BOOL) + ) + ) diff --git a/src/tox/config/source/ini/factor.py b/src/tox/config/source/ini/factor.py new file mode 100644 index 000000000..ad1223690 --- /dev/null +++ b/src/tox/config/source/ini/factor.py @@ -0,0 +1,86 @@ +import itertools +import re + + +def filter_for_env(value, name): + current = ( + set(itertools.chain.from_iterable([(i for i, _ in a) for a in find_factor_groups(name)])) + if name is not None + else set() + ) + overall = [] + for factors, content in expand_factors(value): + if factors is None: + overall.append(content) + else: + for group in factors: + for name, negate in group: + contains = name in current + if contains == negate: + break + else: + overall.append(content) + result = "\n".join(overall) + return result + + +def find_envs(value): + seen = set() + for factors, _ in expand_factors(value): + if factors is not None: + for group in factors: + env = explode_factor(group) + if env not in seen: + yield env + seen.add(env) + + +def extend_factors(value): + for group in find_factor_groups(value): + yield explode_factor(group) + + +def explode_factor(group): + return "-".join([name for name, _ in group]) + + +def expand_factors(value): + for line in value.split("\n"): + match = re.match(r"^((?P[\w{}.!,-]+)\:\s+)?(?P.*?)$", line) + groups = match.groupdict() + factor_expr, content = groups["factor_expr"], groups["content"] + if factor_expr is not None: + factors = find_factor_groups(factor_expr) + yield factors, content + else: + yield None, content + + +def is_negated(factor): + return factor.startswith("!") + + +def name_with_negate(factor): + negated = is_negated(factor) + return (factor[1:] if negated else factor), negated + + +def find_factor_groups(value): + """transform '{py,!pi}-{a,b},c' to [{'py', 'a'}, {'py', 'b'}, {'pi', 'a'}, {'pi', 'b'}, {'c'}]""" + for env in expand_env_with_negation(value): + yield (name_with_negate(f) for f in env.split("-")) + + +def expand_env_with_negation(value): + """transform '{py,!pi}-{a,b},c' to ['py-a', 'py-b', '!pi-a', '!pi-b', 'c']""" + for key, group in itertools.groupby(re.split(r"((?:{[^}]+\})+)|,", value), key=bool): + if key: + group_str = "".join(group).strip() + elements = re.split(r"\{([^}]+)\}", group_str) + parts = [re.sub(r"\s+", "", elem).split(",") for elem in elements] + for variant in itertools.product(*parts): + variant_str = "".join(variant) + yield variant_str + + +__all__ = ("filter_for_env", "find_envs", "expand_factors", "extend_factors") diff --git a/src/tox/config/source/ini/replace.py b/src/tox/config/source/ini/replace.py new file mode 100644 index 000000000..3b19a2dee --- /dev/null +++ b/src/tox/config/source/ini/replace.py @@ -0,0 +1,73 @@ +import os +import re +import sys +from functools import partial + +from tox.config.main import Config +from tox.execute.request import shell_cmd + +RE_ITEM_REF = re.compile( + r""" + (?[^[:{}]+):)? # optional sub_type for special rules + (?P(?:\[(?P
[^,{}]*)\])?(?P[^:,{}]*)) # substitution key + (?::(?P[^{}]*))? # default value + [}] + """, + re.VERBOSE, +) + + +def substitute_once(val, conf, name): + return RE_ITEM_REF.sub(partial(_replace_match, conf, name), val) + + +def replace(value, conf, name): + while True: # substitution found + expanded = substitute_once(value, conf, name) + if expanded == value: + break + value = expanded + return expanded + + +def _replace_match(conf: Config, name, match): + groups = match.groupdict() + sub_type = groups["sub_type"] + value = groups["substitution_value"] + if value == "posargs": + sub_type = value + if sub_type == "env": + replace_value = os.environ.get(groups["key"], groups["default_value"]) + elif sub_type == "posargs": + try: + replace_value = shell_cmd(sys.argv[sys.argv.index("--") + 1 :]) + except ValueError: + replace_value = groups["default_value"] or "" + else: + key_missing_value = groups["default_value"] + if sub_type is not None: + key_missing_value = value + value = sub_type + section = groups["section"] or name + # noinspection PyBroadException + try: + replace_value = conf[section][value] + except Exception: + # noinspection PyBroadException + try: + try: + if groups["section"] is None: + replace_value = conf.core[value] + else: + raise KeyError + except KeyError: + if key_missing_value is None: + raise + replace_value = key_missing_value + except Exception: + start, end = match.span() + replace_value = match.string[start:end] + if replace_value is None: + return "" + return str(replace_value) diff --git a/tests/__init__.py b/src/tox/config/source/toml.py similarity index 100% rename from tests/__init__.py rename to src/tox/config/source/toml.py diff --git a/src/tox/constants.py b/src/tox/constants.py deleted file mode 100644 index c31f2602f..000000000 --- a/src/tox/constants.py +++ /dev/null @@ -1,65 +0,0 @@ -"""All non private names (no leading underscore) here are part of the tox API. - -They live in the tox namespace and can be accessed as tox.[NAMESPACE.]NAME -""" -import os -import re -import sys - -_THIS_FILE = os.path.realpath(os.path.abspath(__file__)) - - -class PYTHON: - PY_FACTORS_RE = re.compile("^(?!py$)(py|pypy|jython)([2-9][0-9]?[0-9]?)?$") - CURRENT_RELEASE_ENV = "py37" - """Should hold currently released py -> for easy updating""" - QUICKSTART_PY_ENVS = ["py27", "py35", "py36", CURRENT_RELEASE_ENV, "pypy", "jython"] - """For choices in tox-quickstart""" - - -class INFO: - DEFAULT_CONFIG_NAME = "tox.ini" - CONFIG_CANDIDATES = ("pyproject.toml", "tox.ini", "setup.cfg") - IS_WIN = sys.platform == "win32" - IS_PYPY = hasattr(sys, "pypy_version_info") - - -class PIP: - SHORT_OPTIONS = ["c", "e", "r", "b", "t", "d"] - LONG_OPTIONS = [ - "build", - "cache-dir", - "client-cert", - "constraint", - "download", - "editable", - "exists-action", - "extra-index-url", - "global-option", - "find-links", - "index-url", - "install-options", - "prefix", - "proxy", - "no-binary", - "only-binary", - "requirement", - "retries", - "root", - "src", - "target", - "timeout", - "trusted-host", - "upgrade-strategy", - ] - INSTALL_SHORT_OPTIONS_ARGUMENT = ["-{}".format(option) for option in SHORT_OPTIONS] - INSTALL_LONG_OPTIONS_ARGUMENT = ["--{}".format(option) for option in LONG_OPTIONS] - - -_HELP_DIR = os.path.join(os.path.dirname(_THIS_FILE), "helper") -VERSION_QUERY_SCRIPT = os.path.join(_HELP_DIR, "get_version.py") -SITE_PACKAGE_QUERY_SCRIPT = os.path.join(_HELP_DIR, "get_site_package_dir.py") -BUILD_REQUIRE_SCRIPT = os.path.join(_HELP_DIR, "build_requires.py") -BUILD_ISOLATED = os.path.join(_HELP_DIR, "build_isolated.py") -PARALLEL_RESULT_JSON_PREFIX = ".tox-result" -PARALLEL_RESULT_JSON_SUFFIX = ".json" diff --git a/src/tox/exception.py b/src/tox/exception.py deleted file mode 100644 index 4c9a3262d..000000000 --- a/src/tox/exception.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import pipes -import signal - - -def exit_code_str(exception_name, command, exit_code): - """String representation for an InvocationError, with exit code - - NOTE: this might also be used by plugin tests (tox-venv at the time of writing), - so some coordination is needed if this is ever moved or a different solution for this hack - is found. - - NOTE: this is a separate function because pytest-mock `spy` does not work on Exceptions - We can use neither a class method nor a static because of https://bugs.python.org/issue23078. - Even a normal method failed with "TypeError: descriptor '__getattribute__' requires a - 'BaseException' object but received a 'type'". - """ - str_ = "{} for command {}".format(exception_name, command) - if exit_code is not None: - str_ += " (exited with code {:d})".format(exit_code) - if (os.name == "posix") and (exit_code > 128): - signals = { - number: name for name, number in vars(signal).items() if name.startswith("SIG") - } - number = exit_code - 128 - name = signals.get(number) - if name: - str_ += ( - "\nNote: this might indicate a fatal error signal " - "({:d} - 128 = {:d}: {})".format(number + 128, number, name) - ) - return str_ - - -class Error(Exception): - def __str__(self): - return "{}: {}".format(self.__class__.__name__, self.args[0]) - - -class MissingSubstitution(Error): - FLAG = "TOX_MISSING_SUBSTITUTION" - """placeholder for debugging configurations""" - - def __init__(self, name): - self.name = name - - -class ConfigError(Error): - """Error in tox configuration.""" - - -class UnsupportedInterpreter(Error): - """Signals an unsupported Interpreter.""" - - -class InterpreterNotFound(Error): - """Signals that an interpreter could not be found.""" - - -class InvocationError(Error): - """An error while invoking a script.""" - - def __init__(self, command, exit_code=None, out=None): - super(Error, self).__init__(command, exit_code) - self.command = command - self.exit_code = exit_code - self.out = out - - def __str__(self): - return exit_code_str(self.__class__.__name__, self.command, self.exit_code) - - -class MissingDirectory(Error): - """A directory did not exist.""" - - -class MissingDependency(Error): - """A dependency could not be found or determined.""" - - -class MissingRequirement(Error): - """A requirement defined in :config:`require` is not met.""" - - def __init__(self, config): - self.config = config - - def __str__(self): - return " ".join(pipes.quote(i) for i in self.config.requires) - - -class BadRequirement(Error): - """A requirement defined in :config:`require` cannot be parsed.""" diff --git a/tests/unit/__init__.py b/src/tox/execute/__init__.py similarity index 100% rename from tests/unit/__init__.py rename to src/tox/execute/__init__.py diff --git a/src/tox/execute/api.py b/src/tox/execute/api.py new file mode 100644 index 000000000..a0b241013 --- /dev/null +++ b/src/tox/execute/api.py @@ -0,0 +1,140 @@ +import logging +import signal +import sys +import threading +from abc import ABC, abstractmethod +from functools import partial +from timeit import default_timer as timer +from typing import Callable, Type + +from colorama import Fore + +from .request import ExecuteRequest +from .stream import CollectWrite + +ContentHandler = Callable[[bytes], None] +Executor = Callable[[ExecuteRequest, ContentHandler, ContentHandler], int] + + +class ExecuteInstance: + def __init__( + self, request: ExecuteRequest, out_handler: ContentHandler, err_handler: ContentHandler + ) -> None: + def _safe_handler(handler, data): + # noinspection PyBroadException + try: + handler(data) + except Exception: # pragma: no cover + pass # pragma: no cover + + self.request = request + self.out_handler = partial(_safe_handler, out_handler) + self.err_handler = partial(_safe_handler, err_handler) + + @abstractmethod + def run(self) -> int: + raise NotImplementedError + + @abstractmethod + def interrupt(self) -> int: + raise NotImplementedError + + +class Outcome: + OK = 0 + + def __init__( + self, + request: ExecuteRequest, + show_on_standard: bool, + exit_code: int, + out: str, + err: str, + start: float, + end: float, + ): + self.request = request + + self.show_on_standard = show_on_standard + + self.exit_code = exit_code + self.out = out + self.err = err + + self.start = start + self.end = end + + def __bool__(self): + return self.exit_code == self.OK + + def assert_success(self, logger): + if self.exit_code != self.OK: + self._assert_fail(logger) + + def _assert_fail(self, logger: logging.Logger): + if self.show_on_standard is False: + if self.out: + print(self.out, file=sys.stdout) + if self.err: + print(Fore.RED, file=sys.stderr, end="") + print(self.err, file=sys.stderr, end="") + print(Fore.RESET, file=sys.stderr) + logger.critical( + "exit code %d for %s: %s in %s", + self.exit_code, + self.request.cwd, + self.request.shell_cmd, + self.elapsed, + ) + raise SystemExit(self.exit_code) + + @property + def elapsed(self): + return self.end - self.start + + +class ToxKeyboardInterrupt(KeyboardInterrupt): + def __init__(self, outcome: Outcome, exc: KeyboardInterrupt): + self.outcome = outcome + self.exc = exc + + +class Execute(ABC): + def __call__(self, request: ExecuteRequest, show_on_standard: bool) -> Outcome: + start = timer() + executor = self.executor() + interrupt = None + try: + with CollectWrite(sys.stdout if show_on_standard else None) as out: + with CollectWrite(sys.stderr if show_on_standard else None, Fore.RED) as err: + instance = executor(request, out.collect, err.collect) + try: + exit_code = instance.run() + except KeyboardInterrupt as exception: + interrupt = exception + while True: + try: + is_main = threading.current_thread() == threading.main_thread() + if is_main: + # disable further interrupts until we finish this, main thread only + signal.signal(signal.SIGINT, signal.SIG_IGN) + except KeyboardInterrupt: # pragma: no cover + continue # pragma: no cover + else: + try: + exit_code = instance.interrupt() + break + finally: + if is_main: # restore signal handler on main thread + signal.signal(signal.SIGINT, signal.default_int_handler) + finally: + end = timer() + result = Outcome(request, show_on_standard, exit_code, out.text, err.text, start, end) + if interrupt is not None: + raise ToxKeyboardInterrupt(result, interrupt) + return result + + @staticmethod + @abstractmethod + def executor() -> Type[ExecuteInstance]: + raise NotImplementedError diff --git a/src/tox/execute/local_sub_process.py b/src/tox/execute/local_sub_process.py new file mode 100644 index 000000000..81dd602f7 --- /dev/null +++ b/src/tox/execute/local_sub_process.py @@ -0,0 +1,148 @@ +"""A execute that runs on local file system via subprocess-es""" +import logging +import os +import select +import signal +import subprocess +import sys +from threading import Event, Thread +from typing import Tuple, Type + +from .api import ContentHandler, Execute, ExecuteInstance, ExecuteRequest, Outcome + +WAIT_INTERRUPT = 0.3 +WAIT_TERMINATE = 0.2 +WAIT_GENERAL = 0.1 + + +class LocalSubProcessExecutor(Execute): + @staticmethod + def executor() -> Type[ExecuteInstance]: + return LocalSubProcessExecuteInstance + + +class LocalSubProcessExecuteInstance(ExecuteInstance): + def __init__( + self, request: ExecuteRequest, out_handler: ContentHandler, err_handler: ContentHandler + ) -> None: + super().__init__(request, out_handler, err_handler) + self.process = None + + def run(self) -> int: + try: + self.process = process = subprocess.Popen( + self.request.cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=None if self.request.allow_stdin else subprocess.PIPE, + cwd=str(self.request.cwd), + env=self.request.env, + shell=False, + creationflags=( + subprocess.CREATE_NEW_PROCESS_GROUP + if sys.platform == "win32" + else 0 + # custom flag needed for Windows signal send ability (CTRL+C) + ), + ) + except OSError as exception: + exit_code = exception.errno + else: + with ReadViaThread(process.stderr, self.err_handler): + with ReadViaThread(process.stdout, self.out_handler): + # wait it out with interruptions to allow KeyboardInterrupt on Windows + while process.poll() is None: + try: + # note poll in general might deadlock if output large + # but we drain in background threads so not an issue here + process.wait(timeout=WAIT_GENERAL) + except subprocess.TimeoutExpired: + continue + exit_code = process.returncode + return exit_code + + def interrupt(self) -> int: + if self.process is not None: + out, err = self._handle_interrupt() # stop it and drain it + self._finalize_output(err, self.err_handler, out, self.out_handler) + return self.process.returncode + return Outcome.OK # pragma: no cover + + @staticmethod + def _finalize_output(err, err_handler, out, out_handler): + out_handler(out) + err_handler(err) + + def _handle_interrupt(self) -> Tuple[bytes, bytes]: + """A three level stop mechanism for children - INT -> TERM -> KILL""" + # communicate will wait for the app to stop, and then drain the standard streams and close them + proc = self.process + logging.error("got KeyboardInterrupt signal") + msg = "from {} {{}} pid {}".format(os.getpid(), proc.pid) + if proc.poll() is None: # still alive, first INT + logging.warning("KeyboardInterrupt %s", msg.format("SIGINT")) + proc.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT) + try: + out, err = proc.communicate(timeout=WAIT_INTERRUPT) + except subprocess.TimeoutExpired: # if INT times out TERM + logging.warning("KeyboardInterrupt %s", msg.format("SIGTERM")) + proc.terminate() + try: + out, err = proc.communicate(timeout=WAIT_INTERRUPT) + except subprocess.TimeoutExpired: # if TERM times out KILL + logging.info("KeyboardInterrupt %s", msg.format("SIGKILL")) + proc.kill() + out, err = proc.communicate() + else: + out, err = proc.communicate() # just drain # pragma: no cover + return out, err + + +class ReadViaThread: + def __init__(self, stream, handler): + self.stream = stream + self.stop = Event() + self.thread = Thread(target=self._read_stream) + self.handler = handler + + def _read_stream(self): + file_no = self.stream.fileno() + while not (self.stream.closed or self.stop.is_set()): + read_available_list, _, __ = select.select([self.stream], [], [], 0.01) + if len(read_available_list): + data = os.read(file_no, 1) + self.handler(data) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + thrown = None + while True: + try: + self.stop.set() + while self.thread.is_alive(): + self.thread.join(WAIT_GENERAL) + except KeyboardInterrupt as exception: # pragma: no cover + thrown = exception # pragma: no cover + continue # pragma: no cover + else: + if thrown is not None: + raise thrown # pragma: no cover + else: # pragma: no cover + break # pragma: no cover + if exc_val is None: # drain what remains if we were not interrupted + try: + data = self.stream.read() + except ValueError: # pragma: no cover + pass # pragma: no cover + else: + while True: + try: + self.handler(data) + break + except KeyboardInterrupt as exception: # pragma: no cover + thrown = exception # pragma: no cover + if thrown is not None: + raise thrown # pragma: no cover diff --git a/tests/unit/session/__init__.py b/src/tox/execute/outcome.py similarity index 100% rename from tests/unit/session/__init__.py rename to src/tox/execute/outcome.py diff --git a/src/tox/execute/request.py b/src/tox/execute/request.py new file mode 100644 index 000000000..22741380e --- /dev/null +++ b/src/tox/execute/request.py @@ -0,0 +1,26 @@ +import sys +from pathlib import Path +from typing import Dict, Sequence + + +class ExecuteRequest: + def __init__(self, cmd: Sequence[str], cwd: Path, env: Dict[str, str], allow_stdin: bool): + self.cmd = cmd + self.cwd = cwd + self.env = env + self.allow_stdin = allow_stdin + + @property + def shell_cmd(self): + return shell_cmd(self.cmd) + + +def shell_cmd(cmd: Sequence[str]) -> str: + if sys.platform.startswith("win"): + from subprocess import list2cmdline + + return list2cmdline(tuple(str(x) for x in cmd)) + else: + from shlex import quote as shlex_quote + + return " ".join(shlex_quote(str(x)) for x in cmd) diff --git a/src/tox/execute/stream.py b/src/tox/execute/stream.py new file mode 100644 index 000000000..a73022833 --- /dev/null +++ b/src/tox/execute/stream.py @@ -0,0 +1,85 @@ +from contextlib import contextmanager +from threading import Event, Lock, Timer +from typing import IO, Optional + +from colorama import Fore + + +class CollectWrite: + """A stream collector that is both time triggered and newline""" + + REFRESH_RATE = 0.1 + + def __init__(self, target: Optional[IO[bytes]], color: Optional[str] = None) -> None: + self._content = bytearray() + self._print_to: Optional[IO[bytes]] = None if target is None else target.buffer + self._do_print: bool = target is not None + self._keep_printing: Event = Event() + self._content_lock: Lock = Lock() + self._print_lock: Lock = Lock() + self._at: int = 0 + self._color = color + self._timer = None + + def __enter__(self): + if self._do_print: + self._start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._do_print: + self._cancel() + self._print(len(self._content)) + + def _start(self): + self.timer = Timer(self.REFRESH_RATE, self._trigger_timer) + self.timer.start() + + def _cancel(self): + self.timer.cancel() + + def collect(self, content: bytes): + with self._content_lock: + self._content.extend(content) + if self._do_print is False: + return + at = content.rfind(b"\n") + if at != -1: + at = len(self._content) - len(content) + at + 1 + if at != -1: + self._cancel() + try: + self._print(at) + finally: + self._start() + + def _trigger_timer(self): + with self._content_lock: + at = len(self._content) + self._print(at) + + def _print(self, at): + with self._print_lock: + if at > self._at: + try: + with self.colored(): + self._print_to.write(self._content[self._at : at]) + self._print_to.flush() + finally: + self._at = at + + @contextmanager + def colored(self): + if self._color is None: + yield + else: + self._print_to.write(self._color.encode("utf-8")) + try: + yield + finally: + self._print_to.write(Fore.RESET.encode("utf-8")) + + @property + def text(self): + with self._content_lock: + return self._content.decode("utf-8") diff --git a/src/tox/helper/__init__.py b/src/tox/helper/__init__.py index e69de29bb..482e21b3b 100644 --- a/src/tox/helper/__init__.py +++ b/src/tox/helper/__init__.py @@ -0,0 +1,19 @@ +from pathlib import Path + +HERE = Path(__file__).absolute().parent + + +def script(name: str): + return HERE / name + + +def isolated_builder(): + return script("build_isolated.py") + + +def wheel_meta(): + return script("wheel_meta.py") + + +def build_requires(): + return script("build_requires.py") diff --git a/src/tox/helper/build_isolated.py b/src/tox/helper/build_isolated.py index 59680ad6d..c32a595d4 100644 --- a/src/tox/helper/build_isolated.py +++ b/src/tox/helper/build_isolated.py @@ -1,12 +1,19 @@ +import json import sys -dist_folder = sys.argv[1] -backend_spec = sys.argv[2] -backend_obj = sys.argv[3] if len(sys.argv) >= 4 else None +into = sys.argv[1] +dist_folder = sys.argv[2] +build_type = sys.argv[3] +extra = json.loads(sys.argv[4]) +backend_spec = sys.argv[5] +backend_obj = sys.argv[6] if len(sys.argv) >= 7 else None backend = __import__(backend_spec, fromlist=[None]) if backend_obj: backend = getattr(backend, backend_obj) -basename = backend.build_sdist(dist_folder, {"--global-option": ["--formats=gztar"]}) -print(basename) +builder = getattr(backend, "build_{}".format(build_type)) +basename = builder(dist_folder, **extra) + +with open(into, "w") as file_handler: + json.dump(basename, file_handler) diff --git a/src/tox/helper/build_requires.py b/src/tox/helper/build_requires.py index 08bf94126..ed3317072 100644 --- a/src/tox/helper/build_requires.py +++ b/src/tox/helper/build_requires.py @@ -1,13 +1,17 @@ +from __future__ import print_function + import json import sys -backend_spec = sys.argv[1] -backend_obj = sys.argv[2] if len(sys.argv) >= 3 else None +into = sys.argv[1] +backend_spec = sys.argv[2] +backend_obj = sys.argv[3] if len(sys.argv) >= 4 else None backend = __import__(backend_spec, fromlist=[None]) if backend_obj: backend = getattr(backend, backend_obj) for_build_requires = backend.get_requires_for_build_sdist(None) -output = json.dumps(for_build_requires) -print(output) + +with open(into, "w") as file_handler: + json.dump(for_build_requires, file_handler) diff --git a/src/tox/helper/get_site_package_dir.py b/src/tox/helper/get_site_package_dir.py deleted file mode 100644 index 584f5103e..000000000 --- a/src/tox/helper/get_site_package_dir.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import unicode_literals - -import distutils.sysconfig -import json -import sys - -data = json.dumps({"dir": distutils.sysconfig.get_python_lib(prefix=sys.argv[1])}) -print(data) diff --git a/src/tox/helper/get_version.py b/src/tox/helper/get_version.py deleted file mode 100644 index 3fcc37e43..000000000 --- a/src/tox/helper/get_version.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import unicode_literals - -import json -import sys - -info = { - "executable": sys.executable, - "name": "pypy" if hasattr(sys, "pypy_version_info") else "python", - "version_info": list(sys.version_info), - "version": sys.version, - "is_64": sys.maxsize > 2 ** 32, - "sysplatform": sys.platform, -} -info_as_dump = json.dumps(info) -print(info_as_dump) diff --git a/src/tox/helper/wheel_meta.py b/src/tox/helper/wheel_meta.py new file mode 100644 index 000000000..679df68a6 --- /dev/null +++ b/src/tox/helper/wheel_meta.py @@ -0,0 +1,15 @@ +from __future__ import print_function + +import json +import sys + +into = sys.argv[1] +extra = json.loads(sys.argv[2]) +backend_spec = sys.argv[3] +backend_obj = sys.argv[4] if len(sys.argv) >= 5 else None + +backend = __import__(backend_spec, fromlist=[None]) +if backend_obj: + backend = getattr(backend, backend_obj) + +for_build_requires = backend.prepare_metadata_for_build_wheel(into, **extra) diff --git a/src/tox/hookspecs.py b/src/tox/hookspecs.py deleted file mode 100644 index 90374cfb5..000000000 --- a/src/tox/hookspecs.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Hook specifications for tox - see https://pluggy.readthedocs.io/""" -import pluggy - -hookspec = pluggy.HookspecMarker("tox") - - -@hookspec -def tox_addoption(parser): - """ add command line options to the argparse-style parser object.""" - - -@hookspec -def tox_configure(config): - """Called after command line options are parsed and ini-file has been read. - - Please be aware that the config object layout may change between major tox versions. - """ - - -@hookspec(firstresult=True) -def tox_package(session, venv): - """Return the package to be installed for the given venv. - - Called once for every environment.""" - - -@hookspec(firstresult=True) -def tox_get_python_executable(envconfig): - """Return a python executable for the given python base name. - - The first plugin/hook which returns an executable path will determine it. - - ``envconfig`` is the testenv configuration which contains - per-testenv configuration, notably the ``.envname`` and ``.basepython`` - setting. - """ - - -@hookspec(firstresult=True) -def tox_testenv_create(venv, action): - """Perform creation action for this venv. - - Some example usage: - - - To *add* behavior but still use tox's implementation to set up a - virtualenv, implement this hook but do not return a value (or explicitly - return ``None``). - - To *override* tox's virtualenv creation, implement this hook and return - a non-``None`` value. - - .. note:: This api is experimental due to the unstable api of - :class:`tox.venv.VirtualEnv`. - - .. note:: This hook uses ``firstresult=True`` (see `pluggy first result only`_) -- hooks - implementing this will be run until one returns non-``None``. - - .. _`pluggy first result only`: https://pluggy.readthedocs.io/en/latest/#first-result-only - """ - - -@hookspec(firstresult=True) -def tox_testenv_install_deps(venv, action): - """Perform install dependencies action for this venv. - - Some example usage: - - - To *add* behavior but still use tox's implementation to install - dependencies, implement this hook but do not return a value (or - explicitly return ``None``). One use-case may be to install (or ensure) - non-python dependencies such as debian packages. - - To *override* tox's installation of dependencies, implement this hook - and return a non-``None`` value. One use-case may be to install via - a different installation tool such as `pip-accel`_ or `pip-faster`_. - - .. note:: This api is experimental due to the unstable api of - :class:`tox.venv.VirtualEnv`. - - .. note:: This hook uses ``firstresult=True`` (see `pluggy first result only`_) -- hooks - implementing this will be run until one returns non-``None``. - - .. _pip-accel: https://github.com/paylogic/pip-accel - .. _pip-faster: https://github.com/Yelp/venv-update - """ - - -@hookspec -def tox_runtest_pre(venv): - """Perform arbitrary action before running tests for this venv. - - This could be used to indicate that tests for a given venv have started, for instance. - """ - - -@hookspec(firstresult=True) -def tox_runtest(venv, redirect): - """Run the tests for this venv. - - .. note:: This hook uses ``firstresult=True`` (see `pluggy first result only`_) -- hooks - implementing this will be run until one returns non-``None``. - """ - - -@hookspec -def tox_runtest_post(venv): - """Perform arbitrary action after running tests for this venv. - - This could be used to have per-venv test reporting of pass/fail status. - """ - - -@hookspec(firstresult=True) -def tox_runenvreport(venv, action): - """Get the installed packages and versions in this venv. - - This could be used for alternative (ie non-pip) package managers, this - plugin should return a ``list`` of type ``str`` - """ - - -@hookspec -def tox_cleanup(session): - """Called just before the session is destroyed, allowing any final cleanup operation""" diff --git a/src/tox/interpreters/__init__.py b/src/tox/interpreters/__init__.py index b8d12cd59..e69de29bb 100644 --- a/src/tox/interpreters/__init__.py +++ b/src/tox/interpreters/__init__.py @@ -1,125 +0,0 @@ -from __future__ import unicode_literals - -import json -import sys - -import tox -from tox import reporter -from tox.constants import SITE_PACKAGE_QUERY_SCRIPT, VERSION_QUERY_SCRIPT - - -class Interpreters: - def __init__(self, hook): - self.name2executable = {} - self.executable2info = {} - self.hook = hook - - def get_executable(self, envconfig): - """ return path object to the executable for the given - name (e.g. python2.7, python3.6, python etc.) - if name is already an existing path, return name. - If an interpreter cannot be found, return None. - """ - try: - return self.name2executable[envconfig.envname] - except KeyError: - exe = self.hook.tox_get_python_executable(envconfig=envconfig) - reporter.verbosity2("{} uses {}".format(envconfig.envname, exe)) - self.name2executable[envconfig.envname] = exe - return exe - - def get_info(self, envconfig): - executable = self.get_executable(envconfig) - name = envconfig.basepython - if not executable: - return NoInterpreterInfo(name=name) - try: - return self.executable2info[executable] - except KeyError: - info = run_and_get_interpreter_info(name, executable) - self.executable2info[executable] = info - return info - - def get_sitepackagesdir(self, info, envdir): - if not info.executable: - return "" - envdir = str(envdir) - try: - res = exec_on_interpreter(str(info.executable), SITE_PACKAGE_QUERY_SCRIPT, str(envdir)) - except ExecFailed as e: - reporter.verbosity1("execution failed: {} -- {}".format(e.out, e.err)) - return "" - else: - return res["dir"] - - -def run_and_get_interpreter_info(name, executable): - assert executable - try: - result = exec_on_interpreter(str(executable), VERSION_QUERY_SCRIPT) - result["version_info"] = tuple(result["version_info"]) # fix json dump transformation - del result["name"] - del result["version"] - result["executable"] = str(executable) - except ExecFailed as e: - return NoInterpreterInfo(name, executable=e.executable, out=e.out, err=e.err) - else: - return InterpreterInfo(name, **result) - - -def exec_on_interpreter(*args): - from subprocess import Popen, PIPE - - popen = Popen(args, stdout=PIPE, stderr=PIPE, universal_newlines=True) - out, err = popen.communicate() - if popen.returncode: - raise ExecFailed(args[0], args[1:], out, err) - if err: - sys.stderr.write(err) - try: - result = json.loads(out) - except Exception: - raise ExecFailed(args[0], args[1:], out, "could not decode {!r}".format(out)) - return result - - -class ExecFailed(Exception): - def __init__(self, executable, source, out, err): - self.executable = executable - self.source = source - self.out = out - self.err = err - - -class InterpreterInfo: - def __init__(self, name, executable, version_info, sysplatform, is_64): - self.name = name - self.executable = executable - self.version_info = version_info - self.sysplatform = sysplatform - self.is_64 = is_64 - - def __str__(self): - return "".format(self.executable, self.version_info) - - -class NoInterpreterInfo: - def __init__(self, name, executable=None, out=None, err="not found"): - self.name = name - self.executable = executable - self.version_info = None - self.out = out - self.err = err - - def __str__(self): - if self.executable: - return "".format(self.executable) - else: - return "".format(self.name) - - -if tox.INFO.IS_WIN: - from .windows import tox_get_python_executable -else: - from .unix import tox_get_python_executable -assert tox_get_python_executable diff --git a/src/tox/interpreters/discovery/__init__.py b/src/tox/interpreters/discovery/__init__.py new file mode 100644 index 000000000..be996a059 --- /dev/null +++ b/src/tox/interpreters/discovery/__init__.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals + +import os +import sys +from distutils.spawn import find_executable + +from .py_info import CURRENT, PythonInfo +from .py_spec import PythonSpec + +IS_WIN = sys.platform == "win32" + + +def get_interpreter(key): + spec = PythonSpec.from_string_spec(key) + for interpreter, impl_must_match in propose_interpreters(spec): + if interpreter.satisfies(spec, impl_must_match): + return interpreter + return None + + +def propose_interpreters(spec): + # 1. we always try with the lowest hanging fruit first, the current interpreter + yield CURRENT, True + + # 2. then maybe it's something exact on PATH - if it was direct lookup implementation no longer counts + interpreter = find_on_path(spec.str_spec) + if interpreter is not None: + yield interpreter, False + + # 3. otherwise fallback to platform logic + if IS_WIN: + from .windows import propose_interpreters + + for interpreter in propose_interpreters(spec): + yield interpreter, True + + # 4. or from the spec we can deduce a name on path that matches + for exe in spec.generate_paths(): + interpreter = find_on_path(exe) + if interpreter is not None: + yield interpreter, True + + +def find_on_path(key): + exe = find_executable(key) + if exe is not None: + exe = os.path.abspath(exe) + interpreter = PythonInfo.from_exe(exe, raise_on_error=False) + return interpreter diff --git a/src/tox/interpreters/discovery/py_info.py b/src/tox/interpreters/discovery/py_info.py new file mode 100644 index 000000000..dbb6e9eb3 --- /dev/null +++ b/src/tox/interpreters/discovery/py_info.py @@ -0,0 +1,177 @@ +"""Get information about an interpreter""" +from __future__ import absolute_import, print_function, unicode_literals + +import copy +import json +import logging +import os +import platform +import sys +from collections import OrderedDict, namedtuple + +IS_WIN = sys.platform == "win32" + +VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) + + +class PythonInfo: + """Contains information for a Python interpreter""" + + def __init__(self): + # qualifies the python + self.platform = sys.platform + self.implementation = platform.python_implementation() + + # this is a tuple in earlier, struct later, unify to our own named tuple + self.version_info = VersionInfo(*list(sys.version_info)) + self.architecture = 64 if sys.maxsize > 2 ** 32 else 32 + + self.executable = sys.executable # executable we were called with + self.original_executable = self.executable + self.base_executable = getattr( + sys, "_base_executable", None + ) # some platforms may set this + + self.version = sys.version + self.os = os.name + + # information about the prefix - determines python home + self.prefix = getattr(sys, "prefix", None) # prefix we think + self.base_prefix = getattr(sys, "base_prefix", None) # venv + self.real_prefix = getattr(sys, "real_prefix", None) # old virtualenv + + # information about the exec prefix - dynamic stdlib modules + self.base_exec_prefix = getattr(sys, "base_exec_prefix", None) + self.exec_prefix = getattr(sys, "exec_prefix", None) + + try: + __import__("venv") + has = True + except ImportError: + has = False + self.has_venv = has + self.path = sys.path + + @property + def is_old_virtualenv(self): + return self.real_prefix is not None + + @property + def is_venv(self): + return self.base_prefix is not None and self.version_info.major == 3 + + def __repr__(self): + return "PythonInfo({!r})".format(self.__dict__) + + def to_json(self): + data = copy.deepcopy(self.__dict__) + # noinspection PyProtectedMember + data["version_info"] = data["version_info"]._asdict() # namedtuple to dictionary + return json.dumps(data) + + @classmethod + def from_json(cls, payload): + data = json.loads(payload) + data["version_info"] = VersionInfo( + **data["version_info"] + ) # restore this to a named tuple structure + info = copy.deepcopy(CURRENT) + info.__dict__ = data + return info + + @property + def system_prefix(self): + return self.real_prefix or self.base_prefix or self.prefix + + @property + def system_exec_prefix(self): + return self.real_prefix or self.base_exec_prefix or self.exec_prefix + + @property + def system_executable(self): + env_prefix = self.real_prefix or self.base_prefix + if env_prefix: + if self.real_prefix is None and self.base_executable is not None: + return self.base_executable + return self.find_exe(env_prefix) + else: + return self.executable + + def find_exe(self, home): + # we don't know explicitly here, do some guess work - our executable name should tell + exe_base_name = os.path.basename(self.executable) + name_candidate = OrderedDict() + name_candidate[exe_base_name] = None + for ver in range(3, -1, -1): + version = ".".join(str(i) for i in sys.version_info[0:ver]) + name = "python{}{}".format(version, ".exe" if IS_WIN else "") + name_candidate[name] = None + candidate_folder = OrderedDict() + if self.executable.startswith(self.prefix): + relative = self.executable[len(self.prefix) : -len(exe_base_name)] + candidate_folder["{}{}".format(home, relative)] = None + candidate_folder[home] = None + for folder in candidate_folder: + for name in name_candidate: + candidate = os.path.join(folder, name) + if os.path.exists(candidate): + return candidate + msg = "failed to detect {} in {}".format( + "|".join(name_candidate.keys()), "|".join(candidate_folder) + ) + raise RuntimeError(msg) + + @classmethod + def from_exe(cls, exe, raise_on_error=True): + import subprocess + import os + + path = "{}.py".format(os.path.splitext(__file__)[0]) + cmd = [exe, path] + try: + process = subprocess.Popen( + cmd, + universal_newlines=True, + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + out, err = process.communicate() + code = process.returncode + except subprocess.CalledProcessError as exception: + out, err, code = exception.stdout, exception.stderr, exception.returncode + if code != 0: + if raise_on_error: + raise RuntimeError( + "failed {} with code {} out {} err {}".format(cmd, code, out, err) + ) + else: + logging.debug("failed %s with code %s out %s err %s", cmd, code, out, err) + return None + + result = cls.from_json(out) + result.executable = exe # keep original executable as this may contain initialization code + return result + + def satisfies(self, req, impl_must_match): + if self.executable == req.path: + return True + if req.path is not None and os.path.isabs(req.path): + return False + if impl_must_match: + if req.implementation is not None and req.implementation != self.implementation: + return False + if req.architecture is not None and req.architecture != self.architecture: + return False + + for our, reqs in zip(self.version_info[0:3], (req.major, req.minor, req.patch)): + if reqs is not None and our is not None and our != reqs: + return False + return True + + +CURRENT = PythonInfo() + + +if __name__ == "__main__": + print(CURRENT.to_json()) diff --git a/src/tox/interpreters/discovery/py_spec.py b/src/tox/interpreters/discovery/py_spec.py new file mode 100644 index 000000000..716aa4f6f --- /dev/null +++ b/src/tox/interpreters/discovery/py_spec.py @@ -0,0 +1,128 @@ +from __future__ import absolute_import, unicode_literals + +import os +import re +import sys +from collections import OrderedDict + +PATTERN = re.compile(r"^(?P[a-zA-Z]+)(?P[0-9.]+)?(?:-(?P32|64))?$") +IS_WIN = sys.platform == "win32" + + +class PythonSpec: + """Contains specification about a Python Interpreter""" + + def __init__(self, str_spec, implementation, major, minor, patch, architecture, path): + self.str_spec = str_spec + self.implementation = implementation + self.major = major + self.minor = minor + self.patch = patch + self.architecture = architecture + self.path = path + + @classmethod + def from_string_spec(cls, string_spec): + impl, major, minor, patch, arch, path = None, None, None, None, None, None + if os.path.isabs(string_spec): + path = string_spec + else: + ok = False + match = re.match(PATTERN, string_spec) + if match: + + def _int_or_none(val): + return None if val is None else int(val) + + try: + groups = match.groupdict() + version = groups["version"] + if version is not None: + versions = tuple(int(i) for i in version.split(".") if i) + if len(versions) > 3: + raise ValueError + if len(versions) == 3: + major, minor, patch = versions + elif len(versions) == 2: + major, minor = versions + elif len(versions) == 1: + version_data = versions[0] + major = int(str(version_data)[0]) # first digit major + if version_data > 9: + minor = int(str(version_data)[1:]) + ok = True + except ValueError: + pass + else: + impl = groups["impl"] + if impl == "py" or impl == "python": + impl = "CPython" + arch = _int_or_none(groups["arch"]) + + if not ok: + path = string_spec + + return cls(string_spec, impl, major, minor, patch, arch, path) + + def generate_paths(self): + impls = [] + if self.implementation: + # first consider implementation as lower case + name = self.implementation.lower() + if name == "cpython": # convention + name = "python" + impls.append(name) + if not IS_WIN: # windows is case insensitive, so also consider implementation as it is + impls.append(self.implementation) + impls.append("python") # finally consider python as alias + impls = list(OrderedDict.fromkeys(impls)) + version = self.major, self.minor, self.patch + try: + version = version[: version.index(None)] + except ValueError: + pass + for impl in impls: + for at in range(len(version)): + cur_ver = version[: len(version) - at] + spec = "{}{}".format(impl, ".".join(str(i) for i in cur_ver)) + yield spec + + @property + def is_abs(self): + return self.path is not None and os.path.isabs(self.path) + + def satisfies(self, req): + if req.is_abs and self.is_abs and self.path != req.path: + return False + if req.implementation is not None and req.implementation != self.implementation: + return False + if req.architecture is not None and req.architecture != self.architecture: + return False + + ok = True + for our, _ in zip((self.major, self.minor, self.patch), (req.major, req.minor, req.patch)): + if req is not None and (our is None or our < req): + ok = False + break + return ok + + def __eq__(self, other): + return type(self) == type(other) and self.__dict__ == other.__dict__ + + def __repr__(self): + return "{}({})".format( + type(self).__name__, + ", ".join( + "{}={}".format(k, getattr(self, k)) + for k in ( + "str_spec", + "implementation", + "major", + "minor", + "patch", + "architecture", + "path", + ) + if getattr(self, k) is not None + ), + ) diff --git a/src/tox/interpreters/discovery/windows/__init__.py b/src/tox/interpreters/discovery/windows/__init__.py new file mode 100644 index 000000000..b959090a3 --- /dev/null +++ b/src/tox/interpreters/discovery/windows/__init__.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +from ..py_info import PythonInfo +from ..py_spec import PythonSpec +from .pep514 import discover_pythons + + +def propose_interpreters(spec): + # see if PEP-514 entries are good + for name, major, minor, arch, exe, _ in discover_pythons(): + # pre-filter + our = PythonSpec(None, name, major, minor, None, arch, exe) + if our.satisfies(spec): + interpreter = PythonInfo.from_exe(exe, raise_on_error=False) + if interpreter is not None: + yield interpreter diff --git a/src/tox/interpreters/windows/pep514.py b/src/tox/interpreters/discovery/windows/pep514.py similarity index 80% rename from src/tox/interpreters/windows/pep514.py rename to src/tox/interpreters/discovery/windows/pep514.py index 2396262a1..4dee385cf 100644 --- a/src/tox/interpreters/windows/pep514.py +++ b/src/tox/interpreters/discovery/windows/pep514.py @@ -1,14 +1,19 @@ """Implement https://www.python.org/dev/peps/pep-0514/ to discover interpreters - Windows only""" -from __future__ import unicode_literals +from __future__ import print_function, unicode_literals import os import re +from logging import basicConfig, getLogger import six -from six.moves import winreg -from tox import reporter -from tox.interpreters.py_spec import PythonSpec +if six.PY3: + import winreg +else: + import _wireg as winreg + + +LOGGER = getLogger(__name__) def enum_keys(key): @@ -46,8 +51,7 @@ def discover_pythons(): 32, ), ]: - for spec in process_set(hive, hive_name, key, flags, default_arch): - yield spec + yield from process_set(hive, hive_name, key, flags, default_arch) def process_set(hive, hive_name, key, flags, default_arch): @@ -56,8 +60,7 @@ def process_set(hive, hive_name, key, flags, default_arch): for company in enum_keys(root_key): if company == "PyLauncher": # reserved continue - for spec in process_company(hive_name, company, root_key, default_arch): - yield spec + yield from process_company(hive_name, company, root_key, default_arch) except OSError: pass @@ -65,8 +68,7 @@ def process_set(hive, hive_name, key, flags, default_arch): def process_company(hive_name, company, root_key, default_arch): with winreg.OpenKeyEx(root_key, company) as company_key: for tag in enum_keys(company_key): - for spec in process_tag(hive_name, company, company_key, tag, default_arch): - yield spec + yield from process_tag(hive_name, company, company_key, tag, default_arch) def process_tag(hive_name, company, company_key, tag, default_arch): @@ -78,7 +80,7 @@ def process_tag(hive_name, company, company_key, tag, default_arch): exe, args = load_exe(hive_name, company, company_key, tag) if exe is not None: name = "python" if company == "PythonCore" else company - yield PythonSpec(name, major, minor, arch, exe, args) + yield name, major, minor, arch, exe, args def load_exe(hive_name, company, company_key, tag): @@ -116,7 +118,7 @@ def load_arch_data(hive_name, company, tag, tag_key, default_arch): def parse_arch(arch_str): - if not isinstance(arch_str, six.string_types): + if not isinstance(arch_str, str): raise ValueError("arch is not string") match = re.match(r"(\d+)bit", arch_str) if match: @@ -126,37 +128,37 @@ def parse_arch(arch_str): def load_version_data(hive_name, company, tag, tag_key): version_str = get_value(tag_key, "SysVersion") - major, minor = None, None + major, minor, patch = None, None, None if version_str is not None: key_path = "{}/{}/{}/SysVersion".format(hive_name, company, tag) try: - major, minor = parse_version(get_value(tag_key, "SysVersion")) + major, minor, patch = parse_version(get_value(tag_key, "SysVersion")) except ValueError as sys_version: msg(key_path, sys_version) if major is None: key_path = "{}/{}/{}".format(hive_name, company, tag) try: - major, minor = parse_version(tag) + major, minor, patch = parse_version(tag) except ValueError as tag_version: msg(key_path, tag_version) - return major, minor + return major, minor, patch def parse_version(version_str): - if not isinstance(version_str, six.string_types): + if not isinstance(version_str, str): raise ValueError("key is not string") - match = re.match(r"(\d+)\.(\d+).*", version_str) + match = re.match(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?", version_str) if match: return tuple(int(i) for i in match.groups()) raise ValueError("invalid format {}".format(version_str)) def msg(path, what): - reporter.verbosity1("PEP-514 violation in Windows Registry at {} error: {}".format(path, what)) + LOGGER.warning("PEP-514 violation in Windows Registry at {} error: {}".format(path, what)) def _run(): - reporter.update_default_reporter(0, reporter.Verbosity.DEBUG) + basicConfig() for spec in discover_pythons(): print(repr(spec)) diff --git a/src/tox/interpreters/py_spec.py b/src/tox/interpreters/py_spec.py deleted file mode 100644 index 04c062e63..000000000 --- a/src/tox/interpreters/py_spec.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import unicode_literals - -import os -import re -import sys - -import six - -import tox - - -class PythonSpec(object): - def __init__(self, name, major, minor, architecture, path, args=None): - self.name = name - self.major = major - self.minor = minor - self.architecture = architecture - self.path = path - self.args = args - - def __repr__(self): - return ( - "{0.__class__.__name__}(name={0.name!r}, major={0.major!r}, minor={0.minor!r}, " - "architecture={0.architecture!r}, path={0.path!r}, args={0.args!r})" - ).format(self) - - def __str__(self): - msg = repr(self) - return msg if six.PY3 else msg.encode("utf-8") - - def satisfies(self, req): - if req.is_abs and self.is_abs and self.path != req.path: - return False - if req.name is not None and req.name != self.name: - return False - if req.architecture is not None and req.architecture != self.architecture: - return False - if req.major is not None and req.major != self.major: - return False - if req.minor is not None and req.minor != self.minor: - return False - if req.major is None and req.minor is not None: - return False - return True - - @property - def is_abs(self): - return self.path is not None and os.path.isabs(self.path) - - @classmethod - def from_name(cls, base_python): - name, major, minor, architecture, path = None, None, None, None, None - if os.path.isabs(base_python): - path = base_python - else: - match = re.match(r"(python|pypy|jython)(\d)?(?:\.(\d+))?(-(32|64))?", base_python) - if match: - groups = match.groups() - name = groups[0] - major = int(groups[1]) if len(groups) >= 2 and groups[1] is not None else None - minor = int(groups[2]) if len(groups) >= 3 and groups[2] is not None else None - architecture = ( - int(groups[3]) if len(groups) >= 4 and groups[3] is not None else None - ) - else: - path = base_python - return cls(name, major, minor, architecture, path) - - -CURRENT = PythonSpec( - "pypy" if tox.constants.INFO.IS_PYPY else "python", - sys.version_info[0], - sys.version_info[1], - 64 if sys.maxsize > 2 ** 32 else 32, - sys.executable, -) diff --git a/src/tox/interpreters/unix.py b/src/tox/interpreters/unix.py deleted file mode 100644 index c8738de90..000000000 --- a/src/tox/interpreters/unix.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import unicode_literals - -import tox - -from .py_spec import CURRENT, PythonSpec -from .via_path import check_with_path - - -@tox.hookimpl -def tox_get_python_executable(envconfig): - base_python = envconfig.basepython - spec = PythonSpec.from_name(base_python) - # first, check current - if spec.name is not None and CURRENT.satisfies(spec): - return CURRENT.path - # second check if the literal base python - candidates = [base_python] - # third check if the un-versioned name is good - if spec.name is not None and spec.name != base_python: - candidates.append(spec.name) - return check_with_path(candidates, spec) diff --git a/src/tox/interpreters/via_path.py b/src/tox/interpreters/via_path.py deleted file mode 100644 index 746eb0651..000000000 --- a/src/tox/interpreters/via_path.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import unicode_literals - -import json -import os -import subprocess -from collections import defaultdict -from threading import Lock - -import py - -from tox import reporter -from tox.constants import VERSION_QUERY_SCRIPT - -from .py_spec import PythonSpec - - -def check_with_path(candidates, spec): - for path in candidates: - base = path - if not os.path.isabs(path): - path = py.path.local.sysfind(path) - if path is not None: - if os.path.exists(str(path)): - cur_spec = exe_spec(path, base) - if cur_spec is not None and cur_spec.satisfies(spec): - return cur_spec.path - - -_SPECS = {} -_SPECK_LOCK = defaultdict(Lock) - - -def exe_spec(python_exe, base): - if not isinstance(python_exe, str): - python_exe = str(python_exe) - with _SPECK_LOCK[python_exe]: - if python_exe not in _SPECS: - info = get_python_info([python_exe]) - if info is not None: - found = PythonSpec( - info["name"], - info["version_info"][0], - info["version_info"][1], - 64 if info["is_64"] else 32, - info["executable"], - ) - reporter.verbosity2("{} ({}) is {}".format(base, python_exe, info)) - else: - found = None - _SPECS[python_exe] = found - return _SPECS[python_exe] - - -def get_python_info(cmd): - proc = subprocess.Popen( - cmd + [VERSION_QUERY_SCRIPT], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - ) - out, err = proc.communicate() - if not proc.returncode: - try: - result = json.loads(out) - except ValueError as exception: - failure = exception - else: - return result - else: - failure = "exit code {}".format(proc.returncode) - reporter.verbosity1("{!r} cmd {!r} out {!r} err {!r} ".format(failure, cmd, out, err)) diff --git a/src/tox/interpreters/windows/__init__.py b/src/tox/interpreters/windows/__init__.py deleted file mode 100644 index 3933ae18a..000000000 --- a/src/tox/interpreters/windows/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import unicode_literals - -from threading import Lock - -import tox - -from ..py_spec import CURRENT, PythonSpec -from ..via_path import check_with_path - - -@tox.hookimpl -def tox_get_python_executable(envconfig): - base_python = envconfig.basepython - spec = PythonSpec.from_name(base_python) - # first, check current - if spec.name is not None and CURRENT.satisfies(spec): - return CURRENT.path - - # second check if the py.exe has it (only for non path specs) - if spec.path is None: - py_exe = locate_via_pep514(spec) - if py_exe is not None: - return py_exe - - # third check if the literal base python is on PATH - candidates = [envconfig.basepython] - # fourth check if the name is on PATH - if spec.name is not None and spec.name != base_python: - candidates.append(spec.name) - # or check known locations - if spec.major is not None and spec.minor is not None: - if spec.name == "python": - # The standard names are in predictable places. - candidates.append(r"c:\python{}{}\python.exe".format(spec.major, spec.minor)) - return check_with_path(candidates, spec) - - -_PY_AVAILABLE = [] -_PY_LOCK = Lock() - - -def locate_via_pep514(spec): - with _PY_LOCK: - if not _PY_AVAILABLE: - from . import pep514 - - _PY_AVAILABLE.extend(pep514.discover_pythons()) - _PY_AVAILABLE.append(CURRENT) - for cur_spec in _PY_AVAILABLE: - if cur_spec.satisfies(spec): - return cur_spec.path diff --git a/src/tox/logs/__init__.py b/src/tox/log/__init__.py similarity index 100% rename from src/tox/logs/__init__.py rename to src/tox/log/__init__.py diff --git a/src/tox/log/command.py b/src/tox/log/command.py new file mode 100644 index 000000000..00e6ee968 --- /dev/null +++ b/src/tox/log/command.py @@ -0,0 +1,13 @@ +from __future__ import absolute_import, unicode_literals + + +class CommandLog: + """Report commands interacting with third party tools""" + + def __init__(self, container): + self.entries = container + + def add_command(self, argv, output, error, retcode): + data = {"command": argv, "output": output, "retcode": retcode, "error": error} + self.entries.append(data) + return data diff --git a/src/tox/logs/env.py b/src/tox/log/env.py similarity index 53% rename from src/tox/logs/env.py rename to src/tox/log/env.py index d83753b71..92a8eb700 100644 --- a/src/tox/logs/env.py +++ b/src/tox/log/env.py @@ -1,35 +1,30 @@ from __future__ import absolute_import, unicode_literals -import json -import subprocess - -from tox.constants import VERSION_QUERY_SCRIPT +from copy import copy from .command import CommandLog -class EnvLog(object): +class EnvLog: """Report the status of a tox environment""" - def __init__(self, result_log, name, dict): + def __init__(self, result_log, name, content): self.reportlog = result_log self.name = name - self.dict = dict + self.content = content - def set_python_info(self, python_executable): - cmd = [str(python_executable), VERSION_QUERY_SCRIPT] - result = subprocess.check_output(cmd, universal_newlines=True) - answer = json.loads(result) - answer["executable"] = python_executable - self.dict["python"] = answer + def set_python_info(self, python_info): + answer = copy(python_info.__dict__) + answer["executable"] = python_info.executable + self.content["python"] = answer def get_commandlog(self, name): """get the command log for a given group name""" - data = self.dict.setdefault(name, []) + data = self.content.setdefault(name, []) return CommandLog(self, data) def set_installed(self, packages): - self.dict["installed_packages"] = packages + self.content["installed_packages"] = packages def set_header(self, installpkg): """ diff --git a/src/tox/logs/result.py b/src/tox/log/result.py similarity index 69% rename from src/tox/logs/result.py rename to src/tox/log/result.py index 78ff9ab18..b65a11206 100644 --- a/src/tox/logs/result.py +++ b/src/tox/log/result.py @@ -1,6 +1,4 @@ """Generate json report of a run""" -from __future__ import absolute_import, unicode_literals - import json import socket import sys @@ -11,13 +9,13 @@ from .env import EnvLog -class ResultLog(object): +class ResultLog: """The result of a tox session""" def __init__(self,): command_log = [] - self.command_log = CommandLog(None, command_log) - self.dict = { + self.command_log = CommandLog(command_log) + self.content = { "reportversion": "1", "toxversion": __version__, "platform": sys.platform, @@ -28,16 +26,16 @@ def __init__(self,): @classmethod def from_json(cls, data): result = cls() - result.dict = json.loads(data) - result.command_log = CommandLog(None, result.dict["commands"]) + result.content = json.loads(data) + result.command_log = CommandLog(result.content["commands"]) return result def get_envlog(self, name): """Return the env log of a environment (create on first call)""" - test_envs = self.dict.setdefault("testenvs", {}) + test_envs = self.content.setdefault("testenvs", {}) env_data = test_envs.setdefault(name, {}) return EnvLog(self, name, env_data) def dumps_json(self): """Return the json dump of the current state, indented""" - return json.dumps(self.dict, indent=2) + return json.dumps(self.content, indent=2) diff --git a/src/tox/logs/command.py b/src/tox/logs/command.py deleted file mode 100644 index a22a2a654..000000000 --- a/src/tox/logs/command.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import absolute_import, unicode_literals - - -class CommandLog(object): - """Report commands interacting with third party tools""" - - def __init__(self, env_log, list): - self.envlog = env_log - self.list = list - - def add_command(self, argv, output, retcode): - data = {"command": argv, "output": output, "retcode": retcode} - self.list.append(data) - return data diff --git a/src/tox/package/__init__.py b/src/tox/package/__init__.py deleted file mode 100644 index f6925162a..000000000 --- a/src/tox/package/__init__.py +++ /dev/null @@ -1,73 +0,0 @@ -import py - -import tox -from tox.reporter import error, info, verbosity0, verbosity2, warning -from tox.util.lock import hold_lock - -from .builder import build_package -from .local import resolve_package -from .view import create_session_view - - -@tox.hookimpl -def tox_package(session, venv): - """Build an sdist at first call return that for all calls""" - if not hasattr(session, "package"): - session.package, session.dist = get_package(session) - return session.package - - -def get_package(session): - """"Perform the package operation""" - config = session.config - if config.skipsdist: - info("skipping sdist step") - return None - lock_file = session.config.toxworkdir.join("{}.lock".format(session.config.isolated_build_env)) - - with hold_lock(lock_file, verbosity0): - package = acquire_package(config, session) - session_package = create_session_view(package, config.temp_dir) - return session_package, package - - -def acquire_package(config, session): - """acquire a source distribution (either by loading a local file or triggering a build)""" - if not config.option.sdistonly and (config.sdistsrc or config.option.installpkg): - path = get_local_package(config) - else: - try: - path = build_package(config, session) - except tox.exception.InvocationError as exception: - error("FAIL could not package project - v = {!r}".format(exception)) - return None - sdist_file = config.distshare.join(path.basename) - if sdist_file != path: - info("copying new sdistfile to {!r}".format(str(sdist_file))) - try: - sdist_file.dirpath().ensure(dir=1) - except py.error.Error: - warning("could not copy distfile to {}".format(sdist_file.dirpath())) - else: - path.copy(sdist_file) - return path - - -def get_local_package(config): - path = config.option.installpkg - if not path: - path = config.sdistsrc - py_path = py.path.local(resolve_package(path)) - info("using package {!r}, skipping 'sdist' activity ".format(str(py_path))) - return py_path - - -@tox.hookimpl -def tox_cleanup(session): - for tox_env in session.venv_dict.values(): - if hasattr(tox_env, "package") and isinstance(tox_env.package, py.path.local): - package = tox_env.package - if package.exists(): - verbosity2("cleanup {}".format(package)) - package.remove() - py.path.local(package.dirname).remove(ignore_errors=True) diff --git a/src/tox/package/builder/__init__.py b/src/tox/package/builder/__init__.py deleted file mode 100644 index 11a06574f..000000000 --- a/src/tox/package/builder/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .isolated import build -from .legacy import make_sdist - - -def build_package(config, session): - if not config.isolated_build: - return make_sdist(config, session) - else: - return build(config, session) diff --git a/src/tox/package/builder/isolated.py b/src/tox/package/builder/isolated.py deleted file mode 100644 index eda10f2d2..000000000 --- a/src/tox/package/builder/isolated.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import unicode_literals - -import json -from collections import namedtuple - -import six -from packaging.requirements import Requirement -from packaging.utils import canonicalize_name - -from tox import reporter -from tox.config import DepConfig, get_py_project_toml -from tox.constants import BUILD_ISOLATED, BUILD_REQUIRE_SCRIPT - -BuildInfo = namedtuple("BuildInfo", ["requires", "backend_module", "backend_object"]) - - -def build(config, session): - build_info = get_build_info(config.setupdir) - package_venv = session.getvenv(config.isolated_build_env) - package_venv.envconfig.deps_matches_subset = True - - # we allow user specified dependencies so the users can write extensions to - # install additional type of dependencies (e.g. binary) - user_specified_deps = package_venv.envconfig.deps - package_venv.envconfig.deps = [DepConfig(r, None) for r in build_info.requires] - package_venv.envconfig.deps.extend(user_specified_deps) - - if package_venv.setupenv(): - package_venv.finishvenv() - if isinstance(package_venv.status, Exception): - raise package_venv.status - - build_requires = get_build_requires(build_info, package_venv, config.setupdir) - # we need to filter out requirements already specified in pyproject.toml or user deps - base_build_deps = { - canonicalize_name(Requirement(r.name).name) for r in package_venv.envconfig.deps - } - build_requires_dep = [ - DepConfig(r, None) - for r in build_requires - if canonicalize_name(Requirement(r).name) not in base_build_deps - ] - if build_requires_dep: - with package_venv.new_action("build_requires", package_venv.envconfig.envdir) as action: - package_venv.run_install_command(packages=build_requires_dep, action=action) - package_venv.finishvenv() - return perform_isolated_build(build_info, package_venv, config.distdir, config.setupdir) - - -def get_build_info(folder): - toml_file = folder.join("pyproject.toml") - - # as per https://www.python.org/dev/peps/pep-0517/ - - def abort(message): - reporter.error("{} inside {}".format(message, toml_file)) - raise SystemExit(1) - - if not toml_file.exists(): - reporter.error("missing {}".format(toml_file)) - raise SystemExit(1) - - config_data = get_py_project_toml(toml_file) - - if "build-system" not in config_data: - abort("build-system section missing") - - build_system = config_data["build-system"] - - if "requires" not in build_system: - abort("missing requires key at build-system section") - if "build-backend" not in build_system: - abort("missing build-backend key at build-system section") - - requires = build_system["requires"] - if not isinstance(requires, list) or not all(isinstance(i, six.text_type) for i in requires): - abort("requires key at build-system section must be a list of string") - - backend = build_system["build-backend"] - if not isinstance(backend, six.text_type): - abort("build-backend key at build-system section must be a string") - - args = backend.split(":") - module = args[0] - obj = args[1] if len(args) > 1 else "" - - return BuildInfo(requires, module, obj) - - -def perform_isolated_build(build_info, package_venv, dist_dir, setup_dir): - with package_venv.new_action( - "perform-isolated-build", package_venv.envconfig.envdir - ) as action: - # need to start with an empty (but existing) source distribution folder - if dist_dir.exists(): - dist_dir.remove(rec=1, ignore_errors=True) - dist_dir.ensure_dir() - - result = package_venv._pcall( - [ - package_venv.envconfig.envpython, - BUILD_ISOLATED, - str(dist_dir), - build_info.backend_module, - build_info.backend_object, - ], - returnout=True, - action=action, - cwd=setup_dir, - ) - reporter.verbosity2(result) - return dist_dir.join(result.split("\n")[-2]) - - -def get_build_requires(build_info, package_venv, setup_dir): - with package_venv.new_action("get-build-requires", package_venv.envconfig.envdir) as action: - result = package_venv._pcall( - [ - package_venv.envconfig.envpython, - BUILD_REQUIRE_SCRIPT, - build_info.backend_module, - build_info.backend_object, - ], - returnout=True, - action=action, - cwd=setup_dir, - ) - return json.loads(result.split("\n")[-2]) diff --git a/src/tox/package/builder/legacy.py b/src/tox/package/builder/legacy.py deleted file mode 100644 index 4ddc03b54..000000000 --- a/src/tox/package/builder/legacy.py +++ /dev/null @@ -1,49 +0,0 @@ -import sys - -import py - -from tox import reporter -from tox.util.path import ensure_empty_dir - - -def make_sdist(config, session): - setup = config.setupdir.join("setup.py") - if not setup.check(): - reporter.error( - "No setup.py file found. The expected location is:\n" - " {}\n" - "You can\n" - " 1. Create one:\n" - " https://packaging.python.org/tutorials/distributing-packages/#setup-py\n" - " 2. Configure tox to avoid running sdist:\n" - " https://tox.readthedocs.io/en/latest/example/general.html" - "#avoiding-expensive-sdist".format(setup) - ) - raise SystemExit(1) - with session.newaction("GLOB", "packaging") as action: - action.setactivity("sdist-make", setup) - ensure_empty_dir(config.distdir) - build_log = action.popen( - [sys.executable, setup, "sdist", "--formats=zip", "--dist-dir", config.distdir], - cwd=config.setupdir, - returnout=True, - ) - reporter.verbosity2(build_log) - try: - return config.distdir.listdir()[0] - except py.error.ENOENT: - # check if empty or comment only - data = [] - with open(str(setup)) as fp: - for line in fp: - if line and line[0] == "#": - continue - data.append(line) - if not "".join(data).strip(): - reporter.error("setup.py is empty") - raise SystemExit(1) - reporter.error( - "No dist directory found. Please check setup.py, e.g with:\n" - " python setup.py sdist" - ) - raise SystemExit(1) diff --git a/src/tox/package/local.py b/src/tox/package/local.py deleted file mode 100644 index aa0751b9e..000000000 --- a/src/tox/package/local.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import re - -import packaging.version -import py - -import tox -from tox import reporter -from tox.exception import MissingDependency - -_SPEC_2_PACKAGE = {} - - -def resolve_package(package_spec): - global _SPEC_2_PACKAGE - try: - return _SPEC_2_PACKAGE[package_spec] - except KeyError: - _SPEC_2_PACKAGE[package_spec] = x = get_latest_version_of_package(package_spec) - return x - - -def get_latest_version_of_package(package_spec): - if not os.path.isabs(str(package_spec)): - return package_spec - p = py.path.local(package_spec) - if p.check(): - return p - if not p.dirpath().check(dir=1): - raise tox.exception.MissingDirectory(p.dirpath()) - reporter.info("determining {}".format(p)) - candidates = p.dirpath().listdir(p.basename) - if len(candidates) == 0: - raise MissingDependency(package_spec) - if len(candidates) > 1: - version_package = [] - for filename in candidates: - version = get_version_from_filename(filename.basename) - if version is not None: - version_package.append((version, filename)) - else: - reporter.warning("could not determine version of: {}".format(str(filename))) - if not version_package: - raise tox.exception.MissingDependency(package_spec) - version_package.sort() - _, package_with_largest_version = version_package[-1] - return package_with_largest_version - else: - return candidates[0] - - -_REGEX_FILE_NAME_WITH_VERSION = re.compile(r"[\w_\-\+\.]+-(.*)\.(zip|tar\.gz)") - - -def get_version_from_filename(basename): - m = _REGEX_FILE_NAME_WITH_VERSION.match(basename) - if m is None: - return None - version = m.group(1) - try: - return packaging.version.Version(version) - except packaging.version.InvalidVersion: - return None diff --git a/src/tox/package/view.py b/src/tox/package/view.py deleted file mode 100644 index 49935a8fa..000000000 --- a/src/tox/package/view.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -from itertools import chain - -import six - -from tox.reporter import verbosity1 - - -def create_session_view(package, temp_dir): - """once we build a package we cannot return that directly, as a subsequent call - might delete that package (in order to do its own build); therefore we need to - return a view of the file that it's not prone to deletion and can be removed when the - session ends - """ - if not package: - return package - package_dir = temp_dir.join("package") - package_dir.ensure(dir=True) - - # we'll number the active instances, and use the max value as session folder for a new build - # note we cannot change package names as PEP-491 (wheel binary format) - # is strict about file name structure - exists = [i.basename for i in package_dir.listdir()] - file_id = max(chain((0,), (int(i) for i in exists if six.text_type(i).isnumeric()))) - - session_dir = package_dir.join(str(file_id + 1)) - session_dir.ensure(dir=True) - session_package = session_dir.join(package.basename) - - # if we can do hard links do that, otherwise just copy - links = False - if hasattr(os, "link"): - try: - os.link(str(package), str(session_package)) - links = True - except (OSError, NotImplementedError): - pass - if not links: - package.copy(session_package) - operation = "links" if links else "copied" - common = session_package.common(package) - verbosity1( - "package {} {} to {} ({})".format( - common.bestrelpath(session_package), operation, common.bestrelpath(package), common - ) - ) - return session_package diff --git a/src/tox/plugin/__init__.py b/src/tox/plugin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/plugin/impl.py b/src/tox/plugin/impl.py new file mode 100644 index 000000000..858b13caf --- /dev/null +++ b/src/tox/plugin/impl.py @@ -0,0 +1,5 @@ +import pluggy + +from .util import NAME + +impl = pluggy.HookimplMarker(NAME) diff --git a/src/tox/plugin/manager.py b/src/tox/plugin/manager.py new file mode 100644 index 000000000..1c079363f --- /dev/null +++ b/src/tox/plugin/manager.py @@ -0,0 +1,58 @@ +from typing import List, Type + +import pluggy + +from tox import provision +from tox.config import core as core_config +from tox.config.cli.parser import ToxParser +from tox.config.sets import ConfigSet +from tox.session.cmd import list_env, show_config +from tox.session.cmd.run import parallel, sequential +from tox.tox_env import builder +from tox.tox_env.api import ToxEnv +from tox.tox_env.python.virtual_env import runner +from tox.tox_env.python.virtual_env.package import dev +from tox.tox_env.python.virtual_env.package.artifact import sdist, wheel +from tox.tox_env.register import REGISTER, ToxEnvRegister + +from . import spec +from .util import NAME + + +class Plugin: + def __init__(self) -> None: + self.manager: pluggy.PluginManager = pluggy.PluginManager(NAME) + self.manager.add_hookspecs(spec) + + internal_plugins = ( + provision, + core_config, + runner, + sdist, + wheel, + dev, + parallel, + sequential, + list_env, + show_config, + ) + + for plugin in internal_plugins: + self.manager.register(plugin) + self.manager.load_setuptools_entrypoints(NAME) + self.manager.register(builder) + + REGISTER.populate(self) + self.manager.check_pending() + + def tox_add_option(self, parser: ToxParser) -> None: + self.manager.hook.tox_add_option(parser=parser) + + def tox_add_core_config(self, core: ConfigSet) -> None: + self.manager.hook.tox_add_core_config(core=core) + + def tox_register_tox_env(self, register: "ToxEnvRegister") -> List[Type[ToxEnv]]: + return self.manager.hook.tox_register_tox_env(register=register) + + +MANAGER = Plugin() diff --git a/src/tox/plugin/spec.py b/src/tox/plugin/spec.py new file mode 100644 index 000000000..22be36087 --- /dev/null +++ b/src/tox/plugin/spec.py @@ -0,0 +1,28 @@ +"""Hook specifications for tox - see https://pluggy.readthedocs.io/""" +from argparse import ArgumentParser +from typing import Type + +import pluggy + +from tox.config.sets import ConfigSet +from tox.tox_env.api import ToxEnv +from tox.tox_env.register import ToxEnvRegister + +from .util import NAME + +hook_spec = pluggy.HookspecMarker(NAME) + + +@hook_spec +def tox_add_option(parser: ArgumentParser) -> None: + """add cli flags""" + + +@hook_spec +def tox_add_core_config(core: ConfigSet) -> None: + """add options to the core section of the tox""" + + +@hook_spec +def tox_register_tox_env(register: ToxEnvRegister) -> Type[ToxEnv]: + """register new tox environment types that can have their own argument""" diff --git a/src/tox/plugin/util.py b/src/tox/plugin/util.py new file mode 100644 index 000000000..30f0740a5 --- /dev/null +++ b/src/tox/plugin/util.py @@ -0,0 +1 @@ +NAME = "tox" diff --git a/src/tox/provision/__init__.py b/src/tox/provision/__init__.py new file mode 100644 index 000000000..6d2820a4c --- /dev/null +++ b/src/tox/provision/__init__.py @@ -0,0 +1,71 @@ +from typing import List + +import importlib_metadata +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version + +from tox.config.sets import ConfigSet +from tox.plugin.impl import impl +from tox.session.state import State +from tox.tox_env.api import ToxEnv +from tox.version import __version__ as current_version + + +def add_tox_requires_min_version(requires, conf): + min_version = conf.core["min_version"] + requires.append(Requirement("tox >= {}".format(min_version))) + return requires + + +def provision(state: State): + core = state.conf.core + provision_tox_env = core["provision_tox_env"] + requires = core["requires"] + + exists = set() + missing = [] + for package in requires: + package_name = canonicalize_name(package.name) + if package_name not in exists: + exists.add(package_name) + dist = importlib_metadata.distribution(package.name) + if not package.specifier.contains(dist.version, prereleases=True): + missing.append(package) + if missing: + for package in missing: + print(package) + run_provision(requires, state.tox_envs[provision_tox_env]) + + +@impl +def tox_add_core_config(core: ConfigSet): + core.add_config( + keys=["min_version", "minversion"], + of_type=Version, + default=current_version, + desc="Define the minimal tox version required to run", + ) + core.add_config( + keys="provision_tox_env", + of_type=str, + default=".tox", + desc="Name of the virtual environment used to provision a tox.", + ) + core.add_config( + keys="requires", + of_type=List[Requirement], + default=[], + desc="Name of the virtual environment used to provision a tox.", + post_process=add_tox_requires_min_version, + ) + core.add_config( + keys=["no_package", "app", "skip_sdist"], + of_type=bool, + default=False, + desc="Is there any packaging involved in this project.", + ) + + +def run_provision(deps: List[Requirement], tox_env: ToxEnv): + """""" diff --git a/src/tox/pytest.py b/src/tox/pytest.py new file mode 100644 index 000000000..ea920e541 --- /dev/null +++ b/src/tox/pytest.py @@ -0,0 +1,181 @@ +import os +import sys +import textwrap +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Sequence + +import pytest + +import tox.run +from tox.execute.api import Outcome +from tox.execute.request import shell_cmd +from tox.report import LOGGER +from tox.run import run as tox_run +from tox.run import setup_state as previous_setup_state +from tox.session.cmd.run.parallel import ENV_VAR_KEY +from tox.session.state import State + + +@pytest.fixture(autouse=True) +def ensure_logging_framework_not_altered(): + before_handlers = list(LOGGER.handlers) + yield + LOGGER.handlers = before_handlers + + +def check_os_environ(): + old = os.environ.copy() + to_clean = {k: os.environ.pop(k, None) for k in {ENV_VAR_KEY, "TOX_WORK_DIR", "PYTHONPATH"}} + + yield + + for key, value in to_clean.items(): + if value is not None: + os.environ[key] = value + + new = os.environ + extra = {k: new[k] for k in set(new) - set(old)} + miss = {k: old[k] for k in set(old) - set(new)} + diff = { + "{} = {} vs {}".format(k, old[k], new[k]) + for k in set(old) & set(new) + if old[k] != new[k] and not k.startswith("PYTEST_") + } + if extra or miss or diff: + msg = "test changed environ" + if extra: + msg += " extra {}".format(extra) + if miss: + msg += " miss {}".format(miss) + if diff: + msg += " diff {}".format(diff) + pytest.fail(msg) + + +check_os_environ_stable = pytest.fixture(autouse=True)(check_os_environ) + + +@pytest.fixture(name="tox_project") +def init_fixture(tmp_path, capsys, monkeypatch): + def _init(files: Dict[str, Any]): + """create tox projects""" + return ToxProject(files, tmp_path, capsys, monkeypatch) + + return _init + + +@pytest.fixture() +def empty_project(tox_project, monkeypatch): + project = tox_project({"tox.ini": ""}) + monkeypatch.chdir(project.path) + return project + + +class ToxProject: + def __init__(self, files: Dict[str, Any], path: Path, capsys, monkeypatch): + self.path: Path = path + self._capsys = capsys + self.monkeypatch = monkeypatch + + def _handle_level(of_path: Path, content: Dict[str, Any]) -> None: + for key, value in content.items(): + if not isinstance(key, str): + raise TypeError("{!r} at {}".format(key, of_path)) # pragma: no cover + at_path = of_path / key + if isinstance(value, dict): + at_path.mkdir(exist_ok=True) + _handle_level(at_path, value) + elif isinstance(value, str): + at_path.write_text(textwrap.dedent(value)) + else: + msg = "could not handle {} with content {!r}".format( # pragma: no cover + at_path / key, value + ) + raise TypeError(msg) # pragma: no cover + + _handle_level(self.path, files) + + @property + def structure(self): + result = {} + for dir_name, _, files in os.walk(str(self.path), topdown=True): + dir_path = Path(dir_name) + into = result + for elem in dir_path.relative_to(self.path).parts: + into = into.setdefault(elem, {}) + for file_name in files: + into[file_name] = (dir_path / file_name).read_text() + return result + + def config(self): + return tox.run.make_config(self.path) + + def run(self, *args) -> "ToxRunOutcome": + cur_dir = os.getcwd() + state = None + os.chdir(str(self.path)) + try: + self._capsys.readouterr() # start with a clean state - drain + code = None + state = None + + def our_setup_state(args): + nonlocal state + state = previous_setup_state(args) + return state + + with self.monkeypatch.context() as m: + m.setattr(tox.run, "setup_state", our_setup_state) + try: + tox_run(args) + except SystemExit as exception: + code = exception.code + out, err = self._capsys.readouterr() + return ToxRunOutcome(args, self.path, code, out, err, state) + finally: + os.chdir(cur_dir) + + def __repr__(self): + return "{}(path={}) at {}".format(type(self).__name__, self.path, id(self)) + + +ToxProjectCreator = Callable[[Dict[str, Any]], ToxProject] + + +class ToxRunOutcome: + def __init__( + self, cmd: Sequence[str], cwd: Path, code: int, out: str, err: str, state: Optional[State] + ) -> None: + extended_cmd = [sys.executable, "-m", "tox"] + extended_cmd.extend(cmd) + self.cmd: List[str] = extended_cmd + self.cwd = cwd + self.code: int = code + self.out: str = out + self.err: str = err + self.state: Optional[State] = state + + @property + def success(self) -> bool: + return self.code == Outcome.OK + + def assert_success(self) -> None: + if not self.success: + assert repr(self) + + def __repr__(self): + return "\n".join( + "{}{}{}".format(k, "\n" if "\n" in v else ": ", v) + for k, v in ( + ("code", str(self.code)), + ("cmd", self.shell_cmd), + ("cwd", str(self.cwd)), + ("standard output", self.out), + ("standard error", self.err), + ) + if v + ) + + @property + def shell_cmd(self): + return shell_cmd(self.cmd) diff --git a/src/tox/report.py b/src/tox/report.py new file mode 100644 index 000000000..672e7551f --- /dev/null +++ b/src/tox/report.py @@ -0,0 +1,61 @@ +import logging +import sys + +from colorama import Fore, Style, init + +LEVELS = { + 0: logging.CRITICAL, + 1: logging.ERROR, + 2: logging.WARNING, + 3: logging.INFO, + 4: logging.DEBUG, + 5: logging.NOTSET, +} + +MAX_LEVEL = max(LEVELS.keys()) +LOGGER = logging.getLogger() + + +class ToxHandler(logging.StreamHandler): + def __init__(self, level): + super().__init__(stream=sys.stdout) + self.setLevel(level) + formatter = self._get_formatter(level) + self.setFormatter(formatter) + + @staticmethod + def _get_formatter(level): + msg_format = "{}{}%(name)s: {}%(message)s{}".format( + Style.BRIGHT, Fore.WHITE, Fore.CYAN, Style.RESET_ALL + ) + if level <= logging.DEBUG: + locate = "pathname" if level > logging.DEBUG else "module" + msg_format += "{} [%(asctime)s] [%({})s:%(lineno)d]{}".format( + Style.DIM, locate, Style.RESET_ALL + ) + formatter = logging.Formatter(msg_format) + return formatter + + +def setup_report(verbosity): + _clean_handlers(LOGGER) + level = _get_level(verbosity) + LOGGER.setLevel(level) + + handler = ToxHandler(level) + LOGGER.addHandler(handler) + + logging.debug("setup logging to %s", logging.getLevelName(level)) + init() + + +def _get_level(verbosity): + if verbosity > MAX_LEVEL: + verbosity = MAX_LEVEL + level = LEVELS[verbosity] + return level + + +def _clean_handlers(log): + for log_handler in list(log.handlers): # remove handlers of libraries + log.removeHandler(log_handler) diff --git a/src/tox/reporter.py b/src/tox/reporter.py deleted file mode 100644 index 47d62adfd..000000000 --- a/src/tox/reporter.py +++ /dev/null @@ -1,156 +0,0 @@ -"""A progress reporter inspired from the logging modules""" -from __future__ import absolute_import, unicode_literals - -import os -import time -from contextlib import contextmanager -from datetime import datetime - -import py - - -class Verbosity(object): - DEBUG = 2 - INFO = 1 - DEFAULT = 0 - QUIET = -1 - EXTRA_QUIET = -2 - - -REPORTER_TIMESTAMP_ON_ENV = str("TOX_REPORTER_TIMESTAMP") -REPORTER_TIMESTAMP_ON = os.environ.get(REPORTER_TIMESTAMP_ON_ENV, False) == "1" -START = datetime.now() - - -class Reporter(object): - def __init__(self, verbose_level=None, quiet_level=None): - kwargs = {} - if verbose_level is not None: - kwargs["verbose_level"] = verbose_level - if quiet_level is not None: - kwargs["quiet_level"] = quiet_level - self._reset(**kwargs) - - def _reset(self, verbose_level=0, quiet_level=0): - self.verbose_level = verbose_level - self.quiet_level = quiet_level - self.reported_lines = [] - self.tw = py.io.TerminalWriter() - - @property - def verbosity(self): - return self.verbose_level - self.quiet_level - - def log_popen(self, cwd, outpath, cmd_args_shell, pid): - """ log information about the action.popen() created process. """ - msg = "[{}] {}$ {}".format(pid, cwd, cmd_args_shell) - if outpath: - if outpath.common(cwd) is not None: - outpath = cwd.bestrelpath(outpath) - msg = "{} >{}".format(msg, outpath) - self.verbosity1(msg, of="logpopen") - - @property - def messages(self): - return [i for _, i in self.reported_lines] - - @contextmanager - def timed_operation(self, name, msg): - self.verbosity2("{} start: {}".format(name, msg), bold=True) - start = time.time() - yield - duration = time.time() - start - self.verbosity2( - "{} finish: {} after {:.2f} seconds".format(name, msg, duration), bold=True - ) - - def separator(self, of, msg, level): - if self.verbosity >= level: - self.reported_lines.append(("separator", "- summary -")) - self.tw.sep(of, msg) - - def logline_if(self, level, of, msg, key=None, **kwargs): - if self.verbosity >= level: - message = str(msg) if key is None else "{}{}".format(key, msg) - self.logline(of, message, **kwargs) - - def logline(self, of, msg, **opts): - self.reported_lines.append((of, msg)) - timestamp = "" - if REPORTER_TIMESTAMP_ON: - timestamp = "{} ".format(datetime.now() - START) - line_msg = "{}{}\n".format(timestamp, msg) - self.tw.write(line_msg, **opts) - - def keyvalue(self, name, value): - if name.endswith(":"): - name += " " - self.tw.write(name, bold=True) - self.tw.write(value) - self.tw.line() - - def line(self, msg, **opts): - self.logline("line", msg, **opts) - - def info(self, msg): - self.logline_if(Verbosity.DEBUG, "info", msg) - - def using(self, msg): - self.logline_if(Verbosity.INFO, "using", msg, "using ", bold=True) - - def good(self, msg): - self.logline_if(Verbosity.QUIET, "good", msg, green=True) - - def warning(self, msg): - self.logline_if(Verbosity.QUIET, "warning", msg, "WARNING: ", red=True) - - def error(self, msg): - self.logline_if(Verbosity.QUIET, "error", msg, "ERROR: ", red=True) - - def skip(self, msg): - self.logline_if(Verbosity.QUIET, "skip", msg, "SKIPPED: ", yellow=True) - - def verbosity0(self, msg, **opts): - self.logline_if(Verbosity.DEFAULT, "verbosity0", msg, **opts) - - def verbosity1(self, msg, of="verbosity1", **opts): - self.logline_if(Verbosity.INFO, of, msg, **opts) - - def verbosity2(self, msg, **opts): - self.logline_if(Verbosity.DEBUG, "verbosity2", msg, **opts) - - def quiet(self, msg): - self.logline_if(Verbosity.QUIET, "quiet", msg) - - -_INSTANCE = Reporter() - - -def update_default_reporter(quiet_level, verbose_level): - _INSTANCE.quiet_level = quiet_level - _INSTANCE.verbose_level = verbose_level - - -def has_level(of): - return _INSTANCE.verbosity > of - - -def verbosity(): - return _INSTANCE.verbosity - - -verbosity0 = _INSTANCE.verbosity0 -verbosity1 = _INSTANCE.verbosity1 -verbosity2 = _INSTANCE.verbosity2 -error = _INSTANCE.error -warning = _INSTANCE.warning -good = _INSTANCE.good -using = _INSTANCE.using -skip = _INSTANCE.skip -info = _INSTANCE.info -line = _INSTANCE.line -separator = _INSTANCE.separator -keyvalue = _INSTANCE.keyvalue -quiet = _INSTANCE.quiet -timed_operation = _INSTANCE.timed_operation -log_popen = _INSTANCE.log_popen diff --git a/src/tox/run.py b/src/tox/run.py new file mode 100644 index 000000000..bcfec4df3 --- /dev/null +++ b/src/tox/run.py @@ -0,0 +1,36 @@ +import sys +from pathlib import Path +from typing import Optional, Sequence + +from tox.config.cli.parse import get_options +from tox.config.main import Config +from tox.config.source.ini import Ini +from tox.session.state import State +from tox.tox_env.builder import build_tox_envs + + +def run(args: Optional[Sequence[str]] = None) -> None: + try: + state = setup_state(args) + command = state.options.command + result = state.handlers[command](state) + if result is None: + result = 0 + raise SystemExit(result) + except KeyboardInterrupt: + raise SystemExit(-2) + + +def make_config(path: Path) -> Config: + tox_ini = path / "tox.ini" + ini_loader = Ini(tox_ini) + return Config(ini_loader) + + +def setup_state(args: Optional[Sequence[str]]) -> State: + if args is None: + args = sys.argv[1:] + options = get_options(*args) + config = make_config(Path().absolute()) + state = build_tox_envs(config, options, args) + return state diff --git a/src/tox/session/__init__.py b/src/tox/session/__init__.py index 048feb94e..e69de29bb 100644 --- a/src/tox/session/__init__.py +++ b/src/tox/session/__init__.py @@ -1,291 +0,0 @@ -""" -Automatically package and test a Python project against configurable -Python2 and Python3 based virtual environments. Environments are -setup by using virtualenv. Configuration is generally done through an -INI-style "tox.ini" file. -""" -from __future__ import absolute_import, unicode_literals - -import json -import os -import re -import subprocess -import sys -from collections import OrderedDict -from contextlib import contextmanager - -import py - -import tox -from tox import reporter -from tox.action import Action -from tox.config import parseconfig -from tox.config.parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY -from tox.config.parallel import OFF_VALUE as PARALLEL_OFF -from tox.logs.result import ResultLog -from tox.reporter import update_default_reporter -from tox.util import set_os_env_var -from tox.util.graph import stable_topological_sort -from tox.util.stdlib import suppress_output -from tox.venv import VirtualEnv - -from .commands.help import show_help -from .commands.help_ini import show_help_ini -from .commands.provision import provision_tox -from .commands.run.parallel import run_parallel -from .commands.run.sequential import run_sequential -from .commands.show_config import show_config -from .commands.show_env import show_envs - - -def cmdline(args=None): - if args is None: - args = sys.argv[1:] - main(args) - - -def setup_reporter(args): - from argparse import ArgumentParser - from tox.config.reporter import add_verbosity_commands - - parser = ArgumentParser(add_help=False) - add_verbosity_commands(parser) - with suppress_output(): - try: - options, _ = parser.parse_known_args(args) - update_default_reporter(options.quiet_level, options.verbose_level) - except SystemExit: - pass - - -def main(args): - setup_reporter(args) - try: - config = load_config(args) - config.logdir.ensure(dir=1) - with set_os_env_var(str("TOX_WORK_DIR"), config.toxworkdir): - session = build_session(config) - exit_code = session.runcommand() - if exit_code is None: - exit_code = 0 - raise SystemExit(exit_code) - except tox.exception.BadRequirement: - raise SystemExit(1) - except KeyboardInterrupt: - raise SystemExit(2) - - -def load_config(args): - try: - config = parseconfig(args) - if config.option.help: - show_help(config) - raise SystemExit(0) - elif config.option.helpini: - show_help_ini(config) - raise SystemExit(0) - except tox.exception.MissingRequirement as exception: - config = exception.config - return config - - -def build_session(config): - return Session(config) - - -class Session(object): - """The session object that ties together configuration, reporting, venv creation, testing.""" - - def __init__(self, config, popen=subprocess.Popen): - self._reset(config, popen) - - def _reset(self, config, popen=subprocess.Popen): - self.config = config - self.popen = popen - self.resultlog = ResultLog() - self.existing_venvs = OrderedDict() - self.venv_dict = {} if self.config.run_provision else self._build_venvs() - - def _build_venvs(self): - try: - need_to_run = OrderedDict((v, self.getvenv(v)) for v in self._evaluated_env_list) - try: - venv_order = stable_topological_sort( - OrderedDict((name, v.envconfig.depends) for name, v in need_to_run.items()) - ) - - venvs = OrderedDict((v, need_to_run[v]) for v in venv_order) - return venvs - except ValueError as exception: - reporter.error("circular dependency detected: {}".format(exception)) - except LookupError: - pass - except tox.exception.ConfigError as exception: - reporter.error(str(exception)) - raise SystemExit(1) - - def getvenv(self, name): - if name in self.existing_venvs: - return self.existing_venvs[name] - env_config = self.config.envconfigs.get(name, None) - if env_config is None: - reporter.error("unknown environment {!r}".format(name)) - raise LookupError(name) - elif env_config.envdir == self.config.toxinidir: - reporter.error("venv {!r} in {} would delete project".format(name, env_config.envdir)) - raise tox.exception.ConfigError("envdir must not equal toxinidir") - env_log = self.resultlog.get_envlog(name) - venv = VirtualEnv(envconfig=env_config, popen=self.popen, env_log=env_log) - self.existing_venvs[name] = venv - return venv - - @property - def _evaluated_env_list(self): - tox_env_filter = os.environ.get("TOX_SKIP_ENV") - tox_env_filter_re = re.compile(tox_env_filter) if tox_env_filter is not None else None - visited = set() - for name in self.config.envlist: - if name in visited: - continue - visited.add(name) - if tox_env_filter_re is not None and tox_env_filter_re.match(name): - msg = "skip environment {}, matches filter {!r}".format( - name, tox_env_filter_re.pattern - ) - reporter.verbosity1(msg) - continue - yield name - - @property - def hook(self): - return self.config.pluginmanager.hook - - def newaction(self, name, msg, *args): - return Action( - name, - msg, - args, - self.config.logdir, - self.config.option.resultjson, - self.resultlog.command_log, - self.popen, - sys.executable, - ) - - def runcommand(self): - reporter.using( - "tox-{} from {} (pid {})".format(tox.__version__, tox.__file__, os.getpid()) - ) - show_description = reporter.has_level(reporter.Verbosity.DEFAULT) - if self.config.run_provision: - provision_tox_venv = self.getvenv(self.config.provision_tox_env) - return provision_tox(provision_tox_venv, self.config.args) - else: - if self.config.option.showconfig: - self.showconfig() - elif self.config.option.listenvs: - self.showenvs(all_envs=False, description=show_description) - elif self.config.option.listenvs_all: - self.showenvs(all_envs=True, description=show_description) - else: - with self.cleanup(): - return self.subcommand_test() - - @contextmanager - def cleanup(self): - self.config.temp_dir.ensure(dir=True) - try: - yield - finally: - self.hook.tox_cleanup(session=self) - - def subcommand_test(self): - if self.config.skipsdist: - reporter.info("skipping sdist step") - else: - for venv in self.venv_dict.values(): - if not venv.envconfig.skip_install: - venv.package = self.hook.tox_package(session=self, venv=venv) - if not venv.package: - return 2 - venv.envconfig.setenv[str("TOX_PACKAGE")] = str(venv.package) - if self.config.option.sdistonly: - return - - within_parallel = PARALLEL_ENV_VAR_KEY in os.environ - try: - if not within_parallel and self.config.option.parallel != PARALLEL_OFF: - run_parallel(self.config, self.venv_dict) - else: - run_sequential(self.config, self.venv_dict) - finally: - retcode = self._summary() - return retcode - - def _add_parallel_summaries(self): - if self.config.option.parallel != PARALLEL_OFF and "testenvs" in self.resultlog.dict: - result_log = self.resultlog.dict["testenvs"] - for tox_env in self.venv_dict.values(): - data = self._load_parallel_env_report(tox_env) - if data and "testenvs" in data and tox_env.name in data["testenvs"]: - result_log[tox_env.name] = data["testenvs"][tox_env.name] - - @staticmethod - def _load_parallel_env_report(tox_env): - """Load report data into memory, remove disk file""" - result_json_path = tox_env.get_result_json_path() - if result_json_path and result_json_path.exists(): - with result_json_path.open("r") as file_handler: - data = json.load(file_handler) - result_json_path.remove() - return data - - def _summary(self): - is_parallel_child = PARALLEL_ENV_VAR_KEY in os.environ - if not is_parallel_child: - reporter.separator("_", "summary", reporter.Verbosity.QUIET) - exit_code = 0 - for venv in self.venv_dict.values(): - report = reporter.good - status = getattr(venv, "status", "undefined") - if isinstance(status, tox.exception.InterpreterNotFound): - msg = " {}: {}".format(venv.envconfig.envname, str(status)) - if self.config.option.skip_missing_interpreters == "true": - report = reporter.skip - else: - exit_code = 1 - report = reporter.error - elif status == "platform mismatch": - msg = " {}: {} ({!r} does not match {!r})".format( - venv.envconfig.envname, str(status), sys.platform, venv.envconfig.platform - ) - report = reporter.skip - elif status and status == "ignored failed command": - msg = " {}: {}".format(venv.envconfig.envname, str(status)) - elif status and status != "skipped tests": - msg = " {}: {}".format(venv.envconfig.envname, str(status)) - report = reporter.error - exit_code = 1 - else: - if not status: - status = "commands succeeded" - msg = " {}: {}".format(venv.envconfig.envname, status) - if not is_parallel_child: - report(msg) - if not exit_code and not is_parallel_child: - reporter.good(" congratulations :)") - path = self.config.option.resultjson - if path: - if not is_parallel_child: - self._add_parallel_summaries() - path = py.path.local(path) - data = self.resultlog.dumps_json() - reporter.line("write json report at: {}".format(path)) - path.write(data) - return exit_code - - def showconfig(self): - show_config(self.config) - - def showenvs(self, all_envs=False, description=False): - show_envs(self.config, all_envs=all_envs, description=description) diff --git a/src/tox/session/cmd/__init__.py b/src/tox/session/cmd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/session/cmd/list_env.py b/src/tox/session/cmd/list_env.py new file mode 100644 index 000000000..c524ad06f --- /dev/null +++ b/src/tox/session/cmd/list_env.py @@ -0,0 +1,48 @@ +from tox.config.cli.parser import ToxParser +from tox.plugin.impl import impl +from tox.session.state import State + + +@impl +def tox_add_option(parser: ToxParser): + our = parser.add_command("list", ["l"], "list environments", list_env) + our.add_argument( + "-d", action="store_true", help="list just default envs", dest="list_default_only" + ) + our.add_argument("-q", action="store_true", help="do not show description", dest="list_quiet") + + +def list_env(state: State): + core = state.conf.core + option = state.options + + default = core["env_list"] # this should be something not affected by env-vars :-| + ignore = {core["provision_tox_env"]}.union(default) + extra = ( + [] if option.list_default_only else [e for e in state.tox_envs.keys() if e not in ignore] + ) + + if not option.list_quiet and default: + print("default environments:") + max_length = max(len(env) for env in (default + extra)) + + def report_env(name: str): + if not option.list_quiet: + text = state.tox_envs[name].conf["description"] + if text is None: + text = "[no description]" + text = text.replace("\n", " ") + msg = "{} -> {}".format(e.ljust(max_length), text).strip() + else: + msg = e + print(msg) + + for e in default: + report_env(e) + if not option.list_default_only and extra: + if not option.list_quiet: + if default: + print("") + print("additional environments:") + for e in extra: + report_env(e) diff --git a/src/tox/session/cmd/run/__init__.py b/src/tox/session/cmd/run/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/session/cmd/run/common.py b/src/tox/session/cmd/run/common.py new file mode 100644 index 000000000..240ac1565 --- /dev/null +++ b/src/tox/session/cmd/run/common.py @@ -0,0 +1,14 @@ +from tox.config.cli.parser import ToxParser + + +def env_run_create_flags(parser: ToxParser): + parser.add_argument( + "-r", + "--recreate", + dest="recreate", + help="recreate the tox environments", + action="store_true", + ) + parser.add_argument( + "-n", "--notest", dest="no_test", help="do not run the test commands", action="store_true" + ) diff --git a/src/tox/session/commands/run/parallel.py b/src/tox/session/cmd/run/parallel.py similarity index 62% rename from src/tox/session/commands/run/parallel.py rename to src/tox/session/cmd/run/parallel.py index 76db97933..a0459a8c3 100644 --- a/src/tox/session/commands/run/parallel.py +++ b/src/tox/session/cmd/run/parallel.py @@ -1,46 +1,96 @@ +import inspect +import logging import os import sys +from argparse import ArgumentTypeError from collections import OrderedDict, deque +from pathlib import Path from threading import Event, Semaphore, Thread -from tox import reporter -from tox.config.parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY -from tox.exception import InvocationError -from tox.util.main import MAIN_FILE +import tox +from tox.config.cli.parser import ToxParser +from tox.plugin.impl import impl +from tox.session.common import env_list_flag +from tox.session.state import State +from tox.util.cpu import auto_detect_cpus from tox.util.spinner import Spinner +from .common import env_run_create_flags + +logger = logging.getLogger(__name__) + +ENV_VAR_KEY = "TOX_PARALLEL_ENV" +OFF_VALUE = 0 +DEFAULT_PARALLEL = OFF_VALUE +MAIN_FILE = Path(inspect.getsourcefile(tox)) / "__main__.py" + + +@impl +def tox_add_option(parser: ToxParser): + our = parser.add_command("run-parallel", ["p"], "run environments in parallel", run_parallel) + env_list_flag(our) + env_run_create_flags(our) + + def parse_num_processes(str_value): + if str_value == "all": + return None + if str_value == "auto": + return auto_detect_cpus() + else: + value = int(str_value) + if value < 0: + raise ArgumentTypeError("value must be positive") + return value + + our.add_argument( + "-p", + "--parallel", + dest="parallel", + help="run tox environments in parallel, the argument controls limit: all," + " auto - cpu count, some positive number, zero is turn off", + action="store", + type=parse_num_processes, + default=DEFAULT_PARALLEL, + metavar="VAL", + ) + our.add_argument( + "-o", + "--parallel-live", + action="store_true", + dest="parallel_live", + help="connect to stdout while running environments", + ) -def run_parallel(config, venv_dict): + +def run_parallel(state: State): """here we'll just start parallel sub-processes""" - live_out = config.option.parallel_live + live_out = state.options.parallel_live disable_spinner = bool(os.environ.get("TOX_PARALLEL_NO_SPINNER") == "1") - args = [sys.executable, MAIN_FILE] + config.args + args = [sys.executable, MAIN_FILE] + state.args try: position = args.index("--") except ValueError: position = len(args) - max_parallel = config.option.parallel + max_parallel = state.options.parallel if max_parallel is None: - max_parallel = len(venv_dict) + max_parallel = len(state.tox_envs) semaphore = Semaphore(max_parallel) finished = Event() - show_progress = ( - not disable_spinner and not live_out and reporter.verbosity() > reporter.Verbosity.QUIET - ) + show_progress = not disable_spinner and not live_out and state.options.verbosity > 2 with Spinner(enabled=show_progress) as spinner: def run_in_thread(tox_env, os_env, processes): output = None env_name = tox_env.envconfig.envname - status = "skipped tests" if config.option.notest else None + status = "skipped tests" if state.options.no_test else None try: - os_env[str(PARALLEL_ENV_VAR_KEY)] = str(env_name) + os_env[str(ENV_VAR_KEY)] = str(env_name) args_sub = list(args) if hasattr(tox_env, "package"): - args_sub.insert(position, str(tox_env.package)) + args_sub.insert(position, str(tox_env.perform_packaging)) args_sub.insert(position, "--installpkg") if tox_env.get_result_json_path(): result_json_index = args_sub.index("--result-json") @@ -60,7 +110,7 @@ def collect_process(process): returnout=print_out, ) - except InvocationError as err: + except Exception as err: status = "parallel child exit code {}".format(err.exit_code) finally: semaphore.release() @@ -68,18 +118,20 @@ def collect_process(process): tox_env.status = status done.add(env_name) outcome = spinner.succeed - if config.option.notest: + if state.options.notest: outcome = spinner.skip elif status is not None: outcome = spinner.fail outcome(env_name) if print_out and output is not None: - reporter.verbosity0(output) + logger.warning(output) threads = deque() processes = {} - todo_keys = set(venv_dict.keys()) - todo = OrderedDict((n, todo_keys & set(v.envconfig.depends)) for n, v in venv_dict.items()) + todo_keys = set(state.env_list) + todo = OrderedDict( + (n, todo_keys & set(v.envconfig.depends)) for n, v in state.tox_envs.items() + ) done = set() try: while todo: @@ -88,7 +140,7 @@ def collect_process(process): # skip if has unfinished dependencies continue del todo[name] - venv = venv_dict[name] + venv = state.tox_envs[name] semaphore.acquire(blocking=True) spinner.add(name) thread = Thread( @@ -106,9 +158,7 @@ def collect_process(process): thread for thread in threads if not thread.join(0.1) and thread.is_alive() ] except KeyboardInterrupt: - reporter.verbosity0( - "[{}] KeyboardInterrupt parallel - stopping children".format(os.getpid()) - ) + logger.error("[{}] KeyboardInterrupt parallel - stopping children".format(os.getpid())) while True: # do not allow to interrupt until children interrupt try: diff --git a/src/tox/session/cmd/run/sequential.py b/src/tox/session/cmd/run/sequential.py new file mode 100644 index 000000000..f33372d8c --- /dev/null +++ b/src/tox/session/cmd/run/sequential.py @@ -0,0 +1,42 @@ +from typing import Dict + +from tox.config.cli.parser import ToxParser +from tox.execute.api import Outcome +from tox.plugin.impl import impl +from tox.session.common import env_list_flag +from tox.session.state import State +from tox.tox_env.api import ToxEnv + +from .common import env_run_create_flags +from .single import run_one + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command("run", ["r"], "run environments", run_sequential) + env_list_flag(our) + env_run_create_flags(our) + + +def run_sequential(state: State) -> int: + status_codes: Dict[str, int] = {} + for name in state.env_list: + tox_env = state.tox_envs[name] + status_codes[name] = run_one(tox_env, state.options.recreate, state.options.no_test) + return report(status_codes, state.tox_envs) + + +def report(status_dict: Dict[str, int], tox_envs: Dict[str, ToxEnv]) -> int: + for name, status in status_dict.items(): + msg = "" + if status == Outcome.OK: + msg = "OK " + else: + msg = "FAIL code {}".format(status) + print(" {}: {}".format(name, msg)) + if all(value == Outcome.OK for name, value in status_dict.items()): + print(" congratulations :)") + return Outcome.OK + else: + print(" evaluation failed :(") + return -1 diff --git a/src/tox/session/cmd/run/single.py b/src/tox/session/cmd/run/single.py new file mode 100644 index 000000000..d90edb2c7 --- /dev/null +++ b/src/tox/session/cmd/run/single.py @@ -0,0 +1,40 @@ +from typing import List, cast + +from tox.config.source.api import Command +from tox.execute.api import Outcome +from tox.tox_env.api import ToxEnv +from tox.tox_env.errors import Recreate +from tox.tox_env.runner import RunToxEnv + + +def run_one(tox_env: RunToxEnv, recreate: bool, no_test: bool) -> int: + if recreate: + tox_env.clean(package_env=recreate) + try: + tox_env.setup() + except Recreate: + tox_env.clean(package_env=recreate) + tox_env.setup() + + code = run_commands(tox_env, no_test) + return code + + +def run_commands(tox_env: ToxEnv, no_test: bool) -> int: + status = Outcome.OK # assume all good + if no_test is False: + keys = ("commands_pre", "commands", "commands_post") + for key in keys: + for cmd in cast(List[Command], tox_env.conf[key]): + current_status = tox_env.execute( + cmd.args, + cwd=tox_env.conf["change_dir"], + allow_stdin=True, + show_on_standard=True, + ) + if current_status.exit_code != Outcome.OK: + return status + return status + + +__all__ = ("run_one",) diff --git a/src/tox/session/cmd/show_config.py b/src/tox/session/cmd/show_config.py new file mode 100644 index 000000000..c3cd504dd --- /dev/null +++ b/src/tox/session/cmd/show_config.py @@ -0,0 +1,25 @@ +from tox.config.cli.parser import ToxParser +from tox.plugin.impl import impl +from tox.session.common import env_list_flag +from tox.session.state import State + + +@impl +def tox_add_option(parser: ToxParser): + our = parser.add_command("config", ["c"], "show tox configuration", display_config) + env_list_flag(our) + + +def display_config(state: State): + if not state.options.env_list: + for key in state.conf.core: + print("{} = {}".format(key, state.conf.core[key])) + print(",".join(state.conf.core.unused())) + for name in state.tox_envs: + tox_env = state.tox_envs[name] + print() + print(f"[{name}]") + print("type = {}".format(type(tox_env).__name__)) + for key in tox_env.conf: + print("{} = {}".format(key, tox_env.conf[key])) + print(",".join(tox_env.conf.unused())) diff --git a/src/tox/session/cmd/version_flag.py b/src/tox/session/cmd/version_flag.py new file mode 100644 index 000000000..c7a216495 --- /dev/null +++ b/src/tox/session/cmd/version_flag.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from tox.config.cli.parser import ToxParser +from tox.plugin.impl import impl + + +@impl +def tox_add_option(parser: ToxParser): + from tox.version import __version__ + import tox + + parser.add_argument( + "--version", + action="version", + version="{} -> {}".format(__version__, Path(tox.__file__).absolute()), + ) diff --git a/src/tox/session/commands/help.py b/src/tox/session/commands/help.py deleted file mode 100644 index 6043e9f64..000000000 --- a/src/tox/session/commands/help.py +++ /dev/null @@ -1,14 +0,0 @@ -from tox import reporter - - -def show_help(config): - reporter.line(config._parser._format_help()) - reporter.line("Environment variables", bold=True) - reporter.line("TOXENV: comma separated list of environments (overridable by '-e')") - reporter.line("TOX_SKIP_ENV: regular expression to filter down from running tox environments") - reporter.line( - "TOX_TESTENV_PASSENV: space-separated list of extra environment variables to be " - "passed into test command environments" - ) - reporter.line("PY_COLORS: 0 disable colorized output, 1 enable (default)") - reporter.line("TOX_PARALLEL_NO_SPINNER: 1 disable spinner for CI, 0 enable (default)") diff --git a/src/tox/session/commands/help_ini.py b/src/tox/session/commands/help_ini.py deleted file mode 100644 index eb1f85b34..000000000 --- a/src/tox/session/commands/help_ini.py +++ /dev/null @@ -1,14 +0,0 @@ -from tox import reporter - - -def show_help_ini(config): - reporter.separator("-", "per-testenv attributes", reporter.Verbosity.INFO) - for env_attr in config._testenv_attr: - reporter.line( - "{:<15} {:<8} default: {}".format( - env_attr.name, "<{}>".format(env_attr.type), env_attr.default - ), - bold=True, - ) - reporter.line(env_attr.help) - reporter.line("") diff --git a/src/tox/session/commands/provision.py b/src/tox/session/commands/provision.py deleted file mode 100644 index 21825fc22..000000000 --- a/src/tox/session/commands/provision.py +++ /dev/null @@ -1,24 +0,0 @@ -"""In case the tox environment is not correctly setup provision it and delegate execution""" -from __future__ import absolute_import, unicode_literals - -import os - -from tox.exception import InvocationError - - -def provision_tox(provision_venv, args): - ensure_meta_env_up_to_date(provision_venv) - with provision_venv.new_action("provision") as action: - provision_args = [str(provision_venv.envconfig.envpython), "-m", "tox"] + args - try: - env = os.environ.copy() - env[str("TOX_PROVISION")] = str("1") - action.popen(provision_args, redirect=False, report_fail=False, env=env) - return 0 - except InvocationError as exception: - return exception.exit_code - - -def ensure_meta_env_up_to_date(provision_venv): - if provision_venv.setupenv(): - provision_venv.finishvenv() diff --git a/src/tox/session/commands/run/sequential.py b/src/tox/session/commands/run/sequential.py deleted file mode 100644 index 907690985..000000000 --- a/src/tox/session/commands/run/sequential.py +++ /dev/null @@ -1,76 +0,0 @@ -import py - -import tox -from tox.exception import InvocationError - - -def run_sequential(config, venv_dict): - for venv in venv_dict.values(): - if venv.setupenv(): - if venv.envconfig.skip_install: - venv.finishvenv() - else: - if venv.envconfig.usedevelop: - develop_pkg(venv, config.setupdir) - elif config.skipsdist: - venv.finishvenv() - else: - installpkg(venv, venv.package) - if venv.status == 0: - runenvreport(venv, config) - if venv.status == 0: - runtestenv(venv, config) - - -def develop_pkg(venv, setupdir): - with venv.new_action("developpkg", setupdir) as action: - try: - venv.developpkg(setupdir, action) - return True - except InvocationError as exception: - venv.status = exception - return False - - -def installpkg(venv, path): - """Install package in the specified virtual environment. - - :param VenvConfig venv: Destination environment - :param str path: Path to the distribution package. - :return: True if package installed otherwise False. - :rtype: bool - """ - venv.env_log.set_header(installpkg=py.path.local(path)) - with venv.new_action("installpkg", path) as action: - try: - venv.installpkg(path, action) - return True - except tox.exception.InvocationError as exception: - venv.status = exception - return False - - -def runenvreport(venv, config): - """ - Run an environment report to show which package - versions are installed in the venv - """ - try: - with venv.new_action("envreport") as action: - packages = config.pluginmanager.hook.tox_runenvreport(venv=venv, action=action) - action.setactivity("installed", ",".join(packages)) - venv.env_log.set_installed(packages) - except InvocationError as exception: - venv.status = exception - - -def runtestenv(venv, config, redirect=False): - if venv.status == 0 and config.option.notest: - venv.status = "skipped tests" - else: - if venv.status: - return - config.pluginmanager.hook.tox_runtest_pre(venv=venv) - if venv.status == 0: - config.pluginmanager.hook.tox_runtest(venv=venv, redirect=redirect) - config.pluginmanager.hook.tox_runtest_post(venv=venv) diff --git a/src/tox/session/commands/show_config.py b/src/tox/session/commands/show_config.py deleted file mode 100644 index efb713ac7..000000000 --- a/src/tox/session/commands/show_config.py +++ /dev/null @@ -1,84 +0,0 @@ -import sys -from collections import OrderedDict - -from packaging.requirements import Requirement -from packaging.utils import canonicalize_name -from six import StringIO -from six.moves import configparser - -from tox import reporter -from tox.util.stdlib import importlib_metadata - -DO_NOT_SHOW_CONFIG_ATTRIBUTES = ( - "interpreters", - "envconfigs", - "envlist", - "pluginmanager", - "envlist_explicit", -) - - -def show_config(config): - parser = configparser.ConfigParser() - - if not config.envlist_explicit or reporter.verbosity() >= reporter.Verbosity.INFO: - tox_info(config, parser) - version_info(parser) - tox_envs_info(config, parser) - - content = StringIO() - parser.write(content) - value = content.getvalue().rstrip() - reporter.verbosity0(value) - - -def tox_envs_info(config, parser): - if config.envlist_explicit: - env_list = config.envlist - elif config.option.listenvs: - env_list = config.envlist_default - else: - env_list = list(config.envconfigs.keys()) - for name in env_list: - env_config = config.envconfigs[name] - values = OrderedDict( - (attr.name, str(getattr(env_config, attr.name))) - for attr in config._parser._testenv_attr - ) - section = "testenv:{}".format(name) - set_section(parser, section, values) - - -def tox_info(config, parser): - info = OrderedDict( - (i, str(getattr(config, i))) - for i in sorted(dir(config)) - if not i.startswith("_") and i not in DO_NOT_SHOW_CONFIG_ATTRIBUTES - ) - info["host_python"] = sys.executable - set_section(parser, "tox", info) - - -def version_info(parser): - versions = OrderedDict() - to_visit = {"tox"} - while to_visit: - current = to_visit.pop() - current_dist = importlib_metadata.distribution(current) - current_name = canonicalize_name(current_dist.metadata["name"]) - versions[current_name] = current_dist.version - if current_dist.requires is not None: - for require in current_dist.requires: - pkg = Requirement(require) - pkg_name = canonicalize_name(pkg.name) - if ( - pkg.marker is None or pkg.marker.evaluate({"extra": ""}) - ) and pkg_name not in versions: - to_visit.add(pkg_name) - set_section(parser, "tox:versions", versions) - - -def set_section(parser, section, values): - parser.add_section(section) - for key, value in values.items(): - parser.set(section, key, value) diff --git a/src/tox/session/commands/show_env.py b/src/tox/session/commands/show_env.py deleted file mode 100644 index ae05c84db..000000000 --- a/src/tox/session/commands/show_env.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -from tox import reporter as report - - -def show_envs(config, all_envs=False, description=False): - env_conf = config.envconfigs # this contains all environments - default = config.envlist_default # this only the defaults - ignore = {config.isolated_build_env, config.provision_tox_env}.union(default) - extra = [e for e in env_conf if e not in ignore] if all_envs else [] - - if description and default: - report.line("default environments:") - max_length = max(len(env) for env in (default + extra)) - - def report_env(e): - if description: - text = env_conf[e].description or "[no description]" - msg = "{} -> {}".format(e.ljust(max_length), text).strip() - else: - msg = e - report.line(msg) - - for e in default: - report_env(e) - if all_envs and extra: - if description: - if default: - report.line("") - report.line("additional environments:") - for e in extra: - report_env(e) diff --git a/src/tox/session/common.py b/src/tox/session/common.py new file mode 100644 index 000000000..b14a31e18 --- /dev/null +++ b/src/tox/session/common.py @@ -0,0 +1,22 @@ +import argparse +from typing import List, Optional + +from tox.config.cli.parser import ToxParser +from tox.config.source.ini import StrConvert + + +def env_list_flag(parser: ToxParser): + class ToxEnvList(argparse.Action): + # noinspection PyShadowingNames + def __call__(self, parser, args, values, option_string=None): + list_envs = StrConvert().to(values, of_type=List[str]) + setattr(args, self.dest, list_envs) + + parser.add_argument( + "-e", + dest="env_list", + help="tox environments to run", + action=ToxEnvList, + default=None, + of_type=Optional[List[str]], + ) diff --git a/src/tox/session/state.py b/src/tox/session/state.py new file mode 100644 index 000000000..20bcafa4e --- /dev/null +++ b/src/tox/session/state.py @@ -0,0 +1,22 @@ +from typing import Dict, List, Optional, cast + +from tox.config.main import Config +from tox.tox_env.runner import RunToxEnv + + +class State: + def __init__(self, conf, tox_envs, opt_parse, args): + self.conf: Config = conf + self.tox_envs: Dict[str, RunToxEnv] = tox_envs + options, unknown, handlers = opt_parse + self.options = options + self.unknown_options = unknown + self.handlers = handlers + self.args = args + + @property + def env_list(self) -> List[str]: + tox_env_keys = cast(Optional[List[str]], self.options.env_list) + if tox_env_keys is None: + tox_env_keys = cast(List[str], self.conf.core["env_list"]) + return tox_env_keys diff --git a/src/tox/tox_env/__init__.py b/src/tox/tox_env/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py new file mode 100644 index 000000000..03bf75f4f --- /dev/null +++ b/src/tox/tox_env/api.py @@ -0,0 +1,111 @@ +import itertools +import logging +import os +import shutil +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Set, cast + +from tox.config.sets import ConfigSet +from tox.execute.api import Execute +from tox.execute.request import ExecuteRequest + +from .cache import Cache + + +class ToxEnv(ABC): + def __init__(self, conf: ConfigSet, core: ConfigSet, options, executor: Execute): + self.conf: ConfigSet = conf + self.core: ConfigSet = core + self.options = options + self._executor = executor + self.register_config() + self._cache = Cache(self.conf["env_dir"] / ".tox-cache") + self._paths: List[Path] = [] + self.logger = logging.getLogger(self.conf["env_name"]) + + def register_config(self): + self.conf.add_constant( + keys=["env_name", "envname"], + desc="the name of the tox environment", + value=self.conf.name, + ) + self.conf.add_config( + keys=["set_env", "setenv"], + of_type=Dict[str, str], + default={}, + desc="environment variables to set when running commands in the tox environment", + ) + self.conf.add_config( + keys=["pass_env", "passenv"], + of_type=List[str], + default={}, + desc="environment variables to pass on to the tox environment", + post_process=lambda v, _: list(itertools.chain.from_iterable(i.split(" ") for i in v)), + ) + self.conf.add_config( + keys=["env_dir", "envdir"], + of_type=Path, + default=lambda conf, name: conf.core["work_dir"] / conf[name]["env_name"], + desc="directory assigned to the tox environment", + ) + + def setup(self) -> None: + """ + 1. env dir exists + 2. contains a runner with the same type + """ + env_dir = cast(Path, self.conf["env_dir"]) + conf = {"name": self.conf.name, "type": type(self).__name__} + with self._cache.compare(conf, ToxEnv.__name__) as (eq, old): + try: + if eq is True: + return + # if either the name or type changed and already exists start over + self.clean() + finally: + env_dir.mkdir(exist_ok=True, parents=True) + + def clean(self): + env_dir = self.conf["env_dir"] + if env_dir.exists(): + logging.info("removing %s", env_dir) + shutil.rmtree(cast(Path, env_dir)) + + @property + def environment_variables(self) -> Dict[str, str]: + result: Dict[str, str] = {} + pass_env: Set[str] = self.conf["pass_env"] + set_env: Dict[str, str] = self.conf["set_env"] + for key, value in os.environ.items(): + if key in pass_env: + result[key] = value + result.update(set_env) + result["PATH"] = os.pathsep.join( + itertools.chain( + (str(i) for i in self._paths), os.environ.get("PATH", "").split(os.pathsep) + ) + ) + return result + + def execute( + self, + cmd: Sequence[str], + allow_stdin: bool, + show_on_standard: Optional[bool] = None, + cwd: Optional[Path] = None, + ): + if cwd is None: + cwd = self.core["tox_root"] + if show_on_standard is None: + show_on_standard = self.options.verbosity > 3 + request = ExecuteRequest(cmd, cwd, self.environment_variables, allow_stdin) + self.logger.warning("run => %s$ %s", request.cwd, request.shell_cmd) + outcome = self._executor(request=request, show_on_standard=show_on_standard) + self.logger.info("exit code %d in %s", outcome.exit_code, outcome.elapsed) + return outcome + + @staticmethod + @abstractmethod + def id() -> str: + raise NotImplementedError diff --git a/src/tox/tox_env/builder.py b/src/tox/tox_env/builder.py new file mode 100644 index 000000000..c07d1eae3 --- /dev/null +++ b/src/tox/tox_env/builder.py @@ -0,0 +1,97 @@ +import copy +from typing import Dict, cast + +from tox.config.cli.parser import ToxParser +from tox.config.main import Config +from tox.config.sets import ConfigSet +from tox.plugin.impl import impl +from tox.session.state import State + +from .package import PackageToxEnv +from .runner import RunToxEnv + + +def build_tox_envs(config: Config, options, args): + builder = Builder(options[0], config) + return State(config, builder.tox_env_to_runner, options, args) + + +class Builder: + def __init__(self, options: str, config: Config): + self.tox_env_to_runner: Dict[str, RunToxEnv] = {} + self._tox_env_to_runner_type: Dict[str, str] = {} + self._pkg_envs: Dict[str, PackageToxEnv] = {} + self.options = options + self._config = config + self._run() + + def _run(self) -> None: + for name in self._config: + if name in self._pkg_envs: + continue + env_conf: ConfigSet = copy.deepcopy(self._config[name]) + tox_env = self._build_run_env(env_conf, name) + self.tox_env_to_runner[name] = tox_env + for key, tox_env in self.tox_env_to_runner.items(): + tox_env.conf.add_constant( + keys=["execute"], + desc="the tox execute used to evaluate this environment", + value=self._tox_env_to_runner_type[key], + ) + + def _build_run_env(self, env_conf: ConfigSet, env_name): + # noinspection PyUnresolvedReferences + env_conf.add_config( + keys="runner", + desc="the tox execute used to evaluate this environment", + of_type=str, + default=self.options.default_runner, + ) + runner = cast(str, env_conf["runner"]) + from .register import REGISTER + + env: RunToxEnv = REGISTER.runner(runner)(env_conf, self._config.core, self.options) + self._tox_env_to_runner_type[env_name] = runner + self._build_package_env(env) + return env + + def _build_package_env(self, env: RunToxEnv) -> None: + pkg_env_gen = env.set_package_env() + try: + name, packager = next(pkg_env_gen) + except StopIteration: + pass + else: + package_tox_env = self._get_package_env(packager, name) + try: + pkg_env_gen.send(package_tox_env) + except StopIteration: + pass + + def _get_package_env(self, packager, pkg_name): + if pkg_name in self._pkg_envs: + package_tox_env: PackageToxEnv = self._pkg_envs[pkg_name] + else: + if pkg_name in self.tox_env_to_runner: # if already detected as runner remove + del self.tox_env_to_runner[pkg_name] + from .register import REGISTER + + package_type = REGISTER.package(packager) + pkg_conf = self._config[pkg_name] + pkg_conf.make_package_conf() + package_tox_env = package_type(pkg_conf, self._config.core, self.options) + self._pkg_envs[pkg_name] = package_tox_env + return package_tox_env + + +@impl +def tox_add_option(parser: ToxParser): + from .register import REGISTER + + parser.add_argument( + "--runner", + dest="default_runner", + help="default execute", + default=REGISTER.default_run_env, + choices=list(REGISTER.run_envs), + ) diff --git a/src/tox/tox_env/cache.py b/src/tox/tox_env/cache.py new file mode 100644 index 000000000..5730256db --- /dev/null +++ b/src/tox/tox_env/cache.py @@ -0,0 +1,39 @@ +import json +from contextlib import contextmanager +from pathlib import Path + + +class Cache: + def __init__(self, path: Path) -> None: + self._path = path + try: + value = json.loads(self._path.read_text()) + except (ValueError, OSError): + value = {} + self._content = value + + @contextmanager + def compare(self, value, section, sub_section=None): + old = self._content.get(section) + if sub_section is not None and old is not None: + old = old.get(sub_section) + + if old == value: + yield True, None + else: + yield False, old + # if no exception thrown update + if sub_section is None: + self._content[section] = value + else: + if self._content.get(section) is None: + self._content[section] = {sub_section: value} + else: + self._content[section][sub_section] = value + self._write() + + def update(self, section, value): + self._content[section] = value + + def _write(self): + self._path.write_text(json.dumps(self._content, indent=2)) diff --git a/src/tox/tox_env/errors.py b/src/tox/tox_env/errors.py new file mode 100644 index 000000000..9a561d152 --- /dev/null +++ b/src/tox/tox_env/errors.py @@ -0,0 +1,10 @@ +class Recreate(Exception): + """Recreate the tox environment""" + + +class Skip(Exception): + """Skip this tox environment""" + + +class Fail(Exception): + """Failed creating env""" diff --git a/src/tox/tox_env/package.py b/src/tox/tox_env/package.py new file mode 100644 index 000000000..390017233 --- /dev/null +++ b/src/tox/tox_env/package.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod +from typing import Any, List + +from .api import ToxEnv + + +class PackageToxEnv(ToxEnv, ABC): + def register_config(self): + super().register_config() + + @abstractmethod + def get_package_dependencies(self, extras=None) -> List[Any]: + raise NotImplementedError + + @abstractmethod + def perform_packaging(self) -> List[Any]: + raise NotImplementedError diff --git a/src/tox/tox_env/python/__init__.py b/src/tox/tox_env/python/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/tox_env/python/api.py b/src/tox/tox_env/python/api.py new file mode 100644 index 000000000..ef70717bd --- /dev/null +++ b/src/tox/tox_env/python/api.py @@ -0,0 +1,122 @@ +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, List, Union + +from packaging.requirements import Requirement + +from tox.config.sets import ConfigSet +from tox.execute.api import Execute +from tox.interpreters.discovery import PythonSpec, get_interpreter +from tox.tox_env.api import ToxEnv +from tox.tox_env.errors import Fail, Recreate + + +class Python(ToxEnv, ABC): + def __init__(self, conf: ConfigSet, core: ConfigSet, options, executor: Execute): + super().__init__(conf, core, options, executor) + self._python = None + self._python_search_done = False + + def register_config(self): + super().register_config() + self.conf.add_config( + keys=["base_python", "basepython"], + of_type=List[str], + default=self.default_base_python(), + desc="environment identifier for python, first one found wins", + ) + self.conf.add_constant( + keys=["envsitepackagesdir"], + desc="the python environments site package", + value=lambda: self.env_site_package_dir(), + ) + + def default_base_python(self) -> List[str]: + result = [] + name = self.conf["env_name"] + spec = PythonSpec.from_string_spec(name) + if spec.path is None: + # if some version spec matched use it + result.append(name) + else: + # otherwise just default to current executable + result.append(sys.executable) + return result + + def env_site_package_dir(self): + """ + If we have the python we just need to look at the last path under prefix. + Debian derivatives change the site-packages to dist-packages, so we need to fix it for site-packages. + """ + python = self._find_base_python() + site_at = next( + Path(p) for p in reversed(python.path) if p.startswith(python.prefix) + ).relative_to(Path(python.prefix)) + return self.conf["env_dir"] / site_at.parent / "site-packages" + + def setup(self) -> None: + """setup a virtual python environment""" + super().setup() + python = self._find_base_python() + conf = self.python_cache(python) + with self._cache.compare(conf, Python.__name__) as (eq, old): + if eq is False: + self.create_python_env(python) + self._paths = self.paths(python) + + def _find_base_python(self): + base_pythons = self.conf["base_python"] + if self._python_search_done is False: + self._python_search_done = True + for base_python in base_pythons: + python = self.get_python(base_python) + if python is not None: + self._python = python + break + if self._python is None: + raise NoInterpreter(base_pythons) + return self._python + + # noinspection PyMethodMayBeStatic + def get_python(self, base): + return get_interpreter(base) + + def cached_install(self, deps, section, of_type): + conf_deps = [str(i) for i in deps] + with self._cache.compare(conf_deps, section, of_type) as (eq, old): + if eq is True: + return + if old is None: + old = [] + missing = [Requirement(i) for i in (set(old) - set(conf_deps))] + if missing: # no way yet to know what to uninstall here (transitive dependencies?) + # bail out and force recreate + raise Recreate() + new_deps = [Requirement(i) for i in (set(conf_deps) - set(old))] + self.install_python_packages(packages=new_deps) + + @abstractmethod + def python_cache(self, python) -> Any: + raise NotImplementedError + + @abstractmethod + def create_python_env(self, python) -> List[Path]: + raise NotImplementedError + + @abstractmethod + def paths(self, python) -> List[Path]: + raise NotImplementedError + + @abstractmethod + def install_python_packages( + self, packages: List[Union[Path, Requirement]], no_deps: bool = False + ) -> None: + raise NotImplementedError + + +class NoInterpreter(Fail): + """could not find interpreter""" + + def __init__(self, base_pythons): + self.python = base_pythons diff --git a/src/tox/tox_env/python/package.py b/src/tox/tox_env/python/package.py new file mode 100644 index 000000000..0853c27a2 --- /dev/null +++ b/src/tox/tox_env/python/package.py @@ -0,0 +1,27 @@ +import sys +from abc import ABC, abstractmethod +from typing import List + +from packaging.requirements import Requirement + +from ..package import PackageToxEnv +from .api import Python + + +class PythonPackage(Python, PackageToxEnv, ABC): + def setup(self) -> None: + """setup the tox environment""" + super().setup() + self.cached_install(self.requires(), PythonPackage.__name__, "requires") + self.cached_install(self.build_requires(), PythonPackage.__name__, "build-requires") + + @abstractmethod + def requires(self) -> List[Requirement]: + raise NotImplementedError + + @abstractmethod + def build_requires(self) -> List[Requirement]: + raise NotImplementedError + + def default_base_python(self) -> List[str]: + return [sys.executable] diff --git a/src/tox/tox_env/python/runner.py b/src/tox/tox_env/python/runner.py new file mode 100644 index 000000000..706f718b4 --- /dev/null +++ b/src/tox/tox_env/python/runner.py @@ -0,0 +1,64 @@ +from abc import ABC +from typing import List, Set + +from packaging.requirements import Requirement + +from tox.tox_env.errors import Recreate, Skip + +from ..runner import RunToxEnv +from .api import NoInterpreter, Python + + +class PythonRun(Python, RunToxEnv, ABC): + def register_config(self): + super().register_config() + self.conf.add_config( + keys="deps", + of_type=List[Requirement], + default=[], + desc="Name of the python dependencies as specified by PEP-440", + ) + self.core.add_config( + keys=["skip_missing_interpreters"], + default=True, + of_type=bool, + desc="skip running missing interpreters", + ) + self.add_package_conf() + + def add_package_conf(self): + if self.core["no_package"] is False: + self.conf.add_config( + keys=["extras"], + of_type=Set[str], + default=[], + desc="extras to install of the target package", + ) + + def _find_base_python(self): + try: + return super()._find_base_python() + except NoInterpreter: + if self.core["skip_missing_interpreters"]: + raise Skip + raise + + def setup(self) -> None: + """setup the tox environment""" + super().setup() + self.cached_install(self.conf["deps"], PythonRun.__name__, "deps") + + if self.package_env is not None: + try: + self.package_env.setup() + except Recreate: + self.package_env.clean() + self.package_env.setup() + + package_deps = self.package_env.get_package_dependencies(self.conf["extras"]) + self.cached_install(package_deps, PythonRun.__name__, "package_deps") + self.install_package() + + def install_package(self): + package = self.package_env.perform_packaging() + self.install_python_packages(package) diff --git a/src/tox/tox_env/python/virtual_env/__init__.py b/src/tox/tox_env/python/virtual_env/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/tox_env/python/virtual_env/api.py b/src/tox/tox_env/python/virtual_env/api.py new file mode 100644 index 000000000..460ee50b7 --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/api.py @@ -0,0 +1,124 @@ +import shutil +import sys +from abc import ABC +from pathlib import Path +from typing import List, Sequence, Union, cast + +from appdirs import user_cache_dir +from packaging.requirements import Requirement + +from tox.config.sets import ConfigSet +from tox.execute.api import Outcome +from tox.execute.local_sub_process import LocalSubProcessExecutor +from tox.interpreters.discovery import PythonInfo + +from ..api import Python + +CACHE_DIR = Path(user_cache_dir(appname="tox", appauthor="tox-dev")) / "virtualenv" / "pip" + + +def copy_overwrite(src: Path, dest: Path): + if dest.exists(): + shutil.rmtree(dest) + if src.is_dir(): + if not dest.is_dir(): + dest.mkdir(parents=True) + for file_name in src.iterdir(): + copy_overwrite(file_name, dest / file_name.name) + else: + shutil.copyfile(str(src), str(dest)) + + +class VirtualEnv(Python, ABC): + def __init__(self, conf: ConfigSet, core: ConfigSet, options): + super().__init__(conf, core, options, LocalSubProcessExecutor()) + + def create_python_env(self, python: PythonInfo): + core_cmd = self.core_cmd(python) + env_dir = cast(Path, self.conf["env_dir"]) + # installing pip is slow - speed up by cache-ing pip + cmd = core_cmd + ("--no-pip", "--clear", env_dir) + result = self.execute(cmd=cmd, allow_stdin=False) + result.assert_success(self.logger) + self._bootstrap_pip(core_cmd, env_dir) + + @staticmethod + def core_cmd(python): + core_cmd = ( + sys.executable, + "-m", + "virtualenv", + "--no-setuptools", + "--no-wheel", + "--no-download", + "--python", + python.executable, + ) + return core_cmd + + def _bootstrap_pip(self, core_cmd, env_dir): + self._get_cached_pip(core_cmd) + self._install_pip_from_cache(env_dir) + + def _get_cached_pip(self, core_cmd): + if not CACHE_DIR.exists(): + CACHE_DIR.mkdir(parents=True) + cmd = core_cmd + ("--clear", CACHE_DIR) + result = self.execute(cmd=cmd, allow_stdin=False) + result.assert_success(self.logger) + + def _install_pip_from_cache(self, env_dir): + package = self.get_site_packages(CACHE_DIR) + target_folder = self.get_site_packages(env_dir) + copy_overwrite(package, target_folder) + target_folder = self.get_bin(env_dir) + for binary in [b for b in self.get_bin(CACHE_DIR).iterdir() if "pip" in b.name]: + target_file = target_folder / binary.name + shutil.copyfile(str(binary), target_file) + content = binary.read_text() + content.replace(str(CACHE_DIR), str(env_dir)) + target_file.write_text(content) + + @staticmethod + def get_bin(folder: Path) -> Path: + return next(p for p in folder.iterdir() if p.name in ("bin", "Script")) + + @staticmethod + def get_site_packages(folder: Path) -> Path: + lib = next(next(i for i in folder.iterdir() if i.name in ("lib", "Lib")).iterdir()) + return lib / "site-packages" + + def paths(self, python: PythonInfo) -> List[Path]: + # we use the original executable as shims may be somewhere else + host_postfix = Path(python.original_executable).relative_to(python.prefix).parent + return [self.conf["env_dir"] / host_postfix] + + def python_cache(self, python: PythonInfo): + return {"version_info": list(python.version_info), "executable": python.executable} + + def install_python_packages( + self, + packages: List[Union[Requirement, Path]], + no_deps: bool = False, + develop=False, + force_reinstall=False, + ) -> None: + if packages: + install_command = self.install_command(develop, force_reinstall, no_deps, packages) + result = self.perform_install(install_command) + result.assert_success(self.logger) + + def perform_install(self, install_command: Sequence[str]) -> Outcome: + return self.execute(cmd=install_command, allow_stdin=False) + + # noinspection PyMethodMayBeStatic + def install_command(self, develop, force_reinstall, no_deps, packages): + install_command = ["python", "-m", "pip", "--disable-pip-version-check", "install"] + if develop is True: + install_command.append("-e") + if no_deps: + install_command.append("--no-deps") + if force_reinstall: + install_command.append("--force-reinstall") + install_command.extend(str(i) for i in packages) + return install_command diff --git a/src/tox/tox_env/python/virtual_env/package/__init__.py b/src/tox/tox_env/python/virtual_env/package/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/tox_env/python/virtual_env/package/api.py b/src/tox/tox_env/python/virtual_env/package/api.py new file mode 100644 index 000000000..4276bb6e3 --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/api.py @@ -0,0 +1,148 @@ +import json +from abc import ABC +from enum import Enum +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import List, Optional, Tuple, cast + +import toml +from packaging.requirements import Requirement + +from tox import helper +from tox.config.sets import ConfigSet +from tox.tox_env.python.package import PythonPackage + +from ..api import VirtualEnv + +try: + import importlib.metadata as imp_meta +except ImportError: + import importlib_metadata as imp_meta + + +TOX_PACKAGE_ENV_ID = "virtualenv-pep-517" + + +class PackageType(Enum): + sdist = 1 + wheel = 2 + dev = 3 + skip = 4 + + +class Pep517VirtualEnvPackage(VirtualEnv, PythonPackage, ABC): + """local file system python virtual environment via the virtualenv package""" + + LEGACY_BUILD_BACKEND = "setuptools.build_meta:__legacy__" + LEGACY_REQUIRES = ["setuptools >= 40.8.0", "wheel"] + + def __init__(self, conf: ConfigSet, core: ConfigSet, options) -> None: + super().__init__(conf, core, options) + backend_module, backend_object, requires = self.load_builder_and_requires() + self._requires: List[Requirement] = requires + self.build_backend_module: str = backend_module + self.build_backend_obj: Optional[str] = backend_object + self._distribution_meta: Optional[imp_meta.PathDistribution] = None + self._build_requires: Optional[List[Requirement]] = None + self._package: Optional[Requirement] = None + + def load_builder_and_requires(self) -> Tuple[str, Optional[str], List[Requirement]]: + py_project_toml = cast(Path, self.core["tox_root"]) / "pyproject.toml" + if py_project_toml.exists(): + py_project = toml.load(py_project_toml) + build_backend = py_project.get("build-system", {}).get( + "build-backend", self.LEGACY_BUILD_BACKEND + ) + requires = py_project.get("build-system", {}).get("requires", self.LEGACY_REQUIRES) + else: + build_backend = self.LEGACY_BUILD_BACKEND + requires = self.LEGACY_REQUIRES + req_as_req = [Requirement(i) for i in requires] + build_backend_info = build_backend.split(":") + backend_module = build_backend_info[0] + backend_obj = build_backend_info[1] if len(build_backend_info) > 1 else None + return backend_module, backend_obj, req_as_req + + def register_config(self): + super().register_config() + self.conf.add_config( + keys=["meta_dir"], + of_type=Path, + default=lambda conf, name: conf[name]["env_dir"] / ".meta", + desc="directory assigned to the tox environment", + ) + self.conf.add_config( + keys=["pkg_dir"], + of_type=Path, + default=lambda conf, name: conf[name]["env_dir"] / "dist", + desc="directory assigned to the tox environment", + ) + + def requires(self) -> List[Requirement]: + return self._requires + + def build_requires(self) -> List[Requirement]: + """get_requires_for_build_sdist/get-requires-for-build-wheel""" + if self._build_requires is None: + + with TemporaryDirectory() as path: + requires_file = Path(path) / "out.json" + cmd = ["python", helper.build_requires(), requires_file, self.build_backend_module] + if self.build_backend_obj: + cmd.append(self.build_backend_obj) + result = self.execute(cmd=cmd, allow_stdin=False) + result.assert_success(self.logger) + with open(requires_file) as file_handler: + self._build_requires = json.load(file_handler) + return self._build_requires + + def get_package_dependencies(self, extras=None) -> List[Requirement]: + self._ensure_meta_present() + if extras is None: + extras = set() + metadata = self._distribution_meta.metadata + result = [] + for key, v in metadata.items(): + if key == "Requires-Dist": + req = Requirement(v) + markers = getattr(req.marker, "_markers", tuple()) or tuple() + for _at, (m_key, op, m_val) in enumerate( + i for i in markers if isinstance(i, tuple) and len(i) == 3 + ): + if m_key.value == "extra" and op.value == "==": + extra = m_val.value + break + else: + extra, _at = None, None + if extra is None or extra in extras: + if _at is not None: + del req.marker._markers[_at] + if len(req.marker._markers) == 0: + req.marker = None + result.append(req) + return result + + def _ensure_meta_present(self): + if self._distribution_meta is None: + self.meta_folder.mkdir(exist_ok=True) + cmd = [ + "python", + helper.wheel_meta(), + self.meta_folder, + json.dumps(self.meta_flags), + self.build_backend_module, + ] + if self.build_backend_obj: + cmd.append(self.build_backend_obj) + result = self.execute(cmd=cmd, allow_stdin=False) + result.assert_success(self.logger) + dist_info = next(self.meta_folder.iterdir()) + self._distribution_meta = imp_meta.PathDistribution(dist_info) + + @property + def meta_folder(self) -> Path: + return cast(Path, self.conf["meta_dir"]) + + @property + def meta_flags(self): + return {"config_settings": None} diff --git a/src/tox/tox_env/python/virtual_env/package/artifact/__init__.py b/src/tox/tox_env/python/virtual_env/package/artifact/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/tox_env/python/virtual_env/package/artifact/api.py b/src/tox/tox_env/python/virtual_env/package/artifact/api.py new file mode 100644 index 000000000..b40642d08 --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/artifact/api.py @@ -0,0 +1,52 @@ +import json +import shutil +from abc import ABC, abstractmethod +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any, Dict, List, cast + +from tox import helper + +from ..api import Pep517VirtualEnvPackage + + +class Pep517VirtualEnvPackageArtifact(Pep517VirtualEnvPackage, ABC): + @property + @abstractmethod + def build_type(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def extra(self) -> Dict[str, Any]: + raise NotImplementedError + + def _build_artifact(self) -> List[Path]: + with TemporaryDirectory() as path: + out_file = Path(path) / "out.json" + dest = cast(Path, self.conf["pkg_dir"]) + if dest.exists(): + shutil.rmtree(str(dest)) + dest.mkdir() + cmd = [ + "python", + helper.isolated_builder(), + out_file, + dest, + self.build_type, + json.dumps(self.extra), + self.build_backend_module, + ] + if self.build_backend_obj: + cmd.append(self.build_backend_obj) + result = self.execute(cmd=cmd, allow_stdin=False, cwd=self.core["tox_root"]) + result.assert_success(self.logger) + with open(out_file) as file_handler: + base_name: str = json.load(file_handler) + return [dest / base_name] + + def perform_packaging(self) -> List[Path]: + """build_wheel/build_sdist""" + if self._package is None: + self._package = self._build_artifact() + return self._package diff --git a/src/tox/tox_env/python/virtual_env/package/artifact/sdist.py b/src/tox/tox_env/python/virtual_env/package/artifact/sdist.py new file mode 100644 index 000000000..d92f5c684 --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/artifact/sdist.py @@ -0,0 +1,25 @@ +from typing import Any, Dict + +from tox.plugin.impl import impl +from tox.tox_env.register import ToxEnvRegister + +from .api import Pep517VirtualEnvPackageArtifact + + +class Pep517VirtualEnvPackageSdist(Pep517VirtualEnvPackageArtifact): + @property + def build_type(self) -> str: + return "sdist" + + @staticmethod + def id() -> str: + return "virtualenv-pep-517-sdist" + + @property + def extra(self) -> Dict[str, Any]: + return {"config_settings": None} + + +@impl +def tox_register_tox_env(register: ToxEnvRegister) -> None: + register.add_package_env(Pep517VirtualEnvPackageSdist) diff --git a/src/tox/tox_env/python/virtual_env/package/artifact/wheel.py b/src/tox/tox_env/python/virtual_env/package/artifact/wheel.py new file mode 100644 index 000000000..a3376107a --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/artifact/wheel.py @@ -0,0 +1,30 @@ +from typing import Any, Dict + +from tox.plugin.impl import impl +from tox.tox_env.register import ToxEnvRegister + +from .api import Pep517VirtualEnvPackageArtifact + + +class Pep517VirtualEnvPackageWheel(Pep517VirtualEnvPackageArtifact): + @property + def build_type(self) -> str: + return "wheel" + + @property + def extra(self) -> Dict[str, Any]: + return { + "config_settings": { + "--global-option": ["--bdist-dir", str(self.conf["env_dir"] / "build")] + }, + "metadata_directory": str(self.meta_folder) if self.meta_folder.exists() else None, + } + + @staticmethod + def id() -> str: + return "virtualenv-pep-517-wheel" + + +@impl +def tox_register_tox_env(register: ToxEnvRegister) -> None: + register.add_package_env(Pep517VirtualEnvPackageWheel) diff --git a/src/tox/tox_env/python/virtual_env/package/dev.py b/src/tox/tox_env/python/virtual_env/package/dev.py new file mode 100644 index 000000000..5b7c37ec3 --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/dev.py @@ -0,0 +1,22 @@ +from pathlib import Path +from typing import List + +from tox.plugin.impl import impl +from tox.tox_env.register import ToxEnvRegister + +from .api import Pep517VirtualEnvPackage + + +class Pep517VirtualEnvPackageDev(Pep517VirtualEnvPackage): + def perform_packaging(self) -> List[Path]: + """no build operation defined for this yet, just an install flag of the package directory""" + return [self.core["tox_root"]] + + @staticmethod + def id() -> str: + return "virtualenv-pep-517-dev" + + +@impl +def tox_register_tox_env(register: ToxEnvRegister) -> None: + register.add_package_env(Pep517VirtualEnvPackageDev) diff --git a/src/tox/tox_env/python/virtual_env/package/util.py b/src/tox/tox_env/python/virtual_env/package/util.py new file mode 100644 index 000000000..ad4c103cb --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/util.py @@ -0,0 +1,14 @@ +from .api import PackageType +from .artifact.sdist import Pep517VirtualEnvPackageSdist +from .artifact.wheel import Pep517VirtualEnvPackageWheel +from .dev import Pep517VirtualEnvPackageDev + + +def virtual_env_package_id(of_type) -> str: + if of_type is PackageType.sdist: + return Pep517VirtualEnvPackageSdist.id() + elif of_type is PackageType.wheel: + return Pep517VirtualEnvPackageWheel.id() + elif of_type is PackageType.dev: + return Pep517VirtualEnvPackageDev.id() + raise KeyError(PackageType.name) diff --git a/src/tox/tox_env/python/virtual_env/runner.py b/src/tox/tox_env/python/virtual_env/runner.py new file mode 100644 index 000000000..f5836a211 --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/runner.py @@ -0,0 +1,91 @@ +from configparser import ConfigParser, NoSectionError + +from tox.plugin.impl import impl +from tox.tox_env.register import ToxEnvRegister + +from ..runner import PythonRun +from .api import VirtualEnv +from .package.api import PackageType +from .package.util import virtual_env_package_id + + +class VirtualEnvRunner(VirtualEnv, PythonRun): + """local file system python virtual environment via the virtualenv package""" + + @staticmethod + def id() -> str: + return "virtualenv" + + def add_package_conf(self): + if self.core["no_package"] is True: + return + self.conf.add_config( + keys="package", + of_type=str, + default=PackageType.sdist.name, + desc="package installation mode - {} ".format(" | ".join(i.name for i in PackageType)), + post_process=lambda key, conf: PackageType[key], + ) + if self.conf["package"] == PackageType.skip: + return + super().add_package_conf() + self.core.add_config( + keys=["package_env", "isolated_build_env"], + of_type=str, + default=".package", + desc="tox environment used to package", + ) + package = self.conf["package"] + self.conf.add_config( + keys="package_tox_env_type", + of_type=str, + default=virtual_env_package_id(package), + desc="tox package type used to package", + ) + if self.conf["package"] is PackageType.wheel: + universal = self.default_universal_wheel() + self.conf.add_config( + keys="universal_wheel", + of_type=bool, + default=universal, + desc="tox package type used to package", + ) + + def default_universal_wheel(self): + parser = ConfigParser() + success = parser.read(filenames=[self.core["tox_root"] / "setup.cfg"]) + universal = False + if success: + try: + universal = parser.get("bdist_wheel", "universal") == "1" + except NoSectionError: + pass + return universal + + def has_package(self): + return self.core["no_package"] or self.conf["package"] is not PackageType.skip + + def package_env_name_type(self): + if self.has_package(): + package = self.conf["package"] + package_env_type = self.conf["package_tox_env_type"] + name = self.core["package_env"] + # we can get away with a single common package if: sdist, dev, universal wheel + if package is PackageType.wheel and self.conf["universal_wheel"] is False: + # if version specific wheel one per env + name = "{}-{}".format(name, self.conf["env_name"]) + return name, package_env_type + + def install_package(self): + package = self.package_env.perform_packaging() + self.install_python_packages( + package, + no_deps=True, + develop=self.conf["package"] is PackageType.dev, + force_reinstall=True, + ) + + +@impl +def tox_register_tox_env(register: ToxEnvRegister): + register.add_run_env(VirtualEnvRunner) diff --git a/src/tox/tox_env/register.py b/src/tox/tox_env/register.py new file mode 100644 index 000000000..4732b7522 --- /dev/null +++ b/src/tox/tox_env/register.py @@ -0,0 +1,43 @@ +from typing import Dict, Iterable, Type + +from .package import PackageToxEnv +from .runner import RunToxEnv + + +class ToxEnvRegister: + def __init__(self): + self._run_envs: Dict[str, Type[RunToxEnv]] = {} + self._package_envs: Dict[str, Type[PackageToxEnv]] = {} + self._default_run_env: str = "" + + def populate(self, manager): + manager.tox_register_tox_env(register=self) + self._default_run_env: str = next(iter(self._run_envs.keys())) + + def add_run_env(self, of_type: Type[RunToxEnv]): + self._run_envs[of_type.id()] = of_type + + def add_package_env(self, of_type: Type[PackageToxEnv]): + self._package_envs[of_type.id()] = of_type + + @property + def run_envs(self) -> Iterable[str]: + return self._run_envs.keys() + + @property + def default_run_env(self) -> str: + return self._default_run_env + + @default_run_env.setter + def default_run_env(self, value: str) -> None: + assert value in self._run_envs, "default env must be in run envs" + self._default_run_env = value + + def runner(self, name: str) -> Type[RunToxEnv]: + return self._run_envs[name] + + def package(self, name: str) -> Type[PackageToxEnv]: + return self._package_envs[name] + + +REGISTER = ToxEnvRegister() diff --git a/src/tox/tox_env/runner.py b/src/tox/tox_env/runner.py new file mode 100644 index 000000000..fdeacd24a --- /dev/null +++ b/src/tox/tox_env/runner.py @@ -0,0 +1,80 @@ +from abc import ABC +from pathlib import Path +from typing import List, Optional + +from tox.config.sets import ConfigSet +from tox.config.source.api import Command, EnvList +from tox.execute.api import Execute + +from .api import ToxEnv +from .package import PackageToxEnv + + +class RunToxEnv(ToxEnv, ABC): + def __init__(self, conf: ConfigSet, core: ConfigSet, options, execute: Execute): + super().__init__(conf, core, options, execute) + self.package_env: Optional[PackageToxEnv] = None + + def register_config(self): + super().register_config() + self.conf.add_config( + keys=["description"], + of_type=str, + default=None, + desc="description attached to the tox environment", + ) + self.conf.add_config( + keys=["commands"], + of_type=List[Command], + default=[], + desc="the commands to be called for testing", + ) + self.conf.add_config( + keys=["commands_pre"], + of_type=List[Command], + default=[], + desc="the commands to be called before testing", + ) + self.conf.add_config( + keys=["commands_post"], + of_type=List[Command], + default=[], + desc="the commands to be called after testing", + ) + self.conf.add_config( + keys=["change_dir", "changedir"], + of_type=Path, + default=lambda conf, name: conf.core["tox_root"], + desc="Change to this working directory when executing the test command.", + ) + self.conf.add_config( + "depends", + of_type=EnvList, + desc="tox environments that this environment depends on (must be run after those)", + default=[], + ) + self.conf.add_config( + "parallel_show_output", + of_type=bool, + default=False, + desc="if set to True the content of the output will always be shown when running in parallel mode", + ) + + def set_package_env(self): + if self.core["no_package"]: + return + res = self.package_env_name_type() + if res is not None: + package_tox_env = yield res + self.package_env = package_tox_env + + def package_env_name_type(self): + raise NotImplementedError + + def has_package(self): + return self.package_env_name_type() is not None + + def clean(self, package_env=True): + super().clean() + if self.package_env: + self.package_env.clean() diff --git a/src/tox/util/cpu.py b/src/tox/util/cpu.py new file mode 100644 index 000000000..a29a220e4 --- /dev/null +++ b/src/tox/util/cpu.py @@ -0,0 +1,13 @@ +import os + + +def cpu_count(): + return len(os.sched_getaffinity(0)) + + +def auto_detect_cpus(): + try: + n = cpu_count() + except NotImplementedError: # pragma: no cov + n = None # pragma: no cov + return n if n else 1 diff --git a/src/tox/util/lock.py b/src/tox/util/lock.py index fd6473407..1234bde49 100644 --- a/src/tox/util/lock.py +++ b/src/tox/util/lock.py @@ -1,23 +1,22 @@ """holds locking functionality that works across processes""" from __future__ import absolute_import, unicode_literals +import logging from contextlib import contextmanager import py from filelock import FileLock, Timeout -from tox.reporter import verbosity1 - @contextmanager -def hold_lock(lock_file, reporter=verbosity1): +def hold_lock(lock_file): py.path.local(lock_file.dirname).ensure(dir=1) lock = FileLock(str(lock_file)) try: try: lock.acquire(0.0001) except Timeout: - reporter("lock file {} present, will block until released".format(lock_file)) + logging.warning("lock file {} present, will block until released".format(lock_file)) lock.acquire() yield finally: @@ -25,8 +24,7 @@ def hold_lock(lock_file, reporter=verbosity1): def get_unique_file(path, prefix, suffix): - """get a unique file in a folder having a given prefix and suffix, - with unique number in between""" + """get a unique file in a folder having a given prefix and suffix, with unique number in between""" lock_file = path.join(".lock") prefix = "{}-".format(prefix) with hold_lock(lock_file): diff --git a/src/tox/util/main.py b/src/tox/util/main.py deleted file mode 100644 index ebd0faa31..000000000 --- a/src/tox/util/main.py +++ /dev/null @@ -1,6 +0,0 @@ -import inspect -import os - -import tox - -MAIN_FILE = os.path.join(os.path.dirname(inspect.getfile(tox)), "__main__.py") diff --git a/src/tox/util/path.py b/src/tox/util/path.py index b7a299810..7e5147b78 100644 --- a/src/tox/util/path.py +++ b/src/tox/util/path.py @@ -1,10 +1,9 @@ +import logging import shutil -from tox import reporter - def ensure_empty_dir(path): if path.check(): - reporter.info(" removing {}".format(path)) + logging.warning(" removing {}".format(path)) shutil.rmtree(str(path), ignore_errors=True) path.ensure(dir=1) diff --git a/src/tox/util/spinner.py b/src/tox/util/spinner.py index 5c66bd14f..d5f3778c8 100644 --- a/src/tox/util/spinner.py +++ b/src/tox/util/spinner.py @@ -8,8 +8,6 @@ from collections import OrderedDict from datetime import datetime -import py - threads = [] if os.name == "nt": @@ -32,7 +30,7 @@ def _file_support_encoding(chars, file): return False -class Spinner(object): +class Spinner: CLEAR_LINE = "\033[K" max_width = 120 UNICODE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] @@ -47,7 +45,7 @@ def __init__(self, enabled=True, refresh_rate=0.1): if _file_support_encoding(self.UNICODE_FRAMES, sys.stdout) else self.ASCII_FRAMES ) - self.stream = py.io.TerminalWriter(file=self._file) + self.stream = sys.stderr self._envs = OrderedDict() self._frame_index = 0 diff --git a/src/tox/util/stdlib.py b/src/tox/util/stdlib.py deleted file mode 100644 index 5f687b737..000000000 --- a/src/tox/util/stdlib.py +++ /dev/null @@ -1,55 +0,0 @@ -import sys -import threading -from contextlib import contextmanager -from tempfile import TemporaryFile - -if sys.version_info >= (3, 8): - from importlib import metadata as importlib_metadata # noqa -else: - import importlib_metadata # noqa - - -def is_main_thread(): - """returns true if we are within the main thread""" - cur_thread = threading.current_thread() - if sys.version_info >= (3, 4): - return cur_thread is threading.main_thread() - else: - # noinspection PyUnresolvedReferences - return isinstance(cur_thread, threading._MainThread) - - -# noinspection PyPep8Naming -@contextmanager -def suppress_output(): - """suppress both stdout and stderr outputs""" - if sys.version_info >= (3, 5): - from contextlib import redirect_stdout, redirect_stderr - else: - - class _RedirectStream(object): - - _stream = None - - def __init__(self, new_target): - self._new_target = new_target - self._old_targets = [] - - def __enter__(self): - self._old_targets.append(getattr(sys, self._stream)) - setattr(sys, self._stream, self._new_target) - return self._new_target - - def __exit__(self, exctype, excinst, exctb): - setattr(sys, self._stream, self._old_targets.pop()) - - class redirect_stdout(_RedirectStream): - _stream = "stdout" - - class redirect_stderr(_RedirectStream): - _stream = "stderr" - - with TemporaryFile("wt") as file: - with redirect_stdout(file): - with redirect_stderr(file): - yield diff --git a/src/tox/venv.py b/src/tox/venv.py deleted file mode 100644 index ef1deb772..000000000 --- a/src/tox/venv.py +++ /dev/null @@ -1,786 +0,0 @@ -import codecs -import json -import os -import pipes -import re -import sys -from itertools import chain - -import py - -import tox -from tox import reporter -from tox.action import Action -from tox.config.parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY -from tox.constants import INFO, PARALLEL_RESULT_JSON_PREFIX, PARALLEL_RESULT_JSON_SUFFIX -from tox.package.local import resolve_package -from tox.util.lock import get_unique_file -from tox.util.path import ensure_empty_dir - -from .config import DepConfig - - -class CreationConfig: - def __init__( - self, - base_resolved_python_sha256, - base_resolved_python_path, - tox_version, - sitepackages, - usedevelop, - deps, - alwayscopy, - ): - self.base_resolved_python_sha256 = base_resolved_python_sha256 - self.base_resolved_python_path = base_resolved_python_path - self.tox_version = tox_version - self.sitepackages = sitepackages - self.usedevelop = usedevelop - self.alwayscopy = alwayscopy - self.deps = deps - - def writeconfig(self, path): - lines = [ - "{} {}".format(self.base_resolved_python_sha256, self.base_resolved_python_path), - "{} {:d} {:d} {:d}".format( - self.tox_version, self.sitepackages, self.usedevelop, self.alwayscopy - ), - ] - for dep in self.deps: - lines.append("{} {}".format(*dep)) - content = "\n".join(lines) - path.ensure() - path.write(content) - return content - - @classmethod - def readconfig(cls, path): - try: - lines = path.readlines(cr=0) - base_resolved_python_info = lines.pop(0).split(None, 1) - tox_version, sitepackages, usedevelop, alwayscopy = lines.pop(0).split(None, 4) - sitepackages = bool(int(sitepackages)) - usedevelop = bool(int(usedevelop)) - alwayscopy = bool(int(alwayscopy)) - deps = [] - for line in lines: - base_resolved_python_sha256, depstring = line.split(None, 1) - deps.append((base_resolved_python_sha256, depstring)) - base_resolved_python_sha256, base_resolved_python_path = base_resolved_python_info - return CreationConfig( - base_resolved_python_sha256, - base_resolved_python_path, - tox_version, - sitepackages, - usedevelop, - deps, - alwayscopy, - ) - except Exception: - return None - - def matches_with_reason(self, other, deps_matches_subset=False): - for attr in ( - "base_resolved_python_sha256", - "base_resolved_python_path", - "tox_version", - "sitepackages", - "usedevelop", - "alwayscopy", - ): - left = getattr(self, attr) - right = getattr(other, attr) - if left != right: - return False, "attr {} {!r}!={!r}".format(attr, left, right) - self_deps = set(self.deps) - other_deps = set(other.deps) - if self_deps != other_deps: - if deps_matches_subset: - diff = other_deps - self_deps - if diff: - return False, "missing in previous {!r}".format(diff) - else: - return False, "{!r}!={!r}".format(self_deps, other_deps) - return True, None - - def matches(self, other, deps_matches_subset=False): - outcome, _ = self.matches_with_reason(other, deps_matches_subset) - return outcome - - -class VirtualEnv(object): - def __init__(self, envconfig=None, popen=None, env_log=None): - self.envconfig = envconfig - self.popen = popen - self._actions = [] - self.env_log = env_log - self._result_json_path = None - - def new_action(self, msg, *args): - config = self.envconfig.config - command_log = self.env_log.get_commandlog( - "test" if msg in ("run-test", "run-test-pre", "run-test-post") else "setup" - ) - return Action( - self.name, - msg, - args, - self.envconfig.envlogdir, - config.option.resultjson, - command_log, - self.popen, - self.envconfig.envpython, - ) - - def get_result_json_path(self): - if self._result_json_path is None: - if self.envconfig.config.option.resultjson: - self._result_json_path = get_unique_file( - self.path, PARALLEL_RESULT_JSON_PREFIX, PARALLEL_RESULT_JSON_SUFFIX - ) - return self._result_json_path - - @property - def hook(self): - return self.envconfig.config.pluginmanager.hook - - @property - def path(self): - """ Path to environment base dir. """ - return self.envconfig.envdir - - @property - def path_config(self): - return self.path.join(".tox-config1") - - @property - def name(self): - """ test environment name. """ - return self.envconfig.envname - - def __repr__(self): - return "".format(self.path) - - def getcommandpath(self, name, venv=True, cwd=None): - """ Return absolute path (str or localpath) for specified command name. - - - If it's a local path we will rewrite it as as a relative path. - - If venv is True we will check if the command is coming from the venv - or is whitelisted to come from external. - """ - name = str(name) - if os.path.isabs(name): - return name - if os.path.split(name)[0] == ".": - path = cwd.join(name) - if path.check(): - return str(path) - - if venv: - path = self._venv_lookup_and_check_external_whitelist(name) - else: - path = self._normal_lookup(name) - - if path is None: - raise tox.exception.InvocationError( - "could not find executable {}".format(pipes.quote(name)) - ) - - return str(path) # will not be rewritten for reporting - - def _venv_lookup_and_check_external_whitelist(self, name): - path = self._venv_lookup(name) - if path is None: - path = self._normal_lookup(name) - if path is not None: - self._check_external_allowed_and_warn(path) - return path - - def _venv_lookup(self, name): - return py.path.local.sysfind(name, paths=[self.envconfig.envbindir]) - - def _normal_lookup(self, name): - return py.path.local.sysfind(name) - - def _check_external_allowed_and_warn(self, path): - if not self.is_allowed_external(path): - reporter.warning( - "test command found but not installed in testenv\n" - " cmd: {}\n" - " env: {}\n" - "Maybe you forgot to specify a dependency? " - "See also the whitelist_externals envconfig setting.\n\n" - "DEPRECATION WARNING: this will be an error in tox 4 and above!".format( - path, self.envconfig.envdir - ) - ) - - def is_allowed_external(self, p): - tryadd = [""] - if tox.INFO.IS_WIN: - tryadd += [os.path.normcase(x) for x in os.environ["PATHEXT"].split(os.pathsep)] - p = py.path.local(os.path.normcase(str(p))) - for x in self.envconfig.whitelist_externals: - for add in tryadd: - if p.fnmatch(x + add): - return True - return False - - def update(self, action): - """ return status string for updating actual venv to match configuration. - if status string is empty, all is ok. - """ - rconfig = CreationConfig.readconfig(self.path_config) - if self.envconfig.recreate: - reason = "-r flag" - else: - if rconfig is None: - reason = "no previous config {}".format(self.path_config) - else: - live_config = self._getliveconfig() - deps_subset_match = getattr(self.envconfig, "deps_matches_subset", False) - outcome, reason = rconfig.matches_with_reason(live_config, deps_subset_match) - if reason is None: - action.info("reusing", self.envconfig.envdir) - return - action.info("cannot reuse", reason) - if rconfig is None: - action.setactivity("create", self.envconfig.envdir) - else: - action.setactivity("recreate", self.envconfig.envdir) - try: - self.hook.tox_testenv_create(action=action, venv=self) - self.just_created = True - except tox.exception.UnsupportedInterpreter as exception: - return exception - try: - self.hook.tox_testenv_install_deps(action=action, venv=self) - except tox.exception.InvocationError as exception: - return "could not install deps {}; v = {!r}".format(self.envconfig.deps, exception) - - def _getliveconfig(self): - base_resolved_python_path = self.envconfig.python_info.executable - version = tox.__version__ - sitepackages = self.envconfig.sitepackages - develop = self.envconfig.usedevelop - alwayscopy = self.envconfig.alwayscopy - deps = [] - for dep in self.get_resolved_dependencies(): - dep_name_sha256 = getdigest(dep.name) - deps.append((dep_name_sha256, dep.name)) - base_resolved_python_sha256 = getdigest(base_resolved_python_path) - return CreationConfig( - base_resolved_python_sha256, - base_resolved_python_path, - version, - sitepackages, - develop, - deps, - alwayscopy, - ) - - def get_resolved_dependencies(self): - dependencies = [] - for dependency in self.envconfig.deps: - if dependency.indexserver is None: - package = resolve_package(package_spec=dependency.name) - if package != dependency.name: - dependency = dependency.__class__(package) - dependencies.append(dependency) - return dependencies - - def getsupportedinterpreter(self): - return self.envconfig.getsupportedinterpreter() - - def matching_platform(self): - return re.match(self.envconfig.platform, sys.platform) - - def finish(self): - previous_config = CreationConfig.readconfig(self.path_config) - live_config = self._getliveconfig() - if previous_config is None or not previous_config.matches(live_config): - content = live_config.writeconfig(self.path_config) - reporter.verbosity1("write config to {} as {!r}".format(self.path_config, content)) - - def _needs_reinstall(self, setupdir, action): - setup_py = setupdir.join("setup.py") - setup_cfg = setupdir.join("setup.cfg") - args = [self.envconfig.envpython, str(setup_py), "--name"] - env = self._get_os_environ() - output = action.popen( - args, cwd=setupdir, redirect=False, returnout=True, env=env, capture_err=False - ) - name = next( - (i for i in output.split("\n") if i and not i.startswith("pydev debugger:")), "" - ) - args = [ - self.envconfig.envpython, - "-c", - "import sys; import json; print(json.dumps(sys.path))", - ] - out = action.popen(args, redirect=False, returnout=True, env=env) - try: - sys_path = json.loads(out) - except ValueError: - sys_path = [] - egg_info_fname = ".".join((name.replace("-", "_"), "egg-info")) - for d in reversed(sys_path): - egg_info = py.path.local(d).join(egg_info_fname) - if egg_info.check(): - break - else: - return True - needs_reinstall = any( - conf_file.check() and conf_file.mtime() > egg_info.mtime() - for conf_file in (setup_py, setup_cfg) - ) - - # Ensure the modification time of the egg-info folder is updated so we - # won't need to do this again. - # TODO(stephenfin): Remove once the minimum version of setuptools is - # high enough to include https://github.com/pypa/setuptools/pull/1427/ - if needs_reinstall: - egg_info.setmtime() - - return needs_reinstall - - def install_pkg(self, dir, action, name, is_develop=False): - assert action is not None - - if getattr(self, "just_created", False): - action.setactivity(name, dir) - self.finish() - pip_flags = ["--exists-action", "w"] - else: - if is_develop and not self._needs_reinstall(dir, action): - action.setactivity("{}-noop".format(name), dir) - return - action.setactivity("{}-nodeps".format(name), dir) - pip_flags = ["--no-deps"] + ([] if is_develop else ["-U"]) - pip_flags.extend(["-v"] * min(3, reporter.verbosity() - 2)) - if self.envconfig.extras: - dir += "[{}]".format(",".join(self.envconfig.extras)) - target = [dir] - if is_develop: - target.insert(0, "-e") - self._install(target, extraopts=pip_flags, action=action) - - def developpkg(self, setupdir, action): - self.install_pkg(setupdir, action, "develop-inst", is_develop=True) - - def installpkg(self, sdistpath, action): - self.install_pkg(sdistpath, action, "inst") - - def _installopts(self, indexserver): - options = [] - if indexserver: - options += ["-i", indexserver] - if self.envconfig.pip_pre: - options.append("--pre") - return options - - def run_install_command(self, packages, action, options=()): - def expand(val): - # expand an install command - if val == "{packages}": - for package in packages: - yield package - elif val == "{opts}": - for opt in options: - yield opt - else: - yield val - - cmd = list(chain.from_iterable(expand(val) for val in self.envconfig.install_command)) - - env = self._get_os_environ() - self.ensure_pip_os_environ_ok(env) - - old_stdout = sys.stdout - sys.stdout = codecs.getwriter("utf8")(sys.stdout) - try: - self._pcall( - cmd, - cwd=self.envconfig.config.toxinidir, - action=action, - redirect=reporter.verbosity() < reporter.Verbosity.DEBUG, - env=env, - ) - finally: - sys.stdout = old_stdout - - def ensure_pip_os_environ_ok(self, env): - for key in ("PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"): - env.pop(key, None) - if all("PYTHONPATH" not in i for i in (self.envconfig.passenv, self.envconfig.setenv)): - # If PYTHONPATH not explicitly asked for, remove it. - if "PYTHONPATH" in env: - if sys.version_info < (3, 4) or bool(env["PYTHONPATH"]): - # https://docs.python.org/3/whatsnew/3.4.html#changes-in-python-command-behavior - # In a posix shell, setting the PATH environment variable to an empty value is - # equivalent to not setting it at all. - reporter.warning( - "Discarding $PYTHONPATH from environment, to override " - "specify PYTHONPATH in 'passenv' in your configuration." - ) - env.pop("PYTHONPATH") - - # installing packages at user level may mean we're not installing inside the venv - env["PIP_USER"] = "0" - - # installing without dependencies may lead to broken packages - env["PIP_NO_DEPS"] = "0" - - def _install(self, deps, extraopts=None, action=None): - if not deps: - return - d = {} - ixservers = [] - for dep in deps: - if isinstance(dep, (str, py.path.local)): - dep = DepConfig(str(dep), None) - assert isinstance(dep, DepConfig), dep - if dep.indexserver is None: - ixserver = self.envconfig.config.indexserver["default"] - else: - ixserver = dep.indexserver - d.setdefault(ixserver, []).append(dep.name) - if ixserver not in ixservers: - ixservers.append(ixserver) - assert ixserver.url is None or isinstance(ixserver.url, str) - - for ixserver in ixservers: - packages = d[ixserver] - options = self._installopts(ixserver.url) - if extraopts: - options.extend(extraopts) - self.run_install_command(packages=packages, options=options, action=action) - - def _get_os_environ(self, is_test_command=False): - if is_test_command: - # for executing tests we construct a clean environment - env = {} - for env_key in self.envconfig.passenv: - if env_key in os.environ: - env[env_key] = os.environ[env_key] - else: - # for executing non-test commands we use the full - # invocation environment - env = os.environ.copy() - - # in any case we honor per-testenv setenv configuration - env.update(self.envconfig.setenv) - - env["VIRTUAL_ENV"] = str(self.path) - return env - - def test( - self, - redirect=False, - name="run-test", - commands=None, - ignore_outcome=None, - ignore_errors=None, - display_hash_seed=False, - ): - if commands is None: - commands = self.envconfig.commands - if ignore_outcome is None: - ignore_outcome = self.envconfig.ignore_outcome - if ignore_errors is None: - ignore_errors = self.envconfig.ignore_errors - with self.new_action(name) as action: - cwd = self.envconfig.changedir - if display_hash_seed: - env = self._get_os_environ(is_test_command=True) - # Display PYTHONHASHSEED to assist with reproducibility. - action.setactivity(name, "PYTHONHASHSEED={!r}".format(env.get("PYTHONHASHSEED"))) - for i, argv in enumerate(commands): - # have to make strings as _pcall changes argv[0] to a local() - # happens if the same environment is invoked twice - message = "commands[{}] | {}".format( - i, " ".join([pipes.quote(str(x)) for x in argv]) - ) - action.setactivity(name, message) - # check to see if we need to ignore the return code - # if so, we need to alter the command line arguments - if argv[0].startswith("-"): - ignore_ret = True - if argv[0] == "-": - del argv[0] - else: - argv[0] = argv[0].lstrip("-") - else: - ignore_ret = False - - try: - self._pcall( - argv, - cwd=cwd, - action=action, - redirect=redirect, - ignore_ret=ignore_ret, - is_test_command=True, - ) - except tox.exception.InvocationError as err: - if ignore_outcome: - msg = "command failed but result from testenv is ignored\ncmd:" - reporter.warning("{} {}".format(msg, err)) - self.status = "ignored failed command" - continue # keep processing commands - - reporter.error(str(err)) - self.status = "commands failed" - if not ignore_errors: - break # Don't process remaining commands - except KeyboardInterrupt: - self.status = "keyboardinterrupt" - raise - - def _pcall( - self, - args, - cwd, - venv=True, - is_test_command=False, - action=None, - redirect=True, - ignore_ret=False, - returnout=False, - env=None, - ): - if env is None: - env = self._get_os_environ(is_test_command=is_test_command) - - # construct environment variables - env.pop("VIRTUALENV_PYTHON", None) - bin_dir = str(self.envconfig.envbindir) - env["PATH"] = os.pathsep.join([bin_dir, os.environ["PATH"]]) - reporter.verbosity2("setting PATH={}".format(env["PATH"])) - - # get command - args[0] = self.getcommandpath(args[0], venv, cwd) - if sys.platform != "win32" and "TOX_LIMITED_SHEBANG" in os.environ: - args = prepend_shebang_interpreter(args) - - cwd.ensure(dir=1) # ensure the cwd exists - return action.popen( - args, - cwd=cwd, - env=env, - redirect=redirect, - ignore_ret=ignore_ret, - returnout=returnout, - report_fail=not is_test_command, - ) - - def setupenv(self): - if self.envconfig._missing_subs: - self.status = ( - "unresolvable substitution(s): {}. " - "Environment variables are missing or defined recursively.".format( - ",".join(["'{}'".format(m) for m in self.envconfig._missing_subs]) - ) - ) - return - if not self.matching_platform(): - self.status = "platform mismatch" - return # we simply omit non-matching platforms - with self.new_action("getenv", self.envconfig.envdir) as action: - self.status = 0 - default_ret_code = 1 - envlog = self.env_log - try: - status = self.update(action=action) - except IOError as e: - if e.args[0] != 2: - raise - status = ( - "Error creating virtualenv. Note that spaces in paths are " - "not supported by virtualenv. Error details: {!r}".format(e) - ) - except tox.exception.InvocationError as e: - status = e - except tox.exception.InterpreterNotFound as e: - status = e - if self.envconfig.config.option.skip_missing_interpreters == "true": - default_ret_code = 0 - if status: - str_status = str(status) - command_log = envlog.get_commandlog("setup") - command_log.add_command(["setup virtualenv"], str_status, default_ret_code) - self.status = status - if default_ret_code == 0: - reporter.skip(str_status) - else: - reporter.error(str_status) - return False - command_path = self.getcommandpath("python") - envlog.set_python_info(command_path) - return True - - def finishvenv(self): - with self.new_action("finishvenv"): - self.finish() - return True - - -def getdigest(path): - path = py.path.local(path) - if not path.check(file=1): - return "0" * 32 - return path.computehash("sha256") - - -def prepend_shebang_interpreter(args): - # prepend interpreter directive (if any) to argument list - # - # When preparing virtual environments in a file container which has large - # length, the system might not be able to invoke shebang scripts which - # define interpreters beyond system limits (e.x. Linux as a limit of 128; - # BINPRM_BUF_SIZE). This method can be used to check if the executable is - # a script containing a shebang line. If so, extract the interpreter (and - # possible optional argument) and prepend the values to the provided - # argument list. tox will only attempt to read an interpreter directive of - # a maximum size of 2048 bytes to limit excessive reading and support UNIX - # systems which may support a longer interpret length. - try: - with open(args[0], "rb") as f: - if f.read(1) == b"#" and f.read(1) == b"!": - MAXINTERP = 2048 - interp = f.readline(MAXINTERP).rstrip().decode("UTF-8") - interp_args = interp.split(None, 1)[:2] - return interp_args + args - except (UnicodeDecodeError, IOError): - pass - return args - - -_SKIP_VENV_CREATION = os.environ.get("_TOX_SKIP_ENV_CREATION_TEST", False) == "1" - - -@tox.hookimpl -def tox_testenv_create(venv, action): - config_interpreter = venv.getsupportedinterpreter() - args = [sys.executable, "-m", "virtualenv"] - if venv.envconfig.sitepackages: - args.append("--system-site-packages") - if venv.envconfig.alwayscopy: - args.append("--always-copy") - if not venv.envconfig.download: - args.append("--no-download") - # add interpreter explicitly, to prevent using default (virtualenv.ini) - args.extend(["--python", str(config_interpreter)]) - - cleanup_for_venv(venv) - - base_path = venv.path.dirpath() - base_path.ensure(dir=1) - args.append(venv.path.basename) - if not _SKIP_VENV_CREATION: - try: - venv._pcall( - args, - venv=False, - action=action, - cwd=base_path, - redirect=reporter.verbosity() < reporter.Verbosity.DEBUG, - ) - except KeyboardInterrupt: - venv.status = "keyboardinterrupt" - raise - return True # Return non-None to indicate plugin has completed - - -def cleanup_for_venv(venv): - within_parallel = PARALLEL_ENV_VAR_KEY in os.environ - # if the directory exists and it doesn't look like a virtualenv, produce - # an error - if venv.path.exists(): - dir_items = set(os.listdir(str(venv.path))) - {".lock", "log"} - dir_items = {p for p in dir_items if not p.startswith(".tox-") or p == ".tox-config1"} - else: - dir_items = set() - - if not ( - # doesn't exist => OK - not venv.path.exists() - # does exist, but it's empty => OK - or not dir_items - # tox has marked this as an environment it has created in the past - or ".tox-config1" in dir_items - # it exists and we're on windows with Lib and Scripts => OK - or (INFO.IS_WIN and dir_items > {"Scripts", "Lib"}) - # non-windows, with lib and bin => OK - or dir_items > {"bin", "lib"} - # pypy has a different lib folder => OK - or dir_items > {"bin", "lib_pypy"} - ): - venv.status = "error" - reporter.error( - "cowardly refusing to delete `envdir` (it does not look like a virtualenv): " - "{}".format(venv.path) - ) - raise SystemExit(2) - - if within_parallel: - if venv.path.exists(): - # do not delete the log folder as that's used by parent - for content in venv.path.listdir(): - if not content.basename == "log": - content.remove(rec=1, ignore_errors=True) - else: - ensure_empty_dir(venv.path) - - -@tox.hookimpl -def tox_testenv_install_deps(venv, action): - deps = venv.get_resolved_dependencies() - if deps: - depinfo = ", ".join(map(str, deps)) - action.setactivity("installdeps", depinfo) - venv._install(deps, action=action) - return True # Return non-None to indicate plugin has completed - - -@tox.hookimpl -def tox_runtest(venv, redirect): - venv.test(redirect=redirect) - return True # Return non-None to indicate plugin has completed - - -@tox.hookimpl -def tox_runtest_pre(venv): - venv.status = 0 - ensure_empty_dir(venv.envconfig.envtmpdir) - venv.envconfig.envtmpdir.ensure(dir=1) - venv.test( - name="run-test-pre", - commands=venv.envconfig.commands_pre, - redirect=False, - ignore_outcome=False, - ignore_errors=False, - display_hash_seed=True, - ) - - -@tox.hookimpl -def tox_runtest_post(venv): - venv.test( - name="run-test-post", - commands=venv.envconfig.commands_post, - redirect=False, - ignore_outcome=False, - ignore_errors=False, - ) - - -@tox.hookimpl -def tox_runenvreport(venv, action): - # write out version dependency information - args = venv.envconfig.list_dependencies_command - output = venv._pcall(args, cwd=venv.envconfig.config.toxinidir, action=action, returnout=True) - # the output contains a mime-header, skip it - output = output.split("\n\n")[-1] - packages = output.strip().split("\n") - return packages # Return non-None to indicate plugin has completed diff --git a/tests/conftest.py b/tests/conftest.py index ec59f4a1c..c19050447 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1 @@ -# FIXME this seems unnecessary -# TODO move fixtures here and only keep helper functions/classes in the plugin -# TODO _pytest_helpers might be a better name than _pytestplugin then? -# noinspection PyUnresolvedReferences -from tox._pytestplugin import * # noqa +pytest_plugins = "tox.pytest" diff --git a/tests/integration/test_jython_env_create.py b/tests/integration/test_jython_env_create.py deleted file mode 100644 index bccdbd423..000000000 --- a/tests/integration/test_jython_env_create.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest - - -# TODO -@pytest.mark.skip(reason="needs jython and dev cut of virtualenv") -def test_jython_create(initproj, cmd): - initproj( - "py_jython-0.1", - filedefs={ - "tox.ini": """ - [tox] - skipsdist = true - envlist = jython - commands = python -c 'import sys; print(sys.executable)' - """ - }, - ) - result = cmd("--notest", "-vvv") - result.assert_success() diff --git a/tests/integration/test_package_int.py b/tests/integration/test_package_int.py deleted file mode 100644 index a982c2a07..000000000 --- a/tests/integration/test_package_int.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Tests that require external access (e.g. pip install, virtualenv creation)""" -import os -import subprocess -import sys - -import pytest -from pathlib2 import Path - -from tests.lib import need_git - - -@pytest.mark.network -def test_package_setuptools(initproj, cmd): - initproj( - "magic-0.1", - filedefs={ - "tox.ini": """\ - [tox] - isolated_build = true - [testenv:.package] - basepython = {} - """.format( - sys.executable - ), - "pyproject.toml": """\ - [build-system] - requires = ["setuptools >= 35.0.2", "setuptools_scm >= 2.0.0, <3"] - build-backend = "setuptools.build_meta" - """, - }, - ) - run(cmd, "magic-0.1.tar.gz") - - -@pytest.mark.network -@need_git -@pytest.mark.skipif(sys.version_info < (3, 0), reason="flit is Python 3 only") -def test_package_flit(initproj, cmd): - initproj( - "magic-0.1", - filedefs={ - "tox.ini": """\ - [tox] - isolated_build = true - [testenv:.package] - basepython = {} - """.format( - sys.executable - ), - "pyproject.toml": """\ - [build-system] - requires = ["flit"] - build-backend = "flit.buildapi" - - [tool.flit.metadata] - module = "magic" - author = "Happy Harry" - author-email = "happy@harry.com" - home-page = "https://github.com/happy-harry/is" - requires = [ - "tox", - ] - """, - ".gitignore": ".tox", - }, - add_missing_setup_py=False, - ) - env = os.environ.copy() - env["GIT_COMMITTER_NAME"] = "committer joe" - env["GIT_AUTHOR_NAME"] = "author joe" - env["EMAIL"] = "joe@example.com" - subprocess.check_call(["git", "init"], env=env) - subprocess.check_call(["git", "add", "-A", "."], env=env) - subprocess.check_call(["git", "commit", "-m", "first commit", "--no-gpg-sign"], env=env) - - run(cmd, "magic-0.1.tar.gz") - - -@pytest.mark.network -@pytest.mark.skipif(sys.version_info < (3, 0), reason="poetry is Python 3 only") -def test_package_poetry(initproj, cmd): - initproj( - "magic-0.1", - filedefs={ - "tox.ini": """\ - [tox] - isolated_build = true - [testenv:.package] - basepython = {} - """.format( - sys.executable - ), - "pyproject.toml": """\ - [build-system] - requires = ["poetry>=0.12"] - build-backend = "poetry.masonry.api" - - [tool.poetry] - name = "magic" - version = "0.1.0" - description = "" - authors = ["Name "] - """, - ".gitignore": ".tox", - }, - add_missing_setup_py=False, - ) - run(cmd, "magic-0.1.0.tar.gz") - - -def run(cmd, package): - result = cmd("--sdistonly", "-e", "py", "-v", "-v") - result.assert_success(is_run_test_env=False) - package_venv = (Path() / ".tox" / ".package").resolve() - assert ".package create: {}".format(package_venv) in result.outlines, result.out - assert "write config to {}".format(package_venv / ".tox-config1") in result.out, result.out - package_path = (Path() / ".tox" / "dist" / package).resolve() - assert package_path.exists() - - package_path.unlink() - - # second call re-uses - result2 = cmd("--sdistonly", "-e", "py", "-v", "-v") - - result2.assert_success(is_run_test_env=False) - assert ( - ".package reusing: {}".format(package_venv) in result2.outlines - ), "Second call output:\n{}First call output:\n{}".format(result2.out, result.out) - assert package_path.exists() diff --git a/tests/integration/test_parallel_interrupt.py b/tests/integration/test_parallel_interrupt.py deleted file mode 100644 index 028906e09..000000000 --- a/tests/integration/test_parallel_interrupt.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import signal -import subprocess -import sys -from datetime import datetime - -import pytest -from flaky import flaky -from pathlib2 import Path - -from tox.util.main import MAIN_FILE - - -@flaky(max_runs=3) -@pytest.mark.skipif( - "sys.platform == 'win32'", reason="triggering SIGINT reliably on Windows is hard" -) -def test_parallel_interrupt(initproj, monkeypatch, capfd): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - monkeypatch.setenv(str("TOX_REPORTER_TIMESTAMP"), str("1")) - start = datetime.now() - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = a, b - - [testenv] - skip_install = True - commands = python -c "open('{{envname}}', 'w').write('done'); \ - import time; time.sleep(100)" - whitelist_externals = {} - - """.format( - sys.executable - ) - }, - ) - process = subprocess.Popen( - [sys.executable, MAIN_FILE, "-p", "all"], - creationflags=( - subprocess.CREATE_NEW_PROCESS_GROUP - if sys.platform == "win32" - else 0 - # needed for Windows signal send ability (CTRL+C) - ), - ) - try: - import psutil - - current_process = psutil.Process(process.pid) - except ImportError: - current_process = None - - wait_for_env_startup(process) - - all_children = [] - if current_process is not None: - all_children.append(current_process) - all_children.extend(current_process.children(recursive=True)) - assert len(all_children) >= 1 + 2 + 2, all_children - end = datetime.now() - start - assert end - process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT) - process.wait() - out, err = capfd.readouterr() - output = "{}\n{}".format(out, err) - assert "KeyboardInterrupt parallel - stopping children" in output, output - assert "ERROR: a: parallel child exit code " in output, output - assert "ERROR: b: parallel child exit code " in output, output - for process in all_children: - msg = "{}{}".format(output, "\n".join(repr(i) for i in all_children)) - assert not process.is_running(), msg - - -def wait_for_env_startup(process): - """the environments will write files once they are up""" - signal_files = [Path() / "a", Path() / "b"] - found = False - while True: - if process.poll() is not None: - break - for signal_file in signal_files: - if not signal_file.exists(): - break - else: - found = True - break - if not found or process.poll() is not None: - missing = [f for f in signal_files if not f.exists()] - out, _ = process.communicate() - assert len(missing), out - assert False, out diff --git a/tests/integration/test_provision_int.py b/tests/integration/test_provision_int.py deleted file mode 100644 index be890097c..000000000 --- a/tests/integration/test_provision_int.py +++ /dev/null @@ -1,105 +0,0 @@ -import signal -import subprocess -import sys -import time - -import pytest -from pathlib2 import Path - -from tox.util.main import MAIN_FILE - - -@pytest.mark.skipif( - "sys.platform == 'win32' and sys.version_info < (3,)", - reason="does not run on windows with py2", -) -def test_provision_missing(initproj, cmd): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """\ - [tox] - skipsdist=True - minversion = 3.7.0 - requires = - setuptools == 40.6.3 - [testenv] - commands=python -c "import sys; print(sys.executable); raise SystemExit(1)" - """ - }, - ) - result = cmd("-e", "py") - result.assert_fail() - assert "tox.exception.InvocationError" not in result.output() - assert not result.err - assert ".tox create: " in result.out - assert ".tox installdeps: " in result.out - assert "py create: " in result.out - - at = next(at for at, l in enumerate(result.outlines) if l.startswith("py run-test: ")) + 1 - meta_python = Path(result.outlines[at]) - assert meta_python.exists() - - -@pytest.mark.skipif( - "sys.platform == 'win32'", reason="triggering SIGINT reliably on Windows is hard" -) -def test_provision_interrupt_child(initproj, monkeypatch, capfd): - monkeypatch.delenv(str("PYTHONPATH"), raising=False) - monkeypatch.setenv(str("TOX_REPORTER_TIMESTAMP"), str("1")) - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - skipsdist=True - minversion = 3.7.0 - requires = setuptools == 40.6.3 - tox == 3.7.0 - [testenv:b] - commands=python -c "import time; open('a', 'w').write('content'); \ - time.sleep(10)" - basepython = python - """ - }, - ) - cmd = [sys.executable, MAIN_FILE, "-v", "-v", "-e", "b"] - process = subprocess.Popen( - cmd, - creationflags=( - subprocess.CREATE_NEW_PROCESS_GROUP - if sys.platform == "win32" - else 0 - # needed for Windows signal send ability (CTRL+C) - ), - ) - try: - import psutil - - current_process = psutil.Process(process.pid) - except ImportError: - current_process = None - - signal_file = Path() / "a" - while not signal_file.exists() and process.poll() is None: - time.sleep(0.1) - if process.poll() is not None: - out, err = process.communicate() - assert False, out - - all_process = [] - if current_process is not None: - all_process.append(current_process) - all_process.extend(current_process.children(recursive=False)) - # 1 process for the host tox, 1 for the provisioned - assert len(all_process) >= 2, all_process - - process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT) - process.communicate() - out, err = capfd.readouterr() - assert ".tox KeyboardInterrupt: from" in out, out - - for process in all_process: - assert not process.is_running(), "{}{}".format( - out, "\n".join(repr(i) for i in all_process) - ) diff --git a/tests/integration/tox_env/virtual_env/test_int_setuptools.py b/tests/integration/tox_env/virtual_env/test_int_setuptools.py new file mode 100644 index 000000000..ef6deb4f7 --- /dev/null +++ b/tests/integration/tox_env/virtual_env/test_int_setuptools.py @@ -0,0 +1,46 @@ +from tox.pytest import ToxProjectCreator + + +def test_setuptools_package_py_project(tox_project: ToxProjectCreator): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = py + + [testenv] + commands_pre = + python -c 'import sys; print("start", sys.executable)' + commands = + python -c 'import magic; print(magic.__version__)' + commands_post = + python -c 'import sys; print("end", sys.executable)' + package = wheel + """, + "setup.cfg": """ + [metadata] + name = magic + version = 1.2.3 + [options] + packages = find: + package_dir = + =src + [options.packages.find] + where = src + [bdist_wheel] + universal = 1 + """, + "pyproject.toml": """ + [build-system] + requires = [ + "setuptools >= 40.0.4", + "wheel >= 0.29.0", + ] + build-backend = 'setuptools.build_meta' + """, + "src": {"magic": {"__init__.py": """__version__ = "1.2.3" """}}, + } + ) + outcome = project.run("-vv", "r", "-e", "py") + outcome.assert_success() + assert "\n1.2.3\n" in outcome.out diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py deleted file mode 100644 index 0f711b20e..000000000 --- a/tests/lib/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -import subprocess - -import pytest - - -def need_executable(name, check_cmd): - def wrapper(fn): - try: - subprocess.check_output(check_cmd) - except OSError: - return pytest.mark.skip(reason="{} is not available".format(name))(fn) - return fn - - return wrapper - - -def need_git(fn): - return pytest.mark.git(need_executable("git", ("git", "--version"))(fn)) diff --git a/tests/unit/config/cli/conftest.py b/tests/unit/config/cli/conftest.py new file mode 100644 index 000000000..58f7f5d66 --- /dev/null +++ b/tests/unit/config/cli/conftest.py @@ -0,0 +1,20 @@ +import pytest + +from tox.session.cmd.list_env import list_env +from tox.session.cmd.run.parallel import run_parallel +from tox.session.cmd.run.sequential import run_sequential +from tox.session.cmd.show_config import display_config + + +@pytest.fixture() +def core_handlers(): + return { + "config": display_config, + "c": display_config, + "list": list_env, + "l": list_env, + "run": run_sequential, + "r": run_sequential, + "run-parallel": run_parallel, + "p": run_parallel, + } diff --git a/tests/unit/config/cli/test_cli_env_var.py b/tests/unit/config/cli/test_cli_env_var.py new file mode 100644 index 000000000..fe8cc3a58 --- /dev/null +++ b/tests/unit/config/cli/test_cli_env_var.py @@ -0,0 +1,58 @@ +import pytest + +from tox.config.cli.parse import get_options + + +def test_env_var_exhaustive_parallel_values(monkeypatch, core_handlers): + monkeypatch.setenv("TOX_COMMAND", "run-parallel") + monkeypatch.setenv("TOX_VERBOSE", "5") + monkeypatch.setenv("TOX_QUIET", "1") + monkeypatch.setenv("TOX_ENV_LIST", "py37,py36") + monkeypatch.setenv("TOX_DEFAULT_RUNNER", "magic") + monkeypatch.setenv("TOX_RECREATE", "yes") + monkeypatch.setenv("TOX_NO_TEST", "yes") + monkeypatch.setenv("TOX_PARALLEL", "3") + monkeypatch.setenv("TOX_PARALLEL_LIVE", "no") + + parsed, unknown, handlers = get_options() + assert vars(parsed) == { + "verbose": 5, + "quiet": 1, + "command": "run-parallel", + "env_list": ["py37", "py36"], + "default_runner": "magic", + "recreate": True, + "no_test": True, + "parallel": 3, + "parallel_live": False, + } + assert parsed.verbosity == 4 + assert unknown == [] + assert handlers == core_handlers + + +def test_ini_help(monkeypatch, capsys): + monkeypatch.setenv("TOX_VERBOSE", "5") + monkeypatch.setenv("TOX_QUIET", "1") + with pytest.raises(SystemExit) as context: + get_options("-h") + assert context.value.code == 0 + out, err = capsys.readouterr() + assert not err + assert "from env var TOX_VERBOSE" in out + assert "from env var TOX_QUIET" in out + + +def test_bad_env_var(monkeypatch, capsys, caplog): + monkeypatch.setenv("TOX_VERBOSE", "should-be-number") + monkeypatch.setenv("TOX_QUIET", "1.00") + parsed, _, __ = get_options() + assert parsed.verbose == 2 + assert parsed.quiet == 0 + assert parsed.verbosity == 2 + assert caplog.messages == [ + "env var TOX_VERBOSE='should-be-number' cannot be transformed to " + "because ValueError(\"invalid literal for int() with base 10: 'should-be-number'\")", + "env var TOX_QUIET='1.00' cannot be transformed to " + "because ValueError(\"invalid literal for int() with base 10: '1.00'\")", + ] diff --git a/tests/unit/config/cli/test_cli_ini.py b/tests/unit/config/cli/test_cli_ini.py new file mode 100644 index 000000000..6a40827a8 --- /dev/null +++ b/tests/unit/config/cli/test_cli_ini.py @@ -0,0 +1,137 @@ +import logging +import textwrap +from pathlib import Path + +import pytest +from _pytest.monkeypatch import MonkeyPatch + +from tox.config.cli.parse import get_options + + +@pytest.fixture() +def exhaustive_ini(tmp_path: Path, monkeypatch: MonkeyPatch): + to = tmp_path / "tox.ini" + to.write_text( + textwrap.dedent( + """ + [tox] + verbose = 5 + quiet = 1 + command = run-parallel + env_list = py37, py36 + default_runner = magic + recreate = true + no_test = true + parallel = 3 + parallel_live = True + """ + ) + ) + monkeypatch.setenv("TOX_CONFIG_FILE", str(to)) + return to + + +@pytest.fixture() +def empty_ini(tmp_path: Path, monkeypatch: MonkeyPatch): + to = tmp_path / "tox.ini" + to.write_text( + textwrap.dedent( + """ + [tox] + """ + ) + ) + monkeypatch.setenv("TOX_CONFIG_FILE", str(to)) + return to + + +def test_ini_empty(empty_ini, core_handlers): + parsed, unknown, handlers = get_options() + assert vars(parsed) == { + "verbose": 2, + "quiet": 0, + "command": "run", + "env_list": None, + "default_runner": "virtualenv", + "recreate": False, + "no_test": False, + } + assert parsed.verbosity == 2 + assert unknown == [] + assert handlers == core_handlers + + +def test_ini_exhaustive_parallel_values(exhaustive_ini, core_handlers): + parsed, unknown, handlers = get_options() + assert vars(parsed) == { + "verbose": 5, + "quiet": 1, + "command": "run-parallel", + "env_list": ["py37", "py36"], + "default_runner": "magic", + "recreate": True, + "no_test": True, + "parallel": 3, + "parallel_live": True, + } + assert parsed.verbosity == 4 + assert unknown == [] + assert handlers == core_handlers + + +def test_ini_help(exhaustive_ini, capsys): + with pytest.raises(SystemExit) as context: + get_options("-h") + assert context.value.code == 0 + out, err = capsys.readouterr() + assert not err + assert "config file '{}' active (changed via env var TOX_CONFIG_FILE)".format(exhaustive_ini) + + +def test_bad_cli_ini(tmp_path: Path, monkeypatch: MonkeyPatch, caplog): + caplog.set_level(logging.WARNING) + monkeypatch.setenv("TOX_CONFIG_FILE", str(tmp_path)) + parsed, _, __ = get_options() + assert caplog.messages == [ + "failed to read config file {} because IsADirectoryError(21, 'Is a directory')".format( + tmp_path + ) + ] + assert vars(parsed) == { + "verbose": 2, + "quiet": 0, + "command": "run", + "env_list": None, + "default_runner": "virtualenv", + "recreate": False, + "no_test": False, + } + + +def test_bad_option_cli_ini(tmp_path: Path, monkeypatch: MonkeyPatch, caplog): + caplog.set_level(logging.WARNING) + to = tmp_path / "tox.ini" + to.write_text( + textwrap.dedent( + """ + [tox] + verbose = what + + """ + ) + ) + monkeypatch.setenv("TOX_CONFIG_FILE", str(to)) + parsed, _, __ = get_options() + assert caplog.messages == [ + "{} key verbose as type failed with " + "ValueError(\"invalid literal for int() with base 10: 'what'\")".format(to) + ] + assert vars(parsed) == { + "verbose": 2, + "quiet": 0, + "command": "run", + "env_list": None, + "default_runner": "virtualenv", + "recreate": False, + "no_test": False, + } diff --git a/tests/unit/config/cli/test_parse.py b/tests/unit/config/cli/test_parse.py new file mode 100644 index 000000000..9035ab31e --- /dev/null +++ b/tests/unit/config/cli/test_parse.py @@ -0,0 +1,12 @@ +import pytest + +from tox.config.cli.parse import get_options + + +def test_help_does_not_default_cmd(capsys): + with pytest.raises(SystemExit): + get_options("-h") + out, err = capsys.readouterr() + assert not err + assert "--verbose" in out + assert "command:" in out diff --git a/tests/unit/config/cli/test_parser.py b/tests/unit/config/cli/test_parser.py new file mode 100644 index 000000000..d068182fb --- /dev/null +++ b/tests/unit/config/cli/test_parser.py @@ -0,0 +1,18 @@ +from tox.config.cli.parser import ToxParser + + +def test_parser_const_with_default_none(monkeypatch): + monkeypatch.setenv("TOX_ALPHA", "2") + parser = ToxParser.base() + parser.add_argument( + "-a", + dest="alpha", + action="store_const", + const=1, + default=None, + help="sum the integers (default: find the max)", + ) + parser.fix_defaults() + + result, _ = parser.parse([]) + assert result.alpha == 2 diff --git a/tests/unit/config/ini/replace/conftest.py b/tests/unit/config/ini/replace/conftest.py new file mode 100644 index 000000000..b8a24f13f --- /dev/null +++ b/tests/unit/config/ini/replace/conftest.py @@ -0,0 +1,39 @@ +from contextlib import contextmanager + +import pytest + +from tox.pytest import ToxProjectCreator + + +@pytest.fixture +def replace_one(tox_project: ToxProjectCreator): + @contextmanager + def example(conf): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = a + [testenv] + env = {} + + + """.format( + conf + ) + } + ) + + class Result: + def __init__(self): + self.config = None + self.val = None + + result = Result() + yield result + result.config = project.config() + env_config = result.config["a"] + env_config.add_config(keys="env", of_type=str, default="bad", desc="env") + result.val = env_config["env"] + + return example diff --git a/tests/unit/config/ini/replace/test_replace_env_var.py b/tests/unit/config/ini/replace/test_replace_env_var.py new file mode 100644 index 000000000..fc5984c65 --- /dev/null +++ b/tests/unit/config/ini/replace/test_replace_env_var.py @@ -0,0 +1,27 @@ +def test_replace_env_set(replace_one, monkeypatch): + """If we have a factor that is not specified within the core env-list then that's also an environment""" + with replace_one("{env:MAGIC}") as result: + monkeypatch.setenv("MAGIC", "something good") + assert result.val == "something good" + + +def test_replace_env_missing(replace_one, monkeypatch): + """If we have a factor that is not specified within the core env-list then that's also an environment""" + with replace_one("{env:MAGIC}") as result: + monkeypatch.delenv("MAGIC", raising=False) + assert result.val == "" + + +def test_replace_env_missing_default(replace_one, monkeypatch): + """If we have a factor that is not specified within the core env-list then that's also an environment""" + with replace_one("{env:MAGIC:def}") as result: + monkeypatch.delenv("MAGIC", raising=False) + assert result.val == "def" + + +def test_replace_env_missing_default_from_env(replace_one, monkeypatch): + """If we have a factor that is not specified within the core env-list then that's also an environment""" + with replace_one("{env:MAGIC:{env:MAGIC_DEFAULT}}") as result: + monkeypatch.delenv("MAGIC", raising=False) + monkeypatch.setenv("MAGIC_DEFAULT", "yes") + assert result.val == "yes" diff --git a/tests/unit/config/ini/replace/test_replace_posargs.py b/tests/unit/config/ini/replace/test_replace_posargs.py new file mode 100644 index 000000000..b4aafe8cf --- /dev/null +++ b/tests/unit/config/ini/replace/test_replace_posargs.py @@ -0,0 +1,22 @@ +import sys + + +def test_replace_pos_args_empty_sys_argv(replace_one, monkeypatch): + """If we have a factor that is not specified within the core env-list then that's also an environment""" + with replace_one("{posargs}") as result: + monkeypatch.setattr(sys, "argv", []) + assert result.val == "" + + +def test_replace_pos_args_extra_sys_argv(replace_one, monkeypatch): + """If we have a factor that is not specified within the core env-list then that's also an environment""" + with replace_one("{posargs}") as result: + monkeypatch.setattr(sys, "argv", [sys.executable, "magic"]) + assert result.val == "" + + +def test_replace_pos_args(replace_one, monkeypatch): + """If we have a factor that is not specified within the core env-list then that's also an environment""" + with replace_one("{posargs}") as result: + monkeypatch.setattr(sys, "argv", [sys.executable, "magic", "--", "ok", "what", " yes "]) + assert result.val == "ok what ' yes '" diff --git a/tests/unit/config/ini/replace/test_replace_tox_env.py b/tests/unit/config/ini/replace/test_replace_tox_env.py new file mode 100644 index 000000000..ee14bd47f --- /dev/null +++ b/tests/unit/config/ini/replace/test_replace_tox_env.py @@ -0,0 +1,56 @@ +from textwrap import dedent + +import pytest + +from tox.pytest import ToxProjectCreator + + +@pytest.fixture() +def example(tox_project: ToxProjectCreator): + def func(conf): + project = tox_project( + { + "tox.ini": dedent( + """ + [tox] + env_list = a + [testenv] + {} + """ + ).format(conf) + } + ) + config = project.config() + env_config = config["a"] + return env_config + + return func + + +def test_replace_within_tox_env(example): + env_config = example("r = 1\no = {r}") + env_config.add_config(keys="r", of_type=str, default="r", desc="r") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + result = env_config["o"] + assert result == "1" + + +def test_replace_within_tox_env_missing_no_default_leaves(example): + env_config = example("o = {p}") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + result = env_config["o"] + assert result == "{p}" + + +def test_replace_within_tox_env_missing_default(example): + env_config = example("o = {p:one}") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + result = env_config["o"] + assert result == "one" + + +def test_replace_within_tox_env_missing_default_env_only(example): + env_config = example("o = {[testenv:a]p:one}") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + result = env_config["o"] + assert result == "one" diff --git a/tests/unit/config/ini/test_factor.py b/tests/unit/config/ini/test_factor.py new file mode 100644 index 000000000..07c40b6f4 --- /dev/null +++ b/tests/unit/config/ini/test_factor.py @@ -0,0 +1,151 @@ +from textwrap import dedent +from typing import List + +import pytest + +from tox.config.source.ini import filter_for_env, find_envs +from tox.pytest import ToxProjectCreator + + +def complex_example(): + return dedent( + """ + default + lines + py: py only + !py: not py + {py,!pi}-{a,b}{,-dev},c: complex + extra: extra + more-default + """ + ) + + +def test_factor_env_discover(): + result = list(find_envs(complex_example())) + assert result == [ + "py", + "py-a", + "py-a-dev", + "py-b", + "py-b-dev", + "pi-a", + "pi-a-dev", + "pi-b", + "pi-b-dev", + "c", + "extra", + ] + + +@pytest.mark.parametrize("env", list(find_envs(complex_example()))) +def test_factor_env_filter(env): + text = complex_example() + result = filter_for_env(text, name=env) + assert "default" in result + assert "lines" in result + assert "more-default" in result + if "py" in env: + assert "py only" in result + assert "not py" not in result + else: + assert "py only" not in result + assert "not py" in result + if "extra" == env: + assert "extra" in result + else: + assert "extra" not in result + if env in {"py-a", "py-a-dev", "py-b", "py-b-dev", "c"}: + assert "complex" in result + else: + assert "complex" not in result + + +def test_factor_env_list(tox_project: ToxProjectCreator): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = {py27,py36}-django{ 15, 16 }{,-dev}, docs, flake + """ + } + ) + config = project.config() + result = list(config) + assert result == [ + "py27-django15", + "py27-django15-dev", + "py27-django16", + "py27-django16-dev", + "py36-django15", + "py36-django15-dev", + "py36-django16", + "py36-django16-dev", + "docs", + "flake", + ] + + +def test_simple_env_list(tox_project: ToxProjectCreator): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = docs, flake8 + """ + } + ) + config = project.config() + assert list(config) == ["docs", "flake8"] + + +def test_factor_config(tox_project: ToxProjectCreator): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = {py36,py37}-{django15,django16} + [testenv] + deps = + pytest + django15: Django>=1.5,<1.6 + django16: Django>=1.6,<1.7 + py36: unittest2 + """ + } + ) + config = project.config() + assert list(config) == ["py36-django15", "py36-django16", "py37-django15", "py37-django16"] + for env in config.core["env_list"]: + env_config = config[env] + env_config.add_config( + keys="deps", of_type=List[str], default=[], desc="deps", overwrite=True + ) + deps = env_config["deps"] + assert "pytest" in deps + if "py36" in env: + assert "unittest2" in deps + if "django15" in env: + assert "Django>=1.5,<1.6" in deps + if "django16" in env: + assert "Django>=1.6,<1.7" in deps + + +def test_factor_config_no_env_list_creates_env(tox_project: ToxProjectCreator): + """If we have a factor that is not specified within the core env-list then that's also an environment""" + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = py37-{django15,django16} + [testenv] + deps = + pytest + django15: Django>=1.5,<1.6 + django16: Django>=1.6,<1.7 + py36: unittest2 + """ + } + ) + config = project.config() + assert list(config) == ["py37-django15", "py37-django16", "py36"] diff --git a/tests/unit/config/ini/test_values.py b/tests/unit/config/ini/test_values.py new file mode 100644 index 000000000..a1da251ab --- /dev/null +++ b/tests/unit/config/ini/test_values.py @@ -0,0 +1,35 @@ +from tox.config.source.api import Command +from tox.pytest import ToxProjectCreator + + +def test_commands(tox_project: ToxProjectCreator): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = py + no_package = true + [testenv] + commands_pre = + python -c 'import sys; print("start", sys.executable)' + commands = + pip config list + pip list + commands_post = + python -c 'import sys; print("end", sys.executable)' + """ + } + ) + outcome = project.run("c") + outcome.assert_success() + env_config = outcome.state.tox_envs["py"].conf + assert env_config["commands_pre"] == [ + Command(args=["python", "-c", 'import sys; print("start", sys.executable)']) + ] + assert env_config["commands"] == [ + Command(args=["pip", "config", "list"]), + Command(args=["pip", "list"]), + ] + assert env_config["commands_post"] == [ + Command(args=["python", "-c", 'import sys; print("end", sys.executable)']) + ] diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py deleted file mode 100644 index d3b17970f..000000000 --- a/tests/unit/config/test_config.py +++ /dev/null @@ -1,3072 +0,0 @@ -import os -import re -import sys -from textwrap import dedent - -import py -import pytest -from pluggy import PluginManager - -import tox -from tox.config import ( - CommandParser, - DepOption, - PosargsOption, - SectionReader, - get_homedir, - get_version_info, - getcontextname, - is_section_substitution, - parseconfig, -) -from tox.config.parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY - - -class TestVenvConfig: - def test_config_parsing_minimal(self, tmpdir, newconfig): - config = newconfig( - [], - """ - [testenv:py1] - """, - ) - assert len(config.envconfigs) == 1 - assert config.toxworkdir.realpath() == tmpdir.join(".tox").realpath() - assert config.envconfigs["py1"].basepython == sys.executable - assert config.envconfigs["py1"].deps == [] - assert config.envconfigs["py1"].platform == ".*" - - def test_config_parsing_multienv(self, tmpdir, newconfig): - config = newconfig( - [], - """ - [tox] - toxworkdir = {} - indexserver = - xyz = xyz_repo - [testenv:py1] - deps=hello - [testenv:py2] - deps= - world1 - :xyz:http://hello/world - """.format( - tmpdir - ), - ) - assert config.toxworkdir == tmpdir - assert len(config.envconfigs) == 2 - assert config.envconfigs["py1"].envdir == tmpdir.join("py1") - dep = config.envconfigs["py1"].deps[0] - assert dep.name == "hello" - assert dep.indexserver is None - assert config.envconfigs["py2"].envdir == tmpdir.join("py2") - dep1, dep2 = config.envconfigs["py2"].deps - assert dep1.name == "world1" - assert dep2.name == "http://hello/world" - assert dep2.indexserver.name == "xyz" - assert dep2.indexserver.url == "xyz_repo" - - def test_envdir_set_manually(self, tmpdir, newconfig): - config = newconfig( - [], - """ - [testenv:dev] - envdir = dev - """, - ) - envconfig = config.envconfigs["dev"] - assert envconfig.envdir == tmpdir.join("dev") - - def test_envdir_set_manually_with_substitutions(self, newconfig): - config = newconfig( - [], - """ - [testenv:dev] - envdir = {toxworkdir}/foobar - """, - ) - envconfig = config.envconfigs["dev"] - assert envconfig.envdir == config.toxworkdir.join("foobar") - - def test_force_dep_version(self, initproj): - """ - Make sure we can override dependencies configured in tox.ini when using the command line - option --force-dep. - """ - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - - [testenv] - deps= - dep1==1.0 - dep2>=2.0 - dep3 - dep4==4.0 - """ - }, - ) - config = parseconfig( - ["--force-dep=dep1==1.5", "--force-dep=dep2==2.1", "--force-dep=dep3==3.0"] - ) - assert config.option.force_dep == ["dep1==1.5", "dep2==2.1", "dep3==3.0"] - expected_deps = ["dep1==1.5", "dep2==2.1", "dep3==3.0", "dep4==4.0"] - assert expected_deps == [str(x) for x in config.envconfigs["python"].deps] - - def test_force_dep_with_url(self, initproj): - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - - [testenv] - deps= - dep1==1.0 - https://pypi.org/xyz/pkg1.tar.gz - """ - }, - ) - config = parseconfig(["--force-dep=dep1==1.5"]) - assert config.option.force_dep == ["dep1==1.5"] - expected_deps = ["dep1==1.5", "https://pypi.org/xyz/pkg1.tar.gz"] - assert [str(x) for x in config.envconfigs["python"].deps] == expected_deps - - def test_process_deps(self, newconfig): - config = newconfig( - [], - """ - [testenv] - deps = - -r requirements.txt - yapf>=0.25.0,<0.27 # pyup: < 0.27 # disable updates - --index-url https://pypi.org/simple - pywin32 >=1.0 ; sys_platform == '#my-magic-platform' # so what now - -fhttps://pypi.org/packages - --global-option=foo - -v dep1 - --help dep2 - """, - ) # note that those last two are invalid - expected_deps = [ - "-rrequirements.txt", - "yapf>=0.25.0,<0.27", - "--index-url=https://pypi.org/simple", - "pywin32 >=1.0 ; sys_platform == '#my-magic-platform'", - "-fhttps://pypi.org/packages", - "--global-option=foo", - "-v dep1", - "--help dep2", - ] - assert [str(x) for x in config.envconfigs["python"].deps] == expected_deps - - def test_is_same_dep(self): - """ - Ensure correct parseini._is_same_dep is working with a few samples. - """ - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3") - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3>=2.0") - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3>2.0") - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3<2.0") - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3<=2.0") - assert not DepOption._is_same_dep("pkg_hello-world3==1.0", "otherpkg>=2.0") - - -class TestConfigPlatform: - def test_config_parse_platform(self, newconfig): - config = newconfig( - [], - """ - [testenv:py1] - platform = linux2 - """, - ) - assert len(config.envconfigs) == 1 - assert config.envconfigs["py1"].platform == "linux2" - - def test_config_parse_platform_rex(self, newconfig, mocksession, monkeypatch): - config = newconfig( - [], - """ - [testenv:py1] - platform = a123|b123 - """, - ) - mocksession.config = config - assert len(config.envconfigs) == 1 - venv = mocksession.getvenv("py1") - assert not venv.matching_platform() - monkeypatch.setattr(sys, "platform", "a123") - assert venv.matching_platform() - monkeypatch.setattr(sys, "platform", "b123") - assert venv.matching_platform() - monkeypatch.undo() - assert not venv.matching_platform() - - @pytest.mark.parametrize("plat", ["win", "lin", "osx"]) - def test_config_parse_platform_with_factors(self, newconfig, plat): - config = newconfig( - [], - """ - [tox] - envlist = py27-{win, lin,osx } - [testenv] - platform = - win: win32 - lin: linux2 - """, - ) - assert len(config.envconfigs) == 3 - platform = config.envconfigs["py27-" + plat].platform - expected = {"win": "win32", "lin": "linux2", "osx": ""}.get(plat) - assert platform == expected - - -class TestConfigPackage: - def test_defaults(self, tmpdir, newconfig): - config = newconfig([], "") - assert config.setupdir.realpath() == tmpdir.realpath() - assert config.toxworkdir.realpath() == tmpdir.join(".tox").realpath() - envconfig = config.envconfigs["python"] - assert envconfig.args_are_paths - assert not envconfig.recreate - assert not envconfig.pip_pre - - def test_defaults_distshare(self, newconfig): - config = newconfig([], "") - assert config.distshare == config.homedir.join(".tox", "distshare") - - def test_defaults_changed_dir(self, tmpdir, newconfig): - with tmpdir.mkdir("abc").as_cwd(): - config = newconfig([], "") - assert config.setupdir.realpath() == tmpdir.realpath() - assert config.toxworkdir.realpath() == tmpdir.join(".tox").realpath() - - def test_project_paths(self, tmpdir, newconfig): - config = newconfig( - """ - [tox] - toxworkdir={} - """.format( - tmpdir - ) - ) - assert config.toxworkdir == tmpdir - - -class TestParseconfig: - def test_search_parents(self, tmpdir): - b = tmpdir.mkdir("a").mkdir("b") - toxinipath = tmpdir.ensure("tox.ini") - with b.as_cwd(): - config = parseconfig([]) - assert config.toxinipath == toxinipath - - def test_explicit_config_path(self, tmpdir): - """ - Test explicitly setting config path, both with and without the filename - """ - path = tmpdir.mkdir("tox_tmp_directory") - config_file_path = path.ensure("tox.ini") - - config = parseconfig(["-c", str(config_file_path)]) - assert config.toxinipath == config_file_path - - # Passing directory of the config file should also be possible - # ('tox.ini' filename is assumed) - config = parseconfig(["-c", str(path)]) - assert config.toxinipath == config_file_path - - @pytest.mark.skipif(sys.platform == "win32", reason="no symlinks on Windows") - def test_workdir_gets_resolved(self, tmp_path, monkeypatch): - """ - Test explicitly setting config path, both with and without the filename - """ - real = tmp_path / "real" - real.mkdir() - symlink = tmp_path / "link" - symlink.symlink_to(real) - - (tmp_path / "tox.ini").touch() - monkeypatch.chdir(tmp_path) - config = parseconfig(["--workdir", str(symlink)]) - assert config.toxworkdir == real - - -def test_get_homedir(monkeypatch): - monkeypatch.setattr(py.path.local, "_gethomedir", classmethod(lambda x: {}[1])) - assert not get_homedir() - monkeypatch.setattr(py.path.local, "_gethomedir", classmethod(lambda x: 0 / 0)) - assert not get_homedir() - monkeypatch.setattr(py.path.local, "_gethomedir", classmethod(lambda x: "123")) - assert get_homedir() == "123" - - -class TestGetcontextname: - def test_blank(self, monkeypatch): - monkeypatch.setattr(os, "environ", {}) - assert getcontextname() is None - - def test_jenkins(self, monkeypatch): - monkeypatch.setattr(os, "environ", {"JENKINS_URL": "xyz"}) - assert getcontextname() == "jenkins" - - def test_hudson_legacy(self, monkeypatch): - monkeypatch.setattr(os, "environ", {"HUDSON_URL": "xyz"}) - assert getcontextname() == "jenkins" - - -class TestIniParserAgainstCommandsKey: - """Test parsing commands with substitutions""" - - def test_command_substitution_from_other_section(self, newconfig): - config = newconfig( - """ - [section] - key = whatever - [testenv] - commands = - echo {[section]key} - """ - ) - reader = SectionReader("testenv", config._cfg) - x = reader.getargvlist("commands") - assert x == [["echo", "whatever"]] - - def test_command_substitution_from_other_section_multiline(self, newconfig): - """Ensure referenced multiline commands form from other section injected - as multiple commands.""" - config = newconfig( - """ - [section] - commands = - cmd1 param11 param12 - # comment is omitted - cmd2 param21 \ - param22 - [base] - commands = cmd 1 \ - 2 3 4 - cmd 2 - [testenv] - commands = - {[section]commands} - {[section]commands} - # comment is omitted - echo {[base]commands} - """ - ) - reader = SectionReader("testenv", config._cfg) - x = reader.getargvlist("commands") - expected_deps = [ - "cmd1 param11 param12".split(), - "cmd2 param21 param22".split(), - "cmd1 param11 param12".split(), - "cmd2 param21 param22".split(), - ["echo", "cmd", "1", "2", "3", "4", "cmd", "2"], - ] - assert x == expected_deps - - def test_command_substitution_from_other_section_posargs(self, newconfig): - """Ensure subsitition from other section with posargs succeeds""" - config = newconfig( - """ - [section] - key = thing {posargs} arg2 - [testenv] - commands = - {[section]key} - """ - ) - reader = SectionReader("testenv", config._cfg) - reader.addsubstitutions([r"argpos"]) - x = reader.getargvlist("commands") - assert x == [["thing", "argpos", "arg2"]] - - def test_command_section_and_posargs_substitution(self, newconfig): - """Ensure subsitition from other section with posargs succeeds""" - config = newconfig( - """ - [section] - key = thing arg1 - [testenv] - commands = - {[section]key} {posargs} endarg - """ - ) - reader = SectionReader("testenv", config._cfg) - reader.addsubstitutions([r"argpos"]) - x = reader.getargvlist("commands") - assert x == [["thing", "arg1", "argpos", "endarg"]] - - def test_command_env_substitution(self, newconfig): - """Ensure referenced {env:key:default} values are substituted correctly.""" - config = newconfig( - """ - [testenv:py27] - setenv = - TEST=testvalue - commands = - ls {env:TEST} - """ - ) - envconfig = config.envconfigs["py27"] - assert envconfig.commands == [["ls", "testvalue"]] - assert envconfig.setenv["TEST"] == "testvalue" - - def test_command_env_substitution_global(self, newconfig): - """Ensure referenced {env:key:default} values are substituted correctly.""" - config = newconfig( - """ - [testenv] - setenv = FOO = bar - commands = echo {env:FOO} - """ - ) - envconfig = config.envconfigs["python"] - assert envconfig.commands == [["echo", "bar"]] - - def test_regression_issue595(self, newconfig): - config = newconfig( - """ - [tox] - envlist = foo - [testenv] - setenv = VAR = x - [testenv:bar] - setenv = {[testenv]setenv} - [testenv:baz] - setenv = - """ - ) - assert config.envconfigs["foo"].setenv["VAR"] == "x" - assert config.envconfigs["bar"].setenv["VAR"] == "x" - assert "VAR" not in config.envconfigs["baz"].setenv - - -class TestIniParser: - def test_getstring_single(self, newconfig): - config = newconfig( - """ - [section] - key=value - """ - ) - reader = SectionReader("section", config._cfg) - x = reader.getstring("key") - assert x == "value" - assert not reader.getstring("hello") - x = reader.getstring("hello", "world") - assert x == "world" - - def test_missing_substitution(self, newconfig): - config = newconfig( - """ - [mydefault] - key2={xyz} - """ - ) - reader = SectionReader("mydefault", config._cfg, fallbacksections=["mydefault"]) - assert reader is not None - with pytest.raises(tox.exception.ConfigError): - reader.getstring("key2") - - def test_getstring_fallback_sections(self, newconfig): - config = newconfig( - """ - [mydefault] - key2=value2 - [section] - key=value - """ - ) - reader = SectionReader("section", config._cfg, fallbacksections=["mydefault"]) - x = reader.getstring("key2") - assert x == "value2" - x = reader.getstring("key3") - assert not x - x = reader.getstring("key3", "world") - assert x == "world" - - def test_getstring_substitution(self, newconfig): - config = newconfig( - """ - [mydefault] - key2={value2} - [section] - key={value} - """ - ) - reader = SectionReader("section", config._cfg, fallbacksections=["mydefault"]) - reader.addsubstitutions(value="newvalue", value2="newvalue2") - x = reader.getstring("key2") - assert x == "newvalue2" - x = reader.getstring("key3") - assert not x - x = reader.getstring("key3", "{value2}") - assert x == "newvalue2" - - def test_getlist(self, newconfig): - config = newconfig( - """ - [section] - key2= - item1 - {item2} - """ - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(item1="not", item2="grr") - x = reader.getlist("key2") - assert x == ["item1", "grr"] - - def test_getdict(self, newconfig): - config = newconfig( - """ - [section] - key2= - key1=item1 - key2={item2} - """ - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(item1="not", item2="grr") - x = reader.getdict("key2") - assert "key1" in x - assert "key2" in x - assert x["key1"] == "item1" - assert x["key2"] == "grr" - - x = reader.getdict("key3", {1: 2}) - assert x == {1: 2} - - def test_normal_env_sub_works(self, monkeypatch, newconfig): - monkeypatch.setenv("VAR", "hello") - config = newconfig("[section]\nkey={env:VAR}") - assert SectionReader("section", config._cfg).getstring("key") == "hello" - - def test_missing_env_sub_raises_config_error_in_non_testenv(self, newconfig): - config = newconfig("[section]\nkey={env:VAR}") - with pytest.raises(tox.exception.ConfigError): - SectionReader("section", config._cfg).getstring("key") - - def test_missing_env_sub_populates_missing_subs(self, newconfig): - config = newconfig("[testenv:foo]\ncommands={env:VAR}") - print(SectionReader("section", config._cfg).getstring("commands")) - assert config.envconfigs["foo"]._missing_subs == ["VAR"] - - def test_getstring_environment_substitution_with_default(self, monkeypatch, newconfig): - monkeypatch.setenv("KEY1", "hello") - config = newconfig( - """ - [section] - key1={env:KEY1:DEFAULT_VALUE} - key2={env:KEY2:DEFAULT_VALUE} - key3={env:KEY3:} - """ - ) - reader = SectionReader("section", config._cfg) - x = reader.getstring("key1") - assert x == "hello" - x = reader.getstring("key2") - assert x == "DEFAULT_VALUE" - x = reader.getstring("key3") - assert x == "" - - def test_value_matches_section_substitution(self): - assert is_section_substitution("{[setup]commands}") - - def test_value_doesn_match_section_substitution(self): - assert is_section_substitution("{[ ]commands}") is None - assert is_section_substitution("{[setup]}") is None - assert is_section_substitution("{[setup] commands}") is None - - def test_getstring_other_section_substitution(self, newconfig): - config = newconfig( - """ - [section] - key = rue - [testenv] - key = t{[section]key} - """ - ) - reader = SectionReader("testenv", config._cfg) - x = reader.getstring("key") - assert x == "true" - - def test_argvlist(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 {item1} {item2} - cmd2 {item2} - """ - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(item1="with space", item2="grr") - assert reader.getargvlist("key1") == [] - x = reader.getargvlist("key2") - assert x == [["cmd1", "with", "space", "grr"], ["cmd2", "grr"]] - - def test_argvlist_windows_escaping(self, newconfig): - config = newconfig( - """ - [section] - comm = pytest {posargs} - """ - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions([r"hello\this"]) - argv = reader.getargv("comm") - assert argv == ["pytest", "hello\\this"] - - def test_argvlist_multiline(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 {item1} \ - {item2} - """ - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(item1="with space", item2="grr") - assert reader.getargvlist("key1") == [] - x = reader.getargvlist("key2") - assert x == [["cmd1", "with", "space", "grr"]] - - def test_argvlist_quoting_in_command(self, newconfig): - config = newconfig( - """ - [section] - key1= - cmd1 'part one' \ - 'part two' - """ - ) - reader = SectionReader("section", config._cfg) - x = reader.getargvlist("key1") - assert x == [["cmd1", "part one", "part two"]] - - def test_argvlist_comment_after_command(self, newconfig): - config = newconfig( - """ - [section] - key1= - cmd1 --flag # run the flag on the command - """ - ) - reader = SectionReader("section", config._cfg) - x = reader.getargvlist("key1") - assert x == [["cmd1", "--flag"]] - - def test_argvlist_command_contains_hash(self, newconfig): - config = newconfig( - """ - [section] - key1= - cmd1 --re "use the # symbol for an arg" - """ - ) - reader = SectionReader("section", config._cfg) - x = reader.getargvlist("key1") - assert x == [["cmd1", "--re", "use the # symbol for an arg"]] - - def test_argvlist_positional_substitution(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 [] - cmd2 {posargs:{item2} \ - other} - """ - ) - reader = SectionReader("section", config._cfg) - posargs = ["hello", "world"] - reader.addsubstitutions(posargs, item2="value2") - assert reader.getargvlist("key1") == [] - argvlist = reader.getargvlist("key2") - assert argvlist[0] == ["cmd1"] + posargs - assert argvlist[1] == ["cmd2"] + posargs - - reader = SectionReader("section", config._cfg) - reader.addsubstitutions([], item2="value2") - assert reader.getargvlist("key1") == [] - argvlist = reader.getargvlist("key2") - assert argvlist[0] == ["cmd1"] - assert argvlist[1] == ["cmd2", "value2", "other"] - - def test_argvlist_quoted_posargs(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 --foo-args='{posargs}' - cmd2 -f '{posargs}' - cmd3 -f {posargs} - """ - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(["foo", "bar"]) - assert reader.getargvlist("key1") == [] - x = reader.getargvlist("key2") - expected_deps = [ - ["cmd1", "--foo-args=foo bar"], - ["cmd2", "-f", "foo bar"], - ["cmd3", "-f", "foo", "bar"], - ] - assert x == expected_deps - - def test_argvlist_posargs_with_quotes(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 -f {posargs} - """ - ) - # The operating system APIs for launching processes differ between - # Windows and other OSs. On Windows, the command line is passed as a - # string (and not a list of strings). Python uses the MS C runtime - # rules for splitting this string into `sys.argv`, and those rules - # differ from POSIX shell rules in their treatment of quoted arguments. - if sys.platform.startswith("win"): - substitutions = ["foo", "'bar", "baz'"] - else: - substitutions = ["foo", "bar baz"] - - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(substitutions) - assert reader.getargvlist("key1") == [] - x = reader.getargvlist("key2") - assert x == [["cmd1", "-f", "foo", "bar baz"]] - - def test_positional_arguments_are_only_replaced_when_standing_alone(self, newconfig): - config = newconfig( - """ - [section] - key= - cmd0 [] - cmd1 -m '[abc]' - cmd2 -m '\'something\'' [] - cmd3 something[]else - """ - ) - reader = SectionReader("section", config._cfg) - posargs = ["hello", "world"] - reader.addsubstitutions(posargs) - - argvlist = reader.getargvlist("key") - assert argvlist[0] == ["cmd0"] + posargs - assert argvlist[1] == ["cmd1", "-m", "[abc]"] - assert argvlist[2] == ["cmd2", "-m", "something"] + posargs - assert argvlist[3] == ["cmd3", "something[]else"] - - def test_posargs_are_added_escaped_issue310(self, newconfig): - config = newconfig( - """ - [section] - key= cmd0 {posargs} - """ - ) - reader = SectionReader("section", config._cfg) - posargs = ["hello world", "--x==y z", "--format=%(code)s: %(text)s"] - reader.addsubstitutions(posargs) - argvlist = reader.getargvlist("key") - assert argvlist[0] == ["cmd0"] + posargs - - def test_substitution_with_multiple_words(self, newconfig): - inisource = """ - [section] - key = pytest -n5 --junitxml={envlogdir}/junit-{envname}.xml [] - """ - config = newconfig(inisource) - reader = SectionReader("section", config._cfg) - posargs = ["hello", "world"] - reader.addsubstitutions(posargs, envlogdir="ENV_LOG_DIR", envname="ENV_NAME") - - expected = ["pytest", "-n5", "--junitxml=ENV_LOG_DIR/junit-ENV_NAME.xml", "hello", "world"] - assert reader.getargvlist("key")[0] == expected - - def test_getargv(self, newconfig): - config = newconfig( - """ - [section] - key=some command "with quoting" - """ - ) - reader = SectionReader("section", config._cfg) - expected = ["some", "command", "with quoting"] - assert reader.getargv("key") == expected - - def test_getpath(self, tmpdir, newconfig): - config = newconfig( - """ - [section] - path1={HELLO} - """ - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(toxinidir=tmpdir, HELLO="mypath") - x = reader.getpath("path1", tmpdir) - assert x == tmpdir.join("mypath") - - def test_getbool(self, newconfig): - config = newconfig( - """ - [section] - key1=True - key2=False - key1a=true - key2a=falsE - key5=yes - """ - ) - reader = SectionReader("section", config._cfg) - assert reader.getbool("key1") is True - assert reader.getbool("key1a") is True - assert reader.getbool("key2") is False - assert reader.getbool("key2a") is False - with pytest.raises(KeyError): - reader.getbool("key3") - with pytest.raises(tox.exception.ConfigError) as excinfo: - reader.getbool("key5") - msg, = excinfo.value.args - assert msg == "key5: boolean value 'yes' needs to be 'True' or 'False'" - - -class TestIniParserPrefix: - def test_basic_section_access(self, newconfig): - config = newconfig( - """ - [p:section] - key=value - """ - ) - reader = SectionReader("section", config._cfg, prefix="p") - x = reader.getstring("key") - assert x == "value" - assert not reader.getstring("hello") - x = reader.getstring("hello", "world") - assert x == "world" - - def test_fallback_sections(self, newconfig): - config = newconfig( - """ - [p:mydefault] - key2=value2 - [p:section] - key=value - """ - ) - reader = SectionReader( - "section", config._cfg, prefix="p", fallbacksections=["p:mydefault"] - ) - x = reader.getstring("key2") - assert x == "value2" - x = reader.getstring("key3") - assert not x - x = reader.getstring("key3", "world") - assert x == "world" - - def test_value_matches_prefixed_section_substitution(self): - assert is_section_substitution("{[p:setup]commands}") - - def test_value_doesn_match_prefixed_section_substitution(self): - assert is_section_substitution("{[p: ]commands}") is None - assert is_section_substitution("{[p:setup]}") is None - assert is_section_substitution("{[p:setup] commands}") is None - - def test_other_section_substitution(self, newconfig): - config = newconfig( - """ - [p:section] - key = rue - [p:testenv] - key = t{[p:section]key} - """ - ) - reader = SectionReader("testenv", config._cfg, prefix="p") - x = reader.getstring("key") - assert x == "true" - - -class TestConfigTestEnv: - def test_commentchars_issue33(self, newconfig): - config = newconfig( - """ - [testenv] # hello - deps = http://abc#123 - commands= - python -c "x ; y" - """ - ) - envconfig = config.envconfigs["python"] - assert envconfig.deps[0].name == "http://abc#123" - assert envconfig.commands[0] == ["python", "-c", "x ; y"] - - def test_defaults(self, newconfig): - config = newconfig( - """ - [testenv] - commands= - xyz --abc - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.commands == [["xyz", "--abc"]] - assert envconfig.changedir == config.setupdir - assert envconfig.sitepackages is False - assert envconfig.usedevelop is False - assert envconfig.ignore_errors is False - assert envconfig.envlogdir == envconfig.envdir.join("log") - assert set(envconfig.setenv.definitions.keys()) == { - "PYTHONHASHSEED", - "TOX_ENV_NAME", - "TOX_ENV_DIR", - } - hashseed = envconfig.setenv["PYTHONHASHSEED"] - assert isinstance(hashseed, str) - # The following line checks that hashseed parses to an integer. - int_hashseed = int(hashseed) - # hashseed is random by default, so we can't assert a specific value. - assert int_hashseed > 0 - assert envconfig.ignore_outcome is False - - def test_sitepackages_switch(self, newconfig): - config = newconfig(["--sitepackages"], "") - envconfig = config.envconfigs["python"] - assert envconfig.sitepackages is True - - def test_installpkg_tops_develop(self, newconfig): - config = newconfig( - ["--installpkg=abc"], - """ - [testenv] - usedevelop = True - """, - ) - assert not config.envconfigs["python"].usedevelop - - def test_specific_command_overrides(self, newconfig): - config = newconfig( - """ - [testenv] - commands=xyz - [testenv:py] - commands=abc - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["py"] - assert envconfig.commands == [["abc"]] - - def test_whitelist_externals(self, newconfig): - config = newconfig( - """ - [testenv] - whitelist_externals = xyz - commands=xyz - [testenv:x] - - [testenv:py] - whitelist_externals = xyz2 - commands=abc - """ - ) - assert len(config.envconfigs) == 2 - envconfig = config.envconfigs["py"] - assert envconfig.commands == [["abc"]] - assert envconfig.whitelist_externals == ["xyz2"] - envconfig = config.envconfigs["x"] - assert envconfig.whitelist_externals == ["xyz"] - - def test_changedir(self, newconfig): - config = newconfig( - """ - [testenv] - changedir=xyz - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.changedir.basename == "xyz" - assert envconfig.changedir == config.toxinidir.join("xyz") - - def test_ignore_errors(self, newconfig): - config = newconfig( - """ - [testenv] - ignore_errors=True - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.ignore_errors is True - - def test_envbindir(self, newconfig): - config = newconfig( - """ - [testenv] - basepython=python - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.envpython == envconfig.envbindir.join("python") - - @pytest.mark.parametrize("bp", ["jython", "pypy", "pypy3"]) - def test_envbindir_jython(self, newconfig, bp): - config = newconfig( - """ - [testenv] - basepython={} - """.format( - bp - ) - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - # on win32 and linux virtualenv uses "bin" for pypy/jython - assert envconfig.envbindir.basename == "bin" - if bp == "jython": - assert envconfig.envpython == envconfig.envbindir.join(bp) - - @pytest.mark.parametrize("plat", ["win32", "linux2"]) - def test_passenv_as_multiline_list(self, newconfig, monkeypatch, plat): - monkeypatch.setattr(tox.INFO, "IS_WIN", plat == "win32") - monkeypatch.setenv("A123A", "a") - monkeypatch.setenv("A123B", "b") - monkeypatch.setenv("BX23", "0") - config = newconfig( - """ - [testenv] - passenv = - A123* - # isolated comment - B?23 - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - if plat == "win32": - assert "PATHEXT" in envconfig.passenv - assert "SYSTEMDRIVE" in envconfig.passenv - assert "SYSTEMROOT" in envconfig.passenv - assert "COMSPEC" in envconfig.passenv - assert "TEMP" in envconfig.passenv - assert "TMP" in envconfig.passenv - assert "NUMBER_OF_PROCESSORS" in envconfig.passenv - assert "PROCESSOR_ARCHITECTURE" in envconfig.passenv - assert "USERPROFILE" in envconfig.passenv - assert "MSYSTEM" in envconfig.passenv - else: - assert "TMPDIR" in envconfig.passenv - assert "PATH" in envconfig.passenv - assert "PIP_INDEX_URL" in envconfig.passenv - assert "LANG" in envconfig.passenv - assert "LANGUAGE" in envconfig.passenv - assert "LD_LIBRARY_PATH" in envconfig.passenv - assert PARALLEL_ENV_VAR_KEY in envconfig.passenv - assert "A123A" in envconfig.passenv - assert "A123B" in envconfig.passenv - - @pytest.mark.parametrize("plat", ["win32", "linux2"]) - def test_passenv_as_space_separated_list(self, newconfig, monkeypatch, plat): - monkeypatch.setattr(tox.INFO, "IS_WIN", plat == "win32") - monkeypatch.setenv("A123A", "a") - monkeypatch.setenv("A123B", "b") - monkeypatch.setenv("BX23", "0") - config = newconfig( - """ - [testenv] - passenv = - # comment - A123* B?23 - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - if plat == "win32": - assert "PATHEXT" in envconfig.passenv - assert "SYSTEMDRIVE" in envconfig.passenv - assert "SYSTEMROOT" in envconfig.passenv - assert "TEMP" in envconfig.passenv - assert "TMP" in envconfig.passenv - else: - assert "TMPDIR" in envconfig.passenv - assert "PATH" in envconfig.passenv - assert "PIP_INDEX_URL" in envconfig.passenv - assert "LANG" in envconfig.passenv - assert "LANGUAGE" in envconfig.passenv - assert "A123A" in envconfig.passenv - assert "A123B" in envconfig.passenv - - def test_passenv_with_factor(self, newconfig, monkeypatch): - monkeypatch.setenv("A123A", "a") - monkeypatch.setenv("A123B", "b") - monkeypatch.setenv("A123C", "c") - monkeypatch.setenv("A123D", "d") - monkeypatch.setenv("BX23", "0") - monkeypatch.setenv("CCA43", "3") - monkeypatch.setenv("CB21", "4") - config = newconfig( - """ - [tox] - envlist = {x1,x2} - [testenv] - passenv = - x1: A123A CC* - x1: CB21 - # passed to both environments - A123C - x2: A123B A123D - """ - ) - assert len(config.envconfigs) == 2 - - assert "A123A" in config.envconfigs["x1"].passenv - assert "A123C" in config.envconfigs["x1"].passenv - assert "CCA43" in config.envconfigs["x1"].passenv - assert "CB21" in config.envconfigs["x1"].passenv - assert "A123B" not in config.envconfigs["x1"].passenv - assert "A123D" not in config.envconfigs["x1"].passenv - assert "BX23" not in config.envconfigs["x1"].passenv - - assert "A123B" in config.envconfigs["x2"].passenv - assert "A123D" in config.envconfigs["x2"].passenv - assert "A123A" not in config.envconfigs["x2"].passenv - assert "A123C" in config.envconfigs["x2"].passenv - assert "CCA43" not in config.envconfigs["x2"].passenv - assert "CB21" not in config.envconfigs["x2"].passenv - assert "BX23" not in config.envconfigs["x2"].passenv - - def test_passenv_from_global_env(self, newconfig, monkeypatch): - monkeypatch.setenv("A1", "a1") - monkeypatch.setenv("A2", "a2") - monkeypatch.setenv("TOX_TESTENV_PASSENV", "A1") - config = newconfig( - """ - [testenv] - passenv = A2 - """ - ) - env = config.envconfigs["python"] - assert "A1" in env.passenv - assert "A2" in env.passenv - - def test_passenv_glob_from_global_env(self, newconfig, monkeypatch): - monkeypatch.setenv("A1", "a1") - monkeypatch.setenv("A2", "a2") - monkeypatch.setenv("TOX_TESTENV_PASSENV", "A*") - config = newconfig( - """ - [testenv] - """ - ) - env = config.envconfigs["python"] - assert "A1" in env.passenv - assert "A2" in env.passenv - - def test_no_spinner(self, newconfig, monkeypatch): - monkeypatch.setenv("TOX_PARALLEL_NO_SPINNER", "1") - config = newconfig( - """ - [testenv] - passenv = TOX_PARALLEL_NO_SPINNER - """ - ) - env = config.envconfigs["python"] - assert "TOX_PARALLEL_NO_SPINNER" in env.passenv - - def test_changedir_override(self, newconfig): - config = newconfig( - """ - [testenv] - changedir=xyz - [testenv:python] - changedir=abc - basepython=python3.6 - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.changedir.basename == "abc" - assert envconfig.changedir == config.setupdir.join("abc") - - def test_install_command_setting(self, newconfig): - config = newconfig( - """ - [testenv] - install_command=some_install {packages} - """ - ) - envconfig = config.envconfigs["python"] - assert envconfig.install_command == ["some_install", "{packages}"] - - def test_install_command_must_contain_packages(self, newconfig): - with pytest.raises(tox.exception.ConfigError): - newconfig("[testenv]\ninstall_command=pip install") - - def test_install_command_substitutions(self, newconfig): - config = newconfig( - """ - [testenv] - install_command=some_install --arg={toxinidir}/foo \ - {envname} {opts} {packages} - """ - ) - envconfig = config.envconfigs["python"] - expected_deps = [ - "some_install", - "--arg={}/foo".format(config.toxinidir), - "python", - "{opts}", - "{packages}", - ] - assert envconfig.install_command == expected_deps - - def test_pip_pre(self, newconfig): - config = newconfig( - """ - [testenv] - pip_pre=true - """ - ) - envconfig = config.envconfigs["python"] - assert envconfig.pip_pre - - def test_pip_pre_cmdline_override(self, newconfig): - config = newconfig( - ["--pre"], - """ - [testenv] - pip_pre=false - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.pip_pre - - def test_simple(self, newconfig): - config = newconfig( - """ - [testenv:py36] - basepython=python3.6 - [testenv:py27] - basepython=python2.7 - """ - ) - assert len(config.envconfigs) == 2 - assert "py36" in config.envconfigs - assert "py27" in config.envconfigs - - def test_substitution_error(self, newconfig): - with pytest.raises(tox.exception.ConfigError): - newconfig("[testenv:py27]\nbasepython={xyz}") - - def test_substitution_defaults(self, newconfig): - config = newconfig( - """ - [testenv:py27] - commands = - {toxinidir} - {toxworkdir} - {envdir} - {envbindir} - {envtmpdir} - {envpython} - {homedir} - {distshare} - {envlogdir} - """ - ) - conf = config.envconfigs["py27"] - argv = conf.commands - assert argv[0][0] == config.toxinidir - assert argv[1][0] == config.toxworkdir - assert argv[2][0] == conf.envdir - assert argv[3][0] == conf.envbindir - assert argv[4][0] == conf.envtmpdir - assert argv[5][0] == conf.envpython - assert argv[6][0] == str(config.homedir) - assert argv[7][0] == config.homedir.join(".tox", "distshare") - assert argv[8][0] == conf.envlogdir - - def test_substitution_notfound_issue246(self, newconfig): - config = newconfig( - """ - [testenv:py27] - setenv = - FOO={envbindir} - BAR={envsitepackagesdir} - """ - ) - conf = config.envconfigs["py27"] - env = conf.setenv - assert "FOO" in env - assert "BAR" in env - - def test_substitution_notfound_issue515(self, newconfig): - config = newconfig( - """ - [tox] - envlist = standard-greeting - - [testenv:standard-greeting] - commands = - python -c 'print("Hello, world!")' - - [testenv:custom-greeting] - passenv = - NAME - commands = - python -c 'print("Hello, {env:NAME}!")' - """ - ) - conf = config.envconfigs["standard-greeting"] - assert conf.commands == [["python", "-c", 'print("Hello, world!")']] - - def test_substitution_nested_env_defaults(self, newconfig, monkeypatch): - monkeypatch.setenv("IGNORE_STATIC_DEFAULT", "env") - monkeypatch.setenv("IGNORE_DYNAMIC_DEFAULT", "env") - config = newconfig( - """ - [testenv:py27] - passenv = - IGNORE_STATIC_DEFAULT - USE_STATIC_DEFAULT - IGNORE_DYNAMIC_DEFAULT - USE_DYNAMIC_DEFAULT - setenv = - OTHER_VAR=other - IGNORE_STATIC_DEFAULT={env:IGNORE_STATIC_DEFAULT:default} - USE_STATIC_DEFAULT={env:USE_STATIC_DEFAULT:default} - IGNORE_DYNAMIC_DEFAULT={env:IGNORE_DYNAMIC_DEFAULT:{env:OTHER_VAR}+default} - USE_DYNAMIC_DEFAULT={env:USE_DYNAMIC_DEFAULT:{env:OTHER_VAR}+default} - IGNORE_OTHER_DEFAULT={env:OTHER_VAR:{env:OTHER_VAR}+default} - USE_OTHER_DEFAULT={env:NON_EXISTENT_VAR:{env:OTHER_VAR}+default} - """ - ) - conf = config.envconfigs["py27"] - env = conf.setenv - assert env["IGNORE_STATIC_DEFAULT"] == "env" - assert env["USE_STATIC_DEFAULT"] == "default" - assert env["IGNORE_OTHER_DEFAULT"] == "other" - assert env["USE_OTHER_DEFAULT"] == "other+default" - assert env["IGNORE_DYNAMIC_DEFAULT"] == "env" - assert env["USE_DYNAMIC_DEFAULT"] == "other+default" - - def test_substitution_positional(self, newconfig): - inisource = """ - [testenv:py27] - commands = - cmd1 [hello] \ - world - cmd1 {posargs:hello} \ - world - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "[hello]", "world"] - assert argv[1] == ["cmd1", "hello", "world"] - conf = newconfig(["brave", "new"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "[hello]", "world"] - assert argv[1] == ["cmd1", "brave", "new", "world"] - - def test_substitution_noargs_issue240(self, newconfig): - inisource = """ - [testenv] - commands = echo {posargs:foo} - """ - conf = newconfig([""], inisource).envconfigs["python"] - argv = conf.commands - assert argv[0] == ["echo"] - - def test_substitution_double(self, newconfig): - inisource = """ - [params] - foo = bah - foo2 = [params]foo - - [testenv:py27] - commands = - echo {{[params]foo2}} - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["echo", "bah"] - - def test_posargs_backslashed_or_quoted(self, newconfig): - inisource = r""" - [testenv:py27] - commands = - echo "\{posargs\}" = {posargs} - echo "posargs = " "{posargs}" - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["echo", "{posargs}", "="] - assert argv[1] == ["echo", "posargs = ", ""] - - conf = newconfig(["dog", "cat"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["echo", "{posargs}", "=", "dog", "cat"] - assert argv[1] == ["echo", "posargs = ", "dog cat"] - - def test_rewrite_posargs(self, tmpdir, newconfig): - inisource = """ - [testenv:py27] - args_are_paths = True - changedir = tests - commands = cmd1 {posargs:hello} - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "hello"] - - conf = newconfig(["tests/hello"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "tests/hello"] - - tmpdir.ensure("tests", "hello") - conf = newconfig(["tests/hello"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "hello"] - - def test_rewrite_simple_posargs(self, tmpdir, newconfig): - inisource = """ - [testenv:py27] - args_are_paths = True - changedir = tests - commands = cmd1 {posargs} - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1"] - - conf = newconfig(["tests/hello"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "tests/hello"] - - tmpdir.ensure("tests", "hello") - conf = newconfig(["tests/hello"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "hello"] - - @pytest.mark.parametrize( - "envlist, deps", - [ - (["py27"], ("pytest", "pytest-cov")), - (["py27", "py34"], ("pytest", "py{27,34}: pytest-cov")), - ], - ) - def test_take_dependencies_from_other_testenv(self, newconfig, envlist, deps): - inisource = """ - [tox] - envlist = {envlist} - [testenv] - deps={deps} - [testenv:py27] - deps= - {{[testenv]deps}} - fun - frob{{env:ENV_VAR:>1.0,<2.0}} - """.format( - envlist=",".join(envlist), deps="\n" + "\n".join([" " * 17 + d for d in deps]) - ) - conf = newconfig([], inisource).envconfigs["py27"] - packages = [dep.name for dep in conf.deps] - assert packages == ["pytest", "pytest-cov", "fun", "frob>1.0,<2.0"] - - # https://github.com/tox-dev/tox/issues/706 - @pytest.mark.parametrize("envlist", [["py27", "coverage", "other"]]) - def test_regression_test_issue_706(self, newconfig, envlist): - inisource = """ - [tox] - envlist = {envlist} - [testenv] - deps= - flake8 - coverage: coverage - [testenv:py27] - deps= - {{[testenv]deps}} - fun - """.format( - envlist=",".join(envlist) - ) - conf = newconfig([], inisource).envconfigs["coverage"] - packages = [dep.name for dep in conf.deps] - assert packages == ["flake8", "coverage"] - - conf = newconfig([], inisource).envconfigs["other"] - packages = [dep.name for dep in conf.deps] - assert packages == ["flake8"] - - conf = newconfig([], inisource).envconfigs["py27"] - packages = [dep.name for dep in conf.deps] - assert packages == ["flake8", "fun"] - - def test_factor_expansion(self, newconfig): - inisource = """ - [tox] - envlist = {py27, py37}-cover - [testenv] - deps= - {py27}: foo - {py37}: bar - """ - conf = newconfig([], inisource).envconfigs["py27-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["foo"] - - conf = newconfig([], inisource).envconfigs["py37-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["bar"] - - # Regression test https://github.com/tox-dev/tox/issues/899 - def test_factors_support_curly_braces(self, newconfig): - inisource = """ - [tox] - envlist = - style - sdist - bdist_wheel - {py27,py34,py35,py36,pypy,pypy3}-cover - {py27,py34,py35,py36,pypy,pypy3}-nocov - - [testenv] - deps = - cover: coverage - cover: codecov - {py27}: unittest2 - {py27}: mysql-python - {py27,py36}: mmtf-python - {py27,py35}: reportlab - {py27,py34,py35,py36}: psycopg2-binary - {py27,py34,py35,py35}: mysql-connector-python-rf - {py27,py35,pypy}: rdflib - {pypy,pypy3}: numpy==1.12.1 - {py27,py34,py36}: numpy - {py36}: scipy - {py27}: networkx - {py36}: matplotlib - """ - conf = newconfig([], inisource).envconfigs["style"] - packages = [dep.name for dep in conf.deps] - assert packages == [] - - conf = newconfig([], inisource).envconfigs["py27-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "coverage", - "codecov", - "unittest2", - "mysql-python", - "mmtf-python", - "reportlab", - "psycopg2-binary", - "mysql-connector-python-rf", - "rdflib", - "numpy", - "networkx", - ] - - conf = newconfig([], inisource).envconfigs["py34-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "coverage", - "codecov", - "psycopg2-binary", - "mysql-connector-python-rf", - "numpy", - ] - - conf = newconfig([], inisource).envconfigs["py35-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "coverage", - "codecov", - "reportlab", - "psycopg2-binary", - "mysql-connector-python-rf", - "rdflib", - ] - - conf = newconfig([], inisource).envconfigs["py36-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "coverage", - "codecov", - "mmtf-python", - "psycopg2-binary", - "numpy", - "scipy", - "matplotlib", - ] - - conf = newconfig([], inisource).envconfigs["pypy-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["coverage", "codecov", "rdflib", "numpy==1.12.1"] - - conf = newconfig([], inisource).envconfigs["pypy3-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["coverage", "codecov", "numpy==1.12.1"] - - conf = newconfig([], inisource).envconfigs["py27-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "unittest2", - "mysql-python", - "mmtf-python", - "reportlab", - "psycopg2-binary", - "mysql-connector-python-rf", - "rdflib", - "numpy", - "networkx", - ] - - conf = newconfig([], inisource).envconfigs["py34-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == ["psycopg2-binary", "mysql-connector-python-rf", "numpy"] - - conf = newconfig([], inisource).envconfigs["py35-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == ["reportlab", "psycopg2-binary", "mysql-connector-python-rf", "rdflib"] - - conf = newconfig([], inisource).envconfigs["py36-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == ["mmtf-python", "psycopg2-binary", "numpy", "scipy", "matplotlib"] - - conf = newconfig([], inisource).envconfigs["pypy-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == ["rdflib", "numpy==1.12.1"] - - conf = newconfig([], inisource).envconfigs["pypy3-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["coverage", "codecov", "numpy==1.12.1"] - - # Regression test https://github.com/tox-dev/tox/issues/906 - def test_do_not_substitute_more_than_needed(self, newconfig): - inisource = """ - [tox] - envlist = - django_master-py{36,35} - django20-py{36,35,34,py3} - django111-py{36,35,34,27,py} - django18-py{35,34,27,py} - lint - docs - - [testenv] - deps = - .[test] - django18: {[django]1.8.x} - django111: {[django]1.11.x} - django20: {[django]2.0.x} - django_master: {[django]master} - - [django] - 1.8.x = - Django>=1.8.0,<1.9.0 - django-reversion==1.10.0 - djangorestframework>=3.3.3,<3.7.0 - 1.11.x = - Django>=1.11.0,<2.0.0 - django-reversion>=2.0.8 - djangorestframework>=3.6.2 - 2.0.x = - Django>=2.0,<2.1 - django-reversion>=2.0.8 - djangorestframework>=3.7.3 - master = - https://github.com/django/django/tarball/master - django-reversion>=2.0.8 - djangorestframework>=3.6.2 - """ - conf = newconfig([], inisource).envconfigs["django_master-py36"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - ".[test]", - "https://github.com/django/django/tarball/master", - "django-reversion>=2.0.8", - "djangorestframework>=3.6.2", - ] - - conf = newconfig([], inisource).envconfigs["django20-pypy3"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - ".[test]", - "Django>=2.0,<2.1", - "django-reversion>=2.0.8", - "djangorestframework>=3.7.3", - ] - - conf = newconfig([], inisource).envconfigs["django111-py34"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - ".[test]", - "Django>=1.11.0,<2.0.0", - "django-reversion>=2.0.8", - "djangorestframework>=3.6.2", - ] - - conf = newconfig([], inisource).envconfigs["django18-py27"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - ".[test]", - "Django>=1.8.0,<1.9.0", - "django-reversion==1.10.0", - "djangorestframework>=3.3.3,<3.7.0", - ] - - conf = newconfig([], inisource).envconfigs["lint"] - packages = [dep.name for dep in conf.deps] - assert packages == [".[test]"] - - conf = newconfig([], inisource).envconfigs["docs"] - packages = [dep.name for dep in conf.deps] - assert packages == [".[test]"] - - def test_take_dependencies_from_other_section(self, newconfig): - inisource = """ - [testing:pytest] - deps= - pytest - pytest-cov - [testing:mock] - deps= - mock - [testenv] - deps= - {[testing:pytest]deps} - {[testing:mock]deps} - fun - """ - conf = newconfig([], inisource) - env = conf.envconfigs["python"] - packages = [dep.name for dep in env.deps] - assert packages == ["pytest", "pytest-cov", "mock", "fun"] - - def test_multilevel_substitution(self, newconfig): - inisource = """ - [testing:pytest] - deps= - pytest - pytest-cov - [testing:mock] - deps= - mock - - [testing] - deps= - {[testing:pytest]deps} - {[testing:mock]deps} - - [testenv] - deps= - {[testing]deps} - fun - """ - conf = newconfig([], inisource) - env = conf.envconfigs["python"] - packages = [dep.name for dep in env.deps] - assert packages == ["pytest", "pytest-cov", "mock", "fun"] - - def test_recursive_substitution_cycle_fails(self, newconfig): - inisource = """ - [testing:pytest] - deps= - {[testing:mock]deps} - [testing:mock] - deps= - {[testing:pytest]deps} - - [testenv] - deps= - {[testing:pytest]deps} - """ - with pytest.raises(tox.exception.ConfigError): - newconfig([], inisource) - - def test_single_value_from_other_secton(self, newconfig, tmpdir): - inisource = """ - [common] - changedir = testing - [testenv] - changedir = {[common]changedir} - """ - conf = newconfig([], inisource).envconfigs["python"] - assert conf.changedir.basename == "testing" - assert conf.changedir.dirpath().realpath() == tmpdir.realpath() - - def test_factors(self, newconfig): - inisource = """ - [tox] - envlist = a-x,b - - [testenv] - deps= - dep-all - a: dep-a - b: dep-b - x: dep-x - !a: dep-!a - !b: dep-!b - !x: dep-!x - """ - conf = newconfig([], inisource) - configs = conf.envconfigs - expected = ["dep-all", "dep-a", "dep-x", "dep-!b"] - assert [dep.name for dep in configs["a-x"].deps] == expected - expected = ["dep-all", "dep-b", "dep-!a", "dep-!x"] - assert [dep.name for dep in configs["b"].deps] == expected - expected = ["dep-all", "dep-a", "dep-x", "dep-!b"] - assert [dep.name for dep in configs["a-x"].deps] == expected - expected = ["dep-all", "dep-b", "dep-!a", "dep-!x"] - assert [dep.name for dep in configs["b"].deps] == expected - - def test_factor_ops(self, newconfig): - inisource = """ - [tox] - envlist = {a,b}-{x,y} - - [testenv] - deps= - a,b: dep-a-or-b - a-x: dep-a-and-x - {a,b}-y: dep-ab-and-y - a-!x: dep-a-and-!x - a,!x: dep-a-or-!x - !a-!x: dep-!a-and-!x - !a,!x: dep-!a-or-!x - !a-!b: dep-!a-and-!b - !a-!b-!x-!y: dep-!a-and-!b-and-!x-and-!y - """ - configs = newconfig([], inisource).envconfigs - - def get_deps(env): - return [dep.name for dep in configs[env].deps] - - assert get_deps("a-x") == ["dep-a-or-b", "dep-a-and-x", "dep-a-or-!x"] - expected = ["dep-a-or-b", "dep-ab-and-y", "dep-a-and-!x", "dep-a-or-!x", "dep-!a-or-!x"] - assert get_deps("a-y") == expected - assert get_deps("b-x") == ["dep-a-or-b", "dep-!a-or-!x"] - expected = ["dep-a-or-b", "dep-ab-and-y", "dep-a-or-!x", "dep-!a-and-!x", "dep-!a-or-!x"] - assert get_deps("b-y") == expected - - def test_envconfigs_based_on_factors(self, newconfig): - inisource = """ - [testenv] - some-setting= - a: something - b,c: something - d-e: something - !f: something - !g,!h: something - !i-!j: something - - [unknown-section] - some-setting= - eggs: something - """ - config = newconfig(["-e spam"], inisource) - assert not config.envconfigs - assert config.envlist == ["spam"] - config = newconfig(["-e eggs"], inisource) - assert not config.envconfigs - assert config.envlist == ["eggs"] - config = newconfig(["-e py3-spam"], inisource) - assert not config.envconfigs - assert config.envlist == ["py3-spam"] - for x in "abcdefghij": - env = "py3-{}".format(x) - config = newconfig(["-e {}".format(env)], inisource) - assert sorted(config.envconfigs) == [env] - assert config.envlist == [env] - - def test_default_factors(self, newconfig): - inisource = """ - [tox] - envlist = py{27,34,36}-dep - - [testenv] - deps= - dep: dep - """ - conf = newconfig([], inisource) - configs = conf.envconfigs - for name, config in configs.items(): - assert config.basepython == "python{}.{}".format(name[2], name[3]) - - def test_default_factors_conflict(self, newconfig, capsys): - with pytest.warns(UserWarning, match=r"conflicting basepython .*"): - exe = "pypy3" if tox.INFO.IS_PYPY else "python3" - env = "pypy27" if tox.INFO.IS_PYPY else "py27" - config = newconfig( - """\ - [testenv] - basepython={} - [testenv:{}] - commands = python --version - """.format( - exe, env - ) - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs[env] - assert envconfig.basepython == exe - - def test_default_factors_conflict_lying_name( - self, newconfig, capsys, tmpdir, recwarn, monkeypatch - ): - # we first need to create a lying Python here, let's mock out here - from tox.interpreters import Interpreters - - def get_executable(self, envconfig): - return sys.executable - - monkeypatch.setattr(Interpreters, "get_executable", get_executable) - - major, minor = sys.version_info[0:2] - config = newconfig( - """ - [testenv:py{0}{1}] - basepython=python{0}.{2} - commands = python --version - """.format( - major, minor, minor - 1 - ) - ) - env_config = config.envconfigs["py{}{}".format(major, minor)] - assert env_config.basepython == "python{}.{}".format(major, minor - 1) - assert not recwarn.list, "\n".join(repr(i.message) for i in recwarn.list) - - def test_default_single_digit_factors(self, newconfig, monkeypatch): - from tox.interpreters import Interpreters - - def get_executable(self, envconfig): - return sys.executable - - monkeypatch.setattr(Interpreters, "get_executable", get_executable) - - major, minor = sys.version_info[0:2] - - with pytest.warns(None) as lying: - config = newconfig( - """ - [testenv:py{0}] - basepython=python{0}.{1} - commands = python --version - """.format( - major, minor - 1 - ) - ) - - env_config = config.envconfigs["py{}".format(major)] - assert env_config.basepython == "python{}.{}".format(major, minor - 1) - assert len(lying) == 0, "\n".join(repr(r.message) for r in lying) - - with pytest.warns(None) as truthful: - config = newconfig( - """ - [testenv:py{0}] - basepython=python{0}.{1} - commands = python --version - """.format( - major, minor - ) - ) - - env_config = config.envconfigs["py{}".format(major)] - assert env_config.basepython == "python{}.{}".format(major, minor) - assert len(truthful) == 0, "\n".join(repr(r.message) for r in truthful) - - def test_default_factors_conflict_ignore(self, newconfig, capsys): - with pytest.warns(None) as record: - config = newconfig( - """ - [tox] - ignore_basepython_conflict=True - [testenv] - basepython=python3 - [testenv:py27] - commands = python --version - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["py27"] - assert envconfig.basepython == "python2.7" - assert len(record) == 0, "\n".join(repr(r.message) for r in record) - - def test_factors_in_boolean(self, newconfig): - inisource = """ - [tox] - envlist = py{27,36} - - [testenv] - recreate = - py27: True - """ - configs = newconfig([], inisource).envconfigs - assert configs["py27"].recreate - assert not configs["py36"].recreate - - def test_factors_in_setenv(self, newconfig): - inisource = """ - [tox] - envlist = py27,py36 - - [testenv] - setenv = - py27: X = 1 - """ - configs = newconfig([], inisource).envconfigs - assert configs["py27"].setenv["X"] == "1" - assert "X" not in configs["py36"].setenv - - def test_factor_use_not_checked(self, newconfig): - inisource = """ - [tox] - envlist = py27-{a,b} - - [testenv] - deps = b: test - """ - configs = newconfig([], inisource).envconfigs - assert set(configs.keys()) == {"py27-a", "py27-b"} - - def test_factors_groups_touch(self, newconfig): - inisource = """ - [tox] - envlist = {a,b}{-x,} - - [testenv] - deps= - a,b,x,y: dep - """ - configs = newconfig([], inisource).envconfigs - assert set(configs.keys()) == {"a", "a-x", "b", "b-x"} - - def test_period_in_factor(self, newconfig): - inisource = """ - [tox] - envlist = py27-{django1.6,django1.7} - - [testenv] - deps = - django1.6: Django==1.6 - django1.7: Django==1.7 - """ - configs = newconfig([], inisource).envconfigs - assert sorted(configs) == ["py27-django1.6", "py27-django1.7"] - assert [d.name for d in configs["py27-django1.6"].deps] == ["Django==1.6"] - - def test_ignore_outcome(self, newconfig): - inisource = """ - [testenv] - ignore_outcome=True - """ - config = newconfig([], inisource).envconfigs - assert config["python"].ignore_outcome is True - - -class TestGlobalOptions: - def test_notest(self, newconfig): - config = newconfig([], "") - assert not config.option.notest - config = newconfig(["--notest"], "") - assert config.option.notest - - def test_verbosity(self, newconfig): - config = newconfig([], "") - assert config.option.verbose_level == 0 - config = newconfig(["-v"], "") - assert config.option.verbose_level == 1 - config = newconfig(["-vv"], "") - assert config.option.verbose_level == 2 - - @pytest.mark.parametrize("args, expected", [([], 0), (["-q"], 1), (["-qq"], 2), (["-qqq"], 3)]) - def test_quiet(self, args, expected, newconfig): - config = newconfig(args, "") - assert config.option.quiet_level == expected - - def test_substitution_jenkins_default(self, monkeypatch, newconfig): - monkeypatch.setenv("HUDSON_URL", "xyz") - config = newconfig( - """ - [testenv:py27] - commands = - {distshare} - """ - ) - conf = config.envconfigs["py27"] - argv = conf.commands - expect_path = config.toxworkdir.join("distshare") - assert argv[0][0] == expect_path - - def test_substitution_jenkins_context(self, tmpdir, monkeypatch, newconfig): - monkeypatch.setenv("HUDSON_URL", "xyz") - monkeypatch.setenv("WORKSPACE", str(tmpdir)) - config = newconfig( - """ - [tox:jenkins] - distshare = {env:WORKSPACE}/hello - [testenv:py27] - commands = - {distshare} - """ - ) - conf = config.envconfigs["py27"] - argv = conf.commands - assert argv[0][0] == config.distshare - assert config.distshare == tmpdir.join("hello") - - def test_sdist_specification(self, newconfig): - config = newconfig( - """ - [tox] - sdistsrc = {distshare}/xyz.zip - """ - ) - assert config.sdistsrc == config.distshare.join("xyz.zip") - config = newconfig([], "") - assert not config.sdistsrc - - def test_env_selection_with_section_name(self, newconfig, monkeypatch): - inisource = """ - [tox] - envlist = py36 - [testenv:py36] - basepython=python3.6 - [testenv:py35] - basepython=python3.5 - [testenv:py27] - basepython=python2.7 - """ - config = newconfig([], inisource) - assert config.envlist == ["py36"] - config = newconfig(["-epy35"], inisource) - assert config.envlist == ["py35"] - monkeypatch.setenv("TOXENV", "py35,py36") - config = newconfig([], inisource) - assert config.envlist == ["py35", "py36"] - monkeypatch.setenv("TOXENV", "ALL") - config = newconfig([], inisource) - assert config.envlist == ["py36", "py35", "py27"] - config = newconfig(["-eALL"], inisource) - assert config.envlist == ["py36", "py35", "py27"] - config = newconfig(["-espam"], inisource) - assert config.envlist == ["spam"] - - def test_env_selection_expanded_envlist(self, newconfig, monkeypatch): - inisource = """ - [tox] - envlist = py{36,35,27} - [testenv:py36] - basepython=python3.6 - """ - config = newconfig([], inisource) - assert config.envlist == ["py36", "py35", "py27"] - config = newconfig(["-eALL"], inisource) - assert config.envlist == ["py36", "py35", "py27"] - - def test_py_venv(self, newconfig): - config = newconfig(["-epy"], "") - env = config.envconfigs["py"] - assert str(env.basepython) == sys.executable - - def test_no_implicit_venv_from_cli_with_envlist(self, newconfig): - # See issue 1160. - inisource = """ - [tox] - envlist = stated-factors - """ - config = newconfig(["-etypo-factor"], inisource) - assert "typo-factor" not in config.envconfigs - - def test_correct_basepython_chosen_from_default_factors(self, newconfig): - envs = { - "py": sys.executable, - "py2": "python2", - "py3": "python3", - "py27": "python2.7", - "py36": "python3.6", - "py310": "python3.10", - "pypy": "pypy", - "pypy2": "pypy2", - "pypy3": "pypy3", - "pypy36": "pypy3.6", - "jython": "jython", - } - config = newconfig([], "[tox]\nenvlist={}".format(", ".join(envs))) - assert set(config.envlist) == set(envs) - for name in config.envlist: - basepython = config.envconfigs[name].basepython - assert basepython == envs[name] - - def test_envlist_expansion(self, newconfig): - inisource = """ - [tox] - envlist = py{36,27},docs - """ - config = newconfig([], inisource) - assert config.envlist == ["py36", "py27", "docs"] - - def test_envlist_cross_product(self, newconfig): - inisource = """ - [tox] - envlist = py{36,27}-dep{1,2} - """ - config = newconfig([], inisource) - envs = ["py36-dep1", "py36-dep2", "py27-dep1", "py27-dep2"] - assert config.envlist == envs - - def test_envlist_multiline(self, newconfig): - inisource = """ - [tox] - envlist = - py27 - py34 - """ - config = newconfig([], inisource) - assert config.envlist == ["py27", "py34"] - - def test_skip_missing_interpreters_true(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = True - """ - config = newconfig([], ini_source) - assert config.option.skip_missing_interpreters == "true" - - def test_skip_missing_interpreters_false(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = False - """ - config = newconfig([], ini_source) - assert config.option.skip_missing_interpreters == "false" - - def test_skip_missing_interpreters_cli_no_arg(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = False - """ - config = newconfig(["--skip-missing-interpreters"], ini_source) - assert config.option.skip_missing_interpreters == "true" - - def test_skip_missing_interpreters_cli_not_specified(self, newconfig): - config = newconfig([], "") - assert config.option.skip_missing_interpreters == "false" - - def test_skip_missing_interpreters_cli_overrides_true(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = False - """ - config = newconfig(["--skip-missing-interpreters", "true"], ini_source) - assert config.option.skip_missing_interpreters == "true" - - def test_skip_missing_interpreters_cli_overrides_false(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = True - """ - config = newconfig(["--skip-missing-interpreters", "false"], ini_source) - assert config.option.skip_missing_interpreters == "false" - - def test_defaultenv_commandline(self, newconfig): - config = newconfig(["-epy27"], "") - env = config.envconfigs["py27"] - assert env.basepython == "python2.7" - assert not env.commands - - def test_defaultenv_partial_override(self, newconfig): - inisource = """ - [tox] - envlist = py27 - [testenv:py27] - commands= xyz - """ - config = newconfig([], inisource) - env = config.envconfigs["py27"] - assert env.basepython == "python2.7" - assert env.commands == [["xyz"]] - - -class TestHashseedOption: - def _get_envconfigs(self, newconfig, args=None, tox_ini=None, make_hashseed=None): - if args is None: - args = [] - if tox_ini is None: - tox_ini = """ - [testenv] - """ - if make_hashseed is None: - - def make_hashseed(): - return "123456789" - - original_make_hashseed = tox.config.make_hashseed - tox.config.make_hashseed = make_hashseed - try: - config = newconfig(args, tox_ini) - finally: - tox.config.make_hashseed = original_make_hashseed - return config.envconfigs - - def _get_envconfig(self, newconfig, args=None, tox_ini=None): - envconfigs = self._get_envconfigs(newconfig, args=args, tox_ini=tox_ini) - return envconfigs["python"] - - def _check_hashseed(self, envconfig, expected): - assert envconfig.setenv["PYTHONHASHSEED"] == expected - - def _check_testenv(self, newconfig, expected, args=None, tox_ini=None): - envconfig = self._get_envconfig(newconfig, args=args, tox_ini=tox_ini) - self._check_hashseed(envconfig, expected) - - def test_default(self, newconfig): - self._check_testenv(newconfig, "123456789") - - def test_passing_integer(self, newconfig): - args = ["--hashseed", "1"] - self._check_testenv(newconfig, "1", args=args) - - def test_passing_string(self, newconfig): - args = ["--hashseed", "random"] - self._check_testenv(newconfig, "random", args=args) - - def test_passing_empty_string(self, newconfig): - args = ["--hashseed", ""] - self._check_testenv(newconfig, "", args=args) - - def test_passing_no_argument(self, newconfig): - """Test that passing no arguments to --hashseed is not allowed.""" - args = ["--hashseed"] - try: - self._check_testenv(newconfig, "", args=args) - except SystemExit as exception: - assert exception.code == 2 - return - assert False # getting here means we failed the test. - - def test_setenv(self, newconfig): - """Check that setenv takes precedence.""" - tox_ini = """ - [testenv] - setenv = - PYTHONHASHSEED = 2 - """ - self._check_testenv(newconfig, "2", tox_ini=tox_ini) - args = ["--hashseed", "1"] - self._check_testenv(newconfig, "2", args=args, tox_ini=tox_ini) - - def test_noset(self, newconfig): - args = ["--hashseed", "noset"] - envconfig = self._get_envconfig(newconfig, args=args) - assert set(envconfig.setenv.definitions.keys()) == {"TOX_ENV_DIR", "TOX_ENV_NAME"} - - def test_noset_with_setenv(self, newconfig): - tox_ini = """ - [testenv] - setenv = - PYTHONHASHSEED = 2 - """ - args = ["--hashseed", "noset"] - self._check_testenv(newconfig, "2", args=args, tox_ini=tox_ini) - - def test_one_random_hashseed(self, newconfig): - """Check that different testenvs use the same random seed.""" - tox_ini = """ - [testenv:hash1] - [testenv:hash2] - """ - next_seed = [1000] - - # This function is guaranteed to generate a different value each time. - - def make_hashseed(): - next_seed[0] += 1 - return str(next_seed[0]) - - # Check that make_hashseed() works. - assert make_hashseed() == "1001" - envconfigs = self._get_envconfigs(newconfig, tox_ini=tox_ini, make_hashseed=make_hashseed) - self._check_hashseed(envconfigs["hash1"], "1002") - # Check that hash2's value is not '1003', for example. - self._check_hashseed(envconfigs["hash2"], "1002") - - def test_setenv_in_one_testenv(self, newconfig): - """Check using setenv in one of multiple testenvs.""" - tox_ini = """ - [testenv:hash1] - setenv = - PYTHONHASHSEED = 2 - [testenv:hash2] - """ - envconfigs = self._get_envconfigs(newconfig, tox_ini=tox_ini) - self._check_hashseed(envconfigs["hash1"], "2") - self._check_hashseed(envconfigs["hash2"], "123456789") - - -class TestSetenv: - def test_getdict_lazy(self, newconfig, monkeypatch): - monkeypatch.setenv("X", "2") - config = newconfig( - """ - [testenv:X] - key0 = - key1 = {env:X} - key2 = {env:Y:1} - """ - ) - envconfig = config.envconfigs["X"] - val = envconfig._reader.getdict_setenv("key0") - assert val["key1"] == "2" - assert val["key2"] == "1" - - def test_getdict_lazy_update(self, newconfig, monkeypatch): - monkeypatch.setenv("X", "2") - config = newconfig( - """ - [testenv:X] - key0 = - key1 = {env:X} - key2 = {env:Y:1} - """ - ) - envconfig = config.envconfigs["X"] - val = envconfig._reader.getdict_setenv("key0") - d = {} - d.update(val) - assert d == {"key1": "2", "key2": "1"} - - def test_setenv_uses_os_environ(self, newconfig, monkeypatch): - monkeypatch.setenv("X", "1") - config = newconfig( - """ - [testenv:env1] - setenv = - X = {env:X} - """ - ) - assert config.envconfigs["env1"].setenv["X"] == "1" - - def test_setenv_default_os_environ(self, newconfig, monkeypatch): - monkeypatch.delenv("X", raising=False) - config = newconfig( - """ - [testenv:env1] - setenv = - X = {env:X:2} - """ - ) - assert config.envconfigs["env1"].setenv["X"] == "2" - - def test_setenv_uses_other_setenv(self, newconfig): - config = newconfig( - """ - [testenv:env1] - setenv = - Y = 5 - X = {env:Y} - """ - ) - assert config.envconfigs["env1"].setenv["X"] == "5" - - def test_setenv_recursive_direct(self, newconfig): - config = newconfig( - """ - [testenv:env1] - setenv = - X = {env:X:3} - """ - ) - assert config.envconfigs["env1"].setenv["X"] == "3" - - def test_setenv_overrides(self, newconfig): - config = newconfig( - """ - [testenv] - setenv = - PYTHONPATH = something - ANOTHER_VAL=else - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert "PYTHONPATH" in envconfig.setenv - assert "ANOTHER_VAL" in envconfig.setenv - assert envconfig.setenv["PYTHONPATH"] == "something" - assert envconfig.setenv["ANOTHER_VAL"] == "else" - - def test_setenv_with_envdir_and_basepython(self, newconfig): - config = newconfig( - """ - [testenv] - setenv = - VAL = {envdir} - basepython = {env:VAL} - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert "VAL" in envconfig.setenv - assert envconfig.setenv["VAL"] == envconfig.envdir - assert envconfig.basepython == envconfig.envdir - - def test_setenv_ordering_1(self, newconfig): - config = newconfig( - """ - [testenv] - setenv= - VAL={envdir} - commands=echo {env:VAL} - """ - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert "VAL" in envconfig.setenv - assert envconfig.setenv["VAL"] == envconfig.envdir - assert str(envconfig.envdir) in envconfig.commands[0] - - def test_setenv_cross_section_subst_issue294(self, monkeypatch, newconfig): - """test that we can do cross-section substitution with setenv""" - monkeypatch.delenv("TEST", raising=False) - config = newconfig( - """ - [section] - x = - NOT_TEST={env:TEST:defaultvalue} - - [testenv] - setenv = {[section]x} - """ - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["NOT_TEST"] == "defaultvalue" - - def test_setenv_cross_section_subst_twice(self, monkeypatch, newconfig): - """test that we can do cross-section substitution with setenv""" - monkeypatch.delenv("TEST", raising=False) - config = newconfig( - """ - [section] - x = NOT_TEST={env:TEST:defaultvalue} - [section1] - y = {[section]x} - - [testenv] - setenv = {[section1]y} - """ - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["NOT_TEST"] == "defaultvalue" - - def test_setenv_cross_section_mixed(self, monkeypatch, newconfig): - """test that we can do cross-section substitution with setenv""" - monkeypatch.delenv("TEST", raising=False) - config = newconfig( - """ - [section] - x = NOT_TEST={env:TEST:defaultvalue} - - [testenv] - setenv = {[section]x} - y = 7 - """ - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["NOT_TEST"] == "defaultvalue" - assert envconfig.setenv["y"] == "7" - - -class TestIndexServer: - def test_indexserver(self, newconfig): - config = newconfig( - """ - [tox] - indexserver = - name1 = XYZ - name2 = ABC - """ - ) - assert config.indexserver["default"].url is None - assert config.indexserver["name1"].url == "XYZ" - assert config.indexserver["name2"].url == "ABC" - - def test_parse_indexserver(self, newconfig): - inisource = """ - [tox] - indexserver = - default = https://pypi.somewhere.org - name1 = whatever - """ - config = newconfig([], inisource) - assert config.indexserver["default"].url == "https://pypi.somewhere.org" - assert config.indexserver["name1"].url == "whatever" - config = newconfig(["-i", "qwe"], inisource) - assert config.indexserver["default"].url == "qwe" - assert config.indexserver["name1"].url == "whatever" - config = newconfig(["-i", "name1=abc", "-i", "qwe2"], inisource) - assert config.indexserver["default"].url == "qwe2" - assert config.indexserver["name1"].url == "abc" - - config = newconfig(["-i", "ALL=xzy"], inisource) - assert len(config.indexserver) == 2 - assert config.indexserver["default"].url == "xzy" - assert config.indexserver["name1"].url == "xzy" - - def test_multiple_homedir_relative_local_indexservers(self, newconfig): - inisource = """ - [tox] - indexserver = - default = file://{homedir}/.pip/downloads/simple - local1 = file://{homedir}/.pip/downloads/simple - local2 = file://{toxinidir}/downloads/simple - pypi = https://pypi.org/simple - """ - config = newconfig([], inisource) - expected = "file://{}/.pip/downloads/simple".format(config.homedir) - assert config.indexserver["default"].url == expected - assert config.indexserver["local1"].url == config.indexserver["default"].url - - -class TestConfigConstSubstitutions: - @pytest.mark.parametrize("pathsep", [":", ";"]) - def test_replace_pathsep_unix(self, monkeypatch, newconfig, pathsep): - monkeypatch.setattr("os.pathsep", pathsep) - config = newconfig( - """ - [testenv] - setenv = - PATH = dira{:}dirb{:}dirc - """ - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["PATH"] == pathsep.join(["dira", "dirb", "dirc"]) - - def test_pathsep_regex(self): - """Sanity check for regex behavior for empty colon.""" - regex = tox.config.Replacer.RE_ITEM_REF - match = next(regex.finditer("{:}")) - mdict = match.groupdict() - assert mdict["sub_type"] is None - assert mdict["substitution_value"] == "" - assert mdict["default_value"] == "" - - -class TestParseEnv: - def test_parse_recreate(self, newconfig): - inisource = "" - config = newconfig([], inisource) - assert not config.envconfigs["python"].recreate - config = newconfig(["--recreate"], inisource) - assert config.envconfigs["python"].recreate - config = newconfig(["-r"], inisource) - assert config.envconfigs["python"].recreate - inisource = """ - [testenv:hello] - recreate = True - """ - config = newconfig([], inisource) - assert config.envconfigs["hello"].recreate - - -class TestCmdInvocation: - def test_help(self, cmd, initproj): - initproj("help", filedefs={"tox.ini": ""}) - result = cmd("-h") - assert not result.ret - assert re.match(r"usage:.*help.*", result.out, re.DOTALL) - - def test_version_simple(self, cmd, initproj): - initproj("help", filedefs={"tox.ini": ""}) - result = cmd("--version") - assert not result.ret - assert "{} imported from".format(tox.__version__) in result.out - - def test_version_no_plugins(self): - pm = PluginManager("fakeprject") - version_info = get_version_info(pm) - assert "imported from" in version_info - assert "registered plugins:" not in version_info - - def test_version_with_normal_plugin(self, monkeypatch): - def fake_normal_plugin_distinfo(): - class MockModule: - __file__ = "some-file" - - class MockEggInfo: - project_name = "some-project" - version = "1.0" - - return [(MockModule, MockEggInfo)] - - pm = PluginManager("fakeproject") - monkeypatch.setattr(pm, "list_plugin_distinfo", fake_normal_plugin_distinfo) - version_info = get_version_info(pm) - assert "registered plugins:" in version_info - assert "some-file" in version_info - assert "some-project" in version_info - assert "1.0" in version_info - - def test_version_with_fileless_module(self, monkeypatch): - def fake_no_file_plugin_distinfo(): - class MockModule: - def __repr__(self): - return "some-repr" - - class MockEggInfo: - project_name = "some-project" - version = "1.0" - - return [(MockModule(), MockEggInfo)] - - pm = PluginManager("fakeproject") - monkeypatch.setattr(pm, "list_plugin_distinfo", fake_no_file_plugin_distinfo) - version_info = get_version_info(pm) - assert "registered plugins:" in version_info - assert "some-project" in version_info - assert "some-repr" in version_info - assert "1.0" in version_info - - def test_no_tox_ini(self, cmd, initproj): - initproj("noini-0.5") - result = cmd() - result.assert_fail() - msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" - assert result.err == msg - assert not result.out - - -@pytest.mark.parametrize( - "cli_args,run_envlist", - [ - ("-e py36", ["py36"]), - ("-e py36,py34", ["py36", "py34"]), - ("-e py36,py36", ["py36", "py36"]), - ("-e py36,py34 -e py34,py27", ["py36", "py34", "py34", "py27"]), - ], -) -def test_env_spec(initproj, cli_args, run_envlist): - initproj( - "env_spec", - filedefs={ - "tox.ini": """ - [tox] - envlist = - - [testenv] - commands = python -c "" - """ - }, - ) - args = cli_args.split() - config = parseconfig(args) - assert config.envlist == run_envlist - - -class TestCommandParser: - def test_command_parser_for_word(self): - p = CommandParser("word") - assert list(p.words()) == ["word"] - - def test_command_parser_for_posargs(self): - p = CommandParser("[]") - assert list(p.words()) == ["[]"] - - def test_command_parser_for_multiple_words(self): - p = CommandParser("w1 w2 w3 ") - assert list(p.words()) == ["w1", " ", "w2", " ", "w3"] - - def test_command_parser_for_substitution_with_spaces(self): - p = CommandParser("{sub:something with spaces}") - assert list(p.words()) == ["{sub:something with spaces}"] - - def test_command_parser_with_complex_word_set(self): - complex_case = ( - "word [] [literal] {something} {some:other thing} w{ord} w{or}d w{ord} " - "w{o:rd} w{o:r}d {w:or}d w[]ord {posargs:{a key}}" - ) - p = CommandParser(complex_case) - parsed = list(p.words()) - expected = [ - "word", - " ", - "[]", - " ", - "[literal]", - " ", - "{something}", - " ", - "{some:other thing}", - " ", - "w", - "{ord}", - " ", - "w", - "{or}", - "d", - " ", - "w", - "{ord}", - " ", - "w", - "{o:rd}", - " ", - "w", - "{o:r}", - "d", - " ", - "{w:or}", - "d", - " ", - "w[]ord", - " ", - "{posargs:{a key}}", - ] - - assert parsed == expected - - def test_command_with_runs_of_whitespace(self): - cmd = "cmd1 {item1}\n {item2}" - p = CommandParser(cmd) - parsed = list(p.words()) - assert parsed == ["cmd1", " ", "{item1}", "\n ", "{item2}"] - - def test_command_with_split_line_in_subst_arguments(self): - cmd = dedent( - """ cmd2 {posargs:{item2} - other}""" - ) - p = CommandParser(cmd) - parsed = list(p.words()) - expected = ["cmd2", " ", "{posargs:{item2}\n other}"] - assert parsed == expected - - def test_command_parsing_for_issue_10(self): - cmd = "nosetests -v -a !deferred --with-doctest []" - p = CommandParser(cmd) - parsed = list(p.words()) - expected = [ - "nosetests", - " ", - "-v", - " ", - "-a", - " ", - "!deferred", - " ", - "--with-doctest", - " ", - "[]", - ] - assert parsed == expected - - # @mark_dont_run_on_windows - def test_commands_with_backslash(self, newconfig): - config = newconfig( - [r"hello\world"], - """ - [testenv:py36] - commands = some {posargs} - """, - ) - envconfig = config.envconfigs["py36"] - assert envconfig.commands[0] == ["some", r"hello\world"] - - -def test_isolated_build_env_cannot_be_in_envlist(newconfig, capsys): - inisource = """ - [tox] - envlist = py36,package - isolated_build = True - isolated_build_env = package - """ - with pytest.raises( - tox.exception.ConfigError, match="isolated_build_env package cannot be part of envlist" - ): - newconfig([], inisource) - - out, err = capsys.readouterr() - assert not err - assert not out - - -def test_isolated_build_overrides(newconfig, capsys): - inisource = """ - [tox] - isolated_build = True - - [testenv] - deps = something crazy here - - [testenv:.package] - deps = - """ - config = newconfig([], inisource) - deps = config.envconfigs.get(".package").deps - assert deps == [] - - -@pytest.mark.parametrize( - "key, set_value, default", [("deps", "crazy", []), ("sitepackages", "True", False)] -) -def test_isolated_build_ignores(newconfig, capsys, key, set_value, default): - config = newconfig( - [], - """ - [tox] - isolated_build = True - - [testenv] - {} = {} - """.format( - key, set_value - ), - ) - package_env = config.envconfigs.get(".package") - value = getattr(package_env, key) - assert value == default - - -def test_config_via_pyproject_legacy(initproj): - initproj( - "config_via_pyproject_legacy-0.5", - filedefs={ - "pyproject.toml": ''' - [tool.tox] - legacy_tox_ini = """ - [tox] - envlist = py27 - """ - ''' - }, - ) - config = parseconfig([]) - assert config.envlist == ["py27"] - - -def test_config_bad_pyproject_specified(initproj, capsys): - base = initproj("config_via_pyproject_legacy-0.5", filedefs={"pyproject.toml": ""}) - with pytest.raises(SystemExit): - parseconfig(["-c", str(base.join("pyproject.toml"))]) - - out, err = capsys.readouterr() - msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" - assert err == msg - assert "ERROR:" not in out - - -@pytest.mark.skipif(sys.platform == "win32", reason="no named pipes on Windows") -def test_config_bad_config_type_specified(monkeypatch, tmpdir, capsys): - monkeypatch.chdir(tmpdir) - name = tmpdir.join("named_pipe") - os.mkfifo(str(name)) - with pytest.raises(SystemExit): - parseconfig(["-c", str(name)]) - - out, err = capsys.readouterr() - notes = ( - "ERROR: {} is neither file or directory".format(name), - "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found", - ) - msg = "\n".join(notes) + "\n" - assert err == msg - assert "ERROR:" not in out - - -def test_interactive_na(newconfig, monkeypatch): - monkeypatch.setattr(tox.config, "is_interactive", lambda: False) - config = newconfig( - """ - [testenv:py] - setenv = A = {tty:X:Y} - """ - ) - assert config.envconfigs["py"].setenv["A"] == "Y" - - -def test_interactive_available(newconfig, monkeypatch): - monkeypatch.setattr(tox.config, "is_interactive", lambda: True) - config = newconfig( - """ - [testenv:py] - setenv = A = {tty:X:Y} - """ - ) - assert config.envconfigs["py"].setenv["A"] == "X" - - -def test_interactive(): - tox.config.is_interactive() - - -def test_config_current_py(newconfig, current_tox_py, cmd, tmpdir, monkeypatch): - monkeypatch.chdir(tmpdir) - config = newconfig( - """ - [tox] - envlist = {0} - skipsdist = True - - [testenv:{0}] - commands = python -c "print('all')" - """.format( - current_tox_py - ) - ) - assert config.envconfigs[current_tox_py] - result = cmd() - result.assert_success() - - -def test_posargs_relative_changedir(newconfig, tmpdir): - dir1 = tmpdir.join("dir1").ensure() - tmpdir.join("dir2").ensure() - with tmpdir.as_cwd(): - config = newconfig( - """\ - [tox] - [testenv] - changedir = dir2 - commands = - echo {posargs} - """ - ) - config.option.args = ["dir1", dir1.strpath, "dir3"] - testenv = config.envconfigs["python"] - PosargsOption().postprocess(testenv, config.option.args) - - assert testenv._reader.posargs == [ - # should have relative-ized - os.path.join("..", "dir1"), - # should have stayed the same, - dir1.strpath, - "dir3", - ] - - -def test_config_no_version_data_in__name(newconfig, capsys): - newconfig( - """ - [tox] - envlist = py, pypy, jython - [testenv] - basepython = python - """ - ) - out, err = capsys.readouterr() - assert not out - assert not err diff --git a/tests/unit/config/test_config_parallel.py b/tests/unit/config/test_config_parallel.py deleted file mode 100644 index f2333a793..000000000 --- a/tests/unit/config/test_config_parallel.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest - - -def test_parallel_default(newconfig): - config = newconfig([], "") - assert isinstance(config.option.parallel, int) - assert config.option.parallel == 0 - assert config.option.parallel_live is False - - -def test_parallel_live_on(newconfig): - config = newconfig(["-o"], "") - assert config.option.parallel_live is True - - -def test_parallel_auto(newconfig): - config = newconfig(["-p", "auto"], "") - assert isinstance(config.option.parallel, int) - assert config.option.parallel > 0 - - -def test_parallel_all(newconfig): - config = newconfig(["-p", "all"], "") - assert config.option.parallel is None - - -def test_parallel_number(newconfig): - config = newconfig(["-p", "2"], "") - assert config.option.parallel == 2 - - -def test_parallel_number_negative(newconfig, capsys): - with pytest.raises(SystemExit): - newconfig(["-p", "-1"], "") - - out, err = capsys.readouterr() - assert not out - assert "value must be positive" in err - - -def test_depends(newconfig, capsys): - config = newconfig( - """\ - [tox] - [testenv:py] - depends = py37, py36 - """ - ) - assert config.envconfigs["py"].depends == ("py37", "py36") - - -def test_depends_multi_row_facotr(newconfig, capsys): - config = newconfig( - """\ - [tox] - [testenv:py] - depends = py37, - {py36}-{a,b} - """ - ) - assert config.envconfigs["py"].depends == ("py37", "py36-a", "py36-b") - - -def test_depends_factor(newconfig, capsys): - config = newconfig( - """\ - [tox] - [testenv:py] - depends = {py37, py36}-{cov,no} - """ - ) - assert config.envconfigs["py"].depends == ("py37-cov", "py37-no", "py36-cov", "py36-no") diff --git a/tests/unit/config/test_main.py b/tests/unit/config/test_main.py new file mode 100644 index 000000000..88efddfe3 --- /dev/null +++ b/tests/unit/config/test_main.py @@ -0,0 +1,126 @@ +from collections import OrderedDict +from pathlib import Path +from typing import Dict, Optional, Set, TypeVar + +import pytest + +from tox.config.main import Config +from tox.config.sets import ConfigSet +from tox.config.source.ini import IniLoader +from tox.pytest import ToxProject, ToxProjectCreator + + +@pytest.fixture +def emtpy_project(tox_project: ToxProjectCreator) -> ToxProject: + return tox_project({"tox.ini": ""}) + + +@pytest.fixture +def emtpy_config(emtpy_project: ToxProject) -> Config: + return emtpy_project.config() + + +def test_empty_config_root(emtpy_config, emtpy_project): + assert emtpy_config.core["tox_root"] == emtpy_project.path + + +def test_empty_config_repr(emtpy_config, emtpy_project): + text = repr(emtpy_config) + assert str(emtpy_project.path) in text + assert "config_source=Ini" in text + + +def test_empty_conf_tox_envs(emtpy_config): + tox_env_keys = list(emtpy_config) + assert tox_env_keys == [] + + +def test_empty_conf_get(emtpy_config): + result = emtpy_config["magic"] + assert isinstance(result, ConfigSet) + loaders = result["base"] + assert len(loaders) == 1 + assert isinstance(loaders[0], IniLoader) + + +def test_config_some_envs(tox_project: ToxProjectCreator): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = py38, py37 + [testenv] + deps = 1 + other: 2 + path = path + set = 1,2,3 + optional_none = + dict = + a=1 + b=2 + c=3 + bad_dict = + something + bad_bool = whatever + crazy = something-bad + [testenv:magic] + """ + } + ) + config = project.config() + tox_env_keys = list(config) + assert tox_env_keys == ["py38", "py37", "other", "magic"] + + config_set = config["py37"] + assert repr(config_set) + assert isinstance(config_set, ConfigSet) + assert list(config_set) == ["base"] + + config_set.add_config(keys="deps", of_type=str, default="", desc="ok") + dynamic_materialize = config_set["deps"] + assert dynamic_materialize == "1" + + config_set.add_config(keys="path", of_type=Path, default=Path(), desc="path") + path_materialize = config_set["path"] + assert path_materialize == Path("path") + + config_set.add_config(keys="set", of_type=Set[int], default=set(), desc="set") + set_materialize = config_set["set"] + assert set_materialize == {1, 2, 3} + + config_set.add_config(keys="optional_none", of_type=Optional[int], default=1, desc="set") + optional_none = config_set["optional_none"] + assert optional_none is None + + config_set.add_config(keys="dict", of_type=Dict[str, int], default=dict(), desc="dict") + dict_val = config_set["dict"] + assert dict_val == OrderedDict([("a", 1), ("b", 2), ("c", 3)]) + + config_set.add_config(keys="crazy", of_type=TypeVar, default="1", desc="crazy") + with pytest.raises(TypeError) as context: + assert config_set["crazy"] + assert str(context.value) == "something-bad cannot cast to " + + config_set.add_config(keys="bad_dict", of_type=Dict[str, str], default={}, desc="bad_dict") + with pytest.raises(TypeError) as context: + assert config_set["bad_dict"] + assert str(context.value) == "dictionary lines must be of form key=value, found something" + + config_set.add_config(keys="bad_bool", of_type=bool, default=False, desc="bad_bool") + with pytest.raises(TypeError) as context: + assert config_set["bad_bool"] + error = ( + "value whatever cannot be transformed to bool, valid: 0, 1, false, no, off, on, true, yes" + ) + assert str(context.value) == error + + config_set.add_constant(keys="a", value=1, desc="ok") + const = config_set["a"] + assert const == 1 + + config_set.add_constant(keys="b", value=lambda: 2, desc="ok") + lazy_const = config_set["b"] + assert lazy_const == 2 + + for defined in config_set._defined.values(): + assert repr(defined) diff --git a/tests/unit/execute/bad_process.py b/tests/unit/execute/bad_process.py new file mode 100644 index 000000000..1798d27e6 --- /dev/null +++ b/tests/unit/execute/bad_process.py @@ -0,0 +1,27 @@ +import signal +import sys +import time +from pathlib import Path + + +def handler(signum, frame): + print("how about no signal {}".format(signum), file=sys.stdout) + sys.stdout.flush() # force output now before we get killed + + +signal.signal(signal.SIGTERM, handler) + +idle_file = Path(sys.argv[1]) +start_file = Path(sys.argv[2]) + +idle_file.write_text("") +time.sleep(float(sys.argv[3])) + +while True: + try: + if not start_file.exists(): + start_file.write_text("") + time.sleep(100) + except KeyboardInterrupt: + print("how about no KeyboardInterrupt", file=sys.stderr) + sys.stderr.flush() # force output now before we get killed diff --git a/tests/unit/execute/local_subprocess_sigint.py b/tests/unit/execute/local_subprocess_sigint.py new file mode 100644 index 000000000..680f649af --- /dev/null +++ b/tests/unit/execute/local_subprocess_sigint.py @@ -0,0 +1,35 @@ +import logging +import os +import sys +from pathlib import Path + +from tox.execute import local_sub_process +from tox.execute.api import ToxKeyboardInterrupt + +logging.basicConfig(level=logging.NOTSET) +bad_process = Path(__file__).parent / "bad_process.py" + +executor = local_sub_process.LocalSubProcessExecutor() +local_sub_process.WAIT_GENERAL = 0.05 +request = local_sub_process.ExecuteRequest( + cmd=[ + sys.executable, + bad_process, + sys.argv[1], + sys.argv[2], + str(local_sub_process.WAIT_GENERAL * 3), + ], + cwd=Path().absolute(), + env=os.environ, + allow_stdin=False, +) + + +try: + executor(request, show_on_standard=False) +except ToxKeyboardInterrupt as exception: + outcome = exception.outcome + print(outcome.exit_code) + print(repr(outcome.out)) + print(repr(outcome.err)) + print(outcome.elapsed, end="") diff --git a/tests/unit/execute/test_local_subprocess.py b/tests/unit/execute/test_local_subprocess.py new file mode 100644 index 000000000..42f5c2ece --- /dev/null +++ b/tests/unit/execute/test_local_subprocess.py @@ -0,0 +1,206 @@ +import logging +import os +import signal +import subprocess +import sys +from pathlib import Path + +import psutil +import pytest +from colorama import Fore + +from tox.execute.api import Outcome +from tox.execute.local_sub_process import LocalSubProcessExecutor +from tox.execute.request import ExecuteRequest + + +def test_local_execute_basic_pass(capsys, caplog): + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor() + request = ExecuteRequest( + cmd=[ + sys.executable, + "-c", + "import sys; print('out', end=''); print('err', end='', file=sys.stderr)", + ], + cwd=Path(), + env=os.environ, + allow_stdin=False, + ) + outcome = executor.__call__(request, show_on_standard=False) + assert bool(outcome) is True + assert outcome.exit_code == Outcome.OK + assert outcome.err == "err" + assert outcome.out == "out" + assert outcome.request == request + out, err = capsys.readouterr() + assert not out + assert not err + assert not caplog.records + + +def test_local_execute_basic_pass_show_on_standard(capsys, caplog): + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor() + request = ExecuteRequest( + cmd=[ + sys.executable, + "-c", + "import sys; print('out', end=''); print('err', end='', file=sys.stderr)", + ], + cwd=Path(), + env=os.environ, + allow_stdin=False, + ) + outcome = executor.__call__(request, show_on_standard=True) + assert bool(outcome) is True + assert outcome.exit_code == Outcome.OK + assert outcome.err == "err" + assert outcome.out == "out" + out, err = capsys.readouterr() + assert out == "out" + expected = "{}err{}".format(Fore.RED, Fore.RESET) + assert err == expected + assert not caplog.records + + +def test_local_execute_basic_pass_show_on_standard_newline_flush(capsys, caplog): + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor() + request = ExecuteRequest( + cmd=[sys.executable, "-c", "import sys; print('out'); print('yay')"], + cwd=Path(), + env=os.environ, + allow_stdin=False, + ) + outcome = executor.__call__(request, show_on_standard=True) + assert bool(outcome) is True + assert outcome.exit_code == Outcome.OK + assert not outcome.err + assert outcome.out == "out\nyay\n" + out, err = capsys.readouterr() + assert out == "out\nyay\n" + assert not err + assert not caplog.records + + +def test_local_execute_write_a_lot(capsys, caplog): + executor = LocalSubProcessExecutor() + request = ExecuteRequest( + cmd=[ + sys.executable, + "-c", + "import sys; print('e' * 4096, file=sys.stderr, end=''); print('o' * 4096, file=sys.stdout, end='')", + ], + cwd=Path(), + env=os.environ, + allow_stdin=False, + ) + outcome = executor.__call__(request, show_on_standard=False) + assert bool(outcome) + assert outcome.out == "o" * 4096 + assert outcome.err == "e" * 4096 + + +def test_local_execute_basic_fail(caplog, capsys): + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor() + cwd = Path().absolute() + cmd = [ + sys.executable, + "-c", + "import sys; print('out', end=''); print('err', file=sys.stderr, end=''); sys.exit(3)", + ] + request = ExecuteRequest(cmd=cmd, cwd=cwd, env=os.environ, allow_stdin=False) + + # run test + outcome = executor.__call__(request, show_on_standard=False) + + # assert no output, no logs + out, err = capsys.readouterr() + assert not out + assert not err + assert not caplog.records + + # assert return object + assert bool(outcome) is False + assert outcome.exit_code == 3 + assert outcome.err == "err" + assert outcome.out == "out" + assert outcome.request == request + + # asset fail + logger = logging.getLogger(__name__) + with pytest.raises(SystemExit) as context: + outcome.assert_success(logger) + # asset fail + assert context.value.code == 3 + + out, err = capsys.readouterr() + assert out == "out\n" + expected = "{}err{}\n".format(Fore.RED, Fore.RESET) + assert err == expected + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.levelno == logging.CRITICAL + assert record.msg == "exit code %d for %s: %s in %s" + _code, _cwd, _cmd, _duration = record.args + assert _code == 3 + assert _cwd == cwd + assert _cmd == request.shell_cmd + assert isinstance(_duration, float) + assert _duration > 0 + + +def test_command_does_not_exist(capsys, caplog): + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor() + request = ExecuteRequest( + cmd=["sys-must-be-missing".format(sys.executable)], + cwd=Path().absolute(), + env=os.environ, + allow_stdin=False, + ) + outcome = executor.__call__(request, show_on_standard=False) + + assert bool(outcome) is False + assert outcome.exit_code != Outcome.OK + assert outcome.out == "" + assert outcome.err == "" + assert not caplog.records + + +def test_command_keyboard_interrupt(tmp_path): + send_signal = tmp_path / "send" + process = subprocess.Popen( + [ + sys.executable, + Path(__file__).parent / "local_subprocess_sigint.py", + tmp_path / "idle", + send_signal, + ], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + universal_newlines=True, + ) + while not send_signal.exists(): + assert process.poll() is None + + root = process.pid + child = next(iter(psutil.Process(pid=root).children())).pid + process.send_signal(signal.SIGINT) + out, err = process.communicate() + + assert "ERROR:root:got KeyboardInterrupt signal" in err, err + assert "WARNING:root:KeyboardInterrupt from {} SIGINT pid {}".format(root, child) in err, err + assert "WARNING:root:KeyboardInterrupt from {} SIGTERM pid {}".format(root, child) in err, err + assert "INFO:root:KeyboardInterrupt from {} SIGKILL pid {}".format(root, child) in err, err + + outs = out.split("\n") + + exit_code = int(outs[0]) + assert exit_code == -9 + assert float(outs[3]) > 0 # duration + assert "how about no signal 15" in outs[1], outs[1] # stdout + assert "how about no KeyboardInterrupt" in outs[2], outs[2] # stderr diff --git a/tests/unit/interpreters/discovery/test_spec.py b/tests/unit/interpreters/discovery/test_spec.py new file mode 100644 index 000000000..d2b1b5b7f --- /dev/null +++ b/tests/unit/interpreters/discovery/test_spec.py @@ -0,0 +1,26 @@ +import pytest + +from tox.interpreters.discovery.py_spec import PythonSpec + + +@pytest.mark.parametrize( + "spec, outcome", + [ + ("python3.7", PythonSpec("python3.7", "CPython", 3, 7, None, None, None)), + ("python3.7.1", PythonSpec("python3.7.1", "CPython", 3, 7, 1, None, None)), + ("python3.7.1-32", PythonSpec("python3.7.1-32", "CPython", 3, 7, 1, 32, None)), + ("python3.7.1-64", PythonSpec("python3.7.1-64", "CPython", 3, 7, 1, 64, None)), + ( + "python3.7.1-65", + PythonSpec("python3.7.1-65", None, None, None, None, None, "python3.7.1-65"), + ), + ("python3.6", PythonSpec("python3.6", "CPython", 3, 6, None, None, None)), + ("python37", PythonSpec("python37", "CPython", 3, 7, None, None, None)), + ("python36", PythonSpec("python36", "CPython", 3, 6, None, None, None)), + ("py37", PythonSpec("py37", "CPython", 3, 7, None, None, None)), + ("py36", PythonSpec("py36", "CPython", 3, 6, None, None, None)), + ("py3.7.8.1", PythonSpec("py3.7.8.1", None, None, None, None, None, "py3.7.8.1")), + ], +) +def test_spec(spec, outcome): + assert PythonSpec.from_string_spec(spec) == outcome diff --git a/tests/unit/interpreters/test_interpreters.py b/tests/unit/interpreters/test_interpreters.py deleted file mode 100644 index 31d67ac7e..000000000 --- a/tests/unit/interpreters/test_interpreters.py +++ /dev/null @@ -1,221 +0,0 @@ -from __future__ import unicode_literals - -import os -import stat -import subprocess -import sys - -import py -import pytest - -import tox -from tox import reporter -from tox.config import get_plugin_manager -from tox.interpreters import ( - ExecFailed, - InterpreterInfo, - Interpreters, - NoInterpreterInfo, - run_and_get_interpreter_info, - tox_get_python_executable, -) -from tox.reporter import Verbosity - - -@pytest.fixture(name="interpreters") -def create_interpreters_instance(): - pm = get_plugin_manager() - return Interpreters(hook=pm.hook) - - -@pytest.mark.skipif(tox.INFO.IS_PYPY, reason="testing cpython interpreter discovery") -def test_tox_get_python_executable(): - class envconfig: - basepython = sys.executable - envname = "pyxx" - - def get_exe(name): - envconfig.basepython = name - p = tox_get_python_executable(envconfig) - assert p - return str(p) - - def assert_version_in_output(exe, version): - out = subprocess.check_output((exe, "-V"), stderr=subprocess.STDOUT) - assert version in out.decode() - - p = tox_get_python_executable(envconfig) - assert p == py.path.local(sys.executable) - for major, minor in [(2, 7), (3, 5), (3, 6), (3, 7), (3, 8)]: - name = "python{}.{}".format(major, minor) - if tox.INFO.IS_WIN: - pydir = "python{}{}".format(major, minor) - x = py.path.local(r"c:\{}".format(pydir)) - if not x.check(): - continue - else: - if not py.path.local.sysfind(name) or subprocess.call((name, "-c", "")): - continue - exe = get_exe(name) - assert_version_in_output(exe, "{}.{}".format(major, minor)) - has_py_exe = py.path.local.sysfind("py") is not None - for major in (2, 3): - name = "python{}".format(major) - if has_py_exe: - error_code = subprocess.call(("py", "-{}".format(major), "-c", "")) - if error_code: - continue - elif not py.path.local.sysfind(name): - continue - - exe = get_exe(name) - assert_version_in_output(exe, str(major)) - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="symlink execution unreliable on Windows") -def test_find_alias_on_path(monkeypatch, tmp_path): - reporter.update_default_reporter(Verbosity.DEFAULT, Verbosity.DEBUG) - magic = tmp_path / "magic{}".format(os.path.splitext(sys.executable)[1]) - os.symlink(sys.executable, str(magic)) - monkeypatch.setenv( - str("PATH"), - os.pathsep.join([str(tmp_path)] + os.environ.get(str("PATH"), "").split(os.pathsep)), - ) - - class envconfig: - basepython = "magic" - envname = "pyxx" - - detected = py.path.local.sysfind("magic") - assert detected - - t = tox_get_python_executable(envconfig).lower() - assert t == str(magic).lower() - - -def test_run_and_get_interpreter_info(): - name = os.path.basename(sys.executable) - info = run_and_get_interpreter_info(name, sys.executable) - assert info.version_info == tuple(sys.version_info) - assert info.name == name - assert info.executable == sys.executable - - -class TestInterpreters: - def test_get_executable(self, interpreters): - class envconfig: - basepython = sys.executable - envname = "pyxx" - - x = interpreters.get_executable(envconfig) - assert x == sys.executable - info = interpreters.get_info(envconfig) - assert info.version_info == tuple(sys.version_info) - assert info.executable == sys.executable - assert isinstance(info, InterpreterInfo) - - def test_get_executable_no_exist(self, interpreters): - class envconfig: - basepython = "1lkj23" - envname = "pyxx" - - assert not interpreters.get_executable(envconfig) - info = interpreters.get_info(envconfig) - assert not info.version_info - assert info.name == "1lkj23" - assert not info.executable - assert isinstance(info, NoInterpreterInfo) - - @pytest.mark.skipif("sys.platform == 'win32'", reason="Uses a unix only wrapper") - def test_get_info_uses_hook_path(self, tmp_path): - magic = tmp_path / "magic{}".format(os.path.splitext(sys.executable)[1]) - wrapper = ( - "#!{executable}\n" - "import subprocess\n" - "import sys\n" - 'sys.exit(subprocess.call(["{executable}"] + sys.argv[1:]))\n' - ).format(executable=sys.executable) - magic.write_text(wrapper) - magic.chmod(magic.stat().st_mode | stat.S_IEXEC) - - class MockHook: - def tox_get_python_executable(self, envconfig): - return str(magic) - - class envconfig: - basepython = sys.executable - envname = "magicpy" - - # Check that the wrapper is working first. - # If it isn't, the default is to return the passed path anyway. - subprocess.check_call([str(magic), "--help"]) - - interpreters = Interpreters(hook=MockHook()) - info = interpreters.get_info(envconfig) - assert info.executable == str(magic) - - def test_get_sitepackagesdir_error(self, interpreters): - class envconfig: - basepython = sys.executable - envname = "123" - - info = interpreters.get_info(envconfig) - s = interpreters.get_sitepackagesdir(info, "") - assert s - - -def test_exec_failed(): - x = ExecFailed("my-executable", "my-source", "my-out", "my-err") - assert isinstance(x, Exception) - assert x.executable == "my-executable" - assert x.source == "my-source" - assert x.out == "my-out" - assert x.err == "my-err" - - -class TestInterpreterInfo: - @staticmethod - def info( - name="my-name", - executable="my-executable", - version_info="my-version-info", - sysplatform="my-sys-platform", - ): - return InterpreterInfo(name, executable, version_info, sysplatform, True) - - def test_data(self): - x = self.info("larry", "moe", "shemp", "curly") - assert x.name == "larry" - assert x.executable == "moe" - assert x.version_info == "shemp" - assert x.sysplatform == "curly" - - def test_str(self): - x = self.info(executable="foo", version_info="bar") - assert str(x) == "" - - -class TestNoInterpreterInfo: - def test_default_data(self): - x = NoInterpreterInfo("foo") - assert x.name == "foo" - assert x.executable is None - assert x.version_info is None - assert x.out is None - assert x.err == "not found" - - def test_set_data(self): - x = NoInterpreterInfo("migraine", executable="my-executable", out="my-out", err="my-err") - assert x.name == "migraine" - assert x.executable == "my-executable" - assert x.version_info is None - assert x.out == "my-out" - assert x.err == "my-err" - - def test_str_without_executable(self): - x = NoInterpreterInfo("coconut") - assert str(x) == "" - - def test_str_with_executable(self): - x = NoInterpreterInfo("coconut", executable="bang/em/together") - assert str(x) == "" diff --git a/tests/unit/interpreters/windows/test_pep514.py b/tests/unit/interpreters/windows/test_pep514.py deleted file mode 100644 index cc97457c2..000000000 --- a/tests/unit/interpreters/windows/test_pep514.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import unicode_literals - -import inspect -import subprocess -import sys - -from tox._pytestplugin import mark_dont_run_on_posix - - -@mark_dont_run_on_posix -def test_discover_winreg(): - from tox.interpreters.windows.pep514 import discover_pythons - - list(discover_pythons()) # raises no error - - -@mark_dont_run_on_posix -def test_run_pep514_main_no_warnings(): - # check we trigger no warnings - import tox.interpreters.windows.pep514 as pep514 - - out = subprocess.check_output( - [sys.executable, inspect.getsourcefile(pep514)], universal_newlines=True - ) - assert "PEP-514 violation in Windows Registry " not in out, out diff --git a/tests/unit/interpreters/windows/test_windows.py b/tests/unit/interpreters/windows/test_windows.py deleted file mode 100644 index 43cb7ccad..000000000 --- a/tests/unit/interpreters/windows/test_windows.py +++ /dev/null @@ -1,20 +0,0 @@ -from tox._pytestplugin import mark_dont_run_on_posix - - -@mark_dont_run_on_posix -def test_locate_via_pep514(monkeypatch): - from tox.interpreters.py_spec import CURRENT - import tox.interpreters.windows - - del tox.interpreters.windows._PY_AVAILABLE[:] - exe = tox.interpreters.windows.locate_via_pep514(CURRENT) - assert exe - assert len(tox.interpreters.windows._PY_AVAILABLE) - - import tox.interpreters.windows.pep514 - - def raise_on_call(): - raise RuntimeError() - - monkeypatch.setattr(tox.interpreters.windows.pep514, "discover_pythons", raise_on_call) - assert tox.interpreters.windows.locate_via_pep514(CURRENT) diff --git a/tests/unit/package/builder/test_package_builder_isolated.py b/tests/unit/package/builder/test_package_builder_isolated.py deleted file mode 100644 index 7cec74933..000000000 --- a/tests/unit/package/builder/test_package_builder_isolated.py +++ /dev/null @@ -1,140 +0,0 @@ -import os - -import py -import pytest - -from tox.package.builder.isolated import get_build_info -from tox.reporter import _INSTANCE - - -def test_verbose_isolated_build(initproj, mock_venv, cmd): - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("--sdistonly", "-v", "-v", "-v", "-e", "py") - assert "running sdist" in result.out, result.out - assert "running egg_info" in result.out, result.out - assert "Writing example123-0.5{}setup.cfg".format(os.sep) in result.out, result.out - - -def test_dist_exists_version_change(mock_venv, initproj, cmd): - base = initproj( - "package_toml-{}".format("0.1"), - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("-e", "py") - result.assert_success() - - new_code = base.join("setup.py").read_text("utf-8").replace("0.1", "0.2") - base.join("setup.py").write_text(new_code, "utf-8") - - result = cmd("-e", "py") - result.assert_success() - - -def test_package_isolated_no_pyproject_toml(initproj, cmd): - initproj( - "package_no_toml-0.1", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """ - }, - ) - result = cmd("--sdistonly", "-e", "py") - result.assert_fail() - assert result.outlines == ["ERROR: missing {}".format(py.path.local().join("pyproject.toml"))] - - -def toml_file_check(initproj, version, message, toml): - initproj( - "package_toml-{}".format(version), - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """, - "pyproject.toml": toml, - }, - ) - - with pytest.raises(SystemExit, match="1"): - get_build_info(py.path.local()) - toml_file = py.path.local().join("pyproject.toml") - msg = "ERROR: {} inside {}".format(message, toml_file) - assert _INSTANCE.messages == [msg] - - -def test_package_isolated_toml_no_build_system(initproj): - toml_file_check(initproj, 1, "build-system section missing", "") - - -def test_package_isolated_toml_no_requires(initproj): - toml_file_check( - initproj, - 2, - "missing requires key at build-system section", - """ - [build-system] - """, - ) - - -def test_package_isolated_toml_no_backend(initproj): - toml_file_check( - initproj, - 3, - "missing build-backend key at build-system section", - """ - [build-system] - requires = [] - """, - ) - - -def test_package_isolated_toml_bad_requires(initproj): - toml_file_check( - initproj, - 4, - "requires key at build-system section must be a list of string", - """ - [build-system] - requires = "" - build-backend = "" - """, - ) - - -def test_package_isolated_toml_bad_backend(initproj): - toml_file_check( - initproj, - 5, - "build-backend key at build-system section must be a string", - """ - [build-system] - requires = [] - build-backend = [] - """, - ) diff --git a/tests/unit/package/builder/test_package_builder_legacy.py b/tests/unit/package/builder/test_package_builder_legacy.py deleted file mode 100644 index 401f2db7d..000000000 --- a/tests/unit/package/builder/test_package_builder_legacy.py +++ /dev/null @@ -1,17 +0,0 @@ -import os - - -def test_verbose_legacy_build(initproj, mock_venv, cmd): - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = false - """ - }, - ) - result = cmd("--sdistonly", "-vvv", "-e", "py") - assert "running sdist" in result.out, result.out - assert "running egg_info" in result.out, result.out - assert "Writing example123-0.5{}setup.cfg".format(os.sep) in result.out, result.out diff --git a/tests/unit/package/test_package.py b/tests/unit/package/test_package.py deleted file mode 100644 index 0a1b8bc09..000000000 --- a/tests/unit/package/test_package.py +++ /dev/null @@ -1,122 +0,0 @@ -import re - -from tox.config import parseconfig -from tox.package import get_package -from tox.session import Session - - -def test_install_via_installpkg(mock_venv, initproj, cmd): - base = initproj( - "pkg-0.1", - filedefs={ - "tox.ini": """ - [tox] - install_cmd = python -m -c 'print("ok")' -- {opts} {packages}' - """ - }, - ) - fake_package = base.ensure(".tox", "dist", "pkg123-0.1.zip") - result = cmd("-e", "py", "--notest", "--installpkg", str(fake_package.relto(base))) - result.assert_success() - - -def test_installpkg(tmpdir, newconfig): - p = tmpdir.ensure("pkg123-1.0.zip") - config = newconfig(["--installpkg={}".format(p)], "") - session = Session(config) - _, sdist_path = get_package(session) - assert sdist_path == p - - -def test_sdist_latest(tmpdir, newconfig): - distshare = tmpdir.join("distshare") - config = newconfig( - [], - """ - [tox] - distshare={} - sdistsrc={{distshare}}/pkg123-* - """.format( - distshare - ), - ) - p = distshare.ensure("pkg123-1.4.5.zip") - distshare.ensure("pkg123-1.4.5a1.zip") - session = Session(config) - _, dist = get_package(session) - assert dist == p - - -def test_separate_sdist_no_sdistfile(cmd, initproj, tmpdir): - distshare = tmpdir.join("distshare") - initproj( - ("pkg123-foo", "0.7"), - filedefs={ - "tox.ini": """ - [tox] - distshare={} - """.format( - distshare - ) - }, - ) - result = cmd("--sdistonly", "-e", "py") - assert not result.ret - distshare_files = distshare.listdir() - assert len(distshare_files) == 1 - sdistfile = distshare_files[0] - assert "pkg123-foo-0.7.zip" in str(sdistfile) - - -def test_sdistonly(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - """ - }, - ) - result = cmd("-v", "--sdistonly", "-e", "py") - assert not result.ret - assert re.match(r".*sdist-make.*setup.py.*", result.out, re.DOTALL) - assert "-mvirtualenv" not in result.out - - -def test_make_sdist(initproj): - initproj( - "example123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - """, - }, - ) - config = parseconfig([]) - session = Session(config) - _, sdist = get_package(session) - assert sdist.check() - assert sdist.ext == ".zip" - assert sdist == config.distdir.join(sdist.basename) - _, sdist2 = get_package(session) - assert sdist2 == sdist - sdist.write("hello") - assert sdist.stat().size < 10 - _, sdist_new = get_package(Session(config)) - assert sdist_new == sdist - assert sdist_new.stat().size > 10 - - -def test_package_inject(initproj, cmd, monkeypatch, tmp_path): - monkeypatch.delenv(str("PYTHONPATH"), raising=False) - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [testenv:py] - passenv = PYTHONPATH - commands = python -c 'import os; assert os.path.exists(os.environ["TOX_PACKAGE"])' - """ - }, - ) - result = cmd("-q") - assert result.session.getvenv("py").envconfig.setenv.get("TOX_PACKAGE") diff --git a/tests/unit/package/test_package_parallel.py b/tests/unit/package/test_package_parallel.py deleted file mode 100644 index 70a5b4d1a..000000000 --- a/tests/unit/package/test_package_parallel.py +++ /dev/null @@ -1,128 +0,0 @@ -import os -import traceback - -import py -from flaky import flaky - -from tox.session.commands.run import sequential - - -@flaky(max_runs=3) -def test_tox_parallel_build_safe(initproj, cmd, mock_venv, monkeypatch): - initproj( - "env_var_test", - filedefs={ - "tox.ini": """ - [tox] - envlist = py - install_cmd = python -m -c 'print("ok")' -- {opts} {packages}' - [testenv] - commands = python -c 'import sys; print(sys.version)' - """ - }, - ) - # we try to recreate the following situation - # t1 starts and performs build - # t2 starts, but is blocked from t1 build lock to build - # t1 gets unblocked, t2 can now enter - # t1 is artificially blocked to run test command until t2 finishes build - # (parallel build package present) - # t2 package build finishes both t1 and t2 can now finish and clean up their build packages - import tox.package - import threading - - t1_build_started = threading.Event() - t1_build_blocker = threading.Event() - t2_build_started = threading.Event() - t2_build_finished = threading.Event() - - invoke_result = {} - - def invoke_tox_in_thread(thread_name): - try: - result = cmd("--parallel--safe-build", "-vv") - except Exception as exception: - result = exception, traceback.format_exc() - invoke_result[thread_name] = result - - prev_build_package = tox.package.build_package - - with monkeypatch.context() as m: - - def build_package(config, session): - t1_build_started.set() - t1_build_blocker.wait() - return prev_build_package(config, session) - - m.setattr(tox.package, "build_package", build_package) - - prev_run_test_env = sequential.runtestenv - - def run_test_env(venv, redirect=False): - t2_build_finished.wait() - return prev_run_test_env(venv, redirect) - - m.setattr(sequential, "runtestenv", run_test_env) - - t1 = threading.Thread(target=invoke_tox_in_thread, args=("t1",)) - t1.start() - t1_build_started.wait() - - with monkeypatch.context() as m: - - def build_package(config, session): - t2_build_started.set() - try: - return prev_build_package(config, session) - finally: - t2_build_finished.set() - - m.setattr(tox.package, "build_package", build_package) - - t2 = threading.Thread(target=invoke_tox_in_thread, args=("t2",)) - t2.start() - - # t2 should get blocked by t1 build lock - t2_build_started.wait(timeout=0.1) - assert not t2_build_started.is_set() - - t1_build_blocker.set() # release t1 blocker -> t1 can now finish - # t1 at this point should block at run test until t2 build finishes - t2_build_started.wait() - - t1.join() # wait for both t1 and t2 to finish - t2.join() - - # all threads finished without error - for val in invoke_result.values(): - if isinstance(val, tuple): - assert False, "{!r}\n{}".format(val[0], val[1]) - err = "\n".join( - "{}=\n{}".format(k, v.err).strip() for k, v in invoke_result.items() if v.err.strip() - ) - out = "\n".join( - "{}=\n{}".format(k, v.out).strip() for k, v in invoke_result.items() if v.out.strip() - ) - for val in invoke_result.values(): - assert not val.ret, "{}\n{}".format(err, out) - assert not err - - # when the lock is hit we notify - lock_file = py.path.local().join(".tox", ".package.lock") - msg = "lock file {} present, will block until released".format(lock_file) - assert msg in out - - # intermediate packages are removed at end of build - t1_package = invoke_result["t1"].session.getvenv("py").package - t2_package = invoke_result["t1"].session.getvenv("py").package - assert t1 != t2 - assert not t1_package.exists() - assert not t2_package.exists() - - # the final distribution remains - dist_after = invoke_result["t1"].session.config.distdir.listdir() - assert len(dist_after) == 1 - sdist = dist_after[0] - assert t1_package != sdist - # our set_os_env_var is not thread-safe so clean-up TOX_WORK_DIR - os.environ.pop("TOX_WORK_DIR", None) diff --git a/tests/unit/package/test_package_view.py b/tests/unit/package/test_package_view.py deleted file mode 100644 index 7c1cf4693..000000000 --- a/tests/unit/package/test_package_view.py +++ /dev/null @@ -1,68 +0,0 @@ -import os - -from tox.config import parseconfig -from tox.package import get_package -from tox.session import Session - - -def test_make_sdist_distshare(tmpdir, initproj): - distshare = tmpdir.join("distshare") - initproj( - "example123-0.6", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [tox] - distshare={} - """.format( - distshare - ), - }, - ) - config = parseconfig([]) - session = Session(config) - package, dist = get_package(session) - assert package.check() - assert package.ext == ".zip" - assert package == config.temp_dir.join("package", "1", package.basename) - - assert dist == config.distdir.join(package.basename) - assert dist.check() - assert os.stat(str(dist)).st_ino == os.stat(str(package)).st_ino - - sdist_share = config.distshare.join(package.basename) - assert sdist_share.check() - assert sdist_share.read("rb") == dist.read("rb"), (sdist_share, package) - - -def test_separate_sdist(cmd, initproj, tmpdir): - distshare = tmpdir.join("distshare") - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - distshare={} - sdistsrc={{distshare}}/pkg123-0.7.zip - """.format( - distshare - ) - }, - ) - result = cmd("--sdistonly", "-e", "py") - assert not result.ret - dist_share_files = distshare.listdir() - assert len(dist_share_files) == 1 - assert dist_share_files[0].check() - - result = cmd("-v", "--notest") - result.assert_success() - msg = "python inst: {}".format(result.session.package) - assert msg in result.out, result.out - operation = "copied" if not hasattr(os, "link") else "links" - msg = "package {} {} to {}".format( - os.sep.join(("pkg123", ".tox", ".tmp", "package", "1", "pkg123-0.7.zip")), - operation, - os.sep.join(("distshare", "pkg123-0.7.zip")), - ) - assert msg in result.out, result.out diff --git a/tests/unit/pytest/test_init.py b/tests/unit/pytest/test_init.py new file mode 100644 index 000000000..a0304050b --- /dev/null +++ b/tests/unit/pytest/test_init.py @@ -0,0 +1,42 @@ +from tox.pytest import check_os_environ + + +def test_init_base(tox_project): + project = tox_project( + { + "tox.ini": """ + [tox] + """, + "src": {"__init__.py": "pass", "a": "out", "b": {"c": "out"}, "e": {"f": ""}}, + } + ) + assert str(project.path) in repr(project) + assert project.path.exists() + assert project.structure == { + "tox.ini": "\n[tox]\n", + "src": {"__init__.py": "pass", "a": "out", "e": {"f": ""}, "b": {"c": "out"}}, + } + + +def test_env_var(monkeypatch): + monkeypatch.setenv("MORE", "B") + monkeypatch.setenv("EXTRA", "1") + monkeypatch.setenv("PYTHONPATH", "yes") + gen = check_os_environ() + next(gen) + monkeypatch.setenv("MAGIC", "A") + monkeypatch.setenv("MORE", "D") + monkeypatch.delenv("EXTRA") + + from tox.pytest import pytest as tox_pytest + + exp = "test changed environ extra {'MAGIC': 'A'} miss {'EXTRA': '1'} diff {'MORE = B vs D'}" + + def fail(msg): + assert msg == exp + + monkeypatch.setattr(tox_pytest, "fail", fail) + try: + gen.send(None) + except StopIteration: + pass diff --git a/tests/unit/session/plugin/a/__init__.py b/tests/unit/session/plugin/a/__init__.py deleted file mode 100644 index dbe246397..000000000 --- a/tests/unit/session/plugin/a/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -import pluggy - -hookimpl = pluggy.HookimplMarker("tox") - - -@hookimpl -def tox_addoption(parser): - parser.add_argument("--option", choices=["a", "b"], default="a", required=False) diff --git a/tests/unit/session/plugin/setup.cfg b/tests/unit/session/plugin/setup.cfg deleted file mode 100644 index 285b17de5..000000000 --- a/tests/unit/session/plugin/setup.cfg +++ /dev/null @@ -1,10 +0,0 @@ -[metadata] -name = plugin -description = test stuff -version = 0.1 -[options] -zip_safe = True -packages = find: - -[options.entry_points] -tox = plugin = a diff --git a/tests/unit/session/plugin/setup.py b/tests/unit/session/plugin/setup.py deleted file mode 100644 index 606849326..000000000 --- a/tests/unit/session/plugin/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() diff --git a/tests/unit/session/test_list_env.py b/tests/unit/session/test_list_env.py deleted file mode 100644 index 8281a8296..000000000 --- a/tests/unit/session/test_list_env.py +++ /dev/null @@ -1,218 +0,0 @@ -def test_listenvs(cmd, initproj, monkeypatch): - monkeypatch.delenv(str("TOXENV"), raising=False) - initproj( - "listenvs", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36,py27,py34,pypi,docs - description= py27: run pytest on Python 2.7 - py34: run pytest on Python 3.6 - pypi: publish to PyPI - docs: document stuff - notincluded: random extra - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - """ - }, - ) - - result = cmd("-l") - assert result.outlines == ["py36", "py27", "py34", "pypi", "docs"] - - result = cmd("-l", "-e", "py") - assert result.outlines == ["py36", "py27", "py34", "pypi", "docs"] - - monkeypatch.setenv(str("TOXENV"), str("py")) - result = cmd("-l") - assert result.outlines == ["py36", "py27", "py34", "pypi", "docs"] - - monkeypatch.setenv(str("TOXENV"), str("py36")) - result = cmd("-l") - assert result.outlines == ["py36", "py27", "py34", "pypi", "docs"] - - -def test_listenvs_verbose_description(cmd, initproj): - initproj( - "listenvs_verbose_description", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36,py27,py34,pypi,docs - [testenv] - description= py36: run pytest on Python 3.6 - py27: run pytest on Python 2.7 - py34: run pytest on Python 3.4 - pypi: publish to PyPI - docs: document stuff - notincluded: random extra - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - description = let me overwrite that - """ - }, - ) - result = cmd("-lv") - expected = [ - "default environments:", - "py36 -> run pytest on Python 3.6", - "py27 -> run pytest on Python 2.7", - "py34 -> run pytest on Python 3.4", - "pypi -> publish to PyPI", - "docs -> let me overwrite that", - ] - assert result.outlines[2:] == expected - - -def test_listenvs_all(cmd, initproj, monkeypatch): - initproj( - "listenvs_all", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36,py27,py34,pypi,docs - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - """ - }, - ) - result = cmd("-a") - expected = ["py36", "py27", "py34", "pypi", "docs", "notincluded"] - assert result.outlines == expected - - result = cmd("-a", "-e", "py") - assert result.outlines == ["py36", "py27", "py34", "pypi", "docs", "py", "notincluded"] - - monkeypatch.setenv(str("TOXENV"), str("py")) - result = cmd("-a") - assert result.outlines == ["py36", "py27", "py34", "pypi", "docs", "py", "notincluded"] - - monkeypatch.setenv(str("TOXENV"), str("py36")) - result = cmd("-a") - assert result.outlines == ["py36", "py27", "py34", "pypi", "docs", "notincluded"] - - -def test_listenvs_all_verbose_description(cmd, initproj): - initproj( - "listenvs_all_verbose_description", - filedefs={ - "tox.ini": """ - [tox] - envlist={py27,py36}-{windows,linux} # py35 - [testenv] - description= py27: run pytest on Python 2.7 - py36: run pytest on Python 3.6 - windows: on Windows platform - linux: on Linux platform - docs: generate documentation - commands=pytest {posargs} - - [testenv:docs] - changedir = docs - """ - }, - ) - result = cmd("-av") - expected = [ - "default environments:", - "py27-windows -> run pytest on Python 2.7 on Windows platform", - "py27-linux -> run pytest on Python 2.7 on Linux platform", - "py36-windows -> run pytest on Python 3.6 on Windows platform", - "py36-linux -> run pytest on Python 3.6 on Linux platform", - "", - "additional environments:", - "docs -> generate documentation", - ] - assert result.outlines[-len(expected) :] == expected - - -def test_listenvs_all_verbose_description_no_additional_environments(cmd, initproj): - initproj( - "listenvs_all_verbose_description", - filedefs={ - "tox.ini": """ - [tox] - envlist=py27,py36 - """ - }, - ) - result = cmd("-av") - expected = ["default environments:", "py27 -> [no description]", "py36 -> [no description]"] - assert result.out.splitlines()[-3:] == expected - assert "additional environments" not in result.out - - -def test_listenvs_packaging_excluded(cmd, initproj): - initproj( - "listenvs", - filedefs={ - "tox.ini": """ - [tox] - envlist = py36,py27,py34,pypi,docs - isolated_build = True - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - """ - }, - ) - result = cmd("-a") - expected = ["py36", "py27", "py34", "pypi", "docs", "notincluded"] - assert result.outlines == expected, result.outlines - - -def test_listenvs_all_extra_definition_order_decreasing(cmd, initproj): - initproj( - "listenvs_all", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36 - - [testenv:b] - changedir = whatever - - [testenv:a] - changedir = docs - """ - }, - ) - result = cmd("-a") - expected = ["py36", "b", "a"] - assert result.outlines == expected - - -def test_listenvs_all_extra_definition_order_increasing(cmd, initproj): - initproj( - "listenvs_all", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36 - - [testenv:a] - changedir = whatever - - [testenv:b] - changedir = docs - """ - }, - ) - result = cmd("-a") - expected = ["py36", "a", "b"] - assert result.outlines == expected diff --git a/tests/unit/session/test_parallel.py b/tests/unit/session/test_parallel.py deleted file mode 100644 index fcb51a212..000000000 --- a/tests/unit/session/test_parallel.py +++ /dev/null @@ -1,292 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import json -import os -import subprocess -import sys -import threading - -import pytest -from flaky import flaky - -from tox._pytestplugin import RunResult - - -def test_parallel(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = a, b - isolated_build = true - [testenv] - commands=python -c "import sys; print(sys.executable)" - [testenv:b] - depends = a - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("-p", "all") - result.assert_success() - - -@flaky(max_runs=3) -def test_parallel_live(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - envlist = a, b - [testenv] - commands=python -c "import sys; print(sys.executable)" - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("-p", "all", "-o") - result.assert_success() - - -def test_parallel_circular(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - envlist = a, b - [testenv:a] - depends = b - [testenv:b] - depends = a - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("-p", "1") - result.assert_fail() - assert result.out == "ERROR: circular dependency detected: a | b\n" - - -@pytest.mark.parametrize("live", [True, False]) -def test_parallel_error_report(cmd, initproj, monkeypatch, live): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - envlist = a - [testenv] - skip_install = true - commands=python -c "import sys, os; sys.stderr.write(str(12345) + os.linesep);\ - raise SystemExit(17)" - whitelist_externals = {} - """.format( - sys.executable - ) - }, - ) - args = ["-o"] if live else [] - result = cmd("-p", "all", *args) - result.assert_fail() - msg = result.out - # for live we print the failure logfile, otherwise just stream through (no logfile present) - assert "(exited with code 17)" in result.out, msg - if not live: - assert "ERROR: invocation failed (exit code 1), logfile:" in result.out, msg - assert any(line for line in result.outlines if line == "12345"), result.out - - # single summary at end - summary_lines = [j for j, l in enumerate(result.outlines) if " summary " in l] - assert len(summary_lines) == 1, msg - - assert result.outlines[summary_lines[0] + 1 :] == ["ERROR: a: parallel child exit code 1"] - - -def test_parallel_deadlock(cmd, initproj, monkeypatch): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - tox_ini = """\ -[tox] -envlist = e1,e2 -skipsdist = true - -[testenv] -whitelist_externals = {} -commands = - python -c '[print("hello world") for _ in range(5000)]' -""".format( - sys.executable - ) - - initproj("pkg123-0.7", filedefs={"tox.ini": tox_ini}) - cmd("-p", "2") # used to hang indefinitely - - -def test_parallel_recreate(cmd, initproj, monkeypatch): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - tox_ini = """\ -[tox] -envlist = e1,e2 -skipsdist = true - -[testenv] -whitelist_externals = {} -commands = - python -c '[print("hello world") for _ in range(1)]' -""".format( - sys.executable - ) - cwd = initproj("pkg123-0.7", filedefs={"tox.ini": tox_ini}) - log_dir = cwd / ".tox" / "e1" / "log" - assert not log_dir.exists() - cmd("-p", "2") - after = log_dir.listdir() - assert len(after) >= 2 - - res = cmd("-p", "2", "-rv") - assert res - end = log_dir.listdir() - assert len(end) >= 3 - assert not ({f.basename for f in after} - {f.basename for f in end}) - - -@flaky(max_runs=3) -def test_parallel_show_output(cmd, initproj, monkeypatch): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - tox_ini = """\ -[tox] -envlist = e1,e2,e3 -skipsdist = true - -[testenv] -whitelist_externals = {} -commands = - python -c 'import sys; sys.stderr.write("stderr env"); sys.stdout.write("stdout env")' - -[testenv:e3] -commands = - python -c 'import sys; sys.stderr.write("stderr always "); sys.stdout.write("stdout always ")' -parallel_show_output = True -""".format( - sys.executable - ) - initproj("pkg123-0.7", filedefs={"tox.ini": tox_ini}) - result = cmd("-p", "all") - result.assert_success() - assert "stdout env" not in result.out, result.output() - assert "stderr env" not in result.out, result.output() - assert "stdout always" in result.out, result.output() - assert "stderr always" in result.out, result.output() - - -@pytest.fixture() -def parallel_project(initproj): - return initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - skipsdist = True - envlist = a, b - [testenv] - skip_install = True - commands=python -c "import sys; print(sys.executable)" - """ - }, - ) - - -def test_parallel_no_spinner_on(cmd, parallel_project, monkeypatch): - monkeypatch.setenv(str("TOX_PARALLEL_NO_SPINNER"), str("1")) - result = cmd("-p", "all") - result.assert_success() - assert "[2] a | b" not in result.out - - -def test_parallel_no_spinner_off(cmd, parallel_project, monkeypatch): - monkeypatch.setenv(str("TOX_PARALLEL_NO_SPINNER"), str("0")) - result = cmd("-p", "all") - result.assert_success() - assert "[2] a | b" in result.out - - -def test_parallel_no_spinner_not_set(cmd, parallel_project, monkeypatch): - monkeypatch.delenv(str("TOX_PARALLEL_NO_SPINNER"), raising=False) - result = cmd("-p", "all") - result.assert_success() - assert "[2] a | b" in result.out - - -def test_parallel_result_json(cmd, parallel_project, tmp_path): - parallel_result_json = tmp_path / "parallel.json" - result = cmd("-p", "all", "--result-json", "{}".format(parallel_result_json)) - ensure_result_json_ok(result, parallel_result_json) - - -def ensure_result_json_ok(result, json_path): - if isinstance(result, RunResult): - result.assert_success() - else: - assert not isinstance(result, subprocess.CalledProcessError) - assert json_path.exists() - serial_data = json.loads(json_path.read_text()) - ensure_key_in_env(serial_data) - - -def ensure_key_in_env(serial_data): - for env in ("a", "b"): - for key in ("setup", "test"): - assert key in serial_data["testenvs"][env], json.dumps( - serial_data["testenvs"], indent=2 - ) - - -def test_parallel_result_json_concurrent(cmd, parallel_project, tmp_path): - # first run to set up the environments (env creation is not thread safe) - result = cmd("-p", "all") - result.assert_success() - - invoke_result = {} - - def invoke_tox_in_thread(thread_name, result_json): - try: - # needs to be process to have it's own stdout - invoke_result[thread_name] = subprocess.check_output( - [sys.executable, "-m", "tox", "-p", "all", "--result-json", str(result_json)], - universal_newlines=True, - ) - except subprocess.CalledProcessError as exception: - invoke_result[thread_name] = exception - - # now concurrently - parallel1_result_json = tmp_path / "parallel1.json" - parallel2_result_json = tmp_path / "parallel2.json" - threads = [ - threading.Thread(target=invoke_tox_in_thread, args=(k, p)) - for k, p in (("t1", parallel1_result_json), ("t2", parallel2_result_json)) - ] - [t.start() for t in threads] - [t.join() for t in threads] - - ensure_result_json_ok(invoke_result["t1"], parallel1_result_json) - ensure_result_json_ok(invoke_result["t2"], parallel2_result_json) - # our set_os_env_var is not thread-safe so clean-up TOX_WORK_DIR - os.environ.pop("TOX_WORK_DIR", None) diff --git a/tests/unit/session/test_provision.py b/tests/unit/session/test_provision.py deleted file mode 100644 index c2840197c..000000000 --- a/tests/unit/session/test_provision.py +++ /dev/null @@ -1,253 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import os -import shutil -import subprocess -import sys - -import py -import pytest -from pathlib2 import Path -from six.moves.urllib.parse import urljoin -from six.moves.urllib.request import pathname2url - -from tox.exception import BadRequirement, MissingRequirement - - -@pytest.fixture(scope="session") -def next_tox_major(): - """a tox version we can guarantee to not be available""" - return "10.0.0" - - -def test_provision_min_version_is_requires(newconfig, next_tox_major): - with pytest.raises(MissingRequirement) as context: - newconfig( - [], - """\ - [tox] - minversion = {} - """.format( - next_tox_major - ), - ) - config = context.value.config - - deps = [r.name for r in config.envconfigs[config.provision_tox_env].deps] - assert deps == ["tox >= {}".format(next_tox_major)] - assert config.run_provision is True - assert config.toxworkdir - assert config.toxinipath - assert config.provision_tox_env == ".tox" - assert config.ignore_basepython_conflict is False - - -def test_provision_tox_change_name(newconfig): - config = newconfig( - [], - """\ - [tox] - provision_tox_env = magic - """, - ) - assert config.provision_tox_env == "magic" - - -def test_provision_basepython_global_only(newconfig, next_tox_major): - """we don't want to inherit basepython from global""" - with pytest.raises(MissingRequirement) as context: - newconfig( - [], - """\ - [tox] - minversion = {} - [testenv] - basepython = what - """.format( - next_tox_major - ), - ) - config = context.value.config - base_python = config.envconfigs[".tox"].basepython - assert base_python == sys.executable - - -def test_provision_basepython_local(newconfig, next_tox_major): - """however adhere to basepython when explicilty set""" - with pytest.raises(MissingRequirement) as context: - newconfig( - [], - """\ - [tox] - minversion = {} - [testenv:.tox] - basepython = what - """.format( - next_tox_major - ), - ) - config = context.value.config - base_python = config.envconfigs[".tox"].basepython - assert base_python == "what" - - -def test_provision_bad_requires(newconfig, capsys, monkeypatch): - with pytest.raises(BadRequirement): - newconfig( - [], - """\ - [tox] - requires = sad >sds d ok - """, - ) - out, err = capsys.readouterr() - assert "ERROR: failed to parse InvalidRequirement" in out - assert not err - - -@pytest.fixture() -def plugin(monkeypatch, tmp_path): - dest = tmp_path / "a" - shutil.copytree(str(py.path.local(__file__).dirpath().join("plugin")), str(dest)) - subprocess.check_output([sys.executable, "setup.py", "egg_info"], cwd=str(dest)) - monkeypatch.setenv(str("PYTHONPATH"), str(dest)) - - -def test_provision_cli_args_ignore(cmd, initproj, monkeypatch, plugin): - import tox.config - import tox.session - - prev_ensure = tox.config.ParseIni.ensure_requires_satisfied - - @staticmethod - def ensure_requires_satisfied(config, requires, min_version): - result = prev_ensure(config, requires, min_version) - config.run_provision = True - return result - - monkeypatch.setattr( - tox.config.ParseIni, "ensure_requires_satisfied", ensure_requires_satisfied - ) - prev_get_venv = tox.session.Session.getvenv - - def getvenv(self, name): - venv = prev_get_venv(self, name) - venv.envconfig.envdir = py.path.local(sys.executable).dirpath().dirpath() - venv.setupenv = lambda: True - venv.finishvenv = lambda: True - return venv - - monkeypatch.setattr(tox.session.Session, "getvenv", getvenv) - initproj("test-0.1", {"tox.ini": "[tox]"}) - result = cmd("-a", "--option", "b") - result.assert_success(is_run_test_env=False) - - -def test_provision_cli_args_not_ignored_if_provision_false(cmd, initproj): - initproj("test-0.1", {"tox.ini": "[tox]"}) - result = cmd("-a", "--option", "b") - result.assert_fail(is_run_test_env=False) - - -@pytest.fixture(scope="session") -def wheel(tmp_path_factory): - """create a wheel for a project""" - state = {"at": 0} - - def _wheel(path): - state["at"] += 1 - dest_path = tmp_path_factory.mktemp("wheel-{}-".format(state["at"])) - env = os.environ.copy() - try: - subprocess.check_output( - [ - sys.executable, - "-m", - "pip", - "wheel", - "-w", - str(dest_path), - "--no-deps", - str(path), - ], - universal_newlines=True, - stderr=subprocess.STDOUT, - env=env, - ) - except subprocess.CalledProcessError as exception: - assert not exception.returncode, exception.output - - wheels = list(dest_path.glob("*.whl")) - assert len(wheels) == 1 - wheel = wheels[0] - return wheel - - return _wheel - - -THIS_PROJECT_ROOT = Path(__file__).resolve().parents[3] - - -@pytest.fixture(scope="session") -def tox_wheel(wheel): - return wheel(THIS_PROJECT_ROOT) - - -@pytest.fixture(scope="session") -def magic_non_canonical_wheel(wheel, tmp_path_factory): - magic_proj = tmp_path_factory.mktemp("magic") - (magic_proj / "setup.py").write_text( - "from setuptools import setup\nsetup(name='com.magic.this-is-fun')" - ) - return wheel(magic_proj) - - -def test_provision_non_canonical_dep( - cmd, initproj, monkeypatch, tox_wheel, magic_non_canonical_wheel -): - initproj( - "w-0.1", - { - "tox.ini": """\ - [tox] - envlist = py - requires = - com.magic.this-is-fun - tox == {} - [testenv:.tox] - passenv = * - """.format( - tox_wheel.name.split("-")[1] - ) - }, - ) - find_links = " ".join( - space_path2url(d) for d in (tox_wheel.parent, magic_non_canonical_wheel.parent) - ) - - monkeypatch.setenv(str("PIP_FIND_LINKS"), str(find_links)) - - result = cmd("-a", "-v", "-v") - result.assert_success(is_run_test_env=False) - - -def test_provision_requirement_with_environment_marker(cmd, initproj): - initproj( - "proj", - { - "tox.ini": """\ - [tox] - requires = - package-that-does-not-exist;python_version=="1.0" - """ - }, - ) - result = cmd("-e", "py", "-vv") - result.assert_success(is_run_test_env=False) - - -def space_path2url(path): - at_path = str(path) - if " " not in at_path: - return at_path - return urljoin("file:", pathname2url(os.path.abspath(at_path))) diff --git a/tests/unit/session/test_session.py b/tests/unit/session/test_session.py deleted file mode 100644 index df5814bb6..000000000 --- a/tests/unit/session/test_session.py +++ /dev/null @@ -1,374 +0,0 @@ -import os -import pipes -import sys -import textwrap -from threading import Thread - -import pytest - -import tox -from tox.exception import MissingDependency, MissingDirectory -from tox.package import resolve_package -from tox.reporter import Verbosity - - -def test_resolve_pkg_missing_directory(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - spec = distshare.join("pkg123-*") - with pytest.raises(MissingDirectory): - resolve_package(spec) - - -def test_resolve_pkg_missing_directory_in_distshare(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - spec = distshare.join("pkg123-*") - distshare.ensure(dir=1) - with pytest.raises(MissingDependency): - resolve_package(spec) - - -def test_resolve_pkg_multiple_valid_versions(tmpdir, mocksession): - mocksession.logging_levels(quiet=Verbosity.DEFAULT, verbose=Verbosity.DEBUG) - distshare = tmpdir.join("distshare") - distshare.ensure("pkg123-1.3.5.zip") - p = distshare.ensure("pkg123-1.4.5.zip") - result = resolve_package(distshare.join("pkg123-*")) - assert result == p - mocksession.report.expect("info", "determin*pkg123*") - - -def test_resolve_pkg_with_invalid_version(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - - distshare.ensure("pkg123-1.something_bad.zip") - distshare.ensure("pkg123-1.3.5.zip") - p = distshare.ensure("pkg123-1.4.5.zip") - - result = resolve_package(distshare.join("pkg123-*")) - mocksession.report.expect("warning", "*1.something_bad*") - assert result == p - - -def test_resolve_pkg_with_alpha_version(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - distshare.ensure("pkg123-1.3.5.zip") - distshare.ensure("pkg123-1.4.5a1.tar.gz") - p = distshare.ensure("pkg123-1.4.5.zip") - result = resolve_package(distshare.join("pkg123-*")) - assert result == p - - -def test_resolve_pkg_doubledash(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - p = distshare.ensure("pkg-mine-1.3.0.zip") - res = resolve_package(distshare.join("pkg-mine*")) - assert res == p - distshare.ensure("pkg-mine-1.3.0a1.zip") - res = resolve_package(distshare.join("pkg-mine*")) - assert res == p - - -def test_skip_sdist(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - syntax error - """, - "tox.ini": """ - [tox] - skipsdist=True - [testenv] - commands=python -c "print('done')" - """, - }, - ) - result = cmd() - result.assert_success() - - -def test_skip_install_skip_package(cmd, initproj, mock_venv): - initproj( - "pkg123-0.7", - filedefs={ - "setup.py": """raise RuntimeError""", - "tox.ini": """ - [tox] - envlist = py - - [testenv] - skip_install = true - """, - }, - ) - result = cmd("--notest") - result.assert_success() - - -@pytest.fixture() -def venv_filter_project(initproj, cmd): - def func(*args): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = {py27,py36}-{nocov,cov,diffcov}{,-extra} - skipsdist = true - - [testenv] - skip_install = true - commands = python -c 'print("{envname}")' - """ - }, - ) - result = cmd(*args) - result.assert_success(is_run_test_env=False) - active = [i.name for i in result.session.existing_venvs.values()] - return active, result - - yield func - - -def test_venv_filter_empty_all_active(venv_filter_project, monkeypatch): - monkeypatch.delenv("TOX_SKIP_ENV", raising=False) - active, result = venv_filter_project("-a") - assert result.outlines == [ - "py27-nocov", - "py27-nocov-extra", - "py27-cov", - "py27-cov-extra", - "py27-diffcov", - "py27-diffcov-extra", - "py36-nocov", - "py36-nocov-extra", - "py36-cov", - "py36-cov-extra", - "py36-diffcov", - "py36-diffcov-extra", - ] - assert active == result.outlines - - -def test_venv_filter_match_all_none_active(venv_filter_project, monkeypatch): - monkeypatch.setenv("TOX_SKIP_ENV", ".*") - active, result = venv_filter_project("-a") - assert not active - existing_envs = result.outlines - - _, result = venv_filter_project("-avv") - for name in existing_envs: - msg = "skip environment {}, matches filter '.*'".format(name) - assert msg in result.outlines - - -def test_venv_filter_match_some_some_active(venv_filter_project, monkeypatch): - monkeypatch.setenv("TOX_SKIP_ENV", "py27.*") - active, result = venv_filter_project("-avvv") - assert active == [ - "py36-nocov", - "py36-nocov-extra", - "py36-cov", - "py36-cov-extra", - "py36-diffcov", - "py36-diffcov-extra", - ] - - -@pytest.fixture() -def popen_env_test(initproj, cmd, monkeypatch): - def func(tox_env, isolated_build): - files = { - "tox.ini": """ - [tox] - isolated_build = {} - [testenv:{}] - commands = python -c "print('ok')" - """.format( - "True" if isolated_build else "False", tox_env - ) - } - if isolated_build: - files[ - "pyproject.toml" - ] = """ - [build-system] - requires = ["setuptools >= 35.0.2", "setuptools_scm >= 2.0.0, <3"] - build-backend = 'setuptools.build_meta' - """ - initproj("env_var_test", filedefs=files) - - class IsolatedResult(object): - def __init__(self): - self.popens = [] - self.cwd = None - - res = IsolatedResult() - - class EnvironmentTestRun(Thread): - """we wrap this invocation into a thread to avoid modifying in any way the - current threads environment variable (e.g. on failure of this test incorrect teardown) - """ - - def run(self): - prev_build = tox.session.build_session - - def build_session(config): - res.session = prev_build(config) - res._popen = res.session.popen - monkeypatch.setattr(res.session, "popen", popen) - return res.session - - monkeypatch.setattr(tox.session, "build_session", build_session) - - def popen(cmd, **kwargs): - activity_id = _actions[-1].name - activity_name = _actions[-1].activity - ret = "NOTSET" - try: - ret = res._popen(cmd, **kwargs) - except tox.exception.InvocationError as exception: - ret = exception - finally: - res.popens.append( - (activity_id, activity_name, kwargs.get("env"), ret, cmd) - ) - return ret - - _actions = [] - from tox.action import Action - - _prev_enter = Action.__enter__ - - def enter(self): - _actions.append(self) - return _prev_enter(self) - - monkeypatch.setattr(Action, "__enter__", enter) - - _prev_exit = Action.__exit__ - - def exit_func(self, *args, **kwargs): - del _actions[_actions.index(self)] - _prev_exit(self, *args, **kwargs) - - monkeypatch.setattr(Action, "__exit__", exit_func) - - res.result = cmd("-e", tox_env) - res.cwd = os.getcwd() - - thread = EnvironmentTestRun() - thread.start() - thread.join() - return res - - yield func - - -@pytest.mark.network -def test_tox_env_var_flags_inserted_non_isolated(popen_env_test): - res = popen_env_test("py", False) - assert_popen_env(res) - - -@pytest.mark.network -def test_tox_env_var_flags_inserted_isolated(popen_env_test): - res = popen_env_test("py", True) - assert_popen_env(res) - - -def assert_popen_env(res): - res.result.assert_success() - for tox_id, _, env, __, ___ in res.popens: - assert env["TOX_WORK_DIR"] == os.path.join(res.cwd, ".tox") - if tox_id != "GLOB": - assert env["TOX_ENV_NAME"] == tox_id - assert env["TOX_ENV_DIR"] == os.path.join(res.cwd, ".tox", tox_id) - # ensure native strings for environ for windows - for k, v in env.items(): - assert type(k) is str, (k, v, type(k)) - assert type(v) is str, (k, v, type(v)) - - -def test_command_prev_post_ok(cmd, initproj, mock_venv): - initproj( - "pkg_command_test_123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = py - - [testenv] - commands_pre = python -c 'print("pre")' - commands = python -c 'print("command")' - commands_post = python -c 'print("post")' - """ - }, - ) - result = cmd() - result.assert_success() - expected = textwrap.dedent( - """ - py run-test-pre: commands[0] | python -c 'print("pre")' - pre - py run-test: commands[0] | python -c 'print("command")' - command - py run-test-post: commands[0] | python -c 'print("post")' - post - ___________________________________ summary ___________________________________{} - py: commands succeeded - congratulations :) - """.format( - "_" if sys.platform != "win32" else "" - ) - ).lstrip() - have = result.out.replace(os.linesep, "\n") - actual = have[len(have) - len(expected) :] - assert actual == expected - - -def test_command_prev_fail_command_skip_post_run(cmd, initproj, mock_venv): - initproj( - "pkg_command_test_123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = py - - [testenv] - commands_pre = python -c 'raise SystemExit(2)' - commands = python -c 'print("command")' - commands_post = python -c 'print("post")' - """ - }, - ) - result = cmd() - result.assert_fail() - expected = textwrap.dedent( - """ - py run-test-pre: commands[0] | python -c 'raise SystemExit(2)' - ERROR: InvocationError for command {} -c 'raise SystemExit(2)' (exited with code 2) - py run-test-post: commands[0] | python -c 'print("post")' - post - ___________________________________ summary ___________________________________{} - ERROR: py: commands failed - """.format( - pipes.quote(sys.executable), "_" if sys.platform != "win32" else "" - ) - ) - have = result.out.replace(os.linesep, "\n") - actual = have[len(have) - len(expected) :] - assert actual == expected - - -def test_help_compound_ve_works(cmd, initproj, monkeypatch): - initproj("test-0.1", {"tox.ini": ""}) - result = cmd("-ve", "py", "-a") - result.assert_success(is_run_test_env=False) - assert not result.err - assert result.outlines[0].startswith("using") - assert result.outlines[1].startswith("using") - assert result.outlines[2] == "additional environments:" - assert result.outlines[3] == "py -> [no description]" - assert len(result.outlines) == 4 diff --git a/tests/unit/session/test_show_config.py b/tests/unit/session/test_show_config.py deleted file mode 100644 index 9a8bff4c0..000000000 --- a/tests/unit/session/test_show_config.py +++ /dev/null @@ -1,117 +0,0 @@ -import py -import pytest -from six import PY2, StringIO -from six.moves import configparser - - -def load_config(args, cmd): - result = cmd(*args) - result.assert_success(is_run_test_env=False) - parser = configparser.ConfigParser() - output = StringIO(result.out) - (parser.readfp if PY2 else parser.read_file)(output) - return parser - - -def test_showconfig_with_force_dep_version(cmd, initproj): - initproj( - "force_dep_version", - filedefs={ - "tox.ini": """ - [tox] - - [testenv] - deps= - dep1==2.3 - dep2 - """ - }, - ) - parser = load_config(("--showconfig",), cmd) - assert parser.get("testenv:python", "deps") == "[dep1==2.3, dep2]" - - parser = load_config(("--showconfig", "--force-dep=dep1", "--force-dep=dep2==5.0"), cmd) - assert parser.get("testenv:python", "deps") == "[dep1, dep2==5.0]" - - -@pytest.fixture() -def setup_mixed_conf(initproj): - initproj( - "force_dep_version", - filedefs={ - "tox.ini": """ - [tox] - envlist = py37,py27,pypi,docs - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - """ - }, - ) - - -@pytest.mark.parametrize( - "args, expected", - [ - ( - ["--showconfig"], - [ - "tox", - "tox:versions", - "testenv:py37", - "testenv:py27", - "testenv:pypi", - "testenv:docs", - "testenv:notincluded", - ], - ), - ( - ["--showconfig", "-l"], - [ - "tox", - "tox:versions", - "testenv:py37", - "testenv:py27", - "testenv:pypi", - "testenv:docs", - ], - ), - (["--showconfig", "-e", "py37,py36"], ["testenv:py37", "testenv:py36"]), - ], - ids=["all", "default_only", "-e"], -) -def test_showconfig(cmd, setup_mixed_conf, args, expected): - parser = load_config(args, cmd) - found_sections = parser.sections() - assert found_sections == expected - - -def test_config_specific_ini(tmpdir, cmd): - ini = tmpdir.ensure("hello.ini") - output = load_config(("-c", ini, "--showconfig"), cmd) - assert output.get("tox", "toxinipath") == ini - - -def test_override_workdir(cmd, initproj): - baddir = "badworkdir-123" - gooddir = "overridden-234" - initproj( - "overrideworkdir-0.5", - filedefs={ - "tox.ini": """ - [tox] - toxworkdir={} - """.format( - baddir - ) - }, - ) - result = cmd("--workdir", gooddir, "--showconfig") - assert not result.ret - assert gooddir in result.out - assert baddir not in result.out - assert py.path.local(gooddir).check() - assert not py.path.local(baddir).check() diff --git a/tests/unit/test_call_modes.py b/tests/unit/test_call_modes.py new file mode 100644 index 000000000..d9adb00ac --- /dev/null +++ b/tests/unit/test_call_modes.py @@ -0,0 +1,11 @@ +import subprocess +import sys +from pathlib import Path + + +def test_call_as_module(empty_project): + subprocess.check_output([sys.executable, "-m", "tox", "-h"]) + + +def test_call_as_exe(empty_project): + subprocess.check_output([Path(sys.executable).parent / "tox", "-h"]) diff --git a/tests/unit/test_docs.py b/tests/unit/test_docs.py deleted file mode 100644 index 380bb61ce..000000000 --- a/tests/unit/test_docs.py +++ /dev/null @@ -1,56 +0,0 @@ -import os.path -import re -import textwrap - -import pytest - -import tox -from tox.config import parseconfig - -INI_BLOCK_RE = re.compile( - r"(?P" - r"^(?P *)\.\. (code-block|sourcecode):: ini\n" - r"((?P=indent) +:.*\n)*" - r"\n*" - r")" - r"(?P(^((?P=indent) +.*)?\n)+)", - re.MULTILINE, -) - - -RST_FILES = [] -TOX_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) -for root, _, filenames in os.walk(os.path.join(TOX_ROOT, "docs")): - for filename in filenames: - if filename.endswith(".rst"): - RST_FILES.append(os.path.join(root, filename)) - - -def test_some_files_exist(): - assert RST_FILES - - -@pytest.mark.parametrize("filename", RST_FILES) -def test_all_rst_ini_blocks_parse(filename, tmpdir): - with open(filename) as f: - contents = f.read() - for match in INI_BLOCK_RE.finditer(contents): - code = textwrap.dedent(match.group("code")) - config_path = tmpdir / "tox.ini" - config_path.write(code) - try: - parseconfig(["-c", str(config_path)]) - except tox.exception.MissingRequirement: - pass - except Exception as e: - raise AssertionError( - "Error parsing ini block\n\n" - "{filename}:{lineno}\n\n" - "{code}\n\n" - "{error}\n\n{error!r}".format( - filename=filename, - lineno=contents[: match.start()].count("\n") + 1, - code="\t" + code.replace("\n", "\n\t").strip(), - error=e, - ) - ) diff --git a/tests/unit/test_list_env.py b/tests/unit/test_list_env.py new file mode 100644 index 000000000..646b4e803 --- /dev/null +++ b/tests/unit/test_list_env.py @@ -0,0 +1,29 @@ +import os +import textwrap + +from tox.version import __version__ + + +def test_list_empty(tox_project): + project = tox_project({"tox.ini": ""}) + outcome = project.run("c") + outcome.assert_success() + + expected = textwrap.dedent( + """ + base = [] + tox_root = {0} + work_dir = {0}{1}.tox + temp_dir = {0}{1}.temp + env_list = [] + skip_missing_interpreters = True + min_version = {2} + provision_tox_env = .tox + requires = [={2}')>] + no_package = False + + """.format( + project.path, os.sep, __version__ + ) + ).lstrip() + assert outcome.out == expected diff --git a/tests/unit/test_pytest_plugins.py b/tests/unit/test_pytest_plugins.py deleted file mode 100644 index dcb6e3a06..000000000 --- a/tests/unit/test_pytest_plugins.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Test utility tests, intended to cover use-cases not used in the current -project test suite, e.g. as shown by the code coverage report. - -""" -import os -import sys - -import py.path -import pytest - -from tox._pytestplugin import RunResult, _filedefs_contains, _path_parts - - -class TestInitProj: - @pytest.mark.parametrize( - "kwargs", ({}, {"src_root": None}, {"src_root": ""}, {"src_root": "."}) - ) - def test_no_src_root(self, kwargs, tmpdir, initproj): - initproj("black_knight-42", **kwargs) - init_file = tmpdir.join("black_knight", "black_knight", "__init__.py") - expected = b'""" module black_knight """' + linesep_bytes() + b"__version__ = '42'" - assert init_file.read_binary() == expected - - def test_existing_src_root(self, tmpdir, initproj): - initproj("spam-666", src_root="ham") - assert not tmpdir.join("spam", "spam").check(exists=1) - init_file = tmpdir.join("spam", "ham", "spam", "__init__.py") - expected = b'""" module spam """' + linesep_bytes() + b"__version__ = '666'" - assert init_file.read_binary() == expected - - def test_prebuilt_src_dir_with_no_src_root(self, tmpdir, initproj): - initproj("spam-1.0", filedefs={"spam": {}}) - src_dir = tmpdir.join("spam", "spam") - assert src_dir.check(dir=1) - assert not src_dir.join("__init__.py").check(exists=1) - - def test_prebuilt_src_dir_with_src_root(self, tmpdir, initproj): - initproj( - "spam-1.0", - filedefs={"incontinentia": {"spam": {"__init__.py": "buttocks"}}}, - src_root="incontinentia", - ) - assert not tmpdir.join("spam", "spam").check(exists=1) - init_file = tmpdir.join("spam", "incontinentia", "spam", "__init__.py") - assert init_file.read_binary() == b"buttocks" - - def test_broken_py_path_local_join_workaround_on_Windows(self, tmpdir, initproj, monkeypatch): - # construct an absolute folder path for our src_root folder without the - # Windows drive indicator - src_root = tmpdir.join("spam") - src_root = _path_parts(src_root) - src_root[0] = "" - src_root = "/".join(src_root) - - # make sure tmpdir drive is the current one so the constructed src_root - # folder path gets interpreted correctly on Windows - monkeypatch.chdir(tmpdir) - - # will throw an assertion error if the bug is not worked around - initproj("spam-666", src_root=src_root) - - init_file = tmpdir.join("spam", "spam", "__init__.py") - expected = b'""" module spam """' + linesep_bytes() + b"__version__ = '666'" - assert init_file.read_binary() == expected - - -def linesep_bytes(): - return os.linesep.encode() - - -class TestPathParts: - @pytest.mark.parametrize( - "input, expected", - ( - ("", []), - ("/", ["/"]), - ("//", ["//"]), - ("/a", ["/", "a"]), - ("/a/", ["/", "a"]), - ("/a/b", ["/", "a", "b"]), - ("a", ["a"]), - ("a/b", ["a", "b"]), - ), - ) - def test_path_parts(self, input, expected): - assert _path_parts(input) == expected - - def test_on_py_path(self): - cwd_parts = _path_parts(py.path.local()) - folder_parts = _path_parts(py.path.local("a/b/c")) - assert folder_parts[len(cwd_parts) :] == ["a", "b", "c"] - - -@pytest.mark.parametrize( - "base, filedefs, target, expected", - ( - ("/base", {}, "", False), - ("/base", {}, "/base", False), - ("/base", {"a": {"b": "data"}}, "", True), - ("/base", {"a": {"b": "data"}}, "a", True), - ("/base", {"a": {"b": "data"}}, "a/b", True), - ("/base", {"a": {"b": "data"}}, "a/x", False), - ("/base", {"a": {"b": "data"}}, "a/b/c", False), - ("/base", {"a": {"b": "data"}}, "/base", True), - ("/base", {"a": {"b": "data"}}, "/base/a", True), - ("/base", {"a": {"b": "data"}}, "/base/a/b", True), - ("/base", {"a": {"b": "data"}}, "/base/a/x", False), - ("/base", {"a": {"b": "data"}}, "/base/a/b/c", False), - ("/base", {"a": {"b": "data"}}, "/a", False), - ), -) -def test_filedefs_contains(base, filedefs, target, expected): - assert bool(_filedefs_contains(base, filedefs, target)) == expected - - -def test_run_result_repr(capfd): - with RunResult(["hello", "world"], capfd) as run_result: - # simulate tox writing some unicode output - stdout_buffer = getattr(sys.stdout, "buffer", sys.stdout) - stdout_buffer.write(u"\u2603".encode("UTF-8")) - - # must not `UnicodeError` on repr(...) - ret = repr(run_result) - # must be native `str`, (bytes in py2, str in py3) - assert isinstance(ret, str) diff --git a/tests/unit/test_quickstart.py b/tests/unit/test_quickstart.py deleted file mode 100644 index 724790cc2..000000000 --- a/tests/unit/test_quickstart.py +++ /dev/null @@ -1,268 +0,0 @@ -import os - -import pytest - -import tox -from tox._quickstart import ( - ALTERNATIVE_CONFIG_NAME, - QUICKSTART_CONF, - list_modificator, - main, - post_process_input, - prepare_content, -) - -ALL_PY_ENVS_AS_STRING = ", ".join(tox.PYTHON.QUICKSTART_PY_ENVS) -ALL_PY_ENVS_WO_LAST_AS_STRING = ", ".join(tox.PYTHON.QUICKSTART_PY_ENVS[:-1]) -SIGNS_OF_SANITY = ( - "tox.readthedocs.io", - "[tox]", - "[testenv]", - "envlist = ", - "deps =", - "commands =", -) -"""A bunch of elements to be expected in the generated config as marker for basic sanity""" - - -class _answers: - """Simulate a series of terminal inputs by popping them from a list if called.""" - - def __init__(self, inputs): - self._inputs = [str(i) for i in inputs] - - def extend(self, items): - self._inputs.extend(items) - - def __str__(self): - return "|".join(self._inputs) - - def __call__(self, prompt): - print("prompt: '{}'".format(prompt)) - try: - answer = self._inputs.pop(0) - print("user answer: '{}'".format(answer)) - return answer - except IndexError: - pytest.fail("missing user answer for '{}'".format(prompt)) - - -class _cnf: - """Handle files and args for different test scenarios.""" - - SOME_CONTENT = "dontcare" - - def __init__(self, exists=False, names=None, pass_path=False): - self.original_name = tox.INFO.DEFAULT_CONFIG_NAME - self.names = names or [ALTERNATIVE_CONFIG_NAME] - self.exists = exists - self.pass_path = pass_path - - def __str__(self): - return self.original_name if not self.exists else str(self.names) - - @property - def argv(self): - argv = ["tox-quickstart"] - if self.pass_path: - argv.append(os.getcwd()) - return argv - - @property - def dpath(self): - return os.getcwd() if self.pass_path else "" - - def create(self): - paths_to_create = {self._original_path} - for name in self.names[:-1]: - paths_to_create.add(os.path.join(self.dpath, name)) - for path in paths_to_create: - with open(path, "w") as f: - f.write(self.SOME_CONTENT) - - @property - def generated_content(self): - return self._alternative_content if self.exists else self._original_content - - @property - def already_existing_content(self): - if not self.exists: - if os.path.exists(self._alternative_path): - pytest.fail("alternative path should never exist here") - pytest.fail("checking for already existing content makes not sense here") - return self._original_content - - @property - def path_to_generated(self): - return os.path.join(os.getcwd(), self.names[-1] if self.exists else self.original_name) - - @property - def _original_path(self): - return os.path.join(self.dpath, self.original_name) - - @property - def _alternative_path(self): - return os.path.join(self.dpath, self.names[-1]) - - @property - def _original_content(self): - with open(self._original_path) as f: - return f.read() - - @property - def _alternative_content(self): - with open(self._alternative_path) as f: - return f.read() - - -class _exp: - """Holds test expectations and a user scenario description.""" - - STANDARD_EPECTATIONS = [ALL_PY_ENVS_AS_STRING, "pytest", "pytest"] - - def __init__(self, name, exp=None): - self.name = name - exp = exp or self.STANDARD_EPECTATIONS - # NOTE extra mangling here ensures formatting is the same in file and exp - map_ = {"deps": list_modificator(exp[1]), "commands": list_modificator(exp[2])} - post_process_input(map_) - map_["envlist"] = exp[0] - self.content = prepare_content(QUICKSTART_CONF.format(**map_)) - - def __str__(self): - return self.name - - -@pytest.mark.usefixtures("work_in_clean_dir") -@pytest.mark.parametrize( - argnames="answers, exp, cnf", - ids=lambda param: str(param), - argvalues=( - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "pytest", "pytest"]), - _exp( - "choose versions individually and use pytest", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "pytest", "pytest"], - ), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "py.test", ""]), - _exp( - "choose versions individually and use old fashioned py.test", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "pytest", "py.test"], - ), - _cnf(), - ), - ( - _answers([1, "pytest", ""]), - _exp( - "choose current release Python and pytest with defaut deps", - [tox.PYTHON.CURRENT_RELEASE_ENV, "pytest", "pytest"], - ), - _cnf(), - ), - ( - _answers([1, "pytest -n auto", "pytest-xdist"]), - _exp( - "choose current release Python and pytest with xdist and some args", - [tox.PYTHON.CURRENT_RELEASE_ENV, "pytest, pytest-xdist", "pytest -n auto"], - ), - _cnf(), - ), - ( - _answers([2, "pytest", ""]), - _exp( - "choose py27, current release Python and pytest with defaut deps", - ["py27, {}".format(tox.PYTHON.CURRENT_RELEASE_ENV), "pytest", "pytest"], - ), - _cnf(), - ), - ( - _answers([3, "pytest", ""]), - _exp("choose all supported version and pytest with defaut deps"), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "py.test", ""]), - _exp( - "choose versions individually and use old fashioned py.test", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "pytest", "py.test"], - ), - _cnf(), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("choose no version individually and defaults"), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "python -m unittest discover", ""]), - _exp( - "choose versions individually and use nose with default deps", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "", "python -m unittest discover"], - ), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "nosetests", "nose"]), - _exp( - "choose versions individually and use nose with default deps", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "nose", "nosetests"], - ), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "trial", ""]), - _exp( - "choose versions individually and use twisted tests with default deps", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "twisted", "trial"], - ), - _cnf(), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("existing not overridden, generated to alternative with default name"), - _cnf(exists=True), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("existing not overridden, generated to alternative with custom name"), - _cnf(exists=True, names=["some-other.ini"]), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("existing not override, generated to alternative"), - _cnf(exists=True, names=["tox.ini", "some-other.ini"]), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("existing alternatives are not overridden, generated to alternative"), - _cnf(exists=True, names=["tox.ini", "setup.py", "some-other.ini"]), - ), - ), -) -def test_quickstart(answers, cnf, exp, monkeypatch): - """Test quickstart script using some little helpers. - - :param _answers answers: user interaction simulation - :param _cnf cnf: helper for args and config file paths and contents - :param _exp exp: expectation helper - """ - monkeypatch.setattr("six.moves.input", answers) - monkeypatch.setattr("sys.argv", cnf.argv) - if cnf.exists: - answers.extend(cnf.names) - cnf.create() - main() - print("generated config at {}:\n{}\n".format(cnf.path_to_generated, cnf.generated_content)) - check_basic_sanity(cnf.generated_content, SIGNS_OF_SANITY) - assert cnf.generated_content == exp.content - if cnf.exists: - assert cnf.already_existing_content == cnf.SOME_CONTENT - - -def check_basic_sanity(content, signs): - for sign in signs: - if sign not in content: - pytest.fail("{} not in\n{}".format(sign, content)) diff --git a/tests/unit/test_result.py b/tests/unit/test_result.py deleted file mode 100644 index 04da6b6b4..000000000 --- a/tests/unit/test_result.py +++ /dev/null @@ -1,95 +0,0 @@ -import os -import signal -import socket -import sys - -import py -import pytest - -import tox -from tox.logs import ResultLog - - -@pytest.fixture(name="pkg") -def create_fake_pkg(tmpdir): - pkg = tmpdir.join("hello-1.0.tar.gz") - pkg.write("whatever") - return pkg - - -def test_pre_set_header(): - replog = ResultLog() - d = replog.dict - assert replog.dict == d - assert replog.dict["reportversion"] == "1" - assert replog.dict["toxversion"] == tox.__version__ - assert replog.dict["platform"] == sys.platform - assert replog.dict["host"] == socket.getfqdn() - data = replog.dumps_json() - replog2 = ResultLog.from_json(data) - assert replog2.dict == replog.dict - - -def test_set_header(pkg): - replog = ResultLog() - d = replog.dict - assert replog.dict == d - assert replog.dict["reportversion"] == "1" - assert replog.dict["toxversion"] == tox.__version__ - assert replog.dict["platform"] == sys.platform - assert replog.dict["host"] == socket.getfqdn() - expected = {"basename": "hello-1.0.tar.gz", "sha256": pkg.computehash("sha256")} - env_log = replog.get_envlog("a") - env_log.set_header(installpkg=pkg) - assert env_log.dict["installpkg"] == expected - - data = replog.dumps_json() - replog2 = ResultLog.from_json(data) - assert replog2.dict == replog.dict - - -def test_addenv_setpython(pkg): - replog = ResultLog() - envlog = replog.get_envlog("py36") - envlog.set_python_info(py.path.local(sys.executable)) - envlog.set_header(installpkg=pkg) - assert envlog.dict["python"]["version_info"] == list(sys.version_info) - assert envlog.dict["python"]["version"] == sys.version - assert envlog.dict["python"]["executable"] == sys.executable - - -def test_get_commandlog(pkg): - replog = ResultLog() - envlog = replog.get_envlog("py36") - assert "setup" not in envlog.dict - setuplog = envlog.get_commandlog("setup") - envlog.set_header(installpkg=pkg) - setuplog.add_command(["virtualenv", "..."], "venv created", 0) - expected = [{"command": ["virtualenv", "..."], "output": "venv created", "retcode": 0}] - assert setuplog.list == expected - assert envlog.dict["setup"] - setuplog2 = replog.get_envlog("py36").get_commandlog("setup") - assert setuplog2.list == setuplog.list - - -@pytest.mark.parametrize("exit_code", [None, 0, 5, 128 + signal.SIGTERM, 1234]) -@pytest.mark.parametrize("os_name", ["posix", "nt"]) -def test_invocation_error(exit_code, os_name, mocker, monkeypatch): - monkeypatch.setattr(os, "name", value=os_name) - mocker.spy(tox.exception, "exit_code_str") - result = str(tox.exception.InvocationError("", exit_code=exit_code)) - # check that mocker works, because it will be our only test in - # test_z_cmdline.py::test_exit_code needs the mocker.spy above - assert tox.exception.exit_code_str.call_count == 1 - call_args = tox.exception.exit_code_str.call_args - assert call_args == mocker.call("InvocationError", "", exit_code) - if exit_code is None: - assert "(exited with code" not in result - else: - assert "(exited with code %d)" % exit_code in result - note = "Note: this might indicate a fatal error signal" - if (os_name == "posix") and (exit_code == 128 + signal.SIGTERM): - assert note in result - assert "({} - 128 = {}: SIGTERM)".format(exit_code, signal.SIGTERM) in result - else: - assert note not in result diff --git a/tests/unit/test_venv.py b/tests/unit/test_venv.py deleted file mode 100644 index a574326ad..000000000 --- a/tests/unit/test_venv.py +++ /dev/null @@ -1,1079 +0,0 @@ -import os -import sys - -import py -import pytest - -import tox -from tox.interpreters import NoInterpreterInfo -from tox.session.commands.run.sequential import installpkg, runtestenv -from tox.venv import ( - CreationConfig, - VirtualEnv, - getdigest, - prepend_shebang_interpreter, - tox_testenv_create, - tox_testenv_install_deps, -) - - -def test_getdigest(tmpdir): - assert getdigest(tmpdir) == "0" * 32 - - -def test_getsupportedinterpreter(monkeypatch, newconfig, mocksession): - config = newconfig( - [], - """\ - [testenv:python] - basepython={} - """.format( - sys.executable - ), - ) - mocksession.new_config(config) - venv = mocksession.getvenv("python") - interp = venv.getsupportedinterpreter() - # realpath needed for debian symlinks - assert py.path.local(interp).realpath() == py.path.local(sys.executable).realpath() - monkeypatch.setattr(tox.INFO, "IS_WIN", True) - monkeypatch.setattr(venv.envconfig, "basepython", "jython") - with pytest.raises(tox.exception.UnsupportedInterpreter): - venv.getsupportedinterpreter() - monkeypatch.undo() - monkeypatch.setattr(venv.envconfig, "envname", "py1") - monkeypatch.setattr(venv.envconfig, "basepython", "notexisting") - with pytest.raises(tox.exception.InterpreterNotFound): - venv.getsupportedinterpreter() - monkeypatch.undo() - # check that we properly report when no version_info is present - info = NoInterpreterInfo(name=venv.name) - info.executable = "something" - monkeypatch.setattr(config.interpreters, "get_info", lambda *args, **kw: info) - with pytest.raises(tox.exception.InvocationError): - venv.getsupportedinterpreter() - - -def test_create(mocksession, newconfig): - config = newconfig( - [], - """\ - [testenv:py123] - """, - ) - envconfig = config.envconfigs["py123"] - mocksession.new_config(config) - venv = mocksession.getvenv("py123") - assert venv.path == envconfig.envdir - assert not venv.path.check() - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) >= 1 - args = pcalls[0].args - assert "virtualenv" == str(args[2]) - if not tox.INFO.IS_WIN: - # realpath is needed for stuff like the debian symlinks - our_sys_path = py.path.local(sys.executable).realpath() - assert our_sys_path == py.path.local(args[0]).realpath() - # assert Envconfig.toxworkdir in args - assert venv.getcommandpath("easy_install", cwd=py.path.local()) - interp = venv._getliveconfig().base_resolved_python_path - assert interp == venv.envconfig.python_info.executable - assert venv.path_config.check(exists=False) - - -def test_create_KeyboardInterrupt(mocksession, newconfig, mocker): - config = newconfig( - [], - """\ - [testenv:py123] - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("py123") - with mocker.patch.object(venv, "_pcall", side_effect=KeyboardInterrupt): - with pytest.raises(KeyboardInterrupt): - venv.setupenv() - - assert venv.status == "keyboardinterrupt" - - -def test_commandpath_venv_precedence(tmpdir, monkeypatch, mocksession, newconfig): - config = newconfig( - [], - """\ - [testenv:py123] - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("py123") - envconfig = venv.envconfig - tmpdir.ensure("easy_install") - monkeypatch.setenv("PATH", str(tmpdir), prepend=os.pathsep) - envconfig.envbindir.ensure("easy_install") - p = venv.getcommandpath("easy_install") - assert py.path.local(p).relto(envconfig.envbindir), p - - -def test_create_sitepackages(mocksession, newconfig): - config = newconfig( - [], - """\ - [testenv:site] - sitepackages=True - - [testenv:nosite] - sitepackages=False - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("site") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) >= 1 - args = pcalls[0].args - assert "--system-site-packages" in map(str, args) - mocksession._clearmocks() - - venv = mocksession.getvenv("nosite") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) >= 1 - args = pcalls[0].args - assert "--system-site-packages" not in map(str, args) - assert "--no-site-packages" not in map(str, args) - - -def test_install_deps_wildcard(newmocksession): - mocksession = newmocksession( - [], - """\ - [tox] - distshare = {toxworkdir}/distshare - [testenv:py123] - deps= - {distshare}/dep1-* - """, - ) - venv = mocksession.getvenv("py123") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - distshare = venv.envconfig.config.distshare - distshare.ensure("dep1-1.0.zip") - distshare.ensure("dep1-1.1.zip") - - tox_testenv_install_deps(action=action, venv=venv) - assert len(pcalls) == 2 - args = pcalls[-1].args - assert pcalls[-1].cwd == venv.envconfig.config.toxinidir - - assert py.path.local.sysfind("python") == args[0] - assert ["-m", "pip"] == args[1:3] - assert args[3] == "install" - args = [arg for arg in args if str(arg).endswith("dep1-1.1.zip")] - assert len(args) == 1 - - -def test_install_deps_indexserver(newmocksession): - mocksession = newmocksession( - [], - """\ - [tox] - indexserver = - abc = ABC - abc2 = ABC - [testenv:py123] - deps= - dep1 - :abc:dep2 - :abc2:dep3 - """, - ) - venv = mocksession.getvenv("py123") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - pcalls[:] = [] - - tox_testenv_install_deps(action=action, venv=venv) - # two different index servers, two calls - assert len(pcalls) == 3 - args = " ".join(pcalls[0].args) - assert "-i " not in args - assert "dep1" in args - - args = " ".join(pcalls[1].args) - assert "-i ABC" in args - assert "dep2" in args - args = " ".join(pcalls[2].args) - assert "-i ABC" in args - assert "dep3" in args - - -def test_install_deps_pre(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - pip_pre=true - deps= - dep1 - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - pcalls[:] = [] - - tox_testenv_install_deps(action=action, venv=venv) - assert len(pcalls) == 1 - args = " ".join(pcalls[0].args) - assert "--pre " in args - assert "dep1" in args - - -def test_installpkg_indexserver(newmocksession, tmpdir): - mocksession = newmocksession( - [], - """\ - [tox] - indexserver = - default = ABC - """, - ) - venv = mocksession.getvenv("python") - pcalls = mocksession._pcalls - p = tmpdir.ensure("distfile.tar.gz") - installpkg(venv, p) - # two different index servers, two calls - assert len(pcalls) == 1 - args = " ".join(pcalls[0].args) - assert "-i ABC" in args - - -def test_install_recreate(newmocksession, tmpdir): - pkg = tmpdir.ensure("package.tar.gz") - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - deps=xyz - """, - ) - venv = mocksession.getvenv("python") - - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - installpkg(venv, pkg) - mocksession.report.expect("verbosity0", "*create*") - venv.update(action) - mocksession.report.expect("verbosity0", "*recreate*") - - -def test_install_sdist_extras(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - extras = testing - development - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - pcalls[:] = [] - - venv.installpkg("distfile.tar.gz", action=action) - assert "distfile.tar.gz[testing,development]" in pcalls[-1].args - - -def test_develop_extras(newmocksession, tmpdir): - mocksession = newmocksession( - [], - """\ - [testenv] - extras = testing - development - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - pcalls[:] = [] - - venv.developpkg(tmpdir, action=action) - expected = "{}[testing,development]".format(tmpdir.strpath) - assert expected in pcalls[-1].args - - -def test_env_variables_added_to_needs_reinstall(tmpdir, mocksession, newconfig, monkeypatch): - tmpdir.ensure("setup.py") - monkeypatch.setenv("TEMP_PASS_VAR", "123") - monkeypatch.setenv("TEMP_NOPASS_VAR", "456") - config = newconfig( - [], - """\ - [testenv:python] - passenv = temp_pass_var - setenv = - CUSTOM_VAR = 789 - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "hello") as action: - venv._needs_reinstall(tmpdir, action) - - pcalls = mocksession._pcalls - assert len(pcalls) == 2 - env = pcalls[0].env - - # should have access to setenv vars - assert "CUSTOM_VAR" in env - assert env["CUSTOM_VAR"] == "789" - - # should have access to passenv vars - assert "TEMP_PASS_VAR" in env - assert env["TEMP_PASS_VAR"] == "123" - - # should also have access to full invocation environment, - # for backward compatibility, and to match behavior of venv.run_install_command() - assert "TEMP_NOPASS_VAR" in env - assert env["TEMP_NOPASS_VAR"] == "456" - - -def test_test_hashseed_is_in_output(newmocksession, monkeypatch): - seed = "123456789" - monkeypatch.setattr("tox.config.make_hashseed", lambda: seed) - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - tox.venv.tox_runtest_pre(venv) - mocksession.report.expect("verbosity0", "run-test-pre: PYTHONHASHSEED='{}'".format(seed)) - - -def test_test_runtests_action_command_is_in_output(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - commands = echo foo bar - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - venv.test() - mocksession.report.expect("verbosity0", "*run-test:*commands?0? | echo foo bar") - - -def test_install_error(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - deps=xyz - commands= - qwelkqw - """, - ) - venv = mocksession.getvenv("python") - venv.test() - mocksession.report.expect("error", "*not find*qwelkqw*") - assert venv.status == "commands failed" - - -def test_install_command_not_installed(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - commands= - pytest - """, - ) - venv = mocksession.getvenv("python") - venv.status = 0 - venv.test() - mocksession.report.expect("warning", "*test command found but not*") - assert venv.status == 0 - - -def test_install_command_whitelisted(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - whitelist_externals = pytest - xy* - commands= - pytest - xyz - """, - ) - venv = mocksession.getvenv("python") - venv.test() - mocksession.report.expect("warning", "*test command found but not*", invert=True) - assert venv.status == "commands failed" - - -def test_install_command_not_installed_bash(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - commands= - bash - """, - ) - venv = mocksession.getvenv("python") - venv.test() - mocksession.report.expect("warning", "*test command found but not*") - - -def test_install_python3(newmocksession): - if not py.path.local.sysfind("python3") or tox.INFO.IS_PYPY: - pytest.skip("needs cpython3") - mocksession = newmocksession( - [], - """\ - [testenv:py123] - basepython=python3 - deps= - dep1 - dep2 - """, - ) - venv = mocksession.getvenv("py123") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - args = pcalls[0].args - assert str(args[2]) == "virtualenv" - pcalls[:] = [] - with mocksession.newaction(venv.name, "hello") as action: - venv._install(["hello"], action=action) - assert len(pcalls) == 1 - args = pcalls[0].args - assert py.path.local.sysfind("python") == args[0] - assert ["-m", "pip"] == args[1:3] - for _ in args: - assert "--download-cache" not in args, args - - -class TestCreationConfig: - def test_basic(self, newconfig, mocksession, tmpdir): - config = newconfig([], "") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - cconfig = venv._getliveconfig() - assert cconfig.matches(cconfig) - path = tmpdir.join("configdump") - cconfig.writeconfig(path) - newconfig = CreationConfig.readconfig(path) - assert newconfig.matches(cconfig) - assert cconfig.matches(newconfig) - - def test_matchingdependencies(self, newconfig, mocksession): - config = newconfig( - [], - """\ - [testenv] - deps=abc - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("python") - cconfig = venv._getliveconfig() - config = newconfig( - [], - """\ - [testenv] - deps=xyz - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("python") - otherconfig = venv._getliveconfig() - assert not cconfig.matches(otherconfig) - - def test_matchingdependencies_file(self, newconfig, mocksession): - config = newconfig( - [], - """\ - [tox] - distshare={toxworkdir}/distshare - [testenv] - deps=abc - {distshare}/xyz.zip - """, - ) - xyz = config.distshare.join("xyz.zip") - xyz.ensure() - mocksession.new_config(config) - venv = mocksession.getvenv("python") - cconfig = venv._getliveconfig() - assert cconfig.matches(cconfig) - xyz.write("hello") - newconfig = venv._getliveconfig() - assert not cconfig.matches(newconfig) - - def test_matchingdependencies_latest(self, newconfig, mocksession): - config = newconfig( - [], - """\ - [tox] - distshare={toxworkdir}/distshare - [testenv] - deps={distshare}/xyz-* - """, - ) - config.distshare.ensure("xyz-1.2.0.zip") - xyz2 = config.distshare.ensure("xyz-1.2.1.zip") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - cconfig = venv._getliveconfig() - sha256, path = cconfig.deps[0] - assert path == xyz2 - assert sha256 == path.computehash("sha256") - - def test_python_recreation(self, tmpdir, newconfig, mocksession): - pkg = tmpdir.ensure("package.tar.gz") - config = newconfig(["-v"], "") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - create_config = venv._getliveconfig() - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - assert not venv.path_config.check() - installpkg(venv, pkg) - assert venv.path_config.check() - assert mocksession._pcalls - args1 = map(str, mocksession._pcalls[0].args) - assert "virtualenv" in " ".join(args1) - mocksession.report.expect("*", "*create*") - # modify config and check that recreation happens - mocksession._clearmocks() - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - mocksession.report.expect("*", "*reusing*") - mocksession._clearmocks() - with mocksession.newaction(venv.name, "update") as action: - create_config.base_resolved_python_path = py.path.local("balla") - create_config.writeconfig(venv.path_config) - venv.update(action) - mocksession.report.expect("verbosity0", "*recreate*") - - def test_dep_recreation(self, newconfig, mocksession): - config = newconfig([], "") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - cconfig = venv._getliveconfig() - cconfig.deps[:] = [("1" * 32, "xyz.zip")] - cconfig.writeconfig(venv.path_config) - mocksession._clearmocks() - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - mocksession.report.expect("*", "*recreate*") - - def test_develop_recreation(self, newconfig, mocksession): - config = newconfig([], "") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - cconfig = venv._getliveconfig() - cconfig.usedevelop = True - cconfig.writeconfig(venv.path_config) - mocksession._clearmocks() - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - mocksession.report.expect("verbosity0", "*recreate*") - - -class TestVenvTest: - def test_envbindir_path(self, newmocksession, monkeypatch): - monkeypatch.setenv("PIP_RESPECT_VIRTUALENV", "1") - mocksession = newmocksession( - [], - """\ - [testenv:python] - commands=abc - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - monkeypatch.setenv("PATH", "xyz") - sysfind_calls = [] - monkeypatch.setattr( - "py.path.local.sysfind", - classmethod(lambda *args, **kwargs: sysfind_calls.append(kwargs) or 0 / 0), - ) - - with pytest.raises(ZeroDivisionError): - venv._install(list("123"), action=action) - assert sysfind_calls.pop()["paths"] == [venv.envconfig.envbindir] - with pytest.raises(ZeroDivisionError): - venv.test(action) - assert sysfind_calls.pop()["paths"] == [venv.envconfig.envbindir] - with pytest.raises(ZeroDivisionError): - venv.run_install_command(["qwe"], action=action) - assert sysfind_calls.pop()["paths"] == [venv.envconfig.envbindir] - monkeypatch.setenv("PIP_RESPECT_VIRTUALENV", "1") - monkeypatch.setenv("PIP_REQUIRE_VIRTUALENV", "1") - monkeypatch.setenv("__PYVENV_LAUNCHER__", "1") - - prev_pcall = venv._pcall - - def collect(*args, **kwargs): - env = kwargs["env"] - assert "PIP_RESPECT_VIRTUALENV" not in env - assert "PIP_REQUIRE_VIRTUALENV" not in env - assert "__PYVENV_LAUNCHER__" not in env - assert env["PIP_USER"] == "0" - assert env["PIP_NO_DEPS"] == "0" - return prev_pcall(*args, **kwargs) - - monkeypatch.setattr(venv, "_pcall", collect) - with pytest.raises(ZeroDivisionError): - venv.run_install_command(["qwe"], action=action) - - def test_pythonpath_remove(self, newmocksession, monkeypatch, caplog): - monkeypatch.setenv("PYTHONPATH", "/my/awesome/library") - mocksession = newmocksession( - [], - """\ - [testenv:python] - commands=abc - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - venv.run_install_command(["qwe"], action=action) - mocksession.report.expect("warning", "*Discarding $PYTHONPATH from environment*") - - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert "PYTHONPATH" not in pcalls[0].env - - def test_pythonpath_keep(self, newmocksession, monkeypatch, caplog): - # passenv = PYTHONPATH allows PYTHONPATH to stay in environment - monkeypatch.setenv("PYTHONPATH", "/my/awesome/library") - mocksession = newmocksession( - [], - """\ - [testenv:python] - commands=abc - passenv = PYTHONPATH - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - venv.run_install_command(["qwe"], action=action) - mocksession.report.not_expect("warning", "*Discarding $PYTHONPATH from environment*") - assert "PYTHONPATH" in os.environ - - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert pcalls[0].env["PYTHONPATH"] == "/my/awesome/library" - - def test_pythonpath_empty(self, newmocksession, monkeypatch, caplog): - monkeypatch.setenv("PYTHONPATH", "") - mocksession = newmocksession( - [], - """\ - [testenv:python] - commands=abc - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - venv.run_install_command(["qwe"], action=action) - if sys.version_info < (3, 4): - mocksession.report.expect("warning", "*Discarding $PYTHONPATH from environment*") - else: - with pytest.raises(AssertionError): - mocksession.report.expect("warning", "*Discarding $PYTHONPATH from environment*") - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert "PYTHONPATH" not in pcalls[0].env - - -def test_env_variables_added_to_pcall(tmpdir, mocksession, newconfig, monkeypatch): - monkeypatch.delenv("PYTHONPATH", raising=False) - pkg = tmpdir.ensure("package.tar.gz") - monkeypatch.setenv("X123", "123") - monkeypatch.setenv("YY", "456") - config = newconfig( - [], - """\ - [testenv:python] - commands=python -V - passenv = x123 - setenv = - ENV_VAR = value - PYTHONPATH = value - """, - ) - mocksession._clearmocks() - mocksession.new_config(config) - venv = mocksession.getvenv("python") - installpkg(venv, pkg) - venv.test() - - pcalls = mocksession._pcalls - assert len(pcalls) == 2 - for x in pcalls: - env = x.env - assert env is not None - assert "ENV_VAR" in env - assert env["ENV_VAR"] == "value" - assert env["VIRTUAL_ENV"] == str(venv.path) - assert env["X123"] == "123" - assert "PYTHONPATH" in env - assert env["PYTHONPATH"] == "value" - # all env variables are passed for installation - assert pcalls[0].env["YY"] == "456" - assert "YY" not in pcalls[1].env - - assert {"ENV_VAR", "VIRTUAL_ENV", "PYTHONHASHSEED", "X123", "PATH"}.issubset(pcalls[1].env) - - # setenv does not trigger PYTHONPATH warnings - mocksession.report.not_expect("warning", "*Discarding $PYTHONPATH from environment*") - - # for e in os.environ: - # assert e in env - - -def test_installpkg_no_upgrade(tmpdir, newmocksession): - pkg = tmpdir.ensure("package.tar.gz") - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - venv.just_created = True - venv.envconfig.envdir.ensure(dir=1) - installpkg(venv, pkg) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert pcalls[0].args[1:-1] == ["-m", "pip", "install", "--exists-action", "w"] - - -@pytest.mark.parametrize("count, level", [(0, 0), (1, 0), (2, 0), (3, 1), (4, 2), (5, 3), (6, 3)]) -def test_install_command_verbosity(tmpdir, newmocksession, count, level): - pkg = tmpdir.ensure("package.tar.gz") - mock_session = newmocksession(["-{}".format("v" * count)], "") - env = mock_session.getvenv("python") - env.just_created = True - env.envconfig.envdir.ensure(dir=1) - installpkg(env, pkg) - pcalls = mock_session._pcalls - assert len(pcalls) == 1 - expected = ["-m", "pip", "install", "--exists-action", "w"] + (["-v"] * level) - assert pcalls[0].args[1:-1] == expected - - -def test_installpkg_upgrade(newmocksession, tmpdir): - pkg = tmpdir.ensure("package.tar.gz") - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - assert not hasattr(venv, "just_created") - installpkg(venv, pkg) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - index = pcalls[0].args.index(pkg.basename) - assert index >= 0 - assert "-U" in pcalls[0].args[:index] - assert "--no-deps" in pcalls[0].args[:index] - - -def test_run_install_command(newmocksession): - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - venv.just_created = True - venv.envconfig.envdir.ensure(dir=1) - with mocksession.newaction(venv.name, "hello") as action: - venv.run_install_command(packages=["whatever"], action=action) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - args = pcalls[0].args - assert py.path.local.sysfind("python") == args[0] - assert ["-m", "pip"] == args[1:3] - assert "install" in args - env = pcalls[0].env - assert env is not None - - -def test_run_custom_install_command(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - install_command=easy_install {opts} {packages} - """, - ) - venv = mocksession.getvenv("python") - venv.just_created = True - venv.envconfig.envdir.ensure(dir=1) - with mocksession.newaction(venv.name, "hello") as action: - venv.run_install_command(packages=["whatever"], action=action) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert "easy_install" in pcalls[0].args[0] - assert pcalls[0].args[1:] == ["whatever"] - - -def test_command_relative_issue36(newmocksession, tmpdir, monkeypatch): - mocksession = newmocksession( - [], - """\ - [testenv] - """, - ) - x = tmpdir.ensure("x") - venv = mocksession.getvenv("python") - x2 = venv.getcommandpath("./x", cwd=tmpdir) - assert x == x2 - mocksession.report.not_expect("warning", "*test command found but not*") - x3 = venv.getcommandpath("/bin/bash", cwd=tmpdir) - assert x3 == "/bin/bash" - mocksession.report.not_expect("warning", "*test command found but not*") - monkeypatch.setenv("PATH", str(tmpdir)) - x4 = venv.getcommandpath("x", cwd=tmpdir) - assert x4.endswith(os.sep + "x") - mocksession.report.expect("warning", "*test command found but not*") - - -def test_ignore_outcome_failing_cmd(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - commands=testenv_fail - ignore_outcome=True - """, - ) - - venv = mocksession.getvenv("python") - venv.test() - assert venv.status == "ignored failed command" - mocksession.report.expect("warning", "*command failed but result from testenv is ignored*") - - -def test_tox_testenv_create(newmocksession): - log = [] - - class Plugin: - @tox.hookimpl - def tox_testenv_create(self, action, venv): - assert isinstance(action, tox.session.Action) - assert isinstance(venv, VirtualEnv) - log.append(1) - - @tox.hookimpl - def tox_testenv_install_deps(self, action, venv): - assert isinstance(action, tox.session.Action) - assert isinstance(venv, VirtualEnv) - log.append(2) - - mocksession = newmocksession( - [], - """\ - [testenv] - commands=testenv_fail - ignore_outcome=True - """, - plugins=[Plugin()], - ) - - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - venv.update(action=action) - assert log == [1, 2] - - -def test_tox_testenv_pre_post(newmocksession): - log = [] - - class Plugin: - @tox.hookimpl - def tox_runtest_pre(self): - log.append("started") - - @tox.hookimpl - def tox_runtest_post(self): - log.append("finished") - - mocksession = newmocksession( - [], - """\ - [testenv] - commands=testenv_fail - """, - plugins=[Plugin()], - ) - - venv = mocksession.getvenv("python") - venv.status = None - assert log == [] - runtestenv(venv, venv.envconfig.config) - assert log == ["started", "finished"] - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_empty_instance(tmpdir): - testfile = tmpdir.join("check_shebang_empty_instance.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # empty instance - testfile.write("") - args = prepend_shebang_interpreter(base_args) - assert args == base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_empty_interpreter(tmpdir): - testfile = tmpdir.join("check_shebang_empty_interpreter.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # empty interpreter - testfile.write("#!") - args = prepend_shebang_interpreter(base_args) - assert args == base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_empty_interpreter_ws(tmpdir): - testfile = tmpdir.join("check_shebang_empty_interpreter_ws.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # empty interpreter (whitespaces) - testfile.write("#! \n") - args = prepend_shebang_interpreter(base_args) - assert args == base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_non_utf8(tmpdir): - testfile = tmpdir.join("check_non_utf8.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - testfile.write_binary(b"#!\x9a\xef\x12\xaf\n") - args = prepend_shebang_interpreter(base_args) - assert args == base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_interpreter_simple(tmpdir): - testfile = tmpdir.join("check_shebang_interpreter_simple.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (simple) - testfile.write("#!interpreter") - args = prepend_shebang_interpreter(base_args) - assert args == ["interpreter"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_interpreter_ws(tmpdir): - testfile = tmpdir.join("check_shebang_interpreter_ws.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (whitespaces) - testfile.write("#! interpreter \n\n") - args = prepend_shebang_interpreter(base_args) - assert args == ["interpreter"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_interpreter_arg(tmpdir): - testfile = tmpdir.join("check_shebang_interpreter_arg.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter with argument - testfile.write("#!interpreter argx\n") - args = prepend_shebang_interpreter(base_args) - assert args == ["interpreter", "argx"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_interpreter_args(tmpdir): - testfile = tmpdir.join("check_shebang_interpreter_args.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter with argument (ensure single argument) - testfile.write("#!interpreter argx argx-part2\n") - args = prepend_shebang_interpreter(base_args) - assert args == ["interpreter", "argx argx-part2"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_real(tmpdir): - testfile = tmpdir.join("check_shebang_real.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (real example) - testfile.write("#!/usr/bin/env python\n") - args = prepend_shebang_interpreter(base_args) - assert args == ["/usr/bin/env", "python"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_long_example(tmpdir): - testfile = tmpdir.join("check_shebang_long_example.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (long example) - testfile.write( - "#!this-is-an-example-of-a-very-long-interpret-directive-what-should-" - "be-directly-invoked-when-tox-needs-to-invoked-the-provided-script-" - "name-in-the-argument-list" - ) - args = prepend_shebang_interpreter(base_args) - expected = [ - "this-is-an-example-of-a-very-long-interpret-directive-what-should-be-" - "directly-invoked-when-tox-needs-to-invoked-the-provided-script-name-" - "in-the-argument-list" - ] - - assert args == expected + base_args - - -@pytest.mark.parametrize("download", [True, False, None]) -def test_create_download(mocksession, newconfig, download): - config = newconfig( - [], - """\ - [testenv:env] - {} - """.format( - "download={}".format(download) if download else "" - ), - ) - mocksession.new_config(config) - venv = mocksession.getvenv("env") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - pcalls = mocksession._pcalls - assert len(pcalls) >= 1 - args = pcalls[0].args - if download is True: - assert "--no-download" not in map(str, args) - else: - assert "--no-download" in map(str, args) - mocksession._clearmocks() diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 000000000..324cb0ac3 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,4 @@ +def test_version(): + from tox.version import __version__ + + assert __version__ diff --git a/tests/unit/test_z_cmdline.py b/tests/unit/test_z_cmdline.py deleted file mode 100644 index f8681f74a..000000000 --- a/tests/unit/test_z_cmdline.py +++ /dev/null @@ -1,1071 +0,0 @@ -import json -import os -import re -import shutil -import subprocess -import sys -import tempfile - -import py -import pytest - -import tox -from tox.config import parseconfig -from tox.reporter import Verbosity -from tox.session import Session - -pytest_plugins = "pytester" - - -class TestSession: - def test_log_pcall(self, mocksession): - mocksession.logging_levels(quiet=Verbosity.DEFAULT, verbose=Verbosity.INFO) - mocksession.config.logdir.ensure(dir=1) - assert not mocksession.config.logdir.listdir() - with mocksession.newaction("what", "something") as action: - action.popen(["echo"]) - match = mocksession.report.getnext("logpopen") - log_name = py.path.local(match[1].split(">")[-1].strip()).relto( - mocksession.config.logdir - ) - assert log_name == "what-0.log" - - def test_summary_status(self, initproj, capfd): - initproj( - "logexample123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:hello] - [testenv:world] - """, - }, - ) - config = parseconfig([]) - session = Session(config) - envs = list(session.venv_dict.values()) - assert len(envs) == 2 - env1, env2 = envs - env1.status = "FAIL XYZ" - assert env1.status - env2.status = 0 - assert not env2.status - session._summary() - out, err = capfd.readouterr() - exp = "{}: FAIL XYZ".format(env1.envconfig.envname) - assert exp in out - exp = "{}: commands succeeded".format(env2.envconfig.envname) - assert exp in out - - def test_getvenv(self, initproj): - initproj( - "logexample123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:hello] - [testenv:world] - """, - }, - ) - config = parseconfig([]) - session = Session(config) - venv1 = session.getvenv("hello") - venv2 = session.getvenv("hello") - assert venv1 is venv2 - venv1 = session.getvenv("world") - venv2 = session.getvenv("world") - assert venv1 is venv2 - with pytest.raises(LookupError): - session.getvenv("qwe") - - -def test_notoxini_help_still_works(initproj, cmd): - initproj("example123-0.5", filedefs={"tests": {"test_hello.py": "def test_hello(): pass"}}) - result = cmd("-h") - msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" - assert result.err == msg - assert result.out.startswith("usage: ") - assert any("--help" in l for l in result.outlines), result.outlines - result.assert_success(is_run_test_env=False) - - -def test_notoxini_help_ini_still_works(initproj, cmd): - initproj("example123-0.5", filedefs={"tests": {"test_hello.py": "def test_hello(): pass"}}) - result = cmd("--help-ini") - assert any("setenv" in l for l in result.outlines), result.outlines - result.assert_success(is_run_test_env=False) - - -def test_envdir_equals_toxini_errors_out(cmd, initproj): - initproj( - "interp123-0.7", - filedefs={ - "tox.ini": """ - [testenv] - envdir={toxinidir} - """ - }, - ) - result = cmd() - assert result.outlines[1] == "ERROR: ConfigError: envdir must not equal toxinidir" - assert re.match( - r"ERROR: venv \'python\' in .* would delete project", result.outlines[0] - ), result.outlines[0] - result.assert_fail() - - -def test_envdir_would_delete_some_directory(cmd, initproj): - projdir = initproj( - "example-123", - filedefs={ - "tox.ini": """\ - [tox] - - [testenv:venv] - envdir=example - commands= - """ - }, - ) - - result = cmd("-e", "venv") - assert projdir.join("example/__init__.py").exists() - result.assert_fail() - assert "cowardly refusing to delete `envdir`" in result.out - - -def test_recreate(cmd, initproj): - initproj("example-123", filedefs={"tox.ini": ""}) - cmd("-e", "py", "--notest").assert_success() - cmd("-r", "-e", "py", "--notest").assert_success() - - -def test_run_custom_install_command_error(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tox.ini": """ - [testenv] - install_command=./tox.ini {opts} {packages} - """ - }, - ) - result = cmd() - result.assert_fail() - re.match( - r"ERROR: python: InvocationError for command .* \(exited with code \d+\)", - result.outlines[-1], - ), result.out - - -def test_unknown_interpreter_and_env(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """\ - [testenv:python] - basepython=xyz_unknown_interpreter - [testenv] - changedir=tests - skip_install = true - """, - }, - ) - result = cmd() - result.assert_fail() - assert "ERROR: InterpreterNotFound: xyz_unknown_interpreter" in result.outlines - - result = cmd("-exyz") - result.assert_fail() - assert result.out == "ERROR: unknown environment 'xyz'\n" - - -def test_unknown_interpreter_factor(cmd, initproj): - initproj("py21", filedefs={"tox.ini": "[testenv]\nskip_install=true"}) - result = cmd("-e", "py21") - result.assert_fail() - assert "ERROR: InterpreterNotFound: python2.1" in result.outlines - - -def test_unknown_interpreter(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:python] - basepython=xyz_unknown_interpreter - [testenv] - changedir=tests - """, - }, - ) - result = cmd() - result.assert_fail() - assert any( - "ERROR: InterpreterNotFound: xyz_unknown_interpreter" == l for l in result.outlines - ), result.outlines - - -def test_skip_platform_mismatch(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv] - changedir=tests - platform=x123 - """, - }, - ) - result = cmd() - result.assert_success() - assert any( - "SKIPPED: python: platform mismatch ({!r} does not match 'x123')".format(sys.platform) - == l - for l in result.outlines - ), result.outlines - - -def test_skip_unknown_interpreter(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:python] - basepython=xyz_unknown_interpreter - [testenv] - changedir=tests - """, - }, - ) - result = cmd("--skip-missing-interpreters") - result.assert_success() - msg = "SKIPPED: python: InterpreterNotFound: xyz_unknown_interpreter" - assert any(msg == l for l in result.outlines), result.outlines - - -def test_skip_unknown_interpreter_result_json(cmd, initproj, tmpdir): - report_path = tmpdir.join("toxresult.json") - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:python] - basepython=xyz_unknown_interpreter - [testenv] - changedir=tests - """, - }, - ) - result = cmd("--skip-missing-interpreters", "--result-json", report_path) - result.assert_success() - msg = "SKIPPED: python: InterpreterNotFound: xyz_unknown_interpreter" - assert any(msg == l for l in result.outlines), result.outlines - setup_result_from_json = json.load(report_path)["testenvs"]["python"]["setup"] - for setup_step in setup_result_from_json: - assert "InterpreterNotFound" in setup_step["output"] - assert setup_step["retcode"] == 0 - - -def test_unknown_dep(cmd, initproj): - initproj( - "dep123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv] - deps=qweqwe123 - changedir=tests - """, - }, - ) - result = cmd() - result.assert_fail() - assert result.outlines[-1].startswith("ERROR: python: could not install deps [qweqwe123];") - - -def test_venv_special_chars_issue252(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [tox] - envlist = special&&1 - [testenv:special&&1] - changedir=tests - """, - }, - ) - result = cmd() - result.assert_success() - pattern = re.compile("special&&1 installed: .*pkg123==0.7.*") - assert any(pattern.match(line) for line in result.outlines), result.outlines - - -def test_unknown_environment(cmd, initproj): - initproj("env123-0.7", filedefs={"tox.ini": ""}) - result = cmd("-e", "qpwoei") - result.assert_fail() - assert result.out == "ERROR: unknown environment 'qpwoei'\n" - - -def test_unknown_environment_with_envlist(cmd, initproj): - initproj( - "pkg123", - filedefs={ - "tox.ini": """ - [tox] - envlist = py{36,37}-django{20,21} - """ - }, - ) - result = cmd("-e", "py36-djagno21") - result.assert_fail() - assert result.out == "ERROR: unknown environment 'py36-djagno21'\n" - - -def test_minimal_setup_py_empty(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert result.outlines[-1] == "ERROR: setup.py is empty" - - -def test_minimal_setup_py_comment_only(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """\n# some comment - - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert result.outlines[-1] == "ERROR: setup.py is empty" - - -def test_minimal_setup_py_non_functional(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - import sys - - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert any(re.match(r".*ERROR.*check setup.py.*", l) for l in result.outlines), result.outlines - - -def test_sdist_fails(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - syntax error - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert any( - re.match(r".*FAIL.*could not package project.*", l) for l in result.outlines - ), result.outlines - - -def test_no_setup_py_exits(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [testenv] - commands=python -c "2 + 2" - """ - }, - ) - os.remove("setup.py") - result = cmd() - result.assert_fail() - assert any( - re.match(r".*ERROR.*No setup.py file found.*", l) for l in result.outlines - ), result.outlines - - -def test_package_install_fails(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - from setuptools import setup - setup( - name='pkg123', - description='pkg123 project', - version='0.7', - license='MIT', - platforms=['unix', 'win32'], - packages=['pkg123',], - install_requires=['qweqwe123'], - ) - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert result.outlines[-1].startswith("ERROR: python: InvocationError for command ") - - -@pytest.fixture -def example123(initproj): - yield initproj( - "example123-0.5", - filedefs={ - "tests": { - "test_hello.py": """ - def test_hello(pytestconfig): - pass - """ - }, - "tox.ini": """ - [testenv] - changedir=tests - commands= pytest --basetemp={envtmpdir} \ - --junitxml=junit-{envname}.xml - deps=pytest - """, - }, - ) - - -def test_toxuone_env(cmd, example123): - result = cmd() - result.assert_success() - assert re.match( - r".*generated\W+xml\W+file.*junit-python\.xml" r".*\W+1\W+passed.*", result.out, re.DOTALL - ) - result = cmd("-epython") - result.assert_success() - assert re.match( - r".*\W+1\W+passed.*" r"summary.*" r"python:\W+commands\W+succeeded.*", - result.out, - re.DOTALL, - ) - - -def test_different_config_cwd(cmd, example123): - # see that things work with a different CWD - with example123.dirpath().as_cwd(): - result = cmd("-c", "example123/tox.ini") - result.assert_success() - assert re.match( - r".*\W+1\W+passed.*" r"summary.*" r"python:\W+commands\W+succeeded.*", - result.out, - re.DOTALL, - ) - - -def test_result_json(cmd, initproj, example123): - cwd = initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - deps = setuptools - commands_pre = python -c 'print("START")' - commands = python -c 'print("OK")' - - python -c 'print("1"); raise SystemExit(1)' - python -c 'print("1"); raise SystemExit(2)' - python -c 'print("SHOULD NOT HAPPEN")' - commands_post = python -c 'print("END")' - """ - }, - ) - json_path = cwd / "res.json" - result = cmd("--result-json", json_path) - result.assert_fail() - data = json.loads(json_path.read_text(encoding="utf-8")) - - assert data["reportversion"] == "1" - assert data["toxversion"] == tox.__version__ - - for env_data in data["testenvs"].values(): - for command_type in ("setup", "test"): - if command_type not in env_data: - assert False, "missing {}".format(command_type) - for command in env_data[command_type]: - assert isinstance(command["command"], list) - assert command["output"] - assert "retcode" in command - assert isinstance(command["retcode"], int) - # virtualenv, deps install, package install, freeze - assert len(env_data["setup"]) == 4 - # 1 pre + 3 command + 1 post - assert len(env_data["test"]) == 5 - assert isinstance(env_data["installed_packages"], list) - pyinfo = env_data["python"] - assert isinstance(pyinfo["version_info"], list) - assert pyinfo["version"] - assert pyinfo["executable"] - assert "write json report at: {}".format(json_path) == result.outlines[-1] - - -def test_developz(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - """ - }, - ) - result = cmd("-vv", "--develop") - result.assert_success() - assert "sdist-make" not in result.out - - -def test_usedevelop(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - usedevelop=True - """ - }, - ) - result = cmd("-vv") - result.assert_success() - assert "sdist-make" not in result.out - - -def test_usedevelop_mixed(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv:dev] - usedevelop=True - [testenv:nondev] - usedevelop=False - """ - }, - ) - - # running only 'dev' should not do sdist - result = cmd("-vv", "-e", "dev") - result.assert_success() - assert "sdist-make" not in result.out - - # running all envs should do sdist - result = cmd("-vv") - result.assert_success() - assert "sdist-make" in result.out - - -@pytest.mark.parametrize("skipsdist", [False, True]) -@pytest.mark.parametrize("src_root", [".", "src"]) -def test_test_usedevelop(cmd, initproj, src_root, skipsdist): - name = "example123-spameggs" - base = initproj( - (name, "0.5"), - src_root=src_root, - filedefs={ - "tests": { - "test_hello.py": """ - def test_hello(pytestconfig): - pass - """ - }, - "tox.ini": """ - [testenv] - usedevelop=True - changedir=tests - commands= - pytest --basetemp={envtmpdir} --junitxml=junit-{envname}.xml [] - deps=pytest""" - + """ - skipsdist={} - """.format( - skipsdist - ), - }, - ) - result = cmd("-v") - result.assert_success() - assert re.match( - r".*generated\W+xml\W+file.*junit-python\.xml" r".*\W+1\W+passed.*", result.out, re.DOTALL - ) - assert "sdist-make" not in result.out - result = cmd("-epython") - result.assert_success() - assert "develop-inst-noop" in result.out - assert re.match( - r".*\W+1\W+passed.*" r"summary.*" r"python:\W+commands\W+succeeded.*", - result.out, - re.DOTALL, - ) - - # see that things work with a different CWD - with base.dirpath().as_cwd(): - result = cmd("-c", "{}/tox.ini".format(name)) - result.assert_success() - assert "develop-inst-noop" in result.out - assert re.match( - r".*\W+1\W+passed.*" r"summary.*" r"python:\W+commands\W+succeeded.*", - result.out, - re.DOTALL, - ) - - # see that tests can also fail and retcode is correct - testfile = py.path.local("tests").join("test_hello.py") - assert testfile.check() - testfile.write("def test_fail(): assert 0") - result = cmd() - result.assert_fail() - assert "develop-inst-noop" in result.out - assert re.match( - r".*\W+1\W+failed.*" r"summary.*" r"python:\W+commands\W+failed.*", result.out, re.DOTALL - ) - - # test develop is called if setup.py changes - setup_py = py.path.local("setup.py") - setup_py.write(setup_py.read() + " ") - result = cmd() - result.assert_fail() - assert "develop-inst-nodeps" in result.out - - -def test_warning_emitted(cmd, initproj): - initproj( - "spam-0.0.1", - filedefs={ - "tox.ini": """ - [testenv] - skipsdist=True - usedevelop=True - """, - "setup.py": """ - from setuptools import setup - from warnings import warn - warn("I am a warning") - - setup(name="spam", version="0.0.1") - """, - }, - ) - cmd() - result = cmd() - assert "develop-inst-noop" in result.out - assert "I am a warning" in result.err - - -def _alwayscopy_not_supported(): - # This is due to virtualenv bugs with alwayscopy in some platforms - # see: https://github.com/pypa/virtualenv/issues/565 - supported = True - tmpdir = tempfile.mkdtemp() - try: - with open(os.devnull) as fp: - subprocess.check_call( - [sys.executable, "-m", "virtualenv", "--always-copy", tmpdir], stdout=fp, stderr=fp - ) - except subprocess.CalledProcessError: - supported = False - finally: - shutil.rmtree(tmpdir) - return not supported - - -alwayscopy_not_supported = _alwayscopy_not_supported() - - -@pytest.mark.skipif(alwayscopy_not_supported, reason="Platform doesnt support alwayscopy") -def test_alwayscopy(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - commands={envpython} --version - alwayscopy=True - """ - }, - ) - result = cmd("-vv") - result.assert_success() - assert "virtualenv --always-copy" in result.out - - -def test_alwayscopy_default(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - commands={envpython} --version - """ - }, - ) - result = cmd("-vv") - result.assert_success() - assert "virtualenv --always-copy" not in result.out - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no echo on Windows") -def test_empty_activity_ignored(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - list_dependencies_command=echo - commands={envpython} --version - """ - }, - ) - result = cmd() - result.assert_success() - assert "installed:" not in result.out - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no echo on Windows") -def test_empty_activity_shown_verbose(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - list_dependencies_command=echo - commands={envpython} --version - whitelist_externals = echo - """ - }, - ) - result = cmd("-v") - result.assert_success() - assert "installed:" in result.out - - -def test_test_piphelp(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - # content of: tox.ini - [testenv] - commands=pip -h - """ - }, - ) - result = cmd("-vv") - result.assert_success() - - -def test_notest(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """\ - # content of: tox.ini - [testenv:py26] - basepython={} - """.format( - sys.executable - ) - }, - ) - result = cmd("-v", "--notest") - result.assert_success() - assert re.match(r".*summary.*" r"py26\W+skipped\W+tests.*", result.out, re.DOTALL) - result = cmd("-v", "--notest", "-epy26") - result.assert_success() - assert re.match(r".*py26\W+reusing.*", result.out, re.DOTALL) - - -def test_notest_setup_py_error(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ - from setuptools import setup - setup(name='x', install_requires=['fakefakefakefakefakefake']), - """, - "tox.ini": "", - }, - ) - result = cmd("--notest") - result.assert_fail() - assert re.search("ERROR:.*InvocationError", result.out) - - -def test_devenv(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ - from setuptools import setup - setup(name='x') - """, - "tox.ini": """\ - [tox] - # envlist is ignored for --devenv - envlist = foo,bar,baz - - [testenv] - # --devenv implies --notest - commands = python -c "exit(1)" - """, - }, - ) - result = cmd("--devenv", "venv") - result.assert_success() - # `--devenv` defaults to the `py` environment and a develop install - assert "py develop-inst:" in result.out - assert re.search("py create:.*venv", result.out) - - -def test_devenv_does_not_allow_multiple_environments(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ - from setuptools import setup - setup(name='x') - """, - "tox.ini": """\ - [tox] - envlist=foo,bar,baz - """, - }, - ) - - result = cmd("--devenv", "venv", "-e", "foo,bar") - result.assert_fail() - assert result.err == "ERROR: --devenv requires only a single -e\n" - - -def test_devenv_does_not_delete_project(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ - from setuptools import setup - setup(name='x') - """, - "tox.ini": """\ - [tox] - envlist=foo,bar,baz - """, - }, - ) - - result = cmd("--devenv", "") - result.assert_fail() - assert "would delete project" in result.out - assert "ERROR: ConfigError: envdir must not equal toxinidir" in result.out - - -def test_PYC(initproj, cmd, monkeypatch): - initproj("example123", filedefs={"tox.ini": ""}) - monkeypatch.setenv("PYTHONDOWNWRITEBYTECODE", "1") - result = cmd("-v", "--notest") - result.assert_success() - assert "create" in result.out - - -def test_env_VIRTUALENV_PYTHON(initproj, cmd, monkeypatch): - initproj("example123", filedefs={"tox.ini": ""}) - monkeypatch.setenv("VIRTUALENV_PYTHON", "/FOO") - result = cmd("-v", "--notest") - result.assert_success() - assert "create" in result.out - - -def test_setup_prints_non_ascii(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ -import sys -getattr(sys.stdout, 'buffer', sys.stdout).write(b'\\xe2\\x98\\x83\\n') - -import setuptools -setuptools.setup(name='example123') -""", - "tox.ini": "", - }, - ) - result = cmd("--notest") - result.assert_success() - assert "create" in result.out - - -def test_envsitepackagesdir(cmd, initproj): - initproj( - "pkg512-0.0.5", - filedefs={ - "tox.ini": """ - [testenv] - commands= - python -c "print(r'X:{envsitepackagesdir}')" - """ - }, - ) - result = cmd() - result.assert_success() - assert re.match(r".*\nX:.*tox.*site-packages.*", result.out, re.DOTALL) - - -def test_envsitepackagesdir_skip_missing_issue280(cmd, initproj): - initproj( - "pkg513-0.0.5", - filedefs={ - "tox.ini": """ - [testenv] - basepython=/usr/bin/qwelkjqwle - commands= - {envsitepackagesdir} - """ - }, - ) - result = cmd("--skip-missing-interpreters") - result.assert_success() - assert re.match(r".*SKIPPED:.*qwelkj.*", result.out, re.DOTALL) - - -@pytest.mark.parametrize("verbosity", ["", "-v", "-vv"]) -def test_verbosity(cmd, initproj, verbosity): - initproj( - "pkgX-0.0.5", - filedefs={ - "tox.ini": """ - [testenv] - """ - }, - ) - result = cmd(verbosity) - result.assert_success() - - needle = "Successfully installed pkgX-0.0.5" - if verbosity == "-vv": - assert any(needle in line for line in result.outlines), result.outlines - else: - assert all(needle not in line for line in result.outlines), result.outlines - - -def test_envtmpdir(initproj, cmd): - initproj( - "foo", - filedefs={ - # This file first checks that envtmpdir is existent and empty. Then it - # creates an empty file in that directory. The tox command is run - # twice below, so this is to test whether the directory is cleared - # before the second run. - "check_empty_envtmpdir.py": """if True: - import os - from sys import argv - envtmpdir = argv[1] - assert os.path.exists(envtmpdir) - assert os.listdir(envtmpdir) == [] - open(os.path.join(envtmpdir, 'test'), 'w').close() - """, - "tox.ini": """ - [testenv] - commands=python check_empty_envtmpdir.py {envtmpdir} - """, - }, - ) - - result = cmd() - result.assert_success() - - result = cmd() - result.assert_success() - - -def test_missing_env_fails(initproj, cmd): - initproj("foo", filedefs={"tox.ini": "[testenv:foo]\ncommands={env:VAR}"}) - result = cmd() - result.assert_fail() - assert result.out.endswith( - "foo: unresolvable substitution(s): 'VAR'." - " Environment variables are missing or defined recursively.\n" - ) - - -def test_tox_console_script(initproj): - initproj("help", filedefs={"tox.ini": ""}) - result = subprocess.check_call(["tox", "--help"]) - assert result == 0 - - -def test_tox_quickstart_script(initproj): - initproj("help", filedefs={"tox.ini": ""}) - result = subprocess.check_call(["tox-quickstart", "--help"]) - assert result == 0 - - -def test_tox_cmdline_no_args(monkeypatch, initproj): - initproj("help", filedefs={"tox.ini": ""}) - monkeypatch.setattr(sys, "argv", ["caller_script", "--help"]) - with pytest.raises(SystemExit): - tox.cmdline() - - -def test_tox_cmdline_args(initproj): - initproj("help", filedefs={"tox.ini": ""}) - with pytest.raises(SystemExit): - tox.cmdline(["caller_script", "--help"]) - - -@pytest.mark.parametrize("exit_code", [0, 6]) -def test_exit_code(initproj, cmd, exit_code, mocker): - """ Check for correct InvocationError, with exit code, - except for zero exit code """ - import tox.exception - - mocker.spy(tox.exception, "exit_code_str") - tox_ini_content = "[testenv:foo]\ncommands=python -c 'import sys; sys.exit({:d})'".format( - exit_code - ) - initproj("foo", filedefs={"tox.ini": tox_ini_content}) - cmd() - if exit_code: - # need mocker.spy above - assert tox.exception.exit_code_str.call_count == 1 - (args, kwargs) = tox.exception.exit_code_str.call_args - assert kwargs == {} - (call_error_name, call_command, call_exit_code) = args - assert call_error_name == "InvocationError" - # quotes are removed in result.out - # do not include "python" as it is changed to python.EXE by appveyor - expected_command_arg = " -c 'import sys; sys.exit({:d})'".format(exit_code) - assert expected_command_arg in call_command - assert call_exit_code == exit_code - else: - # need mocker.spy above - assert tox.exception.exit_code_str.call_count == 0 diff --git a/tests/unit/tox_env/python/virtual_env/setuptools/package/test_wheel_build.py b/tests/unit/tox_env/python/virtual_env/setuptools/package/test_wheel_build.py new file mode 100644 index 000000000..aca2f988f --- /dev/null +++ b/tests/unit/tox_env/python/virtual_env/setuptools/package/test_wheel_build.py @@ -0,0 +1,91 @@ +import sys +from pathlib import Path +from typing import List, Sequence + +import pytest +import setuptools +import wheel + +from tox.execute.api import Outcome +from tox.execute.request import ExecuteRequest +from tox.interpreters.discovery import PythonInfo +from tox.pytest import ToxProjectCreator +from tox.tox_env.python.virtual_env.api import VirtualEnv +from tox.tox_env.python.virtual_env.package.artifact.wheel import Pep517VirtualEnvPackageWheel + + +@pytest.fixture() +def use_host_virtualenv(monkeypatch): + # disable install + def perform_install(self, install_command: Sequence[str]) -> Outcome: + install_command = ("python", "-c", "import sys; print(sys.argv)") + tuple(install_command) + return old_cmd(self, install_command) + + old_cmd = VirtualEnv.perform_install + monkeypatch.setattr(VirtualEnv, "perform_install", perform_install) + + # return hots path + def paths(self, python: PythonInfo) -> List[Path]: + return [Path(sys.executable).parent] + + monkeypatch.setattr(VirtualEnv, "paths", paths) + + # return hots path + def create_python_env(self, python: PythonInfo): + return Outcome(ExecuteRequest([], Path(), {}, False), False, Outcome.OK, "", "", 0, 1.0) + + monkeypatch.setattr(VirtualEnv, "create_python_env", create_python_env) + + +def test_setuptools_package_wheel_universal(tox_project: ToxProjectCreator, use_host_virtualenv): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = py + + [testenv] + package = wheel + """, + "setup.cfg": """ + [metadata] + name = magic + version = 1.2.3 + [options] + packages = find: + package_dir = + =src + [options.packages.find] + where = src + [bdist_wheel] + universal = 1 + """, + "pyproject.toml": """ + [build-system] + requires = [ + "setuptools >= {}", + "wheel >= {}", + ] + build-backend = 'setuptools.build_meta' + """.format( + setuptools.__version__, wheel.__version__ + ), + "src": {"magic": {"__init__.py": """__version__ = "1.2.3" """}}, + } + ) + outcome = project.run("r") + tox_env = outcome.state.tox_envs["py"] + package_env = tox_env.package_env + assert isinstance(package_env, Pep517VirtualEnvPackageWheel) + packages = package_env.perform_packaging() + assert len(packages) == 1 + package = packages[0] + assert package.name == "magic-1.2.3-py2.py3-none-any.whl" + + result = outcome.out.split("\n") + py_messages = [i for i in result if "py: " in i] + assert len(py_messages) == 2 # 1 install wheel + 1 report + + package_messages = [i for i in result if ".package: " in i] + # 1 install requires + 1 build requires + 1 build meta + 1 build isolated + assert len(package_messages) == 4 diff --git a/tests/unit/tox_env/python/virtual_env/test_setuptools.py b/tests/unit/tox_env/python/virtual_env/test_setuptools.py new file mode 100644 index 000000000..3ece3d58d --- /dev/null +++ b/tests/unit/tox_env/python/virtual_env/test_setuptools.py @@ -0,0 +1,24 @@ +from tox.pytest import ToxProjectCreator + + +def test_setuptools_project_no_package(tox_project: ToxProjectCreator): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = py + no_package = true + + [testenv] + deps = pip + commands_pre = + python -c 'import sys; print("start", sys.executable)' + commands = + python -c 'import sys; print("do", sys.executable)' + commands_post = + python -c 'import sys; print("end", sys.executable)' + """ + } + ) + outcome = project.run("-e", "py") + outcome.assert_success() diff --git a/tests/unit/tox_env/test_show_config.py b/tests/unit/tox_env/test_show_config.py new file mode 100644 index 000000000..0966cdfea --- /dev/null +++ b/tests/unit/tox_env/test_show_config.py @@ -0,0 +1,17 @@ +from tox.config.source.api import EnvList +from tox.pytest import ToxProjectCreator + + +def test_build_env_basic(tox_project: ToxProjectCreator): + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = py38, py38 + """ + } + ) + result = project.run("c") + state = result.state + assert state.args == ("c",) + assert state.env_list == EnvList(["py38"]) diff --git a/tests/unit/util/test_graph.py b/tests/unit/util/test_graph.py deleted file mode 100644 index c2dbdaad1..000000000 --- a/tests/unit/util/test_graph.py +++ /dev/null @@ -1,64 +0,0 @@ -from collections import OrderedDict - -import pytest - -from tox.util.graph import stable_topological_sort - - -def test_topological_order_specified_only(): - graph = OrderedDict() - graph["A"] = "B", "C" - result = stable_topological_sort(graph) - assert result == ["A"] - - -def test_topological_order(): - graph = OrderedDict() - graph["A"] = "B", "C" - graph["B"] = tuple() - graph["C"] = tuple() - result = stable_topological_sort(graph) - assert result == ["B", "C", "A"] - - -def test_topological_order_cycle(): - graph = OrderedDict() - graph["A"] = "B", "C" - graph["B"] = ("A",) - with pytest.raises(ValueError, match="A | B"): - stable_topological_sort(graph) - - -def test_topological_complex(): - graph = OrderedDict() - graph["A"] = "B", "C" - graph["B"] = "C", "D" - graph["C"] = ("D",) - graph["D"] = tuple() - result = stable_topological_sort(graph) - assert result == ["D", "C", "B", "A"] - - -def test_two_sub_graph(): - graph = OrderedDict() - graph["F"] = tuple() - graph["E"] = tuple() - graph["D"] = "E", "F" - graph["A"] = "B", "C" - graph["B"] = tuple() - graph["C"] = tuple() - - result = stable_topological_sort(graph) - assert result == ["F", "E", "D", "B", "C", "A"] - - -def test_two_sub_graph_circle(): - graph = OrderedDict() - graph["F"] = tuple() - graph["E"] = tuple() - graph["D"] = "E", "F" - graph["A"] = "B", "C" - graph["B"] = ("A",) - graph["C"] = tuple() - with pytest.raises(ValueError, match="A | B"): - stable_topological_sort(graph) diff --git a/tests/unit/util/test_spinner.py b/tests/unit/util/test_spinner.py deleted file mode 100644 index feda43bc1..000000000 --- a/tests/unit/util/test_spinner.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -import datetime -import os -import sys -import time - -import pytest -from freezegun import freeze_time - -from tox.util import spinner - - -@freeze_time("2012-01-14") -def test_spinner(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - with spinner.Spinner(refresh_rate=100) as spin: - for _ in range(len(spin.frames)): - spin.stream.write("\n") - spin.render_frame() - spin.stream.write("\n") - out, err = capfd.readouterr() - lines = out.split("\n") - expected = ["\r{}\r{} [0] ".format(spin.CLEAR_LINE, i) for i in spin.frames] + [ - "\r{}\r{} [0] ".format(spin.CLEAR_LINE, spin.frames[0]), - "\r{}".format(spin.CLEAR_LINE), - ] - assert lines == expected - - -@freeze_time("2012-01-14") -def test_spinner_progress(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - with spinner.Spinner() as spin: - for _ in range(len(spin.frames)): - spin.stream.write("\n") - time.sleep(spin.refresh_rate) - - out, err = capfd.readouterr() - assert not err - assert len({i.strip() for i in out.split("[0]")}) > len(spin.frames) / 2 - - -@freeze_time("2012-01-14") -def test_spinner_atty(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - with spinner.Spinner(refresh_rate=100) as spin: - spin.stream.write("\n") - out, err = capfd.readouterr() - lines = out.split("\n") - posix = os.name == "posix" - expected = [ - "{}\r{}\r{} [0] ".format("\x1b[?25l" if posix else "", spin.CLEAR_LINE, spin.frames[0]), - "\r\x1b[K{}".format("\x1b[?25h" if posix else ""), - ] - assert lines == expected - - -@freeze_time("2012-01-14") -def test_spinner_report(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - with spinner.Spinner(refresh_rate=100) as spin: - spin.stream.write(os.linesep) - spin.add("ok") - spin.add("fail") - spin.add("skip") - spin.succeed("ok") - spin.fail("fail") - spin.skip("skip") - out, err = capfd.readouterr() - lines = out.split(os.linesep) - del lines[0] - expected = [ - "\r{}✔ OK ok in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}✖ FAIL fail in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}⚠ SKIP skip in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}".format(spin.CLEAR_LINE), - ] - assert lines == expected - assert not err - - -def test_spinner_long_text(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - with spinner.Spinner(refresh_rate=100) as spin: - spin.stream.write("\n") - spin.add("a" * 60) - spin.add("b" * 60) - spin.render_frame() - spin.stream.write("\n") - out, err = capfd.readouterr() - assert not err - expected = [ - "\r{}\r{} [2] {} | {}...".format(spin.CLEAR_LINE, spin.frames[1], "a" * 60, "b" * 49), - "\r{}".format(spin.CLEAR_LINE), - ] - lines = out.split("\n") - del lines[0] - assert lines == expected - - -def test_spinner_stdout_not_unicode(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "encoding", "ascii") - with spinner.Spinner(refresh_rate=100) as spin: - for _ in range(len(spin.frames)): - spin.render_frame() - out, err = capfd.readouterr() - assert not err - assert all(f in out for f in spin.frames) - - -@pytest.mark.parametrize( - "seconds, expected", - [ - (0, "0.0 seconds"), - (1.0, "1.0 second"), - (4.0, "4.0 seconds"), - (4.130, "4.13 seconds"), - (4.137, "4.137 seconds"), - (42.12345, "42.123 seconds"), - (61, "1 minute, 1.0 second"), - ], -) -def test_td_human_readable(seconds, expected): - dt = datetime.timedelta(seconds=seconds) - assert spinner.td_human_readable(dt) == expected diff --git a/tests/unit/util/test_util.py b/tests/unit/util/test_util.py deleted file mode 100644 index bdd36ea2e..000000000 --- a/tests/unit/util/test_util.py +++ /dev/null @@ -1,22 +0,0 @@ -import os - -from tox.util import set_os_env_var - - -def test_set_os_env_var_clean_env(monkeypatch): - monkeypatch.delenv("ENV", raising=False) - with set_os_env_var("ENV", "a"): - assert os.environ["ENV"] == "a" - assert "ENV" not in os.environ - - -def test_set_os_env_var_exist_env(monkeypatch): - monkeypatch.setenv("ENV", "b") - with set_os_env_var("ENV", "a"): - assert os.environ["ENV"] == "a" - assert os.environ["ENV"] == "b" - - -def test_set_os_env_var_non_str(): - with set_os_env_var("ENV", 1): - assert os.environ["ENV"] == "1" diff --git a/tox.ini b/tox.ini index fcb1418cf..2d717581f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,33 +1,39 @@ [tox] -envlist = py27, - py34, - py35, - py36, - py37, - pypy, - pypy3, - coverage, - fix_lint, - docs, - package_description +envlist = + py38 + py37 + py35 + py36 + pypy3 + coverage + fix_lint + docs + package_description minversion = 3.7 -isolated_build = true skip_missing_interpreters = true +requires = + tox >= 3.10.0 + virtualenv >= 10 [testenv] description = run the tests with pytest under {basepython} -setenv = PIP_DISABLE_VERSION_CHECK = 1 - COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}} +setenv = COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}} VIRTUALENV_NO_DOWNLOAD = 1 passenv = http_proxy https_proxy no_proxy SSL_CERT_FILE PYTEST_* -deps = pip == 19.1.1 +deps = + pip == 19.2.2 + setuptools >= 41.0.0 + extras = testing -commands = pytest \ +commands = pytest --color=yes \ --cov "{envsitepackagesdir}/tox" \ --cov-config "{toxinidir}/tox.ini" \ --junitxml {toxworkdir}/junit.{envname}.xml \ - -n={env:PYTEST_XDIST_PROC_NR:auto} \ {posargs:.} +package = wheel + +[testenv:py34] +deps = pip == 19.1.1 [testenv:docs] description = invoke sphinx-build to build the HTML docs @@ -58,7 +64,7 @@ passenv = {[testenv]passenv} extras = lint deps = pre-commit >= 1.14.4, < 2 skip_install = True -commands = pre-commit run --all-files --show-diff-on-failure +commands = pre-commit run --all-files --show-diff-on-failure {posargs} python -c 'import pathlib; print("hint: run \{\} install to add checks as pre-commit hook".format(pathlib.Path(r"{envdir}") / "bin" / "pre-commit"))' @@ -94,14 +100,14 @@ commands = echo {posargs} [flake8] max-complexity = 22 -max-line-length = 99 +max-line-length = 120 ignore = E203, W503, C901, E402, B011 [pep8] -max-line-length = 99 +max-line-length = 120 [coverage:run] -branch = true +branch = false parallel = true [coverage:report] @@ -124,7 +130,7 @@ source = src/tox *\src\tox [pytest] -addopts = -ra --showlocals --no-success-flaky-report +addopts = -ra --showlocals rsyncdirs = tests tox looponfailroots = tox tests testpaths = tests @@ -139,7 +145,7 @@ include_trailing_comma = True force_grid_wrap = 0 line_length = 99 known_first_party = tox,tests -known_third_party = apiclient,docutils,filelock,flaky,freezegun,git,httplib2,oauth2client,packaging,pathlib2,pluggy,py,pytest,setuptools,six,sphinx,toml +known_third_party = _pytest,apiclient,appdirs,colorama,docutils,filelock,git,httplib2,importlib_metadata,oauth2client,packaging,pluggy,psutil,py,pytest,setuptools,six,sphinx,toml,wheel [testenv:release] description = do a release, required posarg of the version number