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