diff --git a/changelog/850.feature.rst b/changelog/850.feature.rst new file mode 100644 index 0000000000..aa022b251b --- /dev/null +++ b/changelog/850.feature.rst @@ -0,0 +1 @@ +PEP-518 support: provide a tox configuration flag ``build`` which can be either ``sdist`` or ``wheel``. For ``sdist`` (default) we build the package as before by using ``python setup.py sdist``. However, when ``wheel`` is enabled now we'll use ``pip wheel`` to build it, and we'll also install wheels in these case into the environments. Note: ``pip`` 10 supports specifying project dependencies (such as ``setuptools-scm``, or a given ``setuptools`` version) via ``pyproject.toml``. Once ``pip`` supports building ``sdist`` to we'll migrate over the ``sdist`` build too.`@gaborbernat `_ diff --git a/changelog/851.feature.rst b/changelog/851.feature.rst new file mode 100644 index 0000000000..0b18e7c6ae --- /dev/null +++ b/changelog/851.feature.rst @@ -0,0 +1 @@ +While running tox invokes various commands (such as building the package, pip installing dependencies and so on), these were printed in case they failed as Python arrays. Changed the representation to a shell command, allowing the users to quickly replicate/debug the failure on their own.`@gaborbernat `_ diff --git a/doc/conf.py b/doc/conf.py index dfb0c12b8d..d674f6637f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -78,8 +78,6 @@ def generate_draft_news(): def setup(app): - # from sphinx.ext.autodoc import cut_lines - # app.connect('autodoc-process-docstring', cut_lines(4, what=['module'])) app.add_object_type( "confval", "confval", diff --git a/doc/config.rst b/doc/config.rst index e1986a7dab..2bb065963a 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -70,6 +70,36 @@ and will first lookup global tox settings in this section: is identified. In a future version of tox, this warning will become an error. +.. confval:: build=sdist|wheel|none + + .. versionadded:: 3.1.0 + + tox will try to build the project as a Python package and install it in a fresh virtual + environment before running your test commands. Here one can select the type of build + tox will do for the projects package: + + - if it's ``none`` tox will not try to build the package (implies no install happens later), + - if it's ``sdist`` it will create a source distribution by using: + + .. code-block:: ini + + python setup.py sdist + + - if it's ``wheel`` it will create a Python wheel by using ``pip``: + + .. code-block:: ini + + pip wheel . --no-dep + + .. note:: + + wheel should only be used with pip ``10+`` and a ``pyproject.toml``, the build will + fail if either is missing + + + **Default:** ``sdist`` + + Virtualenv test environment settings ------------------------------------ diff --git a/src/tox/_pytestplugin.py b/src/tox/_pytestplugin.py index 7602a481dc..25dba570bc 100644 --- a/src/tox/_pytestplugin.py +++ b/src/tox/_pytestplugin.py @@ -1,6 +1,7 @@ from __future__ import print_function, unicode_literals import os +import re import textwrap import time from fnmatch import fnmatch @@ -61,15 +62,12 @@ def create_new_config_file_(args, source=None, plugins=()): @pytest.fixture -def cmd(request, capfd, monkeypatch): +def cmd(request, 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): - key = str(b"PYTHONPATH") - python_paths = (i for i in (str(os.getcwd()), os.getenv(key)) if i) - monkeypatch.setenv(key, os.pathsep.join(python_paths)) with RunResult(capfd, argv) as result: try: main([str(x) for x in argv]) @@ -111,6 +109,10 @@ def __repr__(self): self.ret, " ".join(str(i) for i in self.args), self.out, self.err ) + @property + def python_hash_seed(self): + return next(re.finditer(r"PYTHONHASHSEED='([0-9]+)'", self.out)).group(1) + class ReportExpectMock: def __init__(self, session): diff --git a/src/tox/config.py b/src/tox/config.py index 609a10bce4..d39b4aff3b 100755 --- a/src/tox/config.py +++ b/src/tox/config.py @@ -943,6 +943,7 @@ def __init__(self, config, inipath): for name in config.indexserver: config.indexserver[name] = IndexServerConfig(name, override) + config.build = reader.getstring("build", default="sdist") reader.addsubstitutions(toxworkdir=config.toxworkdir) config.distdir = reader.getpath("distdir", "{toxworkdir}/dist") reader.addsubstitutions(distdir=config.distdir) diff --git a/src/tox/session.py b/src/tox/session.py index ea4b953041..333ac21834 100644 --- a/src/tox/session.py +++ b/src/tox/session.py @@ -7,6 +7,7 @@ from __future__ import print_function import os +import pipes import re import shutil import subprocess @@ -14,6 +15,7 @@ import time import py +from pkg_resources import get_distribution import tox from tox._verlib import IrrationalVersionError, NormalizedVersion @@ -136,9 +138,16 @@ def _initlogpath(self, actionid): def popen(self, args, cwd=None, env=None, redirect=True, returnout=False, ignore_ret=False): stdout = outpath = None resultjson = self.session.config.option.resultjson + + cmd_args = [str(x) for x in args] + cmd_args_shell = " ".join(pipes.quote(i) for i in cmd_args) if resultjson or redirect: fout = self._initlogpath(self.id) - fout.write("actionid: {}\nmsg: {}\ncmdargs: {!r}\n\n".format(self.id, self.msg, args)) + fout.write( + "actionid: {}\nmsg: {}\ncmdargs: {!r}\n\n".format( + self.id, self.msg, cmd_args_shell + ) + ) fout.flush() outpath = py.path.local(fout.name) fin = outpath.open("rb") @@ -153,11 +162,13 @@ def popen(self, args, cwd=None, env=None, redirect=True, returnout=False, ignore popen = self._popen(args, cwd, env=env, stdout=stdout, stderr=subprocess.STDOUT) except OSError as e: self.report.error( - "invocation failed (errno {:d}), args: {}, cwd: {}".format(e.errno, args, cwd) + "invocation failed (errno {:d}), args: {}, cwd: {}".format( + e.errno, cmd_args_shell, cwd + ) ) raise popen.outpath = outpath - popen.args = [str(x) for x in args] + popen.args = cmd_args popen.cwd = cwd popen.action = self self._popenlist.append(popen) @@ -431,7 +442,7 @@ def _copyfiles(self, srcdir, pathlist, destdir): target.dirpath().ensure(dir=1) src.copy(target) - def _makesdist(self): + def _get_package(self): setup = self.config.setupdir.join("setup.py") if not setup.check(): self.report.error( @@ -446,19 +457,8 @@ def _makesdist(self): ) raise SystemExit(1) with self.newaction(None, "packaging") as action: - action.setactivity("sdist-make", setup) - self.make_emptydir(self.config.distdir) - action.popen( - [ - sys.executable, - setup, - "sdist", - "--formats=zip", - "--dist-dir", - self.config.distdir, - ], - cwd=self.config.setupdir, - ) + action.setactivity("{}-make".format(self.config.build), setup) + self._make_package(action, setup) try: return self.config.distdir.listdir()[0] except py.error.ENOENT: @@ -478,6 +478,46 @@ def _makesdist(self): ) raise SystemExit(1) + def _make_package(self, action, setup): + self.make_emptydir(self.config.distdir) + if self.config.build == "sdist": + action.popen( + [ + sys.executable, + setup, + "sdist", + "--formats=zip", + "--dist-dir", + self.config.distdir, + ], + cwd=self.config.setupdir, + ) + elif self.config.build == "wheel": + if NormalizedVersion(get_distribution("pip").version).parts[0][0] > 10: + raise RuntimeError("wheel support requires pip 10 or later") + py_project_toml = self.config.setupdir.join("pyproject.toml") + if not py_project_toml.exists(): + raise RuntimeError( + "wheel support requires creating and setting build-requires in {}".format( + py_project_toml + ) + ) + action.popen( + [ + sys.executable, + "-m", + "pip", + "wheel", + "-w", + self.config.distdir, # target folder + "--no-deps", # don't build wheels for package dependencies + self.config.setupdir, # what to build - ourselves + ], + cwd=self.config.setupdir, + ) + else: + raise RuntimeError("invalid build type {}".format(self.config.build)) + def make_emptydir(self, path): if path.check(): self.report.info(" removing {}".format(path)) @@ -576,23 +616,23 @@ def get_installpkg_path(self): if not path: path = self.config.sdistsrc path = self._resolve_pkg(path) - self.report.info("using package {!r}, skipping 'sdist' activity ".format(str(path))) + self.report.info( + "using package {!r}, skipping '{}' activity ".format(str(path), self.config.build) + ) else: try: - path = self._makesdist() + path = self._get_package() except tox.exception.InvocationError: v = sys.exc_info()[1] self.report.error("FAIL could not package project - v = {!r}".format(v)) return sdistfile = self.config.distshare.join(path.basename) if sdistfile != path: - self.report.info("copying new sdistfile to {!r}".format(str(sdistfile))) + self.report.info("copying new package to {!r}".format(str(sdistfile))) try: sdistfile.dirpath().ensure(dir=1) except py.error.Error: - self.report.warning( - "could not copy distfile to {}".format(sdistfile.dirpath()) - ) + self.report.warning("could not copy package to {}".format(sdistfile.dirpath())) else: path.copy(sdistfile) return path diff --git a/src/tox/venv.py b/src/tox/venv.py index baccdee14a..4e31d42815 100755 --- a/src/tox/venv.py +++ b/src/tox/venv.py @@ -403,7 +403,15 @@ def test(self, redirect=False): raise def _pcall( - self, args, cwd, venv=True, testcommand=False, action=None, redirect=True, ignore_ret=False + self, + args, + cwd, + venv=True, + testcommand=False, + action=None, + redirect=True, + ignore_ret=False, + no_python_path=False, ): os.environ.pop("VIRTUALENV_PYTHON", None) @@ -414,6 +422,8 @@ def _pcall( env = self._getenv(testcommand=testcommand) bindir = str(self.envconfig.envbindir) env["PATH"] = p = os.pathsep.join([bindir, os.environ["PATH"]]) + if no_python_path: + env.pop("PYTHONPATH", None) self.session.report.verbosity2("setting PATH={}".format(p)) return action.popen(args, cwd=cwd, env=env, redirect=redirect, ignore_ret=ignore_ret) @@ -487,7 +497,10 @@ def tox_runtest(venv, redirect): 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) + # we clear the PYTHONPATH to allow reporting packages outside of this environment + output = venv._pcall( + args, cwd=venv.envconfig.config.toxinidir, action=action, no_python_path=True + ) # the output contains a mime-header, skip it output = output.split("\n\n")[-1] packages = output.strip().split("\n") diff --git a/tests/test_package_build.py b/tests/test_package_build.py new file mode 100644 index 0000000000..ae46358726 --- /dev/null +++ b/tests/test_package_build.py @@ -0,0 +1,162 @@ +import os +import subprocess +import sys +import textwrap + +import pytest + +from tox.config import parseconfig +from tox.session import Session + + +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 = session.get_installpkg_path() + assert sdist.check() + assert sdist.ext == ".zip" + assert sdist == config.distdir.join(sdist.basename) + sdist_2 = session.get_installpkg_path() + assert sdist_2 == sdist + sdist.write("hello") + assert sdist.stat().size < 10 + sdist_new = Session(config).get_installpkg_path() + assert sdist_new == sdist + assert sdist_new.stat().size > 10 + + +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) + sdist = session.get_installpkg_path() + assert sdist.check() + assert sdist.ext == ".zip" + assert sdist == config.distdir.join(sdist.basename) + sdist_share = config.distshare.join(sdist.basename) + assert sdist_share.check() + assert sdist_share.read("rb") == sdist.read("rb"), (sdist_share, sdist) + + +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) + sdist_path = session.get_installpkg_path() + assert sdist_path == p + + +def test_installpkg(tmpdir, newconfig): + p = tmpdir.ensure("pkg123-1.0.zip") + config = newconfig(["--installpkg={}".format(p)], "") + session = Session(config) + sdist_path = session.get_installpkg_path() + assert sdist_path == p + + +@pytest.mark.integration +@pytest.mark.pip +@pytest.mark.git +def test_pyproject_toml_with_setuptools_scm(initproj, cmd): + initproj( + "demo", + filedefs={ + "demo": { + "__init__.py": """ + from pkg_resources import get_distribution + __version__ = get_distribution(__name__).version + """ + }, + "setup.py": """ + from setuptools import setup, find_packages + setup( + name='demo', + use_scm_version=True, + license='MIT', + platforms=['unix', 'win32'], + packages=find_packages('demo'), + package_dir={'':'demo'}, + ) + """, + "setup.cfg": """ + [bdist_wheel] + universal = 1 + """, + "pyproject.toml": """ + [build-system] + requires = ["setuptools >= 35.0.2", "setuptools_scm >= 2.0.0, <3", "wheel >= 0.29.0"] + """, + "tox.ini": """ + [tox] + build = wheel + envlist = py + + [testenv] + passenv = PYTHONPATH + commands = python -c 'from __future__ import print_function; import demo; print(demo.__version__, end="")' + """, # noqa + }, + ) + env = os.environ.copy() + env["GIT_COMMITTER_NAME"] = "committer joe" + env["GIT_AUTHOR_NAME"] = "author joe" + env["EMAIL"] = "joe@bloomberg.com" + subprocess.check_call(["git", "init"], env=env) + subprocess.check_call(["git", "add", "."], env=env) + subprocess.check_call(["git", "config", "commit.gpgsign", "false"], env=env) + subprocess.check_call(["git", "commit", "-m", "first commit"], env=env) + subprocess.check_call(["git", "tag", "0.1"], env=env) + + result = cmd() + base = textwrap.dedent( + """ GLOB wheel-make: {} + py create: {} + py inst: {} + py installed: demo==0.1 + py runtests: PYTHONHASHSEED='{}' + py runtests: commands[0] | python -c 'from __future__ import print_function; import demo; print(demo.__version__, end="")' + 0.1___________________________________ summary {}___________________________________ + py: commands succeeded + congratulations :) + """ # noqa + ) + cwd = os.getcwd() + expected = base.format( + os.path.join(cwd, "setup.py"), + os.path.join(cwd, ".tox", "py"), + os.path.join(cwd, ".tox", "dist", "demo-0.1-py2.py3-none-any.whl"), + result.python_hash_seed, + "" if sys.platform == "win32" else "_", + ) + assert result.out == expected diff --git a/tests/test_venv.py b/tests/test_venv.py index 9ab5ea875b..5c552e352f 100644 --- a/tests/test_venv.py +++ b/tests/test_venv.py @@ -257,7 +257,7 @@ def test_install_recreate(newmocksession, tmpdir): mocksession.report.expect("verbosity0", "*recreate*") -def test_install_sdist_extras(newmocksession): +def test_install_wheel_extras(newmocksession): mocksession = newmocksession( [], """ @@ -273,8 +273,8 @@ def test_install_sdist_extras(newmocksession): assert len(pcalls) == 1 pcalls[:] = [] - venv.installpkg("distfile.tar.gz", action=action) - assert "distfile.tar.gz[testing,development]" in pcalls[-1].args + venv.installpkg("distfile.whl", action=action) + assert "distfile.whl[testing,development]" in pcalls[-1].args def test_develop_extras(newmocksession, tmpdir): diff --git a/tests/test_z_cmdline.py b/tests/test_z_cmdline.py index 4cc58a2f6c..eb5d030880 100644 --- a/tests/test_z_cmdline.py +++ b/tests/test_z_cmdline.py @@ -81,53 +81,6 @@ def test__resolve_pkg_doubledash(tmpdir, mocksession): class TestSession: - def test_make_sdist(self, initproj): - initproj( - "example123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - """, - }, - ) - config = parseconfig([]) - session = Session(config) - sdist = session.get_installpkg_path() - assert sdist.check() - assert sdist.ext == ".zip" - assert sdist == config.distdir.join(sdist.basename) - sdist2 = session.get_installpkg_path() - assert sdist2 == sdist - sdist.write("hello") - assert sdist.stat().size < 10 - sdist_new = Session(config).get_installpkg_path() - assert sdist_new == sdist - assert sdist_new.stat().size > 10 - - def test_make_sdist_distshare(self, 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) - sdist = session.get_installpkg_path() - assert sdist.check() - assert sdist.ext == ".zip" - assert sdist == config.distdir.join(sdist.basename) - sdist_share = config.distshare.join(sdist.basename) - assert sdist_share.check() - assert sdist_share.read("rb") == sdist.read("rb"), (sdist_share, sdist) - def test_log_pcall(self, mocksession): mocksession.config.logdir.ensure(dir=1) assert not mocksession.config.logdir.listdir() @@ -249,7 +202,7 @@ def test_run_custom_install_command_error(cmd, initproj): ) result = cmd() assert re.match( - r"ERROR: invocation failed \(errno \d+\), args: \['.*[/\\]tox\.ini", result.outlines[-1] + r"ERROR: invocation failed \(errno \d+\), args: .*[/\\]tox\.ini", result.outlines[-1] ) assert result.ret @@ -900,33 +853,6 @@ def test_separate_sdist(cmd, initproj, tmpdir): assert "python inst: {}".format(sdistfile) in result.out -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) - sdist_path = session.get_installpkg_path() - assert sdist_path == p - - -def test_installpkg(tmpdir, newconfig): - p = tmpdir.ensure("pkg123-1.0.zip") - config = newconfig(["--installpkg={}".format(p)], "") - session = Session(config) - sdist_path = session.get_installpkg_path() - assert sdist_path == p - - def test_envsitepackagesdir(cmd, initproj): initproj( "pkg512-0.0.5", diff --git a/tox.ini b/tox.ini index 5ec6211808..59e75ece9c 100644 --- a/tox.ini +++ b/tox.ini @@ -123,7 +123,7 @@ source = src/tox addopts = -rsxX --showlocals rsyncdirs = tests tox looponfailroots = tox tests -norecursedirs = .hg .tox +norecursedirs = .git .tox xfail_strict = True [isort]