Repository: tox-dev/tox
Pull request #953 (2018-08-29 17:48:05)

Add VSTS CI badge to readme
The VSTS badge UI now exists (though it's a little hard to find - see the screen below), so I added one for you.

![image](https://user-images.githubusercontent.com/1693688/44805372-dc955700-ab78-11e8-9ccd-e74900f16839.png)


## Thanks for contributing a pull request!

If you are contributing for the first time or provide a trivial fix don't worry too
much about the checklist - we will help you get started.

## Contribution checklist:

(also see [CONTRIBUTING.rst](https://github.com/tox-dev/tox/tree/master/CONTRIBUTING.rst) for details)

- [ ] wrote descriptive pull request text
- [ ] added/updated test(s)
- [ ] updated/extended the documentation
- [ ] added relevant [issue keyword](https://help.github.com/articles/closing-issues-using-keywords/)
      in message body
- [ ] added news fragment in [changelog folder](https://github.com/tox-dev/tox/tree/master/changelog)
  * fragment name: `<issue number>.<type>.rst` for example (588.bugfix.rst)
  * `<type>` is must be one of `bugfix`, `feature`, `deprecation`,`breaking`, `doc`, `misc`
  * if pr has no issue: consider creating one first or change it to the pr number after creating the pr
  * "sign" fragment with "by @<your username>"
  * please use full sentences with correct case and punctuation, for example: "Fix issue with non-ascii contents in doctest text files - by @superuser."
  * also see [examples](https://github.com/tox-dev/tox/tree/master/changelog/examples.rst)
- [ ] added yourself to `CONTRIBUTORS` (preserving alphabetical order)


codecov[bot]: # [Codecov](https://codecov.io/gh/tox-dev/tox/pull/953?src=pr&el=h1) Report
> Merging [#953](https://codecov.io/gh/tox-dev/tox/pull/953?src=pr&el=desc) into [master](https://codecov.io/gh/tox-dev/tox/commit/c6112ce73e50990991fa5a673ebcc52d597bc50c?src=pr&el=desc) will **decrease** coverage by `1.34%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/tox-dev/tox/pull/953/graphs/tree.svg?width=650&token=DYodAwDCZ5&height=150&src=pr)](https://codecov.io/gh/tox-dev/tox/pull/953?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master    #953      +/-   ##
=========================================
- Coverage   92.85%   91.5%   -1.35%     
=========================================
  Files          12      12              
  Lines        2379    2379              
  Branches      413     413              
=========================================
- Hits         2209    2177      -32     
- Misses        107     138      +31     
- Partials       63      64       +1
```


| [Impacted Files](https://codecov.io/gh/tox-dev/tox/pull/953?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/tox/interpreters.py](https://codecov.io/gh/tox-dev/tox/pull/953/diff?src=pr&el=tree#diff-c3JjL3RveC9pbnRlcnByZXRlcnMucHk=) | `69.84% <0%> (-19.05%)` | :arrow_down: |
| [src/tox/venv.py](https://codecov.io/gh/tox-dev/tox/pull/953/diff?src=pr&el=tree#diff-c3JjL3RveC92ZW52LnB5) | `92.33% <0%> (-1.18%)` | :arrow_down: |
| [src/tox/session.py](https://codecov.io/gh/tox-dev/tox/pull/953/diff?src=pr&el=tree#diff-c3JjL3RveC9zZXNzaW9uLnB5) | `91.57% <0%> (-0.36%)` | :arrow_down: |
| [src/tox/config.py](https://codecov.io/gh/tox-dev/tox/pull/953/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcucHk=) | `95.54% <0%> (-0.25%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tox-dev/tox/pull/953?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tox-dev/tox/pull/953?src=pr&el=footer). Last update [c6112ce...6854e90](https://codecov.io/gh/tox-dev/tox/pull/953?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.rst b/README.rst
index 61e0ab06..86a56e3a 100644
--- a/README.rst
+++ b/README.rst
@@ -10,6 +10,9 @@
 .. image:: https://ci.appveyor.com/api/projects/status/2pw2n1wki9terluu?svg=true
   :target: https://ci.appveyor.com/project/ToxDev/tox
   :alt: Appveyor build status
+.. image:: https://toxdev.visualstudio.com/tox/_apis/build/status/tox%20ci?branchName=master
+  :target: https://toxdev.visualstudio.com/tox/_build/latest?definitionId=9&branchName=master
+  :alt: VSTS build status
 .. image:: https://codecov.io/gh/tox-dev/tox/branch/master/graph/badge.svg
   :target: https://codecov.io/gh/tox-dev/tox
   :alt: Code coverage Status
diff --git a/changelog/951.feature.rst b/changelog/951.feature.rst
new file mode 100644
index 00000000..0dd27638
--- /dev/null
+++ b/changelog/951.feature.rst
@@ -0,0 +1,1 @@
+packaging now is exposed as a hook via ``tox_package(session, venv)`` - by :user:`gaborbernat`
diff --git a/src/tox/config.py b/src/tox/config.py
index f381d843..891eb0a9 100755
--- a/src/tox/config.py
+++ b/src/tox/config.py
@@ -43,6 +43,9 @@ def get_plugin_manager(plugins=()):
     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)
diff --git a/src/tox/hookspecs.py b/src/tox/hookspecs.py
index fca24f4a..e4a2bf2a 100644
--- a/src/tox/hookspecs.py
+++ b/src/tox/hookspecs.py
@@ -17,6 +17,13 @@ def tox_configure(config):
     """
 
 
+@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.
diff --git a/src/tox/package.py b/src/tox/package.py
new file mode 100644
index 00000000..b23cb1a3
--- /dev/null
+++ b/src/tox/package.py
@@ -0,0 +1,85 @@
+import sys
+
+import py
+
+import tox
+
+
+@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 = get_package(session)
+    return session.package
+
+
+def get_package(session):
+    """"Perform the package operation"""
+    config, report = session.config, session.report
+    if config.skipsdist:
+        report.info("skipping sdist step")
+        return None
+    if not config.option.sdistonly and (config.sdistsrc or config.option.installpkg):
+        path = config.option.installpkg
+        if not path:
+            path = config.sdistsrc
+        path = session._resolve_package(path)
+        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))
+            return None
+        sdist_file = config.distshare.join(path.basename)
+        if sdist_file != path:
+            report.info("copying new sdistfile to {!r}".format(str(sdist_file)))
+            try:
+                sdist_file.dirpath().ensure(dir=1)
+            except py.error.Error:
+                report.warning("could not copy distfile to {}".format(sdist_file.dirpath()))
+            else:
+                path.copy(sdist_file)
+    return path
+
+
+def make_sdist(report, config, session):
+    setup = config.setupdir.join("setup.py")
+    if not setup.check():
+        report.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"
+            "     http://tox.readthedocs.io/en/latest/example/general.html"
+            "#avoiding-expensive-sdist".format(setup)
+        )
+        raise SystemExit(1)
+    with session.newaction(None, "packaging") as action:
+        action.setactivity("sdist-make", setup)
+        session.make_emptydir(config.distdir)
+        action.popen(
+            [sys.executable, setup, "sdist", "--formats=zip", "--dist-dir", config.distdir],
+            cwd=config.setupdir,
+        )
+        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():
+                report.error("setup.py is empty")
+                raise SystemExit(1)
+            report.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/session.py b/src/tox/session.py
index ef170809..12db45cf 100644
--- a/src/tox/session.py
+++ b/src/tox/session.py
@@ -431,53 +431,6 @@ class Session:
             target.dirpath().ensure(dir=1)
             src.copy(target)
 
-    def _makesdist(self):
-        setup = self.config.setupdir.join("setup.py")
-        if not setup.check():
-            self.report.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"
-                "     http://tox.readthedocs.io/en/latest/example/general.html"
-                "#avoiding-expensive-sdist".format(setup)
-            )
-            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,
-            )
-            try:
-                return self.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():
-                    self.report.error("setup.py is empty")
-                    raise SystemExit(1)
-                self.report.error(
-                    "No dist directory found. Please check setup.py, e.g with:\n"
-                    "     python setup.py sdist"
-                )
-                raise SystemExit(1)
-
     def make_emptydir(self, path):
         if path.check():
             self.report.info("  removing {}".format(path))
@@ -564,47 +517,14 @@ class Session:
                 venv.status = sys.exc_info()[1]
                 return False
 
-    def get_installpkg_path(self):
-        """
-        :return: Path to the distribution
-        :rtype: py.path.local
-        """
-        if not self.config.option.sdistonly and (
-            self.config.sdistsrc or self.config.option.installpkg
-        ):
-            path = self.config.option.installpkg
-            if not path:
-                path = self.config.sdistsrc
-            path = self._resolve_package(path)
-            self.report.info("using package {!r}, skipping 'sdist' activity ".format(str(path)))
-        else:
-            try:
-                path = self._makesdist()
-            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)))
-                try:
-                    sdistfile.dirpath().ensure(dir=1)
-                except py.error.Error:
-                    self.report.warning(
-                        "could not copy distfile to {}".format(sdistfile.dirpath())
-                    )
-                else:
-                    path.copy(sdistfile)
-        return path
-
     def subcommand_test(self):
         if self.config.skipsdist:
             self.report.info("skipping sdist step")
-            path = None
         else:
-            path = self.get_installpkg_path()
-            if not path:
-                return 2
+            for venv in self.venvlist:
+                venv.package = self.hook.tox_package(session=self, venv=venv)
+                if not venv.package:
+                    return 2
         if self.config.option.sdistonly:
             return
         for venv in self.venvlist:
@@ -617,7 +537,7 @@ class Session:
                     elif self.config.skipsdist:
                         self.finishvenv(venv)
                     else:
-                        self.installpkg(venv, path)
+                        self.installpkg(venv, venv.package)
 
                 self.runenvreport(venv)
                 self.runtestenv(venv)


Test patch:
diff --git a/tests/test_package.py b/tests/test_package.py
new file mode 100644
index 00000000..e8bd0961
--- /dev/null
+++ b/tests/test_package.py
@@ -0,0 +1,140 @@
+import re
+
+from tox.config import parseconfig
+from tox.package import get_package
+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 = 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_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 = get_package(session)
+    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_sdistonly(initproj, cmd):
+    initproj(
+        "example123",
+        filedefs={
+            "tox.ini": """
+    """
+        },
+    )
+    result = cmd("-v", "--sdistonly")
+    assert not result.ret
+    assert re.match(r".*sdist-make.*setup.py.*", result.out, re.DOTALL)
+    assert "-mvirtualenv" not in result.out
+
+
+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")
+    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_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")
+    assert not result.ret
+    sdistfiles = distshare.listdir()
+    assert len(sdistfiles) == 1
+    sdistfile = sdistfiles[0]
+    result = cmd("-v", "--notest")
+    assert not result.ret
+    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 = get_package(session)
+    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 = get_package(session)
+    assert sdist_path == p
diff --git a/tests/test_z_cmdline.py b/tests/test_z_cmdline.py
index 42104c97..20eed9f7 100644
--- a/tests/test_z_cmdline.py
+++ b/tests/test_z_cmdline.py
@@ -45,53 +45,6 @@ def test_report_protocol(newconfig):
 
 
 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()
@@ -787,92 +740,6 @@ def test_env_VIRTUALENV_PYTHON(initproj, cmd, monkeypatch):
     assert "create" in result.out
 
 
-def test_sdistonly(initproj, cmd):
-    initproj(
-        "example123",
-        filedefs={
-            "tox.ini": """
-    """
-        },
-    )
-    result = cmd("-v", "--sdistonly")
-    assert not result.ret
-    assert re.match(r".*sdist-make.*setup.py.*", result.out, re.DOTALL)
-    assert "-mvirtualenv" not in result.out
-
-
-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")
-    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_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")
-    assert not result.ret
-    sdistfiles = distshare.listdir()
-    assert len(sdistfiles) == 1
-    sdistfile = sdistfiles[0]
-    result = cmd("-v", "--notest")
-    assert not result.ret
-    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",