1import codecs
2import json
3import os
4import pipes
5import re
6import sys
7from itertools import chain
8
9import py
10from pkg_resources import to_filename
11
12import tox
13from tox import reporter
14from tox.action import Action
15from tox.config.parallel import ENV_VAR_KEY as PARALLEL_ENV_VAR_KEY
16from tox.constants import PARALLEL_RESULT_JSON_PREFIX, PARALLEL_RESULT_JSON_SUFFIX
17from tox.package.local import resolve_package
18from tox.util.lock import get_unique_file
19from tox.util.path import ensure_empty_dir
20
21from .config import DepConfig
22
23
24class CreationConfig:
25    def __init__(
26        self,
27        base_resolved_python_md5,
28        base_resolved_python_path,
29        tox_version,
30        sitepackages,
31        usedevelop,
32        deps,
33        alwayscopy,
34    ):
35        self.base_resolved_python_md5 = base_resolved_python_md5
36        self.base_resolved_python_path = base_resolved_python_path
37        self.tox_version = tox_version
38        self.sitepackages = sitepackages
39        self.usedevelop = usedevelop
40        self.alwayscopy = alwayscopy
41        self.deps = deps
42
43    def writeconfig(self, path):
44        lines = [
45            "{} {}".format(self.base_resolved_python_md5, self.base_resolved_python_path),
46            "{} {:d} {:d} {:d}".format(
47                self.tox_version, self.sitepackages, self.usedevelop, self.alwayscopy
48            ),
49        ]
50        for dep in self.deps:
51            lines.append("{} {}".format(*dep))
52        content = "\n".join(lines)
53        path.ensure()
54        path.write(content)
55        return content
56
57    @classmethod
58    def readconfig(cls, path):
59        try:
60            lines = path.readlines(cr=0)
61            base_resolved_python_info = lines.pop(0).split(None, 1)
62            tox_version, sitepackages, usedevelop, alwayscopy = lines.pop(0).split(None, 4)
63            sitepackages = bool(int(sitepackages))
64            usedevelop = bool(int(usedevelop))
65            alwayscopy = bool(int(alwayscopy))
66            deps = []
67            for line in lines:
68                base_resolved_python_md5, depstring = line.split(None, 1)
69                deps.append((base_resolved_python_md5, depstring))
70            base_resolved_python_md5, base_resolved_python_path = base_resolved_python_info
71            return CreationConfig(
72                base_resolved_python_md5,
73                base_resolved_python_path,
74                tox_version,
75                sitepackages,
76                usedevelop,
77                deps,
78                alwayscopy,
79            )
80        except Exception:
81            return None
82
83    def matches_with_reason(self, other, deps_matches_subset=False):
84        for attr in (
85            "base_resolved_python_md5",
86            "base_resolved_python_path",
87            "tox_version",
88            "sitepackages",
89            "usedevelop",
90            "alwayscopy",
91        ):
92            left = getattr(self, attr)
93            right = getattr(other, attr)
94            if left != right:
95                return False, "attr {} {!r}!={!r}".format(attr, left, right)
96        self_deps = set(self.deps)
97        other_deps = set(other.deps)
98        if self_deps != other_deps:
99            if deps_matches_subset:
100                diff = other_deps - self_deps
101                if diff:
102                    return False, "missing in previous {!r}".format(diff)
103            else:
104                return False, "{!r}!={!r}".format(self_deps, other_deps)
105        return True, None
106
107    def matches(self, other, deps_matches_subset=False):
108        outcome, _ = self.matches_with_reason(other, deps_matches_subset)
109        return outcome
110
111
112class VirtualEnv(object):
113    def __init__(self, envconfig=None, popen=None, env_log=None):
114        self.envconfig = envconfig
115        self.popen = popen
116        self._actions = []
117        self.env_log = env_log
118        self._result_json_path = None
119
120    def new_action(self, msg, *args):
121        config = self.envconfig.config
122        command_log = self.env_log.get_commandlog(
123            "test" if msg in ("run-test", "run-test-pre", "run-test-post") else "setup"
124        )
125        return Action(
126            self.name,
127            msg,
128            args,
129            self.envconfig.envlogdir,
130            config.option.resultjson,
131            command_log,
132            self.popen,
133            self.envconfig.envpython,
134        )
135
136    def get_result_json_path(self):
137        if self._result_json_path is None:
138            if self.envconfig.config.option.resultjson:
139                self._result_json_path = get_unique_file(
140                    self.path, PARALLEL_RESULT_JSON_PREFIX, PARALLEL_RESULT_JSON_SUFFIX
141                )
142        return self._result_json_path
143
144    @property
145    def hook(self):
146        return self.envconfig.config.pluginmanager.hook
147
148    @property
149    def path(self):
150        """ Path to environment base dir. """
151        return self.envconfig.envdir
152
153    @property
154    def path_config(self):
155        return self.path.join(".tox-config1")
156
157    @property
158    def name(self):
159        """ test environment name. """
160        return self.envconfig.envname
161
162    def __repr__(self):
163        return "<VirtualEnv at {!r}>".format(self.path)
164
165    def getcommandpath(self, name, venv=True, cwd=None):
166        """ Return absolute path (str or localpath) for specified command name.
167
168        - If it's a local path we will rewrite it as as a relative path.
169        - If venv is True we will check if the command is coming from the venv
170          or is whitelisted to come from external.
171        """
172        name = str(name)
173        if os.path.isabs(name):
174            return name
175        if os.path.split(name)[0] == ".":
176            path = cwd.join(name)
177            if path.check():
178                return str(path)
179
180        if venv:
181            path = self._venv_lookup_and_check_external_whitelist(name)
182        else:
183            path = self._normal_lookup(name)
184
185        if path is None:
186            raise tox.exception.InvocationError(
187                "could not find executable {}".format(pipes.quote(name))
188            )
189
190        return str(path)  # will not be rewritten for reporting
191
192    def _venv_lookup_and_check_external_whitelist(self, name):
193        path = self._venv_lookup(name)
194        if path is None:
195            path = self._normal_lookup(name)
196            if path is not None:
197                self._check_external_allowed_and_warn(path)
198        return path
199
200    def _venv_lookup(self, name):
201        return py.path.local.sysfind(name, paths=[self.envconfig.envbindir])
202
203    def _normal_lookup(self, name):
204        return py.path.local.sysfind(name)
205
206    def _check_external_allowed_and_warn(self, path):
207        if not self.is_allowed_external(path):
208            reporter.warning(
209                "test command found but not installed in testenv\n"
210                "  cmd: {}\n"
211                "  env: {}\n"
212                "Maybe you forgot to specify a dependency? "
213                "See also the whitelist_externals envconfig setting.\n\n"
214                "DEPRECATION WARNING: this will be an error in tox 4 and above!".format(
215                    path, self.envconfig.envdir
216                )
217            )
218
219    def is_allowed_external(self, p):
220        tryadd = [""]
221        if tox.INFO.IS_WIN:
222            tryadd += [os.path.normcase(x) for x in os.environ["PATHEXT"].split(os.pathsep)]
223            p = py.path.local(os.path.normcase(str(p)))
224        for x in self.envconfig.whitelist_externals:
225            for add in tryadd:
226                if p.fnmatch(x + add):
227                    return True
228        return False
229
230    def update(self, action):
231        """ return status string for updating actual venv to match configuration.
232            if status string is empty, all is ok.
233        """
234        rconfig = CreationConfig.readconfig(self.path_config)
235        if self.envconfig.recreate:
236            reason = "-r flag"
237        else:
238            if rconfig is None:
239                reason = "no previous config {}".format(self.path_config)
240            else:
241                live_config = self._getliveconfig()
242                deps_subset_match = getattr(self.envconfig, "deps_matches_subset", False)
243                outcome, reason = rconfig.matches_with_reason(live_config, deps_subset_match)
244        if reason is None:
245            action.info("reusing", self.envconfig.envdir)
246            return
247        action.info("cannot reuse", reason)
248        if rconfig is None:
249            action.setactivity("create", self.envconfig.envdir)
250        else:
251            action.setactivity("recreate", self.envconfig.envdir)
252        try:
253            self.hook.tox_testenv_create(action=action, venv=self)
254            self.just_created = True
255        except tox.exception.UnsupportedInterpreter as exception:
256            return exception
257        try:
258            self.hook.tox_testenv_install_deps(action=action, venv=self)
259        except tox.exception.InvocationError as exception:
260            return "could not install deps {}; v = {!r}".format(self.envconfig.deps, exception)
261
262    def _getliveconfig(self):
263        base_resolved_python_path = self.envconfig.python_info.executable
264        version = tox.__version__
265        sitepackages = self.envconfig.sitepackages
266        develop = self.envconfig.usedevelop
267        alwayscopy = self.envconfig.alwayscopy
268        deps = []
269        for dep in self.get_resolved_dependencies():
270            dep_name_md5 = getdigest(dep.name)
271            deps.append((dep_name_md5, dep.name))
272        base_resolved_python_md5 = getdigest(base_resolved_python_path)
273        return CreationConfig(
274            base_resolved_python_md5,
275            base_resolved_python_path,
276            version,
277            sitepackages,
278            develop,
279            deps,
280            alwayscopy,
281        )
282
283    def get_resolved_dependencies(self):
284        dependencies = []
285        for dependency in self.envconfig.deps:
286            if dependency.indexserver is None:
287                package = resolve_package(package_spec=dependency.name)
288                if package != dependency.name:
289                    dependency = dependency.__class__(package)
290            dependencies.append(dependency)
291        return dependencies
292
293    def getsupportedinterpreter(self):
294        return self.envconfig.getsupportedinterpreter()
295
296    def matching_platform(self):
297        return re.match(self.envconfig.platform, sys.platform)
298
299    def finish(self):
300        previous_config = CreationConfig.readconfig(self.path_config)
301        live_config = self._getliveconfig()
302        if previous_config is None or not previous_config.matches(live_config):
303            content = live_config.writeconfig(self.path_config)
304            reporter.verbosity1("write config to {} as {!r}".format(self.path_config, content))
305
306    def _needs_reinstall(self, setupdir, action):
307        setup_py = setupdir.join("setup.py")
308        setup_cfg = setupdir.join("setup.cfg")
309        args = [self.envconfig.envpython, str(setup_py), "--name"]
310        env = self._get_os_environ()
311        output = action.popen(
312            args, cwd=setupdir, redirect=False, returnout=True, env=env, capture_err=False
313        )
314        name = next(
315            (i for i in output.split("\n") if i and not i.startswith("pydev debugger:")), ""
316        )
317        args = [
318            self.envconfig.envpython,
319            "-c",
320            "import sys;  import json; print(json.dumps(sys.path))",
321        ]
322        out = action.popen(args, redirect=False, returnout=True, env=env)
323        try:
324            sys_path = json.loads(out)
325        except ValueError:
326            sys_path = []
327        egg_info_fname = ".".join((to_filename(name), "egg-info"))
328        for d in reversed(sys_path):
329            egg_info = py.path.local(d).join(egg_info_fname)
330            if egg_info.check():
331                break
332        else:
333            return True
334        needs_reinstall = any(
335            conf_file.check() and conf_file.mtime() > egg_info.mtime()
336            for conf_file in (setup_py, setup_cfg)
337        )
338
339        # Ensure the modification time of the egg-info folder is updated so we
340        # won't need to do this again.
341        # TODO(stephenfin): Remove once the minimum version of setuptools is
342        # high enough to include https://github.com/pypa/setuptools/pull/1427/
343        if needs_reinstall:
344            egg_info.setmtime()
345
346        return needs_reinstall
347
348    def install_pkg(self, dir, action, name, is_develop=False):
349        assert action is not None
350
351        if getattr(self, "just_created", False):
352            action.setactivity(name, dir)
353            self.finish()
354            pip_flags = ["--exists-action", "w"]
355        else:
356            if is_develop and not self._needs_reinstall(dir, action):
357                action.setactivity("{}-noop".format(name), dir)
358                return
359            action.setactivity("{}-nodeps".format(name), dir)
360            pip_flags = ["--no-deps"] + ([] if is_develop else ["-U"])
361        pip_flags.extend(["-v"] * min(3, reporter.verbosity() - 2))
362        if self.envconfig.extras:
363            dir += "[{}]".format(",".join(self.envconfig.extras))
364        target = [dir]
365        if is_develop:
366            target.insert(0, "-e")
367        self._install(target, extraopts=pip_flags, action=action)
368
369    def developpkg(self, setupdir, action):
370        self.install_pkg(setupdir, action, "develop-inst", is_develop=True)
371
372    def installpkg(self, sdistpath, action):
373        self.install_pkg(sdistpath, action, "inst")
374
375    def _installopts(self, indexserver):
376        options = []
377        if indexserver:
378            options += ["-i", indexserver]
379        if self.envconfig.pip_pre:
380            options.append("--pre")
381        return options
382
383    def run_install_command(self, packages, action, options=()):
384        def expand(val):
385            # expand an install command
386            if val == "{packages}":
387                for package in packages:
388                    yield package
389            elif val == "{opts}":
390                for opt in options:
391                    yield opt
392            else:
393                yield val
394
395        cmd = list(chain.from_iterable(expand(val) for val in self.envconfig.install_command))
396
397        env = self._get_os_environ()
398        self.ensure_pip_os_environ_ok(env)
399
400        old_stdout = sys.stdout
401        sys.stdout = codecs.getwriter("utf8")(sys.stdout)
402        try:
403            self._pcall(
404                cmd,
405                cwd=self.envconfig.config.toxinidir,
406                action=action,
407                redirect=reporter.verbosity() < reporter.Verbosity.DEBUG,
408                env=env,
409            )
410        finally:
411            sys.stdout = old_stdout
412
413    def ensure_pip_os_environ_ok(self, env):
414        for key in ("PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"):
415            env.pop(key, None)
416        if all("PYTHONPATH" not in i for i in (self.envconfig.passenv, self.envconfig.setenv)):
417            # If PYTHONPATH not explicitly asked for, remove it.
418            if "PYTHONPATH" in env:
419                if sys.version_info < (3, 4) or bool(env["PYTHONPATH"]):
420                    # https://docs.python.org/3/whatsnew/3.4.html#changes-in-python-command-behavior
421                    # In a posix shell, setting the PATH environment variable to an empty value is
422                    # equivalent to not setting it at all.
423                    reporter.warning(
424                        "Discarding $PYTHONPATH from environment, to override "
425                        "specify PYTHONPATH in 'passenv' in your configuration."
426                    )
427                env.pop("PYTHONPATH")
428
429        # installing packages at user level may mean we're not installing inside the venv
430        env["PIP_USER"] = "0"
431
432        # installing without dependencies may lead to broken packages
433        env["PIP_NO_DEPS"] = "0"
434
435    def _install(self, deps, extraopts=None, action=None):
436        if not deps:
437            return
438        d = {}
439        ixservers = []
440        for dep in deps:
441            if isinstance(dep, (str, py.path.local)):
442                dep = DepConfig(str(dep), None)
443            assert isinstance(dep, DepConfig), dep
444            if dep.indexserver is None:
445                ixserver = self.envconfig.config.indexserver["default"]
446            else:
447                ixserver = dep.indexserver
448            d.setdefault(ixserver, []).append(dep.name)
449            if ixserver not in ixservers:
450                ixservers.append(ixserver)
451            assert ixserver.url is None or isinstance(ixserver.url, str)
452
453        for ixserver in ixservers:
454            packages = d[ixserver]
455            options = self._installopts(ixserver.url)
456            if extraopts:
457                options.extend(extraopts)
458            self.run_install_command(packages=packages, options=options, action=action)
459
460    def _get_os_environ(self, is_test_command=False):
461        if is_test_command:
462            # for executing tests we construct a clean environment
463            env = {}
464            for env_key in self.envconfig.passenv:
465                if env_key in os.environ:
466                    env[env_key] = os.environ[env_key]
467        else:
468            # for executing non-test commands we use the full
469            # invocation environment
470            env = os.environ.copy()
471
472        # in any case we honor per-testenv setenv configuration
473        env.update(self.envconfig.setenv)
474
475        env["VIRTUAL_ENV"] = str(self.path)
476        return env
477
478    def test(
479        self,
480        redirect=False,
481        name="run-test",
482        commands=None,
483        ignore_outcome=None,
484        ignore_errors=None,
485        display_hash_seed=False,
486    ):
487        if commands is None:
488            commands = self.envconfig.commands
489        if ignore_outcome is None:
490            ignore_outcome = self.envconfig.ignore_outcome
491        if ignore_errors is None:
492            ignore_errors = self.envconfig.ignore_errors
493        with self.new_action(name) as action:
494            cwd = self.envconfig.changedir
495            if display_hash_seed:
496                env = self._get_os_environ(is_test_command=True)
497                # Display PYTHONHASHSEED to assist with reproducibility.
498                action.setactivity(name, "PYTHONHASHSEED={!r}".format(env.get("PYTHONHASHSEED")))
499            for i, argv in enumerate(commands):
500                # have to make strings as _pcall changes argv[0] to a local()
501                # happens if the same environment is invoked twice
502                message = "commands[{}] | {}".format(
503                    i, " ".join([pipes.quote(str(x)) for x in argv])
504                )
505                action.setactivity(name, message)
506                # check to see if we need to ignore the return code
507                # if so, we need to alter the command line arguments
508                if argv[0].startswith("-"):
509                    ignore_ret = True
510                    if argv[0] == "-":
511                        del argv[0]
512                    else:
513                        argv[0] = argv[0].lstrip("-")
514                else:
515                    ignore_ret = False
516
517                try:
518                    self._pcall(
519                        argv,
520                        cwd=cwd,
521                        action=action,
522                        redirect=redirect,
523                        ignore_ret=ignore_ret,
524                        is_test_command=True,
525                    )
526                except tox.exception.InvocationError as err:
527                    if ignore_outcome:
528                        msg = "command failed but result from testenv is ignored\ncmd:"
529                        reporter.warning("{} {}".format(msg, err))
530                        self.status = "ignored failed command"
531                        continue  # keep processing commands
532
533                    reporter.error(str(err))
534                    self.status = "commands failed"
535                    if not ignore_errors:
536                        break  # Don't process remaining commands
537                except KeyboardInterrupt:
538                    self.status = "keyboardinterrupt"
539                    raise
540
541    def _pcall(
542        self,
543        args,
544        cwd,
545        venv=True,
546        is_test_command=False,
547        action=None,
548        redirect=True,
549        ignore_ret=False,
550        returnout=False,
551        env=None,
552    ):
553        if env is None:
554            env = self._get_os_environ(is_test_command=is_test_command)
555
556        # construct environment variables
557        env.pop("VIRTUALENV_PYTHON", None)
558        bin_dir = str(self.envconfig.envbindir)
559        env["PATH"] = os.pathsep.join([bin_dir, os.environ["PATH"]])
560        reporter.verbosity2("setting PATH={}".format(env["PATH"]))
561
562        # get command
563        args[0] = self.getcommandpath(args[0], venv, cwd)
564        if sys.platform != "win32" and "TOX_LIMITED_SHEBANG" in os.environ:
565            args = prepend_shebang_interpreter(args)
566
567        cwd.ensure(dir=1)  # ensure the cwd exists
568        return action.popen(
569            args,
570            cwd=cwd,
571            env=env,
572            redirect=redirect,
573            ignore_ret=ignore_ret,
574            returnout=returnout,
575            report_fail=not is_test_command,
576        )
577
578    def setupenv(self):
579        if self.envconfig.missing_subs:
580            self.status = (
581                "unresolvable substitution(s): {}. "
582                "Environment variables are missing or defined recursively.".format(
583                    ",".join(["'{}'".format(m) for m in self.envconfig.missing_subs])
584                )
585            )
586            return
587        if not self.matching_platform():
588            self.status = "platform mismatch"
589            return  # we simply omit non-matching platforms
590        with self.new_action("getenv", self.envconfig.envdir) as action:
591            self.status = 0
592            default_ret_code = 1
593            envlog = self.env_log
594            try:
595                status = self.update(action=action)
596            except IOError as e:
597                if e.args[0] != 2:
598                    raise
599                status = (
600                    "Error creating virtualenv. Note that spaces in paths are "
601                    "not supported by virtualenv. Error details: {!r}".format(e)
602                )
603            except tox.exception.InvocationError as e:
604                status = e
605            except tox.exception.InterpreterNotFound as e:
606                status = e
607                if self.envconfig.config.option.skip_missing_interpreters == "true":
608                    default_ret_code = 0
609            if status:
610                str_status = str(status)
611                command_log = envlog.get_commandlog("setup")
612                command_log.add_command(["setup virtualenv"], str_status, default_ret_code)
613                self.status = status
614                if default_ret_code == 0:
615                    reporter.skip(str_status)
616                else:
617                    reporter.error(str_status)
618                return False
619            command_path = self.getcommandpath("python")
620            envlog.set_python_info(command_path)
621            return True
622
623    def finishvenv(self):
624        with self.new_action("finishvenv"):
625            self.finish()
626            return True
627
628
629def getdigest(path):
630    path = py.path.local(path)
631    if not path.check(file=1):
632        return "0" * 32
633    return path.computehash()
634
635
636def prepend_shebang_interpreter(args):
637    # prepend interpreter directive (if any) to argument list
638    #
639    # When preparing virtual environments in a file container which has large
640    # length, the system might not be able to invoke shebang scripts which
641    # define interpreters beyond system limits (e.x. Linux as a limit of 128;
642    # BINPRM_BUF_SIZE). This method can be used to check if the executable is
643    # a script containing a shebang line. If so, extract the interpreter (and
644    # possible optional argument) and prepend the values to the provided
645    # argument list. tox will only attempt to read an interpreter directive of
646    # a maximum size of 2048 bytes to limit excessive reading and support UNIX
647    # systems which may support a longer interpret length.
648    try:
649        with open(args[0], "rb") as f:
650            if f.read(1) == b"#" and f.read(1) == b"!":
651                MAXINTERP = 2048
652                interp = f.readline(MAXINTERP).rstrip().decode("UTF-8")
653                interp_args = interp.split(None, 1)[:2]
654                return interp_args + args
655    except (UnicodeDecodeError, IOError):
656        pass
657    return args
658
659
660_SKIP_VENV_CREATION = os.environ.get("_TOX_SKIP_ENV_CREATION_TEST", False) == "1"
661
662
663@tox.hookimpl
664def tox_testenv_create(venv, action):
665    config_interpreter = venv.getsupportedinterpreter()
666    args = [sys.executable, "-m", "virtualenv"]
667    if venv.envconfig.sitepackages:
668        args.append("--system-site-packages")
669    if venv.envconfig.alwayscopy:
670        args.append("--always-copy")
671    if not venv.envconfig.download:
672        args.append("--no-download")
673    # add interpreter explicitly, to prevent using default (virtualenv.ini)
674    args.extend(["--python", str(config_interpreter)])
675
676    cleanup_for_venv(venv)
677
678    base_path = venv.path.dirpath()
679    base_path.ensure(dir=1)
680    args.append(venv.path.basename)
681    if not _SKIP_VENV_CREATION:
682        try:
683            venv._pcall(
684                args,
685                venv=False,
686                action=action,
687                cwd=base_path,
688                redirect=reporter.verbosity() < reporter.Verbosity.DEBUG,
689            )
690        except KeyboardInterrupt:
691            venv.status = "keyboardinterrupt"
692            raise
693    return True  # Return non-None to indicate plugin has completed
694
695
696def cleanup_for_venv(venv):
697    within_parallel = PARALLEL_ENV_VAR_KEY in os.environ
698    if within_parallel:
699        if venv.path.exists():
700            # do not delete the log folder as that's used by parent
701            for content in venv.path.listdir():
702                if not content.basename == "log":
703                    content.remove(rec=1, ignore_errors=True)
704    else:
705        ensure_empty_dir(venv.path)
706
707
708@tox.hookimpl
709def tox_testenv_install_deps(venv, action):
710    deps = venv.get_resolved_dependencies()
711    if deps:
712        depinfo = ", ".join(map(str, deps))
713        action.setactivity("installdeps", depinfo)
714        venv._install(deps, action=action)
715    return True  # Return non-None to indicate plugin has completed
716
717
718@tox.hookimpl
719def tox_runtest(venv, redirect):
720    venv.test(redirect=redirect)
721    return True  # Return non-None to indicate plugin has completed
722
723
724@tox.hookimpl
725def tox_runtest_pre(venv):
726    venv.status = 0
727    ensure_empty_dir(venv.envconfig.envtmpdir)
728    venv.envconfig.envtmpdir.ensure(dir=1)
729    venv.test(
730        name="run-test-pre",
731        commands=venv.envconfig.commands_pre,
732        redirect=False,
733        ignore_outcome=False,
734        ignore_errors=False,
735        display_hash_seed=True,
736    )
737
738
739@tox.hookimpl
740def tox_runtest_post(venv):
741    venv.test(
742        name="run-test-post",
743        commands=venv.envconfig.commands_post,
744        redirect=False,
745        ignore_outcome=False,
746        ignore_errors=False,
747    )
748
749
750@tox.hookimpl
751def tox_runenvreport(venv, action):
752    # write out version dependency information
753    args = venv.envconfig.list_dependencies_command
754    output = venv._pcall(args, cwd=venv.envconfig.config.toxinidir, action=action, returnout=True)
755    # the output contains a mime-header, skip it
756    output = output.split("\n\n")[-1]
757    packages = output.strip().split("\n")
758    return packages  # Return non-None to indicate plugin has completed
759