diff options
-rw-r--r-- | src/tox/config/sets.py | 2 | ||||
-rw-r--r-- | src/tox/execute/api.py | 6 | ||||
-rw-r--r-- | src/tox/execute/local_sub_process/__init__.py | 2 | ||||
-rw-r--r-- | src/tox/report.py | 13 | ||||
-rw-r--r-- | src/tox/run.py | 8 | ||||
-rw-r--r-- | src/tox/session/cmd/run/sequential.py | 25 | ||||
-rw-r--r-- | src/tox/tox_env/api.py | 148 | ||||
-rw-r--r-- | src/tox/tox_env/info.py | 8 | ||||
-rw-r--r-- | src/tox/tox_env/python/api.py | 21 | ||||
-rw-r--r-- | src/tox/tox_env/python/virtual_env/api.py | 32 | ||||
-rw-r--r-- | tests/unit/execute/test_local_subprocess.py | 4 | ||||
-rw-r--r-- | tox.ini | 1 |
12 files changed, 178 insertions, 92 deletions
diff --git a/src/tox/config/sets.py b/src/tox/config/sets.py index fd5cfdfe..03442af5 100644 --- a/src/tox/config/sets.py +++ b/src/tox/config/sets.py @@ -97,7 +97,7 @@ class ConfigDynamicDefinition(ConfigDefinition[T]): else: value = self.default(conf, src.name) if callable(self.default) else self.default if self.post_process is not None: - self.post_process(value, conf) # noqa + value = self.post_process(value, conf) # noqa self._cache = value return cast(T, self._cache) diff --git a/src/tox/execute/api.py b/src/tox/execute/api.py index 97e77d85..f2e9501f 100644 --- a/src/tox/execute/api.py +++ b/src/tox/execute/api.py @@ -131,6 +131,7 @@ class Outcome: def assert_success(self, logger: logging.Logger) -> None: if self.exit_code != self.OK: self._assert_fail(logger) + self._log_run(logging.INFO, logger) def _assert_fail(self, logger: logging.Logger) -> NoReturn: if self.show_on_standard is False: @@ -140,9 +141,12 @@ class Outcome: print(Fore.RED, file=sys.stderr, end="") print(self.err, file=sys.stderr, end="") print(Fore.RESET, file=sys.stderr) - logger.critical("exit code %d for %s: %s in %s", self.exit_code, self.request.cwd, self.shell_cmd, self.elapsed) + self._log_run(logging.CRITICAL, logger) raise SystemExit(self.exit_code) + def _log_run(self, lvl: int, logger: logging.Logger) -> None: + logger.log(lvl, "exit %d (%.2fs) cwd %s: %s", self.exit_code, self.elapsed, self.request.cwd, self.shell_cmd) + @property def elapsed(self) -> float: return self.end - self.start diff --git a/src/tox/execute/local_sub_process/__init__.py b/src/tox/execute/local_sub_process/__init__.py index b32c1d42..be2aac39 100644 --- a/src/tox/execute/local_sub_process/__init__.py +++ b/src/tox/execute/local_sub_process/__init__.py @@ -89,7 +89,7 @@ class LocalSubProcessExecuteInstance(ExecuteInstance): return exit_code @staticmethod - def get_stream_file_no(key: str) -> Generator[int, Popen[bytes], None]: + def get_stream_file_no(key: str) -> Generator[int, 'Popen[bytes]', None]: if sys.platform != "win32" and getattr(sys, key).isatty(): # on UNIX if tty is set let's forward it via a pseudo terminal import pty diff --git a/src/tox/report.py b/src/tox/report.py index 8d841e65..08240704 100644 --- a/src/tox/report.py +++ b/src/tox/report.py @@ -1,5 +1,6 @@ """Handle reporting from within tox""" import logging +import os import sys from colorama import Fore, Style, init @@ -37,12 +38,16 @@ class ToxHandler(logging.StreamHandler): color = Fore.GREEN fmt = f"{Style.BRIGHT}{Fore.MAGENTA}%(name)s: {color}%(message)s{Style.RESET_ALL}" if enabled_level <= logging.DEBUG: - locate = "pathname" if enabled_level > logging.DEBUG else "module" - fmt = f"%(levelname)s {fmt}{Style.DIM} [%(asctime)s] [%({locate})s:%(lineno)d]{Style.RESET_ALL}" + fmt = f"%(relativeCreated)d %(levelname)s {fmt}{Style.DIM} [%(pathname)s:%(lineno)d]{Style.RESET_ALL}" formatter = logging.Formatter(fmt) return formatter def format(self, record: logging.LogRecord) -> str: + # shorten the pathname to start from within the site-packages folder + basename = os.path.dirname(record.pathname) + sys_path_match = sorted([p for p in sys.path if basename.startswith(p)], key=len, reverse=True) + record.pathname = record.pathname[len(sys_path_match[0]) + 1 :] + if record.levelno >= logging.ERROR: return self.error_formatter.format(record) if record.levelno >= logging.WARNING: @@ -73,3 +78,7 @@ def _get_level(verbosity: int) -> int: def _clean_handlers(log: logging.Logger) -> None: for log_handler in list(log.handlers): # remove handlers of libraries log.removeHandler(log_handler) + + +class HandledError(RuntimeError): + """Error that has been handled so no need for stack trace""" diff --git a/src/tox/run.py b/src/tox/run.py index 854ec3fc..89518f44 100644 --- a/src/tox/run.py +++ b/src/tox/run.py @@ -8,6 +8,7 @@ from tox.config.cli.parse import get_options from tox.config.main import Config from tox.config.override import Override from tox.config.source.ini import ToxIni +from tox.report import HandledError from tox.session.state import State from tox.tox_env.builder import build_tox_envs @@ -16,8 +17,11 @@ def run(args: Optional[Sequence[str]] = None) -> None: try: result = main(sys.argv[1:] if args is None else args) except Exception as exception: - logging.error("%s| %s", type(exception).__name__, str(exception)) - result = -2 + if isinstance(exception, HandledError): + logging.error("%s| %s", type(exception).__name__, str(exception)) + result = -2 + else: + raise except KeyboardInterrupt: result = -2 raise SystemExit(result) diff --git a/src/tox/session/cmd/run/sequential.py b/src/tox/session/cmd/run/sequential.py index f117c633..ac4e4adf 100644 --- a/src/tox/session/cmd/run/sequential.py +++ b/src/tox/session/cmd/run/sequential.py @@ -3,6 +3,8 @@ Run tox environments in sequential order. """ from typing import Dict +from colorama import Fore + from tox.config.cli.parser import ToxParser from tox.execute.api import Outcome from tox.plugin.impl import impl @@ -26,19 +28,22 @@ def run_sequential(state: State) -> int: for name in state.env_list: tox_env = state.tox_envs[name] status_codes[name] = run_one(tox_env, state.options.recreate, state.options.no_test) - return report(status_codes, state.tox_envs) + return report(status_codes, state.tox_envs, state.options.is_colored) + +def report(status_dict: Dict[str, int], tox_envs: Dict[str, RunToxEnv], is_colored: bool) -> int: # noqa + def _print(color: int, msg: str) -> None: + print(f"{color if is_colored else ''}{msg}{Fore.RESET if is_colored else ''}") -def report(status_dict: Dict[str, int], tox_envs: Dict[str, RunToxEnv]) -> int: # noqa + all_ok = True for name, status in status_dict.items(): - if status == Outcome.OK: - msg = "OK " - else: - msg = f"FAIL code {status}" - print(f" {name}: {msg}") - if all(value == Outcome.OK for name, value in status_dict.items()): - print(" congratulations :)") + ok = status == Outcome.OK + msg = "OK " if ok else f"FAIL code {status}" + _print(Fore.GREEN if ok else Fore.RED, f" {name}: {msg}") + all_ok = ok and all_ok + if all_ok: + _print(Fore.GREEN, " congratulations :)") return Outcome.OK else: - print(" evaluation failed :(") + _print(Fore.RED, " evaluation failed :(") return -1 diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py index 25f29226..841e09c8 100644 --- a/src/tox/tox_env/api.py +++ b/src/tox/tox_env/api.py @@ -1,15 +1,16 @@ """ Defines the abstract base traits of a tox environment. """ -import itertools import logging import os +import re import shutil import sys from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Union, cast +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Union +from tox.config.main import Config from tox.config.sets import ConfigSet from tox.execute.api import Execute, Outcome from tox.execute.request import ExecuteRequest @@ -19,23 +20,6 @@ from .info import Info if TYPE_CHECKING: from tox.config.cli.parser import Parsed -if sys.platform == "win32": - PASS_ENV_ALWAYS = [ - "SYSTEMDRIVE", # needed for pip6 - "SYSTEMROOT", # needed for python's crypto module - "PATHEXT", # needed for discovering executables - "COMSPEC", # needed for distutils cygwin compiler - "PROCESSOR_ARCHITECTURE", # platform.machine() - "USERPROFILE", # needed for `os.path.expanduser()` - "MSYSTEM", # controls paths printed format - "TEMP", - "TMP", - ] -else: - PASS_ENV_ALWAYS = [ - "TMPDIR", - ] - class ToxEnv(ABC): def __init__(self, conf: ConfigSet, core: ConfigSet, options: "Parsed"): @@ -47,7 +31,7 @@ class ToxEnv(ABC): self._cache = Info(self.conf["env_dir"]) self._paths: List[Path] = [] self.logger = logging.getLogger(self.conf["env_name"]) - self._env_vars: Dict[str, str] = {} + self._env_vars: Optional[Dict[str, str]] = None def __repr__(self) -> str: return f"{self.__class__.__name__}(name={self.conf['env_name']})" @@ -63,18 +47,6 @@ class ToxEnv(ABC): value=self.conf.name, ) self.conf.add_config( - keys=["set_env", "setenv"], - of_type=Dict[str, str], - default={}, - desc="environment variables to set when running commands in the tox environment", - ) - self.conf.add_config( - keys=["pass_env", "passenv"], - of_type=List[str], - default=[], - desc="environment variables to pass on to the tox environment", - ) - self.conf.add_config( keys=["env_dir", "envdir"], of_type=Path, default=lambda conf, name: conf.core["work_dir"] / conf[name]["env_name"], @@ -87,47 +59,106 @@ class ToxEnv(ABC): desc="a folder that is always reset at the start of the run", ) + def set_env_post_process(values: Dict[str, str], config: Config) -> Dict[str, str]: + env = self.default_set_env() + env.update(values) + return env + + self.conf.add_config( + keys=["set_env", "setenv"], + of_type=Dict[str, str], + default={}, + desc="environment variables to set when running commands in the tox environment", + post_process=set_env_post_process, + ) + + def pass_env_post_process(values: List[str], config: Config) -> List[str]: + values.extend(self.default_pass_env()) + return sorted(list({k: None for k in values}.keys())) + + self.conf.add_config( + keys=["pass_env", "passenv"], + of_type=List[str], + default=[], + desc="environment variables to pass on to the tox environment", + post_process=pass_env_post_process, + ) + + def default_set_env(self) -> Dict[str, str]: + return {} + + def default_pass_env(self) -> List[str]: + env = [ + "https_proxy", + "http_proxy", + "no_proxy", + ] + if sys.platform == "win32": + env.extend( + [ + "TEMP", + "TMP", + ] + ) + else: + env.append("TMPDIR") + return env + def setup(self) -> None: """ 1. env dir exists 2. contains a runner with the same type. """ - env_tmp_dir = cast(Path, self.conf["env_tmp_dir"]) - if env_tmp_dir.exists(): - shutil.rmtree(str(env_tmp_dir), ignore_errors=True) - env_dir = cast(Path, self.conf["env_dir"]) + env_dir: Path = self.conf["env_dir"] conf = {"name": self.conf.name, "type": type(self).__name__} - with self._cache.compare(conf, ToxEnv.__name__) as (eq, old): - try: - if eq is True: - return - # if either the name or type changed and already exists start over - self.clean() - finally: - env_dir.mkdir(exist_ok=True, parents=True) + try: + with self._cache.compare(conf, ToxEnv.__name__) as (eq, old): + try: + if eq is True: + return + # if either the name or type changed and already exists start over + self.clean() + finally: + env_dir.mkdir(exist_ok=True, parents=True) + finally: + self._handle_env_tmp_dir() + + def _handle_env_tmp_dir(self) -> None: + """Ensure exists and empty""" + env_tmp_dir: Path = self.conf["env_tmp_dir"] + if env_tmp_dir.exists(): + logging.debug("removing %s", env_tmp_dir) + shutil.rmtree(env_tmp_dir, ignore_errors=True) + env_tmp_dir.mkdir(parents=True) def clean(self) -> None: - env_dir = self.conf["env_dir"] + env_dir: Path = self.conf["env_dir"] if env_dir.exists(): - logging.info("removing %s", env_dir) - shutil.rmtree(cast(Path, env_dir)) + logging.info("remove tox env folder %s", env_dir) + shutil.rmtree(env_dir) + self._cache.reset() @property def environment_variables(self) -> Dict[str, str]: - if self._env_vars: + if self._env_vars is not None: return self._env_vars - pass_env: List[str] = self.conf["pass_env"] - pass_env.extend(PASS_ENV_ALWAYS) + result: Dict[str, str] = {} + pass_env: List[str] = self.conf["pass_env"] + glob_pass_env = [re.compile(e.replace("*", ".*")) for e in pass_env if "*" in e] + literal_pass_env = [e for e in pass_env if "*" not in e] + for env in literal_pass_env: + if env in os.environ: + result[env] = os.environ[env] + if glob_pass_env: + for env, value in os.environ.items(): + if any(g.match(env) is not None for g in glob_pass_env): + result[env] = value set_env: Dict[str, str] = self.conf["set_env"] - for key, value in os.environ.items(): - if key in pass_env: - set_env[key] = value - self._env_vars.update(set_env) - self._env_vars["PATH"] = os.pathsep.join( - itertools.chain((str(i) for i in self._paths), os.environ.get("PATH", "").split(os.pathsep)) - ) - return self._env_vars + result.update(set_env) + result["PATH"] = os.pathsep.join([str(i) for i in self._paths] + os.environ.get("PATH", "").split(os.pathsep)) + self._env_vars = result + return result def execute( self, @@ -143,7 +174,6 @@ class ToxEnv(ABC): request = ExecuteRequest(cmd, cwd, self.environment_variables, allow_stdin) self.logger.warning("%s run => %s$ %s", self.conf["env_name"], request.cwd, request.shell_cmd) outcome = self._executor(request=request, show_on_standard=show_on_standard, colored=self.options.colored) - self.logger.info("done => code %d in %s for %s", outcome.exit_code, outcome.elapsed, outcome.shell_cmd) return outcome @staticmethod diff --git a/src/tox/tox_env/info.py b/src/tox/tox_env/info.py index 888b76b5..a55213ad 100644 --- a/src/tox/tox_env/info.py +++ b/src/tox/tox_env/info.py @@ -17,10 +17,14 @@ class Info: value = {} self._content = value + def __repr__(self) -> str: + return f"{self.__class__.__name__}(path={self._path})" + @contextmanager def compare( self, value: Any, section: str, sub_section: Optional[str] = None ) -> Iterator[Tuple[bool, Optional[Any]]]: + """Cache""" old = self._content.get(section) if sub_section is not None and old is not None: old = old.get(sub_section) @@ -39,8 +43,8 @@ class Info: self._content[section][sub_section] = value self._write() - def update(self, section: str, value: str) -> None: - self._content[section] = value + def reset(self) -> None: + self._content = {} def _write(self) -> None: self._path.write_text(json.dumps(self._content, indent=2)) diff --git a/src/tox/tox_env/python/api.py b/src/tox/tox_env/python/api.py index bef81e32..2fb6b794 100644 --- a/src/tox/tox_env/python/api.py +++ b/src/tox/tox_env/python/api.py @@ -62,6 +62,21 @@ class Python(ToxEnv, ABC): value=lambda: self.env_site_package_dir(), ) + def default_pass_env(self) -> List[str]: + env = super().default_pass_env() + if sys.platform == "win32": + env.extend( + [ + "SYSTEMROOT", # needed for python's crypto module + "PATHEXT", # needed for discovering executables + "COMSPEC", # needed for distutils cygwin compiler + "PROCESSOR_ARCHITECTURE", # platform.machine() + "USERPROFILE", # needed for `os.path.expanduser()` + "MSYSTEM", # controls paths printed format + ] + ) + return env + def default_base_python(self, conf: "Config", env_name: str) -> List[str]: spec = PythonSpec.from_string_spec(env_name) if spec.implementation is not None: @@ -98,9 +113,7 @@ class Python(ToxEnv, ABC): if self._base_python_searched is False: base_pythons = self.conf["base_python"] self._base_python_searched = True - for base_python in base_pythons: - self._base_python = self._get_python(base_python) - break + self._base_python = self._get_python(base_pythons) if self._base_python is None: self.no_base_python_found(base_pythons) return cast(PythonInfo, self._base_python) @@ -110,7 +123,7 @@ class Python(ToxEnv, ABC): raise NotImplementedError @abstractmethod - def _get_python(self, base_python: str) -> PythonInfo: + def _get_python(self, base_python: List[str]) -> Optional[PythonInfo]: raise NotImplementedError def cached_install(self, deps: Deps, section: str, of_type: str) -> bool: diff --git a/src/tox/tox_env/python/virtual_env/api.py b/src/tox/tox_env/python/virtual_env/api.py index 8c023e81..79db2897 100644 --- a/src/tox/tox_env/python/virtual_env/api.py +++ b/src/tox/tox_env/python/virtual_env/api.py @@ -3,11 +3,10 @@ Declare the abstract base class for tox environments that handle the Python lang """ from abc import ABC from pathlib import Path -from typing import List, Optional, Sequence, cast +from typing import Dict, List, Optional, Sequence, cast from virtualenv import session_via_cli from virtualenv.create.creator import Creator -from virtualenv.discovery.builtin import get_interpreter from virtualenv.run.session import Session from tox.config.cli.parser import Parsed @@ -19,10 +18,24 @@ from ..api import Deps, Python, PythonInfo class VirtualEnv(Python, ABC): + """A python executor that uses the virtualenv project with pip""" + def __init__(self, conf: ConfigSet, core: ConfigSet, options: Parsed): super().__init__(conf, core, options) self._virtualenv_session: Optional[Session] = None # type: ignore[no-any-unimported] + def default_pass_env(self) -> List[str]: + env = super().default_pass_env() + env.append("PIP_*") # we use pip as installer + env.append("VIRTUALENV_*") # we use virtualenv as isolation creator + return env + + def default_set_env(self) -> Dict[str, str]: + env = super().default_set_env() + env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + env["VIRTUALENV_NO_PERIODIC_UPDATE"] = "1" + return env + def executor(self) -> Execute: return LocalSubProcessExecutor() @@ -30,12 +43,12 @@ class VirtualEnv(Python, ABC): def session(self) -> Session: # type: ignore[no-any-unimported] if self._virtualenv_session is None: args = [ - "--no-periodic-update", - "-p", - self.base_python.executable, "--clear", str(cast(Path, self.conf["env_dir"])), ] + base_python: List[str] = self.conf["base_python"] + for base in base_python: + args.extend(["-p", base]) self._virtualenv_session = session_via_cli(args, setup_logging=False) return self._virtualenv_session @@ -46,9 +59,12 @@ class VirtualEnv(Python, ABC): def create_python_env(self) -> None: self.session.run() - def _get_python(self, base_python: str) -> PythonInfo: - info = get_interpreter(base_python) - return PythonInfo(info.version_info, info.system_executable) + def _get_python(self, base_python: List[str]) -> Optional[PythonInfo]: + try: + return PythonInfo(self.creator.interpreter.version_info, self.creator.interpreter.system_executable) + except RuntimeError: + pass + return None def paths(self) -> List[Path]: """Paths to add to the executable""" diff --git a/tests/unit/execute/test_local_subprocess.py b/tests/unit/execute/test_local_subprocess.py index 2dfd1b26..1077de24 100644 --- a/tests/unit/execute/test_local_subprocess.py +++ b/tests/unit/execute/test_local_subprocess.py @@ -146,8 +146,8 @@ def test_local_execute_basic_fail(caplog, capsys): assert len(caplog.records) == 1 record = caplog.records[0] assert record.levelno == logging.CRITICAL - assert record.msg == "exit code %d for %s: %s in %s" - _code, _cwd, _cmd, _duration = record.args + assert record.msg == "exit %d (%.2fs) cwd %s: %s" + _code, _duration, _cwd, _cmd = record.args assert _code == 3 assert _cwd == cwd assert _cmd == request.shell_cmd @@ -15,6 +15,7 @@ minversion = 3.14.0 [testenv] description = run the tests with pytest +package = wheel passenv = PYTEST_* SSL_CERT_FILE |