Skip to content

Commit

Permalink
PEP-517 source distribution support
Browse files Browse the repository at this point in the history
create a ``.package`` virtual environment to perform build
operations inside
  • Loading branch information
gaborbernat committed Aug 30, 2018
1 parent 6259ff1 commit 17d57d1
Show file tree
Hide file tree
Showing 22 changed files with 352 additions and 20 deletions.
2 changes: 2 additions & 0 deletions changelog/573.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- [PEP-517](https://www.python.org/dev/peps/pep-0517/) source distribution support (create a
``.package`` virtual environment to perform build operations inside) by :user:`gaborbernat`
1 change: 1 addition & 0 deletions changelog/820.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- flit support via implementing ``PEP-517``
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def main():
"py >= 1.4.17, <2",
"six >= 1.0.0, <2",
"virtualenv >= 1.11.2",
"toml >=0.9.4",
],
extras_require={
"testing": [
Expand Down
17 changes: 14 additions & 3 deletions src/tox/_pytestplugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ def initproj(tmpdir):
setup.py
"""

def initproj_(nameversion, filedefs=None, src_root="."):
def initproj_(nameversion, filedefs=None, src_root=".", add_missing_setup_py=True):
if filedefs is None:
filedefs = {}
if not src_root:
Expand All @@ -297,7 +297,7 @@ def initproj_(nameversion, filedefs=None, src_root="."):

base.ensure(dir=1)
create_files(base, filedefs)
if not _filedefs_contains(base, filedefs, "setup.py"):
if not _filedefs_contains(base, filedefs, "setup.py") and add_missing_setup_py:
create_files(
base,
{
Expand All @@ -319,7 +319,18 @@ def initproj_(nameversion, filedefs=None, src_root="."):
)
if not _filedefs_contains(base, filedefs, src_root_path.join(name)):
create_files(
src_root_path, {name: {"__init__.py": "__version__ = {!r}".format(version)}}
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))
Expand Down
7 changes: 7 additions & 0 deletions src/tox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,13 @@ def __init__(self, config, inipath): # noqa
)

config.skipsdist = reader.getbool("skipsdist", all_develop)
config.isolated_build = reader.getbool("isolated_build", False)
if config.isolated_build is True:
name = ".package"
if name not in config.envconfigs:
config.envconfigs[name] = self.make_envconfig(
name, testenvprefix + name, reader._subs, config
)

def _make_thread_safe_path(self, config, attr, unique_id):
if config.option.parallel_safe_build:
Expand Down
131 changes: 126 additions & 5 deletions src/tox/package.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import json
import sys
import textwrap
from collections import namedtuple

import pkg_resources
import py
import toml

import tox
from tox.config import DepConfig
from tox.venv import CreationConfig

BuildInfo = namedtuple("BuildInfo", ["requires", "backend_module", "backend_object"])


@tox.hookimpl
Expand All @@ -27,10 +36,9 @@ def get_package(session):
report.info("using package {!r}, skipping 'sdist' activity ".format(str(path)))
else:
try:
path = make_sdist(report, config, session)
except tox.exception.InvocationError:
v = sys.exc_info()[1]
report.error("FAIL could not package project - v = {!r}".format(v))
path = build_package(config, report, session)
except tox.exception.InvocationError as exception:
report.error("FAIL could not package project - v = {!r}".format(exception))
return None
sdist_file = config.distshare.join(path.basename)
if sdist_file != path:
Expand All @@ -44,7 +52,14 @@ def get_package(session):
return path


def make_sdist(report, config, session):
def build_package(config, report, session):
if not config.isolated_build:
return make_sdist_legacy(report, config, session)
else:
build_isolated(config, report, session)


def make_sdist_legacy(report, config, session):
setup = config.setupdir.join("setup.py")
if not setup.check():
report.error(
Expand Down Expand Up @@ -83,3 +98,109 @@ def make_sdist(report, config, session):
" python setup.py sdist"
)
raise SystemExit(1)


def build_isolated(config, report, session):
build_info = get_build_info(config.setupdir, report)
package_venv = session.getvenv(".package")
package_venv.envconfig.deps_matches_subset = True

package_venv.envconfig.deps = [DepConfig(r, None) for r in build_info.requires]
toml_require = {pkg_resources.Requirement(r).key for r in build_info.requires}
if not session.setupenv(package_venv):
raise SystemExit(1)

live_config = package_venv._getliveconfig()
previous_config = CreationConfig.readconfig(package_venv.path_config)
if not previous_config or not previous_config.matches(live_config, True):
session.finishvenv(package_venv)

build_requires = get_build_requires(build_info, package_venv, session)
for requirement in build_requires:
pkg_requirement = pkg_resources.Requirement(requirement)
if pkg_requirement.key not in toml_require:
package_venv.envconfig.deps.append(DepConfig(requirement, None))

if not session.setupenv(package_venv):
raise SystemExit(1)

session.finishvenv(package_venv)
return perform_isolated_build(build_info, package_venv, session, config)


def get_build_info(folder, report):
toml_file = folder.join("pyproject.toml")

# as per https://www.python.org/dev/peps/pep-0517/

def abort(message):
report.error("{} inside {}".format(message, toml_file))
raise SystemExit(1)

if not toml_file.exists():
abort("missing {}".format(toml_file))

with open(toml_file) as file_handler:
config_data = toml.load(file_handler)

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, str) 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, str):
abort("build-backend key at build-system section must be a string")

args = backend.split(":")
module = args[0]
obj = "" if len(args) == 1 else ".{}".format(args[1])

return BuildInfo(requires, module, "{}{}".format(module, obj))


def perform_isolated_build(build_info, package_venv, session, config):
with session.newaction(
package_venv, "perform isolated build", package_venv.envconfig.envdir
) as action:
script = textwrap.dedent(
"""
import sys
import {}
basename = {}.build_{}("{}", {{ "--global-option": ["--formats=gztar"]}})
print(basename)""".format(
build_info.backend_module, build_info.backend_object, "sdist", config.distdir
)
)
config.distdir.ensure_dir()
result = action.popen([package_venv.envconfig.envpython, "-c", script], returnout=True)
return config.distdir.join(result.split("\n")[-2])


def get_build_requires(build_info, package_venv, session):
with session.newaction(
package_venv, "get build requires", package_venv.envconfig.envdir
) as action:
script = textwrap.dedent(
"""
import {}
import json
backend = {}
for_build_requires = backend.get_requires_for_build_{}(None)
print(json.dumps(for_build_requires))
""".format(
build_info.backend_module, build_info.backend_object, "sdist"
)
).strip()
result = action.popen([package_venv.envconfig.envpython, "-c", script], returnout=True)
return json.loads(result.split("\n")[-2])
4 changes: 2 additions & 2 deletions src/tox/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ class Reporter(object):
def __init__(self, session):
self.tw = py.io.TerminalWriter()
self.session = session
self._reportedlines = []
self.reported_lines = []

@property
def verbosity(self):
Expand Down Expand Up @@ -338,7 +338,7 @@ def skip(self, msg):
self.logline("SKIPPED: {}".format(msg), yellow=True)

def logline(self, msg, **opts):
self._reportedlines.append(msg)
self.reported_lines.append(msg)
self.tw.line("{}".format(msg), **opts)

def verbosity0(self, msg, **opts):
Expand Down
21 changes: 15 additions & 6 deletions src/tox/venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def readconfig(cls, path):
except Exception:
return None

def matches(self, other):
def matches(self, other, deps_matches_subset=False):
return (
other
and self.md5 == other.md5
Expand All @@ -62,7 +62,11 @@ def matches(self, other):
and self.sitepackages == other.sitepackages
and self.usedevelop == other.usedevelop
and self.alwayscopy == other.alwayscopy
and self.deps == other.deps
and (
all(d in self.deps for d in other.deps)
if deps_matches_subset is True
else self.deps == other.deps
)
)


Expand Down Expand Up @@ -159,7 +163,13 @@ def update(self, action):
if status string is empty, all is ok.
"""
rconfig = CreationConfig.readconfig(self.path_config)
if not self.envconfig.recreate and rconfig and rconfig.matches(self._getliveconfig()):
if (
not self.envconfig.recreate
and rconfig
and rconfig.matches(
self._getliveconfig(), getattr(self.envconfig, "deps_matches_subset", False)
)
):
action.info("reusing", self.envconfig.envdir)
return
if rconfig is None:
Expand All @@ -173,9 +183,8 @@ def update(self, action):
return sys.exc_info()[1]
try:
self.hook.tox_testenv_install_deps(action=action, venv=self)
except tox.exception.InvocationError:
v = sys.exc_info()[1]
return "could not install deps {}; v = {!r}".format(self.envconfig.deps, v)
except tox.exception.InvocationError as exception:
return "could not install deps {}; v = {!r}".format(self.envconfig.deps, exception)

def _getliveconfig(self):
python = self.envconfig.python_info.executable
Expand Down
Empty file added tests/__init__.py
Empty file.
71 changes: 71 additions & 0 deletions tests/integration/test_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Tests that require external access (e.g. pip install, virtualenv creation)"""
import subprocess

import pytest

from tests.lib import need_git


@pytest.mark.network
def test_package_isolated_build_setuptools(initproj, cmd):
initproj(
"package_toml_setuptools-0.1",
filedefs={
"tox.ini": """
[tox]
isolated_build = true
[testenv:.package]
basepython = python
""",
"pyproject.toml": """
[build-system]
requires = ["setuptools >= 35.0.2", "setuptools_scm >= 2.0.0, <3"]
build-backend = 'setuptools.build_meta'
""",
},
)
result = cmd("--sdistonly")
assert result.ret == 0, result.out

result2 = cmd("--sdistonly")
assert result2.ret == 0, result.out
assert ".package recreate" not in result2.out


@pytest.mark.network
@need_git
def test_package_isolated_build_flit(initproj, cmd):
initproj(
"package_toml_flit-0.1",
filedefs={
"tox.ini": """
[tox]
isolated_build = true
[testenv:.package]
basepython = python
""",
"pyproject.toml": """
[build-system]
requires = ["flit"]
build-backend = "flit.buildapi"
[tool.flit.metadata]
module = "package_toml_flit"
author = "Happy Harry"
author-email = "happy@harry.com"
home-page = "https://github.com/happy-harry/is"
""",
".gitignore": ".tox",
},
add_missing_setup_py=False,
)
subprocess.check_call(["git", "init"])
subprocess.check_call(["git", "add", "-A", "."])
subprocess.check_call(["git", "commit", "-m", "first commit"])
result = cmd("--sdistonly")
assert result.ret == 0, result.out

result2 = cmd("--sdistonly")

assert result2.ret == 0, result.out
assert ".package recreate" not in result2.out
18 changes: 18 additions & 0 deletions tests/lib/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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="%s is not available" % name)(fn)
return fn

return wrapper


def need_git(fn):
return pytest.mark.mercurial(need_executable("git", ("git", "--version"))(fn))
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit 17d57d1

Please sign in to comment.