diff options
author | Bernát Gábor <bgabor8@bloomberg.net> | 2021-04-05 00:59:13 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-05 00:59:13 +0100 |
commit | 18a95899444372822a4a6063a471e865f8c58edf (patch) | |
tree | 60c832841a5ab3ef64b673c5b337be9fc3181841 /src | |
parent | 54e6310f5376c7bd7e2b4871c700fe00cabf1c32 (diff) | |
download | tox-git-18a95899444372822a4a6063a471e865f8c58edf.tar.gz |
Start plugin interface documentation and installer (#1991)
- Add documentation for the plugin interface
- Introduce the installer abstraction
- Rework how we handle tox deps section, requirement and constraint files
- Support for escaping comments in tox.ini configs
Signed-off-by: Bernát Gábor <gaborjbernat@gmail.com>
Diffstat (limited to 'src')
53 files changed, 1669 insertions, 1120 deletions
diff --git a/src/tox/config/cli/parse.py b/src/tox/config/cli/parse.py index 1aed5a7b..63bd6826 100644 --- a/src/tox/config/cli/parse.py +++ b/src/tox/config/cli/parse.py @@ -34,9 +34,9 @@ def _get_base(args: Sequence[str]) -> Tuple[int, ToxHandler, Source]: parsed, _ = tox_parser.parse_known_args(args) guess_verbosity = parsed.verbosity handler = setup_report(guess_verbosity, parsed.is_colored) + from tox.plugin.manager import MANAGER # noqa # load the plugin system right after we setu up report source = discover_source(parsed.config_file, parsed.root_dir) - from tox.plugin.manager import MANAGER # noqa MANAGER.load_inline_plugin(source.path) diff --git a/src/tox/config/cli/parser.py b/src/tox/config/cli/parser.py index 4d73b714..ac27cac9 100644 --- a/src/tox/config/cli/parser.py +++ b/src/tox/config/cli/parser.py @@ -99,13 +99,17 @@ DEFAULT_VERBOSITY = 2 class Parsed(Namespace): + """CLI options""" + @property def verbosity(self) -> int: + """:return: reporting verbosity""" result: int = max(self.verbose - self.quiet, 0) return result @property def is_colored(self) -> bool: + """:return: flag indicating if the output is colored or not""" return cast(bool, self.colored == "yes") diff --git a/src/tox/config/loader/api.py b/src/tox/config/loader/api.py index 8afc1d41..bdbac197 100644 --- a/src/tox/config/loader/api.py +++ b/src/tox/config/loader/api.py @@ -4,7 +4,7 @@ from concurrent.futures import Future from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Generator, List, Mapping, Optional, Set, Type, TypeVar -from tox.plugin.impl import impl +from tox.plugin import impl from .convert import Convert from .str_convert import StrConvert @@ -15,6 +15,10 @@ if TYPE_CHECKING: class Override: + """ + An override for config definitions. + """ + def __init__(self, value: str) -> None: key, equal, self.value = value.partition("=") if not equal: @@ -78,7 +82,7 @@ class Loader(Convert[T]): chain: List[str], ) -> V: """ - Load a value. + Load a value (raw and then convert). :param key: the key under it lives :param of_type: the type to convert to @@ -106,6 +110,17 @@ class Loader(Convert[T]): raw: T, chain: List[str], # noqa: U100 ) -> Generator[T, None, None]: + """ + Materialize the raw configuration value from the loader. + + :param future: a future which when called will provide the converted config value + :param key: the config key + :param of_type: the config type + :param conf: the global config + :param env_name: the tox environment name + :param raw: the raw value + :param chain: a list of config keys already loaded in this build phase + """ yield raw diff --git a/src/tox/config/loader/convert.py b/src/tox/config/loader/convert.py index 07e06ce0..9ce2df9b 100644 --- a/src/tox/config/loader/convert.py +++ b/src/tox/config/loader/convert.py @@ -1,7 +1,6 @@ import sys from abc import ABC, abstractmethod from collections import OrderedDict -from enum import Enum from pathlib import Path from typing import Any, Dict, Generic, Iterator, List, Mapping, Set, Tuple, Type, TypeVar, Union, cast @@ -22,6 +21,14 @@ class Convert(ABC, Generic[T]): """A class that converts a raw type to a given tox (python) type""" def to(self, raw: T, of_type: Type[V], kwargs: Mapping[str, Any]) -> V: + """ + Convert given raw type to python type + + :param raw: the raw type + :param of_type: python type + :param kwargs: additional keyword arguments for conversion + :return: the converted type + """ from_module = getattr(of_type, "__module__", None) if from_module in ("typing", "typing_extensions"): return self._to_typing(raw, of_type, kwargs) # type: ignore[return-value] @@ -37,8 +44,6 @@ class Convert(ABC, Generic[T]): return self.to_str(raw) # type: ignore[return-value] elif isinstance(raw, of_type): return raw - elif issubclass(of_type, Enum): - return cast(V, getattr(of_type, str(raw))) return of_type(raw, **kwargs) # type: ignore[call-arg] def _to_typing(self, raw: T, of_type: Type[V], kwargs: Mapping[str, Any]) -> V: @@ -82,39 +87,90 @@ class Convert(ABC, Generic[T]): @staticmethod @abstractmethod def to_str(value: T) -> str: # noqa: U100 + """ + Convert to string. + + :param value: the value to convert + :returns: a string representation of the value + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_bool(value: T) -> bool: # noqa: U100 + """ + Convert to boolean. + + :param value: the value to convert + :returns: a boolean representation of the value + """ raise NotImplementedError @staticmethod @abstractmethod def to_list(value: T, of_type: Type[Any]) -> Iterator[T]: # noqa: U100 + """ + Convert to list. + + :param value: the value to convert + :param of_type: the type of elements in the list + :returns: a list representation of the value + """ raise NotImplementedError @staticmethod @abstractmethod def to_set(value: T, of_type: Type[Any]) -> Iterator[T]: # noqa: U100 + """ + Convert to set. + + :param value: the value to convert + :param of_type: the type of elements in the set + :returns: a set representation of the value + """ raise NotImplementedError @staticmethod @abstractmethod def to_dict(value: T, of_type: Tuple[Type[Any], Type[Any]]) -> Iterator[Tuple[T, T]]: # noqa: U100 + """ + Convert to dictionary. + + :param value: the value to convert + :param of_type: a tuple indicating the type of the key and the value + :returns: a iteration of key-value pairs that gets populated into a dict + """ raise NotImplementedError @staticmethod @abstractmethod def to_path(value: T) -> Path: # noqa: U100 + """ + Convert to path. + + :param value: the value to convert + :returns: path representation of the value + """ raise NotImplementedError @staticmethod @abstractmethod def to_command(value: T) -> Command: # noqa: U100 + """ + Convert to a command to execute. + + :param value: the value to convert + :returns: command representation of the value + """ raise NotImplementedError @staticmethod @abstractmethod def to_env_list(value: T) -> EnvList: # noqa: U100 - raise NotImplementedError + """ + Convert to a tox EnvList. - @staticmethod - @abstractmethod - def to_bool(value: T) -> bool: # noqa: U100 + :param value: the value to convert + :returns: a list of tox environments from the value + """ raise NotImplementedError diff --git a/src/tox/config/loader/ini/__init__.py b/src/tox/config/loader/ini/__init__.py index b9e6d243..7b4a3c3e 100644 --- a/src/tox/config/loader/ini/__init__.py +++ b/src/tox/config/loader/ini/__init__.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from tox.config.main import Config V = TypeVar("V") +_COMMENTS = re.compile(r"(\s)*(?<!\\)#.*") class IniLoader(StrConvert, Loader[str]): @@ -31,15 +32,13 @@ class IniLoader(StrConvert, Loader[str]): value = self._section[key] collapsed_newlines = value.replace("\r", "").replace("\\\n", "") # collapse explicit new-line escape # strip comments - strip_comments = "\n".join( - no_comment - for no_comment in ( - re.sub(r"(\s)*(?<!\\)#.*", "", line) - for line in collapsed_newlines.split("\n") - if not line.startswith("#") - ) - if no_comment.strip() - ) + elements: List[str] = [] + for line in collapsed_newlines.split("\n"): + if not line.startswith("#"): + part = _COMMENTS.sub("", line) + elements.append(part.replace("\\#", "#")) + strip_comments = "\n".join(elements) + if conf is None: # conf is None when we're loading the global tox configuration file for the CLI factor_filtered = strip_comments # we don't support factor and replace functionality there else: diff --git a/src/tox/config/loader/stringify.py b/src/tox/config/loader/stringify.py index c9ce8cc8..b1816c41 100644 --- a/src/tox/config/loader/stringify.py +++ b/src/tox/config/loader/stringify.py @@ -1,10 +1,9 @@ -from enum import Enum from pathlib import Path from typing import Any, Mapping, Sequence, Set, Tuple from tox.config.set_env import SetEnv from tox.config.types import Command, EnvList -from tox.tox_env.python.req_file import RequirementsFile +from tox.tox_env.python.pip.req_file import PythonDeps def stringify(value: Any) -> Tuple[str, bool]: @@ -22,8 +21,6 @@ def stringify(value: Any) -> Tuple[str, bool]: return "\n".join(f"{stringify(k)[0]}={stringify(v)[0]}" for k, v in value.items()), True if isinstance(value, (Sequence, Set)): return "\n".join(stringify(i)[0] for i in value), True - if isinstance(value, Enum): - return value.name, False if isinstance(value, EnvList): return "\n".join(e for e in value.envs), True if isinstance(value, Command): @@ -31,8 +28,8 @@ def stringify(value: Any) -> Tuple[str, bool]: if isinstance(value, SetEnv): env_var_keys = sorted(value) return stringify({k: value.load(k) for k in env_var_keys}) - if isinstance(value, RequirementsFile): - return stringify(value.validate_and_expand()) + if isinstance(value, PythonDeps): + return stringify([next(iter(v.keys())) if isinstance(v, dict) else v for v in value.validate_and_expand()]) return str(value), False diff --git a/src/tox/config/main.py b/src/tox/config/main.py index 63a3e659..87a363e4 100644 --- a/src/tox/config/main.py +++ b/src/tox/config/main.py @@ -1,6 +1,6 @@ from collections import OrderedDict, defaultdict from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple from tox.config.loader.api import Loader, Override, OverrideMap @@ -12,6 +12,8 @@ if TYPE_CHECKING: class Config: + """Main configuration object for tox.""" + def __init__( self, config_source: Source, @@ -20,8 +22,8 @@ class Config: pos_args: Optional[Sequence[str]], work_dir: Path, ) -> None: - self.pos_args = pos_args - self.work_dir = work_dir + self._pos_args = None if pos_args is None else tuple(pos_args) + self._work_dir = work_dir self._root = root self._overrides: OverrideMap = defaultdict(list) @@ -33,6 +35,32 @@ class Config: self._core_set: Optional[CoreConfigSet] = None self.register_config_set: Callable[[str, EnvConfigSet], Any] = lambda n, e: None + @property + def pos_args(self) -> Optional[Tuple[str, ...]]: + """:return: positional arguments""" + return self._pos_args + + @property + def work_dir(self) -> Path: + """:return: working directory for this project""" + return self._work_dir + + @property + def src_path(self) -> Path: + """:return: the location of the tox configuration source""" + return self._src.path + + def __iter__(self) -> Iterator[str]: + """:return: an iterator that goes through existing environments""" + return self._src.envs(self.core) + + def __repr__(self) -> str: + return f"{type(self).__name__}(config_source={self._src!r})" + + def __contains__(self, item: str) -> bool: + """:return: check if an environment already exists""" + return any(name for name in self if name == item) + @classmethod def make(cls, parsed: "Parsed", pos_args: Optional[Sequence[str]], source: Source) -> "Config": """Make a tox configuration object.""" @@ -50,6 +78,7 @@ class Config: @property def core(self) -> CoreConfigSet: + """:return: the core configuration""" if self._core_set is not None: return self._core_set core = CoreConfigSet(self, self._root) @@ -65,6 +94,14 @@ class Config: def get_env( self, item: str, package: bool = False, loaders: Optional[Sequence[Loader[Any]]] = None ) -> EnvConfigSet: + """ + Return the configuration for a given tox environment (will create if not exist yet). + + :param item: the name of the environment + :param package: a flag indicating if the environment is of type packaging or not (only used for creation) + :param loaders: loaders to use for this configuration (only used for creation) + :return: the tox environments config + """ try: return self._env_to_set[item] except KeyError: @@ -78,16 +115,3 @@ class Config: # configuration values self.register_config_set(item, env) return env - - def __iter__(self) -> Iterator[str]: - return self._src.envs(self.core) - - def __repr__(self) -> str: - return f"{type(self).__name__}(config_source={self._src!r})" - - def __contains__(self, item: str) -> bool: - return any(name for name in self if name == item) - - @property - def src_path(self) -> Path: - return self._src.path diff --git a/src/tox/config/sets.py b/src/tox/config/sets.py index c5d13937..d9515c5e 100644 --- a/src/tox/config/sets.py +++ b/src/tox/config/sets.py @@ -49,6 +49,14 @@ class ConfigSet: ) -> ConfigDynamicDefinition[V]: """ Add configuration value. + + :param keys: the keys under what to register the config (first is primary key) + :param of_type: the type of the config value + :param default: the default value of the config value + :param desc: a help message describing the configuration + :param post_process: a callback to post-process the configuration value after it has been loaded + :param kwargs: additional arguments to pass to the configuration type at construction time + :return: the new dynamic config definition """ keys_ = self._make_keys(keys) definition = ConfigDynamicDefinition(keys_, desc, self._name, of_type, default, post_process, kwargs) @@ -56,6 +64,14 @@ class ConfigSet: return cast(ConfigDynamicDefinition[V], result) def add_constant(self, keys: Union[str, Sequence[str]], desc: str, value: V) -> ConfigConstantDefinition[V]: + """ + Add a constant value. + + :param keys: the keys under what to register the config (first is primary key) + :param desc: a help message describing the configuration + :param value: the config value to use + :return: the new constant config value + """ keys_ = self._make_keys(keys) definition = ConfigConstantDefinition(keys_, desc, self._name, value) result = self._add_conf(keys_, definition) @@ -83,9 +99,22 @@ class ConfigSet: return definition def __getitem__(self, item: str) -> Any: + """ + Get the config value for a given key (will materialize in case of dynamic config). + + :param item: the config key + :return: the configuration value + """ return self.load(item) def load(self, item: str, chain: Optional[List[str]] = None) -> Any: + """ + Get the config value for a given key (will materialize in case of dynamic config). + + :param item: the config key + :param chain: a chain of configuration keys already loaded for this load operation (used to detect circles) + :return: the configuration value + """ config_definition = self._defined[item] if chain is None: chain = [] @@ -101,13 +130,20 @@ class ConfigSet: return f"{self.__class__.__name__}({', '.join(values)})" def __iter__(self) -> Iterator[str]: + """:return: iterate through the defined config keys (primary keys used)""" return iter(self._keys.keys()) def __contains__(self, item: str) -> bool: + """ + Check if a configuration key is within the config set. + + :param item: the configuration value + :return: a boolean indicating the truthiness of the statement + """ return item in self._alias def unused(self) -> List[str]: - """Return a list of keys present in the config source but not used""" + """:return: Return a list of keys present in the config source but not used""" found: Set[str] = set() # keys within loaders (only if the loader is not a parent too) parents = {id(i.parent) for i in self.loaders if i.parent is not None} @@ -118,10 +154,18 @@ class ConfigSet: return sorted(found) def primary_key(self, key: str) -> str: + """ + Get the primary key for a config key. + + :param key: the config key + :return: the key that's considered the primary for the input key + """ return self._alias[key] class CoreConfigSet(ConfigSet): + """Configuration set for the core tox config""" + def __init__(self, conf: "Config", root: Path) -> None: super().__init__(conf, name=None) self.add_config( @@ -156,6 +200,8 @@ class CoreConfigSet(ConfigSet): class EnvConfigSet(ConfigSet): + """Configuration set for a tox environment""" + def __init__(self, conf: "Config", name: Optional[str]): super().__init__(conf, name=name) self.default_set_env_loader: Callable[[], Mapping[str, str]] = lambda: {} diff --git a/src/tox/config/source/api.py b/src/tox/config/source/api.py index c2b03bbe..6855b661 100644 --- a/src/tox/config/source/api.py +++ b/src/tox/config/source/api.py @@ -16,23 +16,39 @@ class Source(ABC): FILENAME = "" def __init__(self, path: Path) -> None: - self.path: Path = path + self.path: Path = path #: the path to the configuration source @abstractmethod def get_core(self, override_map: OverrideMap) -> Iterator[Loader[Any]]: # noqa: U100 - """Return the core loader from this source.""" + """ + Return a loader that loads the core configuration values. + + :param override_map: a list of overrides to apply + :returns: the core loader from this source + """ raise NotImplementedError @abstractmethod def get_env_loaders( self, env_name: str, override_map: OverrideMap, package: bool, conf: ConfigSet # noqa: U100 ) -> Iterator[Loader[Any]]: - """Return the load for this environment.""" + """ + Return the load for this environment. + + :param env_name: the environment name + :param override_map: a list of overrides to apply + :param package: a flag indicating if this is a package environment, otherwise is of type run + :param conf: the config set to use + :returns: an iterable of loaders extracting config value from this source + """ raise NotImplementedError @abstractmethod def envs(self, core_conf: "CoreConfigSet") -> Iterator[str]: # noqa: U100 - """Return a list of environments defined within this source""" + """ + :param core_conf: the core configuration set + :returns: a list of environments defined within this source + """ raise NotImplementedError diff --git a/src/tox/config/source/discover.py b/src/tox/config/source/discover.py index 320074d2..41482f87 100644 --- a/src/tox/config/source/discover.py +++ b/src/tox/config/source/discover.py @@ -43,12 +43,13 @@ def _locate_source() -> Optional[Source]: def _load_exact_source(config_file: Path) -> Source: - for src_type in SOURCE_TYPES: # pragma: no branch # SOURCE_TYPES will never be empty - if src_type.FILENAME == config_file.name: - try: - return src_type(config_file) - except ValueError: - pass + # if the filename matches to the letter some config file name do not fallback to other source types + exact_match = next((s for s in SOURCE_TYPES if config_file.name == s.FILENAME), None) # pragma: no cover + for src_type in (exact_match,) if exact_match is not None else SOURCE_TYPES: # pragma: no branch + try: + return src_type(config_file) + except ValueError: + pass raise HandledError(f"could not recognize config file {config_file}") diff --git a/src/tox/config/source/legacy_toml.py b/src/tox/config/source/legacy_toml.py index 527a0860..a100ff66 100644 --- a/src/tox/config/source/legacy_toml.py +++ b/src/tox/config/source/legacy_toml.py @@ -9,7 +9,7 @@ class LegacyToml(IniSource): FILENAME = "pyproject.toml" def __init__(self, path: Path): - if not path.exists(): + if path.name != self.FILENAME or not path.exists(): raise ValueError toml_content = toml.loads(path.read_text()) try: diff --git a/src/tox/config/source/setup_cfg.py b/src/tox/config/source/setup_cfg.py index 4f4c39f0..a0667923 100644 --- a/src/tox/config/source/setup_cfg.py +++ b/src/tox/config/source/setup_cfg.py @@ -6,8 +6,8 @@ from .ini import IniSource class SetupCfg(IniSource): """Configuration sourced from a tox.ini file""" - FILENAME = "setup.cfg" CORE_PREFIX = "tox:tox" + FILENAME = "setup.cfg" def __init__(self, path: Path): super().__init__(path) diff --git a/src/tox/config/types.py b/src/tox/config/types.py index 345eb7c6..cbfc9d4d 100644 --- a/src/tox/config/types.py +++ b/src/tox/config/types.py @@ -5,9 +5,16 @@ from tox.execute.request import shell_cmd class Command: + """A command to execute.""" + def __init__(self, args: List[str]) -> None: - self.ignore_exit_code = args[0] == "-" - self.args = args[1:] if self.ignore_exit_code else args + """ + Create a new command to execute + + :param args: the command line arguments (first value can be ``-`` to indicate ignore the exit code) + """ + self.ignore_exit_code: bool = args[0] == "-" #: a flag indicating if the exit code should be ignored + self.args: List[str] = args[1:] if self.ignore_exit_code else args #: the command line arguments def __repr__(self) -> str: return f"{type(self).__name__}(args={(['-'] if self.ignore_exit_code else [])+ self.args!r})" @@ -20,11 +27,19 @@ class Command: @property def shell(self) -> str: + """:return: a shell representation of the command (platform dependent)""" return shell_cmd(self.args) class EnvList: + """A tox environment list""" + def __init__(self, envs: Sequence[str]) -> None: + """ + Crate a new tox environment list. + + :param envs: the list of tox environments + """ self.envs = list(OrderedDict((e, None) for e in envs).keys()) def __repr__(self) -> str: @@ -37,9 +52,11 @@ class EnvList: return not (self == other) def __iter__(self) -> Iterator[str]: + """:return: iterator that goes through the defined env-list""" return iter(self.envs) def __bool__(self) -> bool: + """:return: ``True`` if there are any environments defined""" return bool(self.envs) diff --git a/src/tox/execute/api.py b/src/tox/execute/api.py index aa996ef2..4aa880fd 100644 --- a/src/tox/execute/api.py +++ b/src/tox/execute/api.py @@ -146,15 +146,28 @@ class Outcome: cmd: Sequence[str], metadata: Dict[str, Any], ): - self.request = request - self.show_on_standard = show_on_standard - self.exit_code = exit_code - self.out = out - self.err = err - self.start = start - self.end = end - self.cmd = cmd - self.metadata = metadata + """ + Create a new execution outcome. + + :param request: the execution request + :param show_on_standard: a flag indicating if the execution was shown on stdout/stderr + :param exit_code: the exit code for the execution + :param out: the standard output of the execution + :param err: the standard error of the execution + :param start: a timer sample for the start of the execution + :param end: a timer sample for the end of the execution + :param cmd: the command as executed + :param metadata: additional metadata attached to the execution + """ + self.request = request #: the execution request + self.show_on_standard = show_on_standard #: a flag indicating if the execution was shown on stdout/stderr + self.exit_code = exit_code #: the exit code for the execution + self.out = out #: the standard output of the execution + self.err = err #: the standard error of the execution + self.start = start #: a timer sample for the start of the execution + self.end = end #: a timer sample for the end of the execution + self.cmd = cmd #: the command as executed + self.metadata = metadata #: additional metadata attached to the execution def __bool__(self) -> bool: return self.exit_code == self.OK @@ -166,6 +179,7 @@ class Outcome: ) def assert_success(self) -> None: + """Assert that the execution succeeded""" if self.exit_code is not None and self.exit_code != self.OK: self._assert_fail() self.log_run_done(logging.INFO) @@ -186,6 +200,11 @@ class Outcome: raise SystemExit(self.exit_code) def log_run_done(self, lvl: int) -> None: + """ + Log that the run was done. + + :param lvl: the level on what to log as interpreted by :func:`logging.log` + """ req = self.request metadata = "" if self.metadata: @@ -202,9 +221,11 @@ class Outcome: @property def elapsed(self) -> float: + """:return: time the execution took in seconds""" return self.end - self.start def out_err(self) -> Tuple[str, str]: + """:return: a tuple of the standard output and standard error""" return self.out, self.err diff --git a/src/tox/execute/local_sub_process/__init__.py b/src/tox/execute/local_sub_process/__init__.py index ed4f6690..5fb21d4b 100644 --- a/src/tox/execute/local_sub_process/__init__.py +++ b/src/tox/execute/local_sub_process/__init__.py @@ -96,7 +96,7 @@ class LocalSubprocessExecuteStatus(ExecuteStatus): logging.warning("send signal %s to %d from %d", f"SIGKILL({SIGKILL})", to_pid, host_pid) proc.kill() while proc.poll() is None: - continue + continue # pragma: no cover else: # pragma: no cover # difficult to test, process must die just as it's being interrupted logging.warning("process already dead with %s within %s", proc.returncode, os.getpid()) logging.warning("interrupt finished with success") diff --git a/src/tox/execute/request.py b/src/tox/execute/request.py index c1ac6eb0..8cccbf9a 100644 --- a/src/tox/execute/request.py +++ b/src/tox/execute/request.py @@ -6,12 +6,13 @@ from typing import Dict, List, Sequence, Union class StdinSource(Enum): - OFF = 0 - USER = 1 - API = 2 + OFF = 0 #: input disabled + USER = 1 #: input via the standard input + API = 2 #: input via programmatic access @staticmethod def user_only() -> "StdinSource": + """:return: ``USER`` if the standard input is tty type else ``OFF``""" return StdinSource.USER if sys.stdin.isatty() else StdinSource.OFF @@ -21,16 +22,26 @@ class ExecuteRequest: def __init__( self, cmd: Sequence[Union[str, Path]], cwd: Path, env: Dict[str, str], stdin: StdinSource, run_id: str ) -> None: + """ + Create a new execution request. + + :param cmd: the command to run + :param cwd: the current working directory + :param env: the environment variables + :param stdin: the type of standard input allowed + :param run_id: an id to identify this run + """ if len(cmd) == 0: raise ValueError("cannot execute an empty command") - self.cmd: List[str] = [str(i) for i in cmd] - self.cwd = cwd - self.env = env - self.stdin = stdin - self.run_id = run_id + self.cmd: List[str] = [str(i) for i in cmd] #: the command to run + self.cwd = cwd #: the working directory to use + self.env = env #: the environment variables to use + self.stdin = stdin #: the type of standard input interaction allowed + self.run_id = run_id #: an id to identify this run @property def shell_cmd(self) -> str: + """:return: the command to run as a shell command""" try: exe = str(Path(self.cmd[0]).relative_to(self.cwd)) except ValueError: diff --git a/src/tox/journal/env.py b/src/tox/journal/env.py index 89e8fade..c42aaa9c 100644 --- a/src/tox/journal/env.py +++ b/src/tox/journal/env.py @@ -14,10 +14,30 @@ class EnvJournal: self._executes: List[Tuple[str, Outcome]] = [] def __setitem__(self, key: str, value: Any) -> None: + """ + Add a new entry under key into the event journal. + + :param key: the key under what to add the data + :param value: the data to add + """ self._content[key] = value + def __bool__(self) -> bool: + """:return: a flag indicating if the event journal is on or not""" + return self._enabled + + def add_execute(self, outcome: Outcome, run_id: str) -> None: + """ + Add a command execution to the journal. + + :param outcome: the execution outcome + :param run_id: the execution id + """ + self._executes.append((run_id, outcome)) + @property def content(self) -> Dict[str, Any]: + """:return: the env journal content (merges explicit keys and execution commands)""" tests: List[Dict[str, Any]] = [] setup: List[Dict[str, Any]] = [] for run_id, outcome in self._executes: @@ -42,11 +62,5 @@ class EnvJournal: self["setup"] = setup return self._content - def __bool__(self) -> bool: - return self._enabled - - def add_execute(self, outcome: Outcome, run_id: str) -> None: - self._executes.append((run_id, outcome)) - __all__ = ("EnvJournal",) diff --git a/src/tox/plugin/__init__.py b/src/tox/plugin/__init__.py index 01dc773d..0a41e001 100644 --- a/src/tox/plugin/__init__.py +++ b/src/tox/plugin/__init__.py @@ -1,5 +1,31 @@ """ -API for the plugin system used. +tox uses `pluggy <https://pluggy.readthedocs.io/>`_ to customize the default behaviour. For example the following code +snippet would define a new ``--magic`` command line interface flag the user can specify: + +.. code-block:: python + + from tox.config.cli.parser import ToxParser + from tox.plugin import impl + + + @impl + def tox_add_option(parser: ToxParser) -> None: + parser.add_argument("--magic", action="store_true", help="magical flag") + +You can define such hooks either in a package installed alongside tox or within a ``toxfile.py`` found alongside your +tox configuration file (root of your project). """ +from typing import Any, Callable, TypeVar + +import pluggy + +NAME = "tox" #: the name of the tox hook + +_F = TypeVar("_F", bound=Callable[..., Any]) +impl: Callable[[_F], _F] = pluggy.HookimplMarker(NAME) #: decorator to mark tox plugin hooks + -NAME = "tox" +__all__ = ( + "NAME", + "impl", +) diff --git a/src/tox/plugin/impl.py b/src/tox/plugin/impl.py deleted file mode 100644 index fd7a909c..00000000 --- a/src/tox/plugin/impl.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Any, Callable, TypeVar - -import pluggy - -from . import NAME - -F = TypeVar("F", bound=Callable[..., Any]) -impl: Callable[[F], F] = pluggy.HookimplMarker(NAME) diff --git a/src/tox/plugin/inline.py b/src/tox/plugin/inline.py index 053f6eef..177a7d22 100644 --- a/src/tox/plugin/inline.py +++ b/src/tox/plugin/inline.py @@ -1,22 +1,29 @@ +import importlib import sys from pathlib import Path -from runpy import run_path from types import ModuleType -from typing import Any, Dict, Optional +from typing import Optional def load_inline(path: Path) -> Optional[ModuleType]: # nox uses here the importlib.machinery.SourceFileLoader but I consider this similarly good, and we can keep any # name for the tox file, it's content will always be loaded in the this module from a system point of view - path = path.parent / "tox_.py" - if path.exists(): - return _load_plugin(path) + for name in ("toxfile", "☣"): + candidate = path.parent / f"{name}.py" + if candidate.exists(): + return _load_plugin(candidate) return None def _load_plugin(path: Path) -> ModuleType: - loaded_module: Dict[str, Any] = run_path(str(path), run_name="__tox__") # type: ignore # python/typeshed - 4965 - for key, value in loaded_module.items(): - if not key.startswith("_"): - globals()[key] = value - return sys.modules[__name__] + in_folder = path.parent + module_name = path.stem + + sys.path.insert(0, str(in_folder)) + try: + if module_name in sys.modules: + del sys.modules[module_name] # pragma: no cover + module = importlib.import_module(module_name) + return module + finally: + del sys.path[0] diff --git a/src/tox/plugin/manager.py b/src/tox/plugin/manager.py index c3f9dcd6..9da131a6 100644 --- a/src/tox/plugin/manager.py +++ b/src/tox/plugin/manager.py @@ -1,6 +1,5 @@ """Contains the plugin manager object""" from pathlib import Path -from typing import List, Type, cast import pluggy @@ -12,7 +11,6 @@ from tox.session import state from tox.session.cmd import depends, devenv, legacy, list_env, quickstart, show_config, version_flag from tox.session.cmd.run import parallel, sequential from tox.tox_env import package as package_api -from tox.tox_env.api import ToxEnv from tox.tox_env.python.virtual_env import runner from tox.tox_env.python.virtual_env.package import api from tox.tox_env.register import REGISTER, ToxEnvRegister @@ -47,8 +45,6 @@ class Plugin: self.manager.register(plugin) self.manager.load_setuptools_entrypoints(NAME) self.manager.register(state) - - REGISTER.populate(self) self.manager.check_pending() def tox_add_option(self, parser: ToxParser) -> None: @@ -57,13 +53,16 @@ class Plugin: def tox_add_core_config(self, core: ConfigSet) -> None: self.manager.hook.tox_add_core_config(core=core) - def tox_register_tox_env(self, register: "ToxEnvRegister") -> List[Type[ToxEnv]]: - return cast(List[Type[ToxEnv]], self.manager.hook.tox_register_tox_env(register=register)) + def tox_register_tox_env(self, register: "ToxEnvRegister") -> None: + self.manager.hook.tox_register_tox_env(register=register) def load_inline_plugin(self, path: Path) -> None: result = load_inline(path) if result is not None: self.manager.register(result) + REGISTER._register_tox_env_types(self) # noqa + if result is not None: #: recheck pending for the inline plugins + self.manager.check_pending() MANAGER = Plugin() diff --git a/src/tox/plugin/spec.py b/src/tox/plugin/spec.py index ab5294ca..e691742c 100644 --- a/src/tox/plugin/spec.py +++ b/src/tox/plugin/spec.py @@ -1,33 +1,67 @@ -"""Hook specifications for the tox project - see https://pluggy.readthedocs.io/""" from argparse import ArgumentParser -from typing import Any, Callable, Type, TypeVar, cast +from typing import Any, Callable, TypeVar, cast import pluggy +from tox.config.main import Config from tox.config.sets import ConfigSet -from tox.tox_env.api import ToxEnv from tox.tox_env.register import ToxEnvRegister from . import NAME -F = TypeVar("F", bound=Callable[..., Any]) -_hook_spec = pluggy.HookspecMarker(NAME) +_F = TypeVar("_F", bound=Callable[..., Any]) +_spec_marker = pluggy.HookspecMarker(NAME) -def hook_spec(func: F) -> F: - return cast(F, _hook_spec(func)) +def _spec(func: _F) -> _F: + return cast(_F, _spec_marker(func)) -@hook_spec -def tox_add_option(parser: ArgumentParser) -> None: # noqa - """add cli flags""" +@_spec +def tox_register_tox_env(register: ToxEnvRegister) -> None: # noqa: U100 + """ + Register new tox environment type that. You can register: + - **run environment**: by default this is a local subprocess backed virtualenv Python + - **packaging environment**: by default this is a PEP-517 compliant local subprocess backed virtualenv Python -@hook_spec -def tox_add_core_config(core: ConfigSet) -> None: # noqa - """add options to the core section of the tox""" + :param register: a object that can be used to register new tox environment types + """ -@hook_spec -def tox_register_tox_env(register: ToxEnvRegister) -> Type[ToxEnv]: # noqa - """register new tox environment types that can have their own argument""" +@_spec +def tox_add_option(parser: ArgumentParser) -> None: # noqa: U100 + """ + Add a command line argument. This is the first hook to be called, right after the logging setup and config source + discovery. + + :param parser: the command line parser + """ + + +@_spec +def tox_add_core_config(core: ConfigSet) -> None: # noqa: U100 + """ + Define a new core (non test environment bound) settings for tox. Called the first time the core configuration is + used (at the start of the provision check). + + :param core: the core configuration object + """ + + +@_spec +def tox_configure(config: Config) -> None: # noqa: U100 + """ + Called after command line options are parsed and ini-file has been read. + + :param config: the configuration object + """ + + +__all__ = ( + "NAME", + "tox_register_tox_env", + "tox_add_option", + "tox_add_core_config", + "tox_configure", +) diff --git a/src/tox/provision.py b/src/tox/provision.py index ef06a813..94d23fca 100644 --- a/src/tox/provision.py +++ b/src/tox/provision.py @@ -13,13 +13,12 @@ from packaging.version import Version from tox.config.loader.memory import MemoryLoader from tox.config.sets import CoreConfigSet from tox.execute.api import StdinSource -from tox.plugin.impl import impl +from tox.plugin import impl from tox.report import HandledError from tox.session.state import State from tox.tox_env.errors import Skip -from tox.tox_env.python.req_file import RequirementsFile +from tox.tox_env.python.pip.req_file import PythonDeps from tox.tox_env.python.runner import PythonRun -from tox.tox_env.python.virtual_env.package.api import PackageType from tox.version import __version__ as current_version if sys.version_info >= (3, 8): # pragma: no cover (py38+) @@ -89,9 +88,8 @@ def provision(state: State) -> Union[int, bool]: if not missing: return False deps = ", ".join(f"{p} ({ver})" for p, ver in missing) - logging.warning( - "will run in automatically provisioned tox, host %s is missing [requires (has)]: %s", sys.executable, deps - ) + msg = "will run in automatically provisioned tox, host %s is missing [requires (has)]: %s" + logging.warning(msg, sys.executable, deps) return run_provision(requires, state) @@ -99,9 +97,9 @@ def run_provision(deps: List[Requirement], state: State) -> int: # noqa """""" loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) base=[], # disable inheritance for provision environments - package=PackageType.skip, # no packaging for this please + package="skip", # no packaging for this please # use our own dependency specification - deps=RequirementsFile("\n".join(str(d) for d in deps), root=state.conf.core["tox_root"]), + deps=PythonDeps("\n".join(str(d) for d in deps), root=state.conf.core["tox_root"]), pass_env=["*"], # do not filter environment variables, will be handled by provisioned tox ) provision_tox_env: str = state.conf.core["provision_tox_env"] @@ -111,7 +109,7 @@ def run_provision(deps: List[Requirement], state: State) -> int: # noqa logging.info("will run in a automatically provisioned python environment under %s", env_python) recreate = state.options.no_recreate_provision is False if state.options.recreate else False try: - tox_env.ensure_setup(recreate=recreate) + tox_env.setup(recreate=recreate) except Skip as exception: raise HandledError(f"cannot provision tox environment {tox_env.conf['env_name']} because {exception}") args: List[str] = [str(env_python), "-m", "tox"] diff --git a/src/tox/pytest.py b/src/tox/pytest.py index 29c98107..f7c84502 100644 --- a/src/tox/pytest.py +++ b/src/tox/pytest.py @@ -286,12 +286,12 @@ def enable_pep517_backend_coverage() -> Iterator[None]: # noqa: PT004 result.append("COV_*") return result - previous = Pep517VirtualEnvPackage.default_pass_env + previous = Pep517VirtualEnvPackage._default_pass_env try: - Pep517VirtualEnvPackage.default_pass_env = default_pass_env # type: ignore + Pep517VirtualEnvPackage._default_pass_env = default_pass_env # type: ignore yield finally: - Pep517VirtualEnvPackage.default_pass_env = previous # type: ignore + Pep517VirtualEnvPackage._default_pass_env = previous # type: ignore class ToxRunOutcome: diff --git a/src/tox/report.py b/src/tox/report.py index 60f8de93..572982b5 100644 --- a/src/tox/report.py +++ b/src/tox/report.py @@ -93,6 +93,8 @@ class NamedBytesIO(BytesIO): class ToxHandler(logging.StreamHandler): + # """Controls tox output.""" + def __init__(self, level: int, is_colored: bool, out_err: OutErr) -> None: self._local = _LogThreadLocal(out_err) super().__init__(stream=self.stdout) @@ -100,35 +102,44 @@ class ToxHandler(logging.StreamHandler): if is_colored: deinit() init() - self.error_formatter = self._get_formatter(logging.ERROR, level, is_colored) - self.warning_formatter = self._get_formatter(logging.WARNING, level, is_colored) - self.remaining_formatter = self._get_formatter(logging.INFO, level, is_colored) + self._error_formatter = self._get_formatter(logging.ERROR, level, is_colored) + self._warning_formatter = self._get_formatter(logging.WARNING, level, is_colored) + self._remaining_formatter = self._get_formatter(logging.INFO, level, is_colored) @contextmanager def with_context(self, name: str) -> Iterator[None]: + """ + Set a new tox environment context + + :param name: the name of the tox environment + """ with self._local.with_name(name): yield @property def name(self) -> str: # type: ignore[override] + """:return: the current tox environment name""" return self._local.name # pragma: no cover - @property # type: ignore[override] - def stream(self) -> IO[str]: # type: ignore[override] - return self.stdout - - @stream.setter - def stream(self, value: IO[str]) -> None: # noqa: U100 - """ignore anyone changing this""" - @property def stdout(self) -> TextIOWrapper: + """:return: the current standard output""" return self._local.out_err[0] @property def stderr(self) -> TextIOWrapper: + """:return: the current standard error""" return self._local.out_err[1] + @property # type: ignore[override] + def stream(self) -> IO[str]: # type: ignore[override] + """:return: the current stream to write to (alias for the current standard output)""" + return self.stdout + + @stream.setter + def stream(self, value: IO[str]) -> None: # noqa: U100 + """ignore anyone changing this""" + @contextmanager def suspend_out_err(self, yes: bool, out_err: Optional[OutErr] = None) -> Iterator[OutErr]: with self._local.suspend_out_err(yes, out_err) as out_err_res: @@ -171,10 +182,10 @@ class ToxHandler(logging.StreamHandler): record.pathname = record.pathname[len_sys_path_match + 1 :] if record.levelno >= logging.ERROR: - return self.error_formatter.format(record) + return self._error_formatter.format(record) if record.levelno >= logging.WARNING: - return self.warning_formatter.format(record) - return self.remaining_formatter.format(record) + return self._warning_formatter.format(record) + return self._remaining_formatter.format(record) @staticmethod @contextmanager diff --git a/src/tox/session/cmd/depends.py b/src/tox/session/cmd/depends.py index b2e38c09..d60785ff 100644 --- a/src/tox/session/cmd/depends.py +++ b/src/tox/session/cmd/depends.py @@ -1,7 +1,7 @@ from typing import Dict, List from tox.config.cli.parser import ToxParser -from tox.plugin.impl import impl +from tox.plugin import impl from tox.session.cmd.run.common import run_order from tox.session.state import State @@ -30,7 +30,7 @@ def depends(state: State) -> int: print(" " * at, end="") print(env, end="") if env != "ALL": - names = " | ".join(e.conf.name for e in state.tox_env(env).package_envs()) + names = " | ".join(e.conf.name for e in state.tox_env(env).package_envs) if names: print(f" ~ {names}", end="") print("") diff --git a/src/tox/session/cmd/devenv.py b/src/tox/session/cmd/devenv.py index f63b2ae1..a784f86a 100644 --- a/src/tox/session/cmd/devenv.py +++ b/src/tox/session/cmd/devenv.py @@ -2,7 +2,7 @@ from pathlib import Path from tox.config.cli.parser import ToxParser from tox.config.loader.memory import MemoryLoader -from tox.plugin.impl import impl +from tox.plugin import impl from tox.report import HandledError from tox.session.cmd.run.common import env_run_create_flags from tox.session.cmd.run.sequential import run_sequential diff --git a/src/tox/session/cmd/legacy.py b/src/tox/session/cmd/legacy.py index 9ebb670c..f8b868aa 100644 --- a/src/tox/session/cmd/legacy.py +++ b/src/tox/session/cmd/legacy.py @@ -1,7 +1,7 @@ from pathlib import Path from tox.config.cli.parser import DEFAULT_VERBOSITY, ToxParser -from tox.plugin.impl import impl +from tox.plugin import impl from tox.session.cmd.run.common import env_run_create_flags from tox.session.cmd.run.parallel import OFF_VALUE, parallel_flags, run_parallel from tox.session.cmd.run.sequential import run_sequential diff --git a/src/tox/session/cmd/list_env.py b/src/tox/session/cmd/list_env.py index 3138f6b9..89b6446e 100644 --- a/src/tox/session/cmd/list_env.py +++ b/src/tox/session/cmd/list_env.py @@ -2,7 +2,7 @@ Print available tox environments. """ from tox.config.cli.parser import ToxParser -from tox.plugin.impl import impl +from tox.plugin import impl from tox.session.state import State diff --git a/src/tox/session/cmd/quickstart.py b/src/tox/session/cmd/quickstart.py index b2b05c21..2395ca16 100644 --- a/src/tox/session/cmd/quickstart.py +++ b/src/tox/session/cmd/quickstart.py @@ -5,7 +5,7 @@ from textwrap import dedent from packaging.version import Version from tox.config.cli.parser import ToxParser -from tox.plugin.impl import impl +from tox.plugin import impl from tox.session.state import State from tox.version import __version__ diff --git a/src/tox/session/cmd/run/common.py b/src/tox/session/cmd/run/common.py index 4e28e848..adc63d25 100644 --- a/src/tox/session/cmd/run/common.py +++ b/src/tox/session/cmd/run/common.py @@ -319,10 +319,13 @@ def _handle_one_run_done(result: ToxEnvRunResult, spinner: ToxSpinner, state: St if live is False and state.options.parallel_live is False: # teardown background run tox_env = state.tox_env(result.name) out_err = tox_env.close_and_read_out_err() # sync writes from buffer to stdout/stderr - has_package = tox_env.package_env is not None - pkg_out_err = tox_env.package_env.close_and_read_out_err() if has_package else None # type: ignore + pkg_out_err_list = [] + for package_env in tox_env.package_envs: + out_err = package_env.close_and_read_out_err() + if out_err is not None: # pragma: no branch + pkg_out_err_list.append(out_err) if not success or tox_env.conf["parallel_show_output"]: - if pkg_out_err is not None: # pragma: no branch # first show package build + for pkg_out_err in pkg_out_err_list: state.log_handler.write_out_err(pkg_out_err) # pragma: no cover if out_err is not None: # pragma: no branch # first show package build state.log_handler.write_out_err(out_err) diff --git a/src/tox/session/cmd/run/parallel.py b/src/tox/session/cmd/run/parallel.py index d68d84ba..a41c6d9d 100644 --- a/src/tox/session/cmd/run/parallel.py +++ b/src/tox/session/cmd/run/parallel.py @@ -6,7 +6,7 @@ from argparse import ArgumentParser, ArgumentTypeError from typing import Optional from tox.config.cli.parser import ToxParser -from tox.plugin.impl import impl +from tox.plugin import impl from tox.session.common import env_list_flag from tox.session.state import State from tox.util.cpu import auto_detect_cpus diff --git a/src/tox/session/cmd/run/sequential.py b/src/tox/session/cmd/run/sequential.py index ee935e56..625a4da1 100644 --- a/src/tox/session/cmd/run/sequential.py +++ b/src/tox/session/cmd/run/sequential.py @@ -2,7 +2,7 @@ Run tox environments in sequential order. """ from tox.config.cli.parser import ToxParser -from tox.plugin.impl import impl +from tox.plugin import impl from tox.session.common import env_list_flag from tox.session.state import State diff --git a/src/tox/session/cmd/run/single.py b/src/tox/session/cmd/run/single.py index ba65d702..7f6760cd 100644 --- a/src/tox/session/cmd/run/single.py +++ b/src/tox/session/cmd/run/single.py @@ -39,7 +39,7 @@ def _evaluate(tox_env: RunToxEnv, recreate: bool, no_test: bool) -> Tuple[bool, outcomes: List[Outcome] = [] try: try: - tox_env.ensure_setup(recreate=recreate) + tox_env.setup(recreate=recreate) code, outcomes = run_commands(tox_env, no_test) except Skip as exception: LOGGER.warning("skipped because %s", exception) diff --git a/src/tox/session/cmd/show_config.py b/src/tox/session/cmd/show_config.py index 934b5038..3bb437c1 100644 --- a/src/tox/session/cmd/show_config.py +++ b/src/tox/session/cmd/show_config.py @@ -10,7 +10,7 @@ from colorama import Fore from tox.config.cli.parser import ToxParser from tox.config.loader.stringify import stringify from tox.config.sets import ConfigSet -from tox.plugin.impl import impl +from tox.plugin import impl from tox.session.common import env_list_flag from tox.session.state import State from tox.tox_env.api import ToxEnv @@ -56,7 +56,7 @@ def show_config(state: State) -> int: run_env = state.tox_env(name) # get again to get the temporary state if run_env.conf.name in selected: _print_env(run_env) - for pkg_env in run_env.package_envs(): + for pkg_env in run_env.package_envs: if pkg_env.conf.name in done_pkg_envs: continue done_pkg_envs.add(pkg_env.conf.name) diff --git a/src/tox/session/cmd/version_flag.py b/src/tox/session/cmd/version_flag.py index 8086a4fc..754cf26e 100644 --- a/src/tox/session/cmd/version_flag.py +++ b/src/tox/session/cmd/version_flag.py @@ -4,7 +4,7 @@ Display the version information about tox. from pathlib import Path from tox.config.cli.parser import ToxParser -from tox.plugin.impl import impl +from tox.plugin import impl @impl diff --git a/src/tox/session/state.py b/src/tox/session/state.py index 04907cac..9535d0ff 100644 --- a/src/tox/session/state.py +++ b/src/tox/session/state.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Dict, Iterator, Optional, Sequence, Set, Tuple from tox.config.main import Config from tox.config.sets import EnvConfigSet from tox.journal import Journal -from tox.plugin.impl import impl +from tox.plugin import impl from tox.report import HandledError, ToxHandler from tox.session.common import CliEnv from tox.tox_env.package import PackageToxEnv @@ -93,19 +93,10 @@ class State: self._build_package_env(env) def _build_package_env(self, env: RunToxEnv) -> None: - pkg_env_gen = env.create_package_env() - while True: - try: - name, packager = next(pkg_env_gen) - except StopIteration: - return - else: - with self.log_handler.with_context(name): - package_tox_env = self._get_package_env(packager, name) - try: - pkg_env_gen.send(package_tox_env) - except StopIteration: - return + for tag, name, core_type in env.iter_package_env_types(): + with self.log_handler.with_context(name): + package_tox_env = self._get_package_env(core_type, name) + env.notify_of_package_env(tag, package_tox_env) def _get_package_env(self, packager: str, name: str) -> PackageToxEnv: if name in self._pkg_env: # if already created reuse diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py index 51e81d01..46709b23 100644 --- a/src/tox/tox_env/api.py +++ b/src/tox/tox_env/api.py @@ -10,7 +10,7 @@ from abc import ABC, abstractmethod from contextlib import contextmanager from io import BytesIO from pathlib import Path -from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Sequence, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union, cast from tox.config.set_env import SetEnv from tox.config.sets import CoreConfigSet, EnvConfigSet @@ -19,6 +19,7 @@ from tox.execute.request import ExecuteRequest from tox.journal import EnvJournal from tox.report import OutErr, ToxHandler from tox.tox_env.errors import Recreate, Skip +from tox.tox_env.installer import Installer from .info import Info @@ -29,50 +30,55 @@ LOGGER = logging.getLogger(__name__) class ToxEnv(ABC): + """A tox environment.""" + def __init__( self, conf: EnvConfigSet, core: CoreConfigSet, options: "Parsed", journal: EnvJournal, log_handler: ToxHandler ) -> None: - self.journal = journal - self.conf: EnvConfigSet = conf - self.core: CoreConfigSet = core - self.options = options - self._executor: Optional[Execute] = None - self.register_config() - self._cache = Info(self.conf["env_dir"]) - self._paths: List[Path] = [] + """Create a new tox environment. + + :param conf: the config set to use for this environment + :param core: the core config set + :param options: CLI options + :param journal: tox environment journal + :param log_handler: handler to the tox reporting system + """ + self.journal: EnvJournal = journal #: handler to the tox reporting system + self.conf: EnvConfigSet = conf #: the config set to use for this environment + self.core: CoreConfigSet = core #: the core tox config set + self.options: Parsed = options #: CLI options + self.log_handler: ToxHandler = log_handler #: handler to the tox reporting system + + #: encode the run state of various methods (setup/clean/etc) + self._run_state = {"setup": False, "clean": False, "teardown": False} + self._paths_private: List[Path] = [] #: a property holding the PATH environment variables self._hidden_outcomes: Optional[List[Outcome]] = [] - self.log_handler = log_handler self._env_vars: Optional[Dict[str, str]] = None self._suspended_out_err: Optional[OutErr] = None - self.setup_done = False - self.clean_done = False self._execute_statuses: Dict[int, ExecuteStatus] = {} self._interrupted = False - self.skipped = False - def interrupt(self) -> None: - logging.warning("interrupt tox environment: %s", self.conf.name) - self._interrupted = True - for status in list(self._execute_statuses.values()): - status.interrupt() + self.register_config() + self.cache = Info(self.env_dir) - def __repr__(self) -> str: - return f"{self.__class__.__name__}(name={self.conf['env_name']})" + @staticmethod + @abstractmethod + def id() -> str: + raise NotImplementedError @property + @abstractmethod def executor(self) -> Execute: - if self._executor is None: - self._executor = self.build_executor() - return self._executor + raise NotImplementedError @property - def has_display_suspended(self) -> bool: - return self._suspended_out_err is not None - @abstractmethod - def build_executor(self) -> Execute: + def installer(self) -> Installer[Any]: raise NotImplementedError + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.conf['env_name']})" + def register_config(self) -> None: self.conf.add_constant( keys=["env_name", "envname"], @@ -82,16 +88,16 @@ class ToxEnv(ABC): self.conf.add_config( keys=["env_dir", "envdir"], of_type=Path, - default=lambda conf, name: cast(Path, conf.core["work_dir"]) / cast(str, self.conf["env_name"]), + default=lambda conf, name: cast(Path, conf.core["work_dir"]) / self.name, desc="directory assigned to the tox environment", ) self.conf.add_config( keys=["env_tmp_dir", "envtmpdir"], of_type=Path, - default=lambda conf, name: cast(Path, conf.core["work_dir"]) / cast(str, self.conf["env_name"]) / "tmp", + default=lambda conf, name: cast(Path, conf.core["work_dir"]) / self.name / "tmp", desc="a folder that is always reset at the start of the run", ) - self.conf.default_set_env_loader = self.default_set_env + self.conf.default_set_env_loader = self._default_set_env self.conf.add_config( keys=["platform"], of_type=str, @@ -100,7 +106,7 @@ class ToxEnv(ABC): ) def pass_env_post_process(values: List[str]) -> List[str]: - values.extend(self.default_pass_env()) + values.extend(self._default_pass_env()) return sorted({k: None for k in values}.keys()) self.conf.add_config( @@ -123,16 +129,30 @@ class ToxEnv(ABC): desc="always recreate virtual environment if this option is true, otherwise leave it up to tox", ) - def default_set_env(self) -> Dict[str, str]: + @property + def env_dir(self) -> Path: + """:return: the tox environments environment folder""" + return cast(Path, self.conf["env_dir"]) + + @property + def env_tmp_dir(self) -> Path: + """:return: the tox environments temp folder""" + return cast(Path, self.conf["env_tmp_dir"]) + + @property + def name(self) -> str: + return cast(str, self.conf["env_name"]) + + def _default_set_env(self) -> Dict[str, str]: return {} - def default_pass_env(self) -> List[str]: + def _default_pass_env(self) -> List[str]: env = [ "https_proxy", # HTTP proxy configuration "http_proxy", # HTTP proxy configuration "no_proxy", # HTTP proxy configuration - "LANG", # localication - "LANGUAGE", # localication + "LANG", # localization + "LANGUAGE", # localization "CURL_CA_BUNDLE", # curl certificates "SSL_CERT_FILE", # https certificates "LD_LIBRARY_PATH", # location of libs @@ -153,38 +173,42 @@ class ToxEnv(ABC): env.append("TMPDIR") # temporary file location return env - def setup(self) -> None: + def setup(self, recreate: bool = False) -> None: """ - 1. env dir exists - 2. contains a runner with the same type. + Setup the tox environment. + + :param recreate: flag to force recreation of the environment from scratch """ - conf = {"name": self.conf.name, "type": type(self).__name__} - try: - with self._cache.compare(conf, ToxEnv.__name__) as (eq, old): - if eq is False and old is not None: # recreate if already created and not equals - logging.warning(f"env type changed from {old} to {conf}, will recreate") - raise Recreate # recreate if already exists and type changed - self.setup_done = True - finally: - self._handle_env_tmp_dir() - - def ensure_setup(self, recreate: bool = False) -> None: - self.check_platform() - if self.setup_done is True: - return - if self.conf["recreate"]: - recreate = True - if recreate: - self.clean() - try: - self.setup() - except Recreate: - if not recreate: # pragma: no cover - self.clean(force=True) - self.setup() - self.setup_has_been_done() - - def check_platform(self) -> None: + if self._run_state["setup"] is False: # pragma: no branch + self._platform_check() + recreate = recreate or cast(bool, self.conf["recreate"]) + if recreate: + self._clean() + try: + self._setup_env() + self._setup_with_env() + except Recreate as exception: # once we might try over + if not recreate: # pragma: no cover + logging.warning(f"recreate env because {exception.args[0]}") + self._clean(force=True) + self._setup_env() + self._setup_with_env() + else: + self._done_with_setup() + finally: + self._run_state.update({"setup": True, "clean": False}) + + def teardown(self) -> None: + if not self._run_state["teardown"]: + try: + self._teardown() + finally: + self._run_state.update({"teardown": True}) + + def _teardown(self) -> None: + pass + + def _platform_check(self) -> None: """skip env when platform does not match""" platform_str: str = self.conf["platform"] if platform_str: @@ -192,29 +216,48 @@ class ToxEnv(ABC): if match is None: raise Skip(f"platform {self.runs_on_platform} does not match {platform_str}") - def setup_has_been_done(self) -> None: + @property + @abstractmethod + def runs_on_platform(self) -> str: + raise NotImplementedError + + def _setup_env(self) -> None: + """ + 1. env dir exists + 2. contains a runner with the same type. + """ + conf = {"name": self.conf.name, "type": type(self).__name__} + with self.cache.compare(conf, ToxEnv.__name__) as (eq, old): + if eq is False and old is not None: # recreate if already created and not equals + raise Recreate(f"env type changed from {old} to {conf}") + self._handle_env_tmp_dir() + + def _setup_with_env(self) -> None: + pass + + def _done_with_setup(self) -> None: """called when setup is done""" def _handle_env_tmp_dir(self) -> None: """Ensure exists and empty""" - env_tmp_dir: Path = self.conf["env_tmp_dir"] + env_tmp_dir = self.env_tmp_dir if env_tmp_dir.exists() and next(env_tmp_dir.iterdir(), None) is not None: LOGGER.debug("clear env temp folder %s", env_tmp_dir) shutil.rmtree(env_tmp_dir, ignore_errors=True) env_tmp_dir.mkdir(parents=True, exist_ok=True) - def clean(self, force: bool = False) -> None: # noqa: U100 - if self.clean_done: # pragma: no branch + def _clean(self, force: bool = False) -> None: # noqa: U100 + if self._run_state["clean"]: # pragma: no branch return # pragma: no cover - env_dir: Path = self.conf["env_dir"] + env_dir = self.env_dir if env_dir.exists(): LOGGER.warning("remove tox env folder %s", env_dir) shutil.rmtree(env_dir) - self._cache.reset() - self.setup_done, self.clean_done = False, True + self.cache.reset() + self._run_state.update({"setup": False, "clean": True}) @property - def environment_variables(self) -> Dict[str, str]: + def _environment_variables(self) -> Dict[str, str]: if self._env_vars is not None: return self._env_vars result: Dict[str, str] = {} @@ -232,27 +275,29 @@ class ToxEnv(ABC): # load/paths_env might trigger a load of the environment variables, set result here, returns current state self._env_vars = result # set PATH here in case setting and environment variable requires access to the environment variable PATH - result["PATH"] = os.environ.get("PATH", "") + result["PATH"] = self._make_path() for key in set_env: result[key] = set_env.load(key) - result["PATH"] = self.paths_env() return result @property - def paths(self) -> List[Path]: - return self._paths - - @paths.setter - def paths(self, value: List[Path]) -> None: - self._paths = value - if self._env_vars is not None: # pragma: no branch # also update the environment variables with the new value - self._env_vars["PATH"] = self.paths_env() - - def paths_env(self) -> str: - # remove duplicates and prepend the tox env paths - values = dict.fromkeys(str(i) for i in self.paths) + def _paths(self) -> List[Path]: + return self._paths_private + + @_paths.setter + def _paths(self, value: List[Path]) -> None: + self._paths_private = value + # also update the environment variable with the new value + if self._env_vars is not None: # pragma: no branch + # remove duplicates and prepend the tox env paths + result = self._make_path() + self._env_vars["PATH"] = result + + def _make_path(self) -> str: + values = dict.fromkeys(str(i) for i in self._paths) values.update(dict.fromkeys(os.environ.get("PATH", "").split(os.pathsep))) - return os.pathsep.join(values) + result = os.pathsep.join(values) + return result def execute( self, @@ -270,6 +315,13 @@ class ToxEnv(ABC): raise RuntimeError # pragma: no cover return status.outcome + def interrupt(self) -> None: + """Interrupt the execution of a tox environment.""" + logging.warning("interrupt tox environment: %s", self.conf.name) + self._interrupted = True + for status in list(self._execute_statuses.values()): + status.interrupt() + @contextmanager def execute_async( self, @@ -286,7 +338,7 @@ class ToxEnv(ABC): cwd = self.core["tox_root"] if show is None: show = self.options.verbosity > 3 - request = ExecuteRequest(cmd, cwd, self.environment_variables, stdin, run_id) + request = ExecuteRequest(cmd, cwd, self._environment_variables, stdin, run_id) if _CWD == request.cwd: repr_cwd = "" else: @@ -322,14 +374,9 @@ class ToxEnv(ABC): ) as execute_status: yield execute_status - @staticmethod - @abstractmethod - def id() -> str: - raise NotImplementedError - @contextmanager def display_context(self, suspend: bool) -> Iterator[None]: - with self.log_context(): + with self._log_context(): with self.log_handler.suspend_out_err(suspend, self._suspended_out_err) as out_err: if suspend: # only set if suspended self._suspended_out_err = out_err @@ -345,17 +392,13 @@ class ToxEnv(ABC): return out_b, err_b @contextmanager - def log_context(self) -> Iterator[None]: + def _log_context(self) -> Iterator[None]: with self.log_handler.with_context(self.conf.name): yield - def teardown(self) -> None: - """Any cleanup operation on environment done""" - @property - @abstractmethod - def runs_on_platform(self) -> str: - raise NotImplementedError + def _has_display_suspended(self) -> bool: + return self._suspended_out_err is not None _CWD = Path.cwd() diff --git a/src/tox/tox_env/installer.py b/src/tox/tox_env/installer.py new file mode 100644 index 00000000..6ea40193 --- /dev/null +++ b/src/tox/tox_env/installer.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +if TYPE_CHECKING: + from tox.tox_env.api import ToxEnv # noqa + +T = TypeVar("T", bound="ToxEnv") + + +class Installer(ABC, Generic[T]): + def __init__(self, tox_env: T) -> None: + self._env = tox_env + self._register_config() + + @abstractmethod + def _register_config(self) -> None: + """Register configurations for the installer""" + raise NotImplementedError + + @abstractmethod + def installed(self) -> Any: + """:returns: a list of packages installed (JSON dump-able)""" + raise NotImplementedError + + @abstractmethod + def install(self, arguments: Any, section: str, of_type: str) -> None: # noqa: U100 + raise NotImplementedError diff --git a/src/tox/tox_env/package.py b/src/tox/tox_env/package.py index b025de11..3d1b9ea0 100644 --- a/src/tox/tox_env/package.py +++ b/src/tox/tox_env/package.py @@ -3,14 +3,12 @@ A tox environment that can build packages. """ from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Any, Generator, List, Tuple, cast - -from packaging.requirements import Requirement +from threading import Lock +from typing import TYPE_CHECKING, Any, Generator, List, Set, Tuple, cast from tox.config.sets import CoreConfigSet, EnvConfigSet from tox.journal import EnvJournal from tox.report import ToxHandler -from tox.util.threading import AtomicCounter from .api import ToxEnv @@ -18,13 +16,24 @@ if TYPE_CHECKING: from tox.config.cli.parser import Parsed +class Package: + """package""" + + +class PathPackage(Package): + def __init__(self, path: Path) -> None: + super().__init__() + self.path = path + + class PackageToxEnv(ToxEnv, ABC): def __init__( self, conf: EnvConfigSet, core: CoreConfigSet, options: "Parsed", journal: EnvJournal, log_handler: ToxHandler ) -> None: super().__init__(conf, core, options, journal, log_handler) self.recreate_package = options.no_recreate_pkg is False if options.recreate else False - self.ref_count = AtomicCounter() + self._envs: Set[str] = set() + self._lock = Lock() def register_config(self) -> None: super().register_config() @@ -47,16 +56,20 @@ class PackageToxEnv(ToxEnv, ABC): """allow creating sub-package envs""" @abstractmethod - def get_package_dependencies(self, for_env: EnvConfigSet) -> List[Requirement]: # noqa: U100 + def perform_packaging(self, for_env: EnvConfigSet) -> List[Package]: # noqa: U100 raise NotImplementedError - @abstractmethod - def perform_packaging(self, name: str) -> List[Path]: # noqa: U100 - raise NotImplementedError - - def clean(self, force: bool = False) -> None: + def _clean(self, force: bool = False) -> None: if force or self.recreate_package: # only recreate if user did not opt out - super().clean(force) + super()._clean(force) + + def notify_of_run_env(self, conf: EnvConfigSet) -> None: + with self._lock: + self._envs.add(conf.name) - def package_envs(self, name: str) -> Generator["PackageToxEnv", None, None]: # noqa: U100 - yield self + def teardown_env(self, conf: EnvConfigSet) -> None: + with self._lock: + self._envs.remove(conf.name) + has_envs = bool(self._envs) + if not has_envs: + self._teardown() diff --git a/src/tox/tox_env/python/api.py b/src/tox/tox_env/python/api.py index 81968be4..6bbc3cb0 100644 --- a/src/tox/tox_env/python/api.py +++ b/src/tox/tox_env/python/api.py @@ -1,13 +1,11 @@ """ Declare the abstract base class for tox environments that handle the Python language. """ -import logging import sys from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Union, cast +from typing import Any, Dict, List, NamedTuple, Optional, cast -from packaging.requirements import Requirement from packaging.tags import INTERPRETER_SHORT_NAMES from virtualenv.discovery.py_spec import PythonSpec @@ -46,30 +44,6 @@ class PythonInfo(NamedTuple): return self.implementation.lower() -class PythonDep: - def __init__(self, value: Union[Path, Requirement]) -> None: - self._value = value - - @property - def value(self) -> Union[Path, Requirement]: - return self._value - - def __str__(self) -> str: - return str(self._value) - - def __eq__(self, other: Any) -> bool: - return type(self) == type(other) and str(self) == str(other) - - def __ne__(self, other: Any) -> bool: - return not (self == other) - - def __repr__(self) -> str: - return f"{self.__class__.__name__}(value={self.value!r})" - - -PythonDeps = Sequence[PythonDep] - - class Python(ToxEnv, ABC): def __init__( self, conf: EnvConfigSet, core: CoreConfigSet, options: Parsed, journal: EnvJournal, log_handler: ToxHandler @@ -102,8 +76,8 @@ class Python(ToxEnv, ABC): value=lambda: self.env_python(), ) - def default_pass_env(self) -> List[str]: - env = super().default_pass_env() + def _default_pass_env(self) -> List[str]: + env = super()._default_pass_env() if sys.platform == "win32": # pragma: win32 cover env.extend( [ @@ -152,30 +126,42 @@ class Python(ToxEnv, ABC): """The binary folder within the tox environment""" raise NotImplementedError - def setup(self) -> None: + def _setup_env(self) -> None: """setup a virtual python environment""" + super()._setup_env() conf = self.python_cache() - with self._cache.compare(conf, Python.__name__) as (eq, old): - if eq is False: # if changed create + with self.cache.compare(conf, Python.__name__) as (eq, old): + if old is None: # does not exist -> create self.create_python_env() - self.paths = self.python_env_paths() # now that the environment exist we can add them to the path - super().setup() + elif eq is False: # pragma: no branch # exists but changed -> recreate + raise Recreate(self._diff_msg(conf, old)) + self._paths = self.prepend_env_var_path() # now that the environment exist we can add them to the path + + @staticmethod + def _diff_msg(conf: Dict[str, Any], old: Dict[str, Any]) -> str: + result: List[str] = [] + added = [f"{k}={v!r}" for k, v in conf.items() if k not in old] + if added: # pragma: no branch + result.append(f"added {' | '.join(added)}") + removed = [f"{k}={v!r}" for k, v in old.items() if k not in conf] + if removed: + result.append(f"removed {' | '.join(removed)}") + changed = [f"{k}={v!r}->{old[k]!r}" for k, v in conf.items() if k in old and v != old[k]] + if changed: + result.append(f"changed {' | '.join(changed)}") + return f'python {", ".join(result)}' @abstractmethod - def python_env_paths(self) -> List[Path]: + def prepend_env_var_path(self) -> List[Path]: raise NotImplementedError - def setup_has_been_done(self) -> None: + def _done_with_setup(self) -> None: """called when setup is done""" - super().setup_has_been_done() + super()._done_with_setup() if self.journal: - outcome = self.get_installed_packages() + outcome = self.installer.installed() self.journal["installed_packages"] = outcome - @abstractmethod - def get_installed_packages(self) -> List[str]: - raise NotImplementedError - def python_cache(self) -> Dict[str, Any]: return { "version_info": list(self.base_python.version_info), @@ -194,46 +180,30 @@ class Python(ToxEnv, ABC): raise Skip(f"could not find python interpreter with spec(s): {', '.join(base_pythons)}") raise NoInterpreter(base_pythons) if self.journal: - value = { - "executable": str(self._base_python.executable), - "implementation": self._base_python.implementation, - "version_info": tuple(self.base_python.version_info), - "version": self._base_python.version, - "is_64": self._base_python.is_64, - "sysplatform": self._base_python.platform, - "extra_version_info": None, - } + value = self._get_env_journal_python() self.journal["python"] = value return cast(PythonInfo, self._base_python) + def _get_env_journal_python(self) -> Dict[str, Any]: + assert self._base_python is not None + return { + "executable": str(self._base_python.executable), + "implementation": self._base_python.implementation, + "version_info": tuple(self.base_python.version_info), + "version": self._base_python.version, + "is_64": self._base_python.is_64, + "sysplatform": self._base_python.platform, + "extra_version_info": None, + } + @abstractmethod def _get_python(self, base_python: List[str]) -> Optional[PythonInfo]: # noqa: U100 raise NotImplementedError - def cached_install(self, deps: PythonDeps, section: str, of_type: str) -> bool: - conf_deps: List[str] = [str(i) for i in deps] - with self._cache.compare(conf_deps, section, of_type) as (eq, old): - if eq is True: - return True - if old is None: - old = [] - missing = [PythonDep(Requirement(i)) for i in (set(old) - set(conf_deps))] - if missing: # no way yet to know what to uninstall here (transitive dependencies?) - # bail out and force recreate - logging.warning(f"recreate env because dependencies removed: {', '.join(str(i) for i in missing)}") - raise Recreate - new_deps = [PythonDep(Requirement(i)) for i in conf_deps if i not in old] - self.install_python_packages(packages=new_deps, of_type=of_type) - return False - @abstractmethod def create_python_env(self) -> None: raise NotImplementedError - @abstractmethod - def install_python_packages(self, packages: PythonDeps, of_type: str, no_deps: bool = False) -> None: # noqa: U100 - raise NotImplementedError - class NoInterpreter(Fail): """could not find interpreter""" diff --git a/src/tox/tox_env/python/package.py b/src/tox/tox_env/python/package.py index cc5ecfa3..5964bcaf 100644 --- a/src/tox/tox_env/python/package.py +++ b/src/tox/tox_env/python/package.py @@ -2,23 +2,45 @@ A tox build environment that handles Python packages. """ from abc import ABC, abstractmethod -from typing import Tuple +from pathlib import Path +from typing import Any, Sequence, Tuple from packaging.requirements import Requirement -from ..package import PackageToxEnv -from .api import Python, PythonDep +from ..package import Package, PackageToxEnv, PathPackage +from .api import Python -class PythonPackage(Python, PackageToxEnv, ABC): +class PythonPackage(Package): + """python package""" + + +class PythonPathPackageWithDeps(PathPackage): + def __init__(self, path: Path, deps: Sequence[Any]) -> None: + super().__init__(path=path) + self.deps: Sequence[Package] = deps + + +class WheelPackage(PythonPathPackageWithDeps): + """wheel package""" + + +class SdistPackage(PythonPathPackageWithDeps): + """sdist package""" + + +class DevLegacyPackage(PythonPathPackageWithDeps): + """legacy dev package""" + + +class PythonPackageToxEnv(Python, PackageToxEnv, ABC): def register_config(self) -> None: super().register_config() - def setup(self) -> None: + def _setup_env(self) -> None: """setup the tox environment""" - super().setup() - requires = [PythonDep(i) for i in self.requires()] - self.cached_install(requires, PythonPackage.__name__, "requires") + super()._setup_env() + self.installer.install(self.requires(), PythonPackageToxEnv.__name__, "requires") @abstractmethod def requires(self) -> Tuple[Requirement, ...]: diff --git a/src/tox/tox_env/python/pip/__init__.py b/src/tox/tox_env/python/pip/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/src/tox/tox_env/python/pip/__init__.py diff --git a/src/tox/tox_env/python/pip/pip_install.py b/src/tox/tox_env/python/pip/pip_install.py new file mode 100644 index 00000000..b0e90781 --- /dev/null +++ b/src/tox/tox_env/python/pip/pip_install.py @@ -0,0 +1,211 @@ +import logging +from collections import defaultdict +from typing import Any, Dict, List, Optional, Sequence, Set, Union + +from packaging.requirements import Requirement + +from tox.config.cli.parser import DEFAULT_VERBOSITY +from tox.config.main import Config +from tox.config.types import Command +from tox.execute.request import StdinSource +from tox.tox_env.errors import Recreate +from tox.tox_env.installer import Installer +from tox.tox_env.package import PathPackage +from tox.tox_env.python.api import Python +from tox.tox_env.python.package import DevLegacyPackage, SdistPackage, WheelPackage +from tox.tox_env.python.pip.req_file import ( + ConstraintFile, + EditablePathReq, + Flags, + PathReq, + PythonDeps, + RequirementsFile, + UrlReq, +) + + +class Pip(Installer[Python]): + """Pip is a python installer that can install packages as defined by PEP-508 and PEP-517 """ + + def _register_config(self) -> None: + self._env.conf.add_config( + keys=["pip_pre"], + of_type=bool, + default=False, + desc="install the latest available pre-release (alpha/beta/rc) of dependencies without a specified version", + ) + self._env.conf.add_config( + keys=["install_command"], + of_type=Command, + default=self.default_install_command, + post_process=self.post_process_install_command, + desc="install the latest available pre-release (alpha/beta/rc) of dependencies without a specified version", + ) + self._env.conf.add_config( + keys=["list_dependencies_command"], + of_type=Command, + default=Command(["python", "-m", "pip", "freeze", "--all"]), + desc="install the latest available pre-release (alpha/beta/rc) of dependencies without a specified version", + ) + + def default_install_command(self, conf: Config, env_name: Optional[str]) -> Command: # noqa + isolated_flag = "-E" if self._env.base_python.version_info.major == 2 else "-I" + cmd = Command(["python", isolated_flag, "-m", "pip", "install", "{opts}", "{packages}"]) + return self.post_process_install_command(cmd) + + def post_process_install_command(self, cmd: Command) -> Command: + install_command = cmd.args + pip_pre: bool = self._env.conf["pip_pre"] + try: + opts_at = install_command.index("{opts}") + except ValueError: + if pip_pre: + install_command.append("--pre") + else: + if pip_pre: + install_command[opts_at] = "--pre" + else: + install_command.pop(opts_at) + return cmd + + def installed(self) -> List[str]: + cmd: Command = self._env.conf["list_dependencies_command"] + result = self._env.execute( + cmd=cmd.args, + stdin=StdinSource.OFF, + run_id="freeze", + show=self._env.options.verbosity > DEFAULT_VERBOSITY, + ) + result.assert_success() + return result.out.splitlines() + + def install(self, arguments: Any, section: str, of_type: str) -> None: + if isinstance(arguments, PythonDeps): + self._install_requirement_file(arguments, section, of_type) + elif isinstance(arguments, Sequence): + self._install_list_of_deps(arguments, section, of_type) + else: + logging.warning(f"pip cannot install {arguments!r}") + raise SystemExit(1) + + def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type: str) -> None: + result = arguments.validate_and_expand() + new_set = arguments.unroll() + # content we can have here in a nested fashion + # the the entire universe does not resolve anymore, therefore we only cache the first level + # root level -> Union[Flags, Requirement, PathReq, EditablePathReq, UrlReq, ConstraintFile, RequirementsFile] + # if the constraint file changes recreate + with self._env.cache.compare(new_set, section, of_type) as (eq, old): + if not eq: # pick all options and constraint files, do not pick other equal requirements + new_deps: List[str] = [] + found: Set[int] = set() + has_dep = False + for entry, as_cache in zip(result, new_set): + entry_as_str = str(entry) + found_pos = None + for at_pos, value in enumerate(old or []): + if (next(iter(value)) if isinstance(value, dict) else value) == entry_as_str: + found_pos = at_pos + break + if found_pos is not None: + found.add(found_pos) + if isinstance(entry, Flags): + if found_pos is None and old is not None: + raise Recreate(f"new flag {entry}") + new_deps.extend(entry.as_args()) + elif isinstance(entry, Requirement): + if found_pos is None: + has_dep = True + new_deps.append(str(entry)) + elif isinstance(entry, (PathReq, EditablePathReq, UrlReq)): + if found_pos is None: + has_dep = True + new_deps.extend(entry.as_args()) + elif isinstance(entry, ConstraintFile): + if found_pos is None and old is not None: + raise Recreate(f"new constraint file {entry}") + if old is not None and old[found_pos] != as_cache: + raise Recreate(f"constraint file {entry.rel_path} changed") + new_deps.extend(entry.as_args()) + elif isinstance(entry, RequirementsFile): + if found_pos is None: + has_dep = True + new_deps.extend(entry.as_args()) + elif old is not None and old[found_pos] != as_cache: + raise Recreate(f"requirements file {entry.rel_path} changed") + else: + # can only happen when we introduce new content and we don't handle it in any of the branches + logging.warning(f"pip cannot install {entry!r}") # pragma: no cover + raise SystemExit(1) # pragma: no cover + if len(found) != len(old or []): + missing = " ".join( + (next(iter(o)) if isinstance(o, dict) else o) for i, o in enumerate(old or []) if i not in found + ) + raise Recreate(f"dependencies removed: {missing}") + if new_deps: + if not has_dep: + logging.warning(f"no dependencies for tox env {self._env.name} within {of_type}") + raise SystemExit(1) + self._execute_installer(new_deps, of_type) + + def _install_list_of_deps( + self, + arguments: Sequence[Union[Requirement, WheelPackage, SdistPackage, DevLegacyPackage, PathPackage]], + section: str, + of_type: str, + ) -> None: + groups: Dict[str, List[str]] = defaultdict(list) + for arg in arguments: + if isinstance(arg, Requirement): + groups["req"].append(str(arg)) + elif isinstance(arg, (WheelPackage, SdistPackage)): + groups["req"].extend(str(i) for i in arg.deps) + groups["pkg"].append(str(arg.path)) + elif isinstance(arg, DevLegacyPackage): + groups["req"].extend(str(i) for i in arg.deps) + groups["dev_pkg"].append(str(arg.path)) + elif isinstance(arg, PathPackage): + groups["path_pkg"].append(str(arg.path)) + else: + logging.warning(f"pip cannot install {arg!r}") + raise SystemExit(1) + req_of_type = f"{of_type}_deps" if groups["pkg"] or groups["dev_pkg"] else of_type + for value in groups.values(): + value.sort() + with self._env.cache.compare(groups["req"], section, req_of_type) as (eq, old): + if not eq: + miss = sorted(set(old or []) - set(groups["req"])) + if miss: # no way yet to know what to uninstall here (transitive dependencies?) + raise Recreate(f"dependencies removed: {', '.join(str(i) for i in miss)}") # pragma: no branch + new_deps = sorted(set(groups["req"]) - set(old or [])) + if new_deps: + self._execute_installer(new_deps, req_of_type) + install_args = ["--force-reinstall", "--no-deps"] + if groups["pkg"]: + self._execute_installer(install_args + groups["pkg"], of_type) + if groups["dev_pkg"]: + for entry in groups["dev_pkg"]: + install_args.extend(("-e", str(entry))) + self._execute_installer(install_args, of_type) + if groups["path_pkg"]: + self._execute_installer(groups["path_pkg"], of_type) + + def _execute_installer(self, deps: Sequence[Any], of_type: str) -> None: + cmd = self.build_install_cmd(deps) + outcome = self._env.execute(cmd, stdin=StdinSource.OFF, run_id=f"install_{of_type}") + outcome.assert_success() + + def build_install_cmd(self, args: Sequence[str]) -> List[str]: + cmd: Command = self._env.conf["install_command"] + install_command = cmd.args + try: + opts_at = install_command.index("{packages}") + except ValueError: + opts_at = len(install_command) + result = install_command[:opts_at] + result.extend(args) + result.extend(install_command[opts_at + 1 :]) + return result + + +__all__ = ("Pip",) diff --git a/src/tox/tox_env/python/pip/req_file.py b/src/tox/tox_env/python/pip/req_file.py new file mode 100644 index 00000000..5f10cc9d --- /dev/null +++ b/src/tox/tox_env/python/pip/req_file.py @@ -0,0 +1,365 @@ +import logging +import os +import re +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union + +from packaging.requirements import InvalidRequirement, Requirement + +LOGGER = logging.getLogger(__name__) + + +VCS = ["ftp", "ssh", "git", "hg", "bzr", "sftp", "svn"] +VALID_SCHEMAS = ["http", "https", "file"] + VCS + + +def is_url(name: str) -> bool: + return get_url_scheme(name) in VALID_SCHEMAS + + +def get_url_scheme(url: str) -> Optional[str]: + return None if ":" not in url else url.split(":", 1)[0].lower() + + +NO_ARG = { + "--no-index", + "--prefer-binary", + "--require-hashes", + "--pre", +} +ONE_ARG = { + "-i", + "--index-url", + "--extra-index-url", + "-e", + "--editable", + "-c", + "--constraint", + "-r", + "--requirement", + "-f", + "--find-links", + "--trusted-host", + "--use-feature", + "--no-binary", + "--only-binary", +} +ONE_ARG_ESCAPE = { + "-c", + "--constraint", + "-r", + "--requirement", + "-f", + "--find-links", + "-e", + "--editable", +} + + +class PipRequirementEntry(ABC): + @abstractmethod + def as_args(self) -> Iterable[str]: + raise NotImplementedError + + @abstractmethod + def __eq__(self, other: Any) -> bool: # noqa: U100 + raise NotImplementedError + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + @abstractmethod + def __str__(self) -> str: + raise NotImplementedError + + +class Flags(PipRequirementEntry): + def __init__(self, *args: str) -> None: + self.args: Iterable[str] = args + + def as_args(self) -> Iterable[str]: + return self.args + + def __eq__(self, other: Any) -> bool: + return isinstance(other, Flags) and self.args == other.args + + def __str__(self) -> str: + return " ".join(self.args) + + +class RequirementWithFlags(Requirement, Flags): + def __init__(self, requirement_string: str, args: Sequence[str]) -> None: + Requirement.__init__(self, requirement_string) + Flags.__init__(self, *args) + + def as_args(self) -> Iterable[str]: + return (Requirement.__str__(self), *self.args) + + def __eq__(self, other: Any) -> bool: + return ( + isinstance(other, RequirementWithFlags) + and self.args == other.args + and Requirement.__str__(self) == Requirement.__str__(other) + ) + + def __str__(self) -> str: + return " ".join((Requirement.__str__(self), *self.args)) + + +class PathReq(PipRequirementEntry): + def __init__(self, path: Path) -> None: + self.path = path + + def as_args(self) -> Iterable[str]: + return (str(self.path),) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, self.__class__) and self.path == other.path + + def __str__(self) -> str: + return str(self.path) + + +class EditablePathReq(PathReq): + def as_args(self) -> Iterable[str]: + return "-e", str(self.path) + + def __str__(self) -> str: + return f"-e {self.path}" + + +class UrlReq(PipRequirementEntry): + def __init__(self, url: str) -> None: + self.url = url + + def as_args(self) -> Iterable[str]: + return (self.url,) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, UrlReq) and self.url == other.url + + def __str__(self) -> str: + return self.url + + +class PythonDeps: + """A sub-set form of the requirements files (support tox 3 syntax, and --hash is not valid on CLI)""" + + def __init__(self, raw: str, root: Optional[Path] = None): + self._root = Path().cwd() if root is None else root.resolve() + self._raw = raw + self._result: Optional[List[Any]] = None + + def validate_and_expand(self) -> List[Any]: + if self._result is None: + raw = self._normalize_raw() + result: List[Any] = [] + ini_dir = self.root + for at, line in enumerate(raw.splitlines(), start=1): + line = re.sub(r"(?<!\\)\s#.*", "", line).strip() + if not line or line.startswith("#"): + continue + if line.startswith("-"): + self._expand_flag(ini_dir, line, result) + else: + self._expand_non_flag(at, ini_dir, line, result) + self._result = result + return self._result + + def _normalize_raw(self) -> str: + # a line ending in an unescaped \ is treated as a line continuation and the newline following it is effectively + # ignored + raw = "".join(self._raw.replace("\r", "").split("\\\n")) + lines: List[str] = [] + for line in raw.splitlines(): + # for tox<4 supporting requirement/constraint files via -rreq.txt/-creq.txt + arg_match = next( + ( + arg + for arg in ONE_ARG + if line.startswith(arg) + and len(line) > len(arg) + and not (line[len(arg)].isspace() or line[len(arg)] == "=") + ), + None, + ) + if arg_match is not None: + line = f"{arg_match} {line[len(arg_match):]}" + # escape spaces + escape_match = next((e for e in ONE_ARG_ESCAPE if line.startswith(e) and line[len(e)].isspace()), None) + if escape_match is not None: + # escape not already escaped spaces + escaped = re.sub(r"(?<!\\)(\s)", r"\\\1", line[len(escape_match) + 1 :]) + line = f"{line[:len(escape_match)]} {escaped}" + lines.append(line) + adjusted = "\n".join(lines) + raw = f"{adjusted}\n" if raw.endswith("\\\n") else adjusted # preserve trailing newline if input has it + return raw + + def __str__(self) -> str: + return self._raw + + @property + def root(self) -> Path: + return self._root + + def _expand_non_flag(self, at: int, ini_dir: Path, line: str, result: List[Any]) -> None: # noqa + requirement, extra = self._load_requirement_with_extra(line) + try: + if not extra: + req = Requirement(requirement) + else: + req = RequirementWithFlags(requirement, extra) + except InvalidRequirement as exc: + if is_url(line) or any(line.startswith(f"{v}+") and is_url(line[len(v) + 1 :]) for v in VCS): + result.append(UrlReq(line)) + else: + path = ini_dir / line + try: + is_valid_file = path.exists() and (path.is_file() or path.is_dir()) + except OSError: # https://bugs.python.org/issue42855 # pragma: no cover + is_valid_file = False # pragma: no cover + if not is_valid_file: + raise ValueError(f"{at}: {line}") from exc + result.append(PathReq(path)) + else: + result.append(req) + + def _load_requirement_with_extra(self, line: str) -> Tuple[str, List[str]]: + return line, [] + + def _expand_flag(self, ini_dir: Path, line: str, result: List[Any]) -> None: + words = list(re.split(r"(?<!\\)(\s|=)", line, maxsplit=1)) + first = words[0] + if first in NO_ARG: + if len(words) != 1: # argument provided + raise ValueError(line) + result.append(Flags(first)) + elif first in ONE_ARG: + if len(words) != 3: # no argument provided + raise ValueError(line) + if len(re.split(r"(?<!\\)\s", words[2])) > 1: # too many arguments provided + raise ValueError(line) + if first in ("-r", "--requirement", "-c", "--constraint"): + raw_path = line[len(first) + 1 :].strip() + unescaped_path = re.sub(r"\\(\s)", r"\1", raw_path) + path = Path(unescaped_path) + if not path.is_absolute(): + path = ini_dir / path + if not path.exists(): + raise ValueError(f"requirement file path {str(path)!r} does not exist") + of_type = RequirementsFile if first in ("-r", "--requirement") else ConstraintFile + req_file = of_type(path, root=self.root) + req_file.validate_and_expand() + result.append(req_file) + elif first in ("-e", "--editable"): + result.append(EditablePathReq(Path(words[2]))) + elif first in [ + "-i", + "--index-url", + "--extra-index-url", + "-f", + "--find-links", + "--trusted-host", + "--use-feature", + "--no-binary", + "--only-binary", + ]: + result.append(Flags(first, words[2])) + else: + raise ValueError(first) + else: + raise ValueError(line) + + def unroll(self) -> List[Union[Dict[str, Any], str]]: + into: List[Union[Dict[str, Any], str]] = [] + for element in self.validate_and_expand(): + if isinstance(element, (RequirementsFile, ConstraintFile)): + res: Union[Dict[str, Any], str] = {str(element): element.unroll()} + elif isinstance(element, (Requirement, Flags, PathReq, EditablePathReq, UrlReq)): + res = str(element) + else: # pragma: no cover + raise ValueError(element) # pragma: no cover + into.append(res) + return into + + +class _BaseRequirementsFile(PythonDeps, PipRequirementEntry): + """ + Specification is defined within pip itself and documented under: + - https://pip.pypa.io/en/stable/reference/pip_install/#requirements-file-format + - https://github.com/pypa/pip/blob/master/src/pip/_internal/req/constructors.py#L291 + """ + + arg_flag: str = "" + + def __init__(self, path: Path, root: Path): + self.path = path + super().__init__(path.read_text(), root=root) + + def __str__(self) -> str: + return f"{self.arg_flag} {self.rel_path}" + + def as_args(self) -> Iterable[str]: + return self.arg_flag, str(self.rel_path) + + @property + def rel_path(self) -> Path: + try: + return self.path.relative_to(self.root) + except ValueError: + return self.path + + def __eq__(self, other: Any) -> bool: + return isinstance(other, self.__class__) and self.path == other.path + + def _normalize_raw(self) -> str: + # a line ending in an unescaped \ is treated as a line continuation and the newline following it is effectively + # ignored + raw = "".join(self._raw.replace("\r", "").split("\\\n")) + # Since version 10, pip supports the use of environment variables inside the requirements file. + # You can now store sensitive data (tokens, keys, etc.) in environment variables and only specify the variable + # name for your requirements, letting pip lookup the value at runtime. + # You have to use the POSIX format for variable names including brackets around the uppercase name as shown in + # this example: ${API_TOKEN}. pip will attempt to find the corresponding environment variable defined on the + # host system at runtime. + while True: + match = re.search(r"\$\{([A-Z_]+)\}", raw) + if match is None: + break + value = os.environ.get(match.groups()[0], "") + start, end = match.span() + raw = f"{raw[:start]}{value}{raw[end:]}" + return raw + + +_HASH = re.compile(r"\B--hash(=|\s+)sha(256:[a-z0-9]{64}|384:[a-z0-9]{96}|521:[a-z0-9]{128})\b") + + +class RequirementsFile(_BaseRequirementsFile): + arg_flag = "-r" + + def _load_requirement_with_extra(self, line: str) -> Tuple[str, List[str]]: + args = [f"--hash=sha{i[1]}" for i in _HASH.findall(line)] + value = _HASH.sub("", line).strip() + return value, args + + +class ConstraintFile(_BaseRequirementsFile): + arg_flag = "-c" + + +__all__ = ( + "Flags", + "RequirementWithFlags", + "PathReq", + "EditablePathReq", + "UrlReq", + "PythonDeps", + "RequirementsFile", + "ConstraintFile", + "ONE_ARG", + "ONE_ARG_ESCAPE", + "NO_ARG", +) diff --git a/src/tox/tox_env/python/req_file.py b/src/tox/tox_env/python/req_file.py deleted file mode 100644 index d19a943b..00000000 --- a/src/tox/tox_env/python/req_file.py +++ /dev/null @@ -1,209 +0,0 @@ -import logging -import os -import re -from contextlib import contextmanager -from pathlib import Path -from tempfile import mkstemp -from typing import Iterator, List, Optional - -from packaging.requirements import InvalidRequirement, Requirement - -LOGGER = logging.getLogger(__name__) - - -VCS = ["ftp", "ssh", "git", "hg", "bzr", "sftp", "svn"] -VALID_SCHEMAS = ["http", "https", "file"] + VCS - - -def is_url(name: str) -> bool: - return get_url_scheme(name) in VALID_SCHEMAS - - -def get_url_scheme(url: str) -> Optional[str]: - return None if ":" not in url else url.split(":", 1)[0].lower() - - -NO_ARG = { - "--no-index", - "--prefer-binary", - "--require-hashes", - "--pre", -} -ONE_ARG = { - "-i", - "--index-url", - "--extra-index-url", - "-e", - "--editable", - "-c", - "--constraint", - "-r", - "--requirement", - "-f", - "--find-links", - "--trusted-host", - "--use-feature", - "--no-binary", - "--only-binary", -} -ONE_ARG_ESCAPE = { - "-c", - "--constraint", - "-r", - "--requirement", - "-f", - "--find-links", - "-e", - "--editable", -} - - -class RequirementsFile: - """ - Specification is defined within pip itself and documented under: - - https://pip.pypa.io/en/stable/reference/pip_install/#requirements-file-format - - https://github.com/pypa/pip/blob/master/src/pip/_internal/req/constructors.py#L291 - """ - - def __init__(self, raw: str, within_tox_ini: bool = True, root: Optional[Path] = None) -> None: - self._root = Path().cwd() if root is None else root.resolve() - if within_tox_ini: # patch the content coming from tox.ini - lines: List[str] = [] - for line in raw.splitlines(): - # for tox<4 supporting requirement/constraint files via -rreq.txt/-creq.txt - arg_match = next( - ( - arg - for arg in ONE_ARG - if line.startswith(arg) - and len(line) > len(arg) - and not (line[len(arg)].isspace() or line[len(arg)] == "=") - ), - None, - ) - if arg_match is not None: - line = f"{arg_match} {line[len(arg_match):]}" - # escape spaces - escape_match = next((e for e in ONE_ARG_ESCAPE if line.startswith(e) and line[len(e)].isspace()), None) - if escape_match is not None: - # escape not already escaped spaces - escaped = re.sub(r"(?<!\\)(\s)", r"\\\1", line[len(escape_match) + 1 :]) - line = f"{line[:len(escape_match)]} {escaped}" - lines.append(line) - adjusted = "\n".join(lines) - raw = f"{adjusted}\n" if raw.endswith("\\\n") else adjusted # preserve trailing newline if input has it - self._raw = raw - - def __str__(self) -> str: - return self._raw - - @property - def root(self) -> Path: - return self._root - - def validate_and_expand(self) -> List[str]: - raw = self._normalize_raw() - result: List[str] = [] - ini_dir = self.root - for at, line in enumerate(raw.splitlines(), start=1): - line = re.sub(r"(?<!\\)\s#.*", "", line).strip() - if not line or line.startswith("#"): - continue - if line.startswith("-"): - self._expand_flag(ini_dir, line, result) - else: - self._expand_non_flag(at, ini_dir, line, result) - return result - - def _expand_non_flag(self, at: int, ini_dir: Path, line: str, result: List[str]) -> None: # noqa - try: - at = line.index(" --hash") - requirement, hash_part = line[:at], re.sub(r"\s+", " ", line[at + 1 :]) - except ValueError: - requirement, hash_part = line, "" - try: - req = Requirement(requirement) - except InvalidRequirement as exc: - if is_url(line) or any(line.startswith(f"{v}+") and is_url(line[len(v) + 1 :]) for v in VCS): - result.append(line) - else: - path = ini_dir / line - try: - is_valid_file = path.exists() and (path.is_file() or path.is_dir()) - except OSError: # https://bugs.python.org/issue42855 # pragma: no cover - is_valid_file = False # pragma: no cover - if not is_valid_file: - raise ValueError(f"{at}: {line}") from exc - result.append(str(path)) - else: - entry = str(req) - if hash_part: - entry = f"{entry} {hash_part}" - result.append(entry) - - def _expand_flag(self, ini_dir: Path, line: str, result: List[str]) -> None: - words = list(re.split(r"(?<!\\)(\s|=)", line, maxsplit=1)) - first = words[0] - if first in NO_ARG: - if len(words) != 1: # argument provided - raise ValueError(line) - result.append(first) - elif first in ONE_ARG: - if len(words) != 3: # no argument provided - raise ValueError(line) - if len(re.split(r"(?<!\\)\s", words[2])) > 1: # too many arguments provided - raise ValueError(line) - if first in ("-r", "--requirement", "-c", "--constraint"): - raw_path = line[len(first) + 1 :].strip() - unescaped_path = re.sub(r"\\(\s)", r"\1", raw_path) - path = Path(unescaped_path) - if not path.is_absolute(): - path = ini_dir / path - if not path.exists(): - raise ValueError(f"requirement file path {str(path)!r} does not exist") - req_file = RequirementsFile(path.read_text(), within_tox_ini=False, root=self.root) - result.extend(req_file.validate_and_expand()) - else: - result.append(f"{first} {words[2]}") - else: - raise ValueError(line) - - def _normalize_raw(self) -> str: - # a line ending in an unescaped \ is treated as a line continuation and the newline following it is effectively - # ignored - raw = "".join(self._raw.replace("\r", "").split("\\\n")) - # Since version 10, pip supports the use of environment variables inside the requirements file. - # You can now store sensitive data (tokens, keys, etc.) in environment variables and only specify the variable - # name for your requirements, letting pip lookup the value at runtime. - # You have to use the POSIX format for variable names including brackets around the uppercase name as shown in - # this example: ${API_TOKEN}. pip will attempt to find the corresponding environment variable defined on the - # host system at runtime. - while True: - match = re.search(r"\$\{([A-Z_]+)\}", raw) - if match is None: - break - value = os.environ.get(match.groups()[0], "") - start, end = match.span() - raw = f"{raw[:start]}{value}{raw[end:]}" - return raw - - @contextmanager - def with_file(self) -> Iterator[Path]: - file_no, path = mkstemp(dir=self.root, prefix="requirements-", suffix=".txt") - try: - try: - with open(path, "wt") as f: - f.write(self._raw) - finally: - os.close(file_no) - yield Path(path) - finally: - os.unlink(path) - - -__all__ = ( - "RequirementsFile", - "ONE_ARG", - "ONE_ARG_ESCAPE", - "NO_ARG", -) diff --git a/src/tox/tox_env/python/runner.py b/src/tox/tox_env/python/runner.py index 257e4761..8a57467a 100644 --- a/src/tox/tox_env/python/runner.py +++ b/src/tox/tox_env/python/runner.py @@ -1,21 +1,22 @@ """ A tox run environment that handles the Python language. """ -import logging -from abc import ABC, abstractmethod +from abc import ABC from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Set, cast +from typing import Iterator, List, Optional, Set, Tuple from tox.config.cli.parser import Parsed +from tox.config.main import Config from tox.config.sets import CoreConfigSet, EnvConfigSet from tox.journal import EnvJournal -from tox.report import ToxHandler -from tox.tox_env.errors import Recreate, Skip -from tox.tox_env.package import PackageToxEnv +from tox.report import HandledError, ToxHandler +from tox.tox_env.errors import Skip +from tox.tox_env.package import Package, PathPackage +from tox.tox_env.python.package import PythonPackageToxEnv +from tox.tox_env.python.pip.req_file import PythonDeps from ..runner import RunToxEnv -from .api import Python, PythonDep -from .req_file import RequirementsFile +from .api import Python class PythonRun(Python, RunToxEnv, ABC): @@ -23,16 +24,15 @@ class PythonRun(Python, RunToxEnv, ABC): self, conf: EnvConfigSet, core: CoreConfigSet, options: Parsed, journal: EnvJournal, log_handler: ToxHandler ): super().__init__(conf, core, options, journal, log_handler) - self._packages: List[PythonDep] = [] def register_config(self) -> None: super().register_config() deps_kwargs = {"root": self.core["toxinidir"]} self.conf.add_config( keys="deps", - of_type=RequirementsFile, + of_type=PythonDeps, kwargs=deps_kwargs, - default=RequirementsFile("", **deps_kwargs), + default=PythonDeps("", **deps_kwargs), desc="Name of the python dependencies as specified by PEP-440", ) self.core.add_config( @@ -42,55 +42,97 @@ class PythonRun(Python, RunToxEnv, ABC): desc="skip running missing interpreters", ) - def before_package_install(self) -> None: - super().before_package_install() - # install deps - requirements_file: RequirementsFile = self.conf["deps"] - requirement_file_content = requirements_file.validate_and_expand() - requirement_file_content.sort() # stable order dependencies - with self._cache.compare(requirement_file_content, PythonRun.__name__, "deps") as (eq, old): - if not eq: - # if new env, or additions only a simple install will do - missing: Set[str] = set() if old is None else set(old) - set(requirement_file_content) - if not missing: - if requirement_file_content: - self.install_deps(requirement_file_content) - else: # otherwise, no idea how to compute the diff, instead just start from scratch - logging.warning(f"recreate env because dependencies removed: {', '.join(str(i) for i in missing)}") - raise Recreate - - def install_package(self) -> List[Path]: - package_env = cast(PackageToxEnv, self.package_env) - explicit_install_package: Optional[Path] = getattr(self.options, "install_pkg", None) - if explicit_install_package is None: - # 1. install package dependencies - with package_env.display_context(suspend=self.has_display_suspended): - try: - package_deps = package_env.get_package_dependencies(self.conf) - except Skip as exception: - raise Skip(f"{exception.args[0]} for package environment {package_env.conf['env_name']}") - self.cached_install([PythonDep(p) for p in package_deps], PythonRun.__name__, "package_deps") - - # 2. install the package - with package_env.display_context(suspend=self.has_display_suspended): - self._packages = [PythonDep(p) for p in package_env.perform_packaging(self.conf.name)] + def iter_package_env_types(self) -> Iterator[Tuple[str, str, str]]: + yield from super().iter_package_env_types() + if self.pkg_type == "wheel": + wheel_build_env: str = self.conf["wheel_build_env"] + if wheel_build_env not in self._package_envs: # pragma: no branch + package_tox_env_type = self.conf["package_tox_env_type"] + yield "wheel", wheel_build_env, package_tox_env_type + + @property + def _package_types(self) -> Tuple[str, ...]: + return "wheel", "sdist", "dev-legacy", "skip" + + def _register_package_conf(self) -> bool: + desc = f"package installation mode - {' | '.join(i for i in self._package_types)} " + if not super()._register_package_conf(): + self.conf.add_constant(["package"], desc, "skip") + return False + self.conf.add_config(keys="usedevelop", desc="use develop mode", default=False, of_type=bool) + develop_mode = self.conf["usedevelop"] or getattr(self.options, "develop", False) + if develop_mode: + self.conf.add_constant(["package"], desc, "dev-legacy") else: - # ideally here we should parse the package dependencies, but that would break tox 3 behaviour - # and might not be trivial (e.g. in case of sdist), for now keep legacy functionality - self._packages = [PythonDep(explicit_install_package)] - self.install_python_packages( - self._packages, "package", **self.install_package_args() # type: ignore[no-untyped-call] - ) - return [i.value for i in self._packages if isinstance(i.value, Path)] + self.conf.add_config(keys="package", of_type=str, default="sdist", desc=desc) + pkg_type = self.pkg_type + + if pkg_type == "skip": + return False + + if pkg_type == "wheel": - @abstractmethod - def install_package_args(self) -> Dict[str, Any]: - raise NotImplementedError + def default_wheel_tag(conf: "Config", env_name: Optional[str]) -> str: + # https://www.python.org/dev/peps/pep-0427/#file-name-convention + # when building wheels we need to ensure that the built package is compatible with the target env + # compatibility is documented within https://www.python.org/dev/peps/pep-0427/#file-name-convention + # a wheel tag example: {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl + # python only code are often compatible at major level (unless universal wheel in which case both 2/3) + # c-extension codes are trickier, but as of today both poetry/setuptools uses pypa/wheels logic + # https://github.com/pypa/wheel/blob/master/src/wheel/bdist_wheel.py#L234-L280 + default_package_env = self._package_envs["default"] + self_py = self.base_python + if self_py is not None and isinstance(default_package_env, PythonPackageToxEnv): + default_pkg_py = default_package_env.base_python + if ( + default_pkg_py.version_no_dot == self_py.version_no_dot + and default_pkg_py.impl_lower == self_py.impl_lower + ): + return default_package_env.conf.name + if self_py is None: + raise ValueError(f"could not resolve base python for {self.conf.name}") + return f"{default_package_env.conf.name}-{self_py.impl_lower}{self_py.version_no_dot}" - @abstractmethod - def install_deps(self, args: Sequence[str]) -> None: # noqa: U100 - raise NotImplementedError + self.conf.add_config( + keys=["wheel_build_env"], + of_type=str, + default=default_wheel_tag, + desc="wheel tag to use for building applications", + ) + self.conf.add_config( + keys=["extras"], + of_type=Set[str], + default=set(), + desc="extras to install of the target package", + ) + return True @property - def packages(self) -> List[str]: - return [str(d.value) for d in self._packages] + def pkg_type(self) -> str: + pkg_type: str = self.conf["package"] + if pkg_type not in self._package_types: + values = ", ".join(i for i in self._package_types) + raise HandledError(f"invalid package config type {pkg_type} requested, must be one of {values}") + return pkg_type + + def _setup_env(self) -> None: + super()._setup_env() + # install deps + requirements_file: PythonDeps = self.conf["deps"] + self.installer.install(requirements_file, PythonRun.__name__, "deps") + + def _build_packages(self) -> List[Package]: + explicit_install_package: Optional[Path] = getattr(self.options, "install_pkg", None) + if explicit_install_package is not None: + return [PathPackage(explicit_install_package)] + + package_env = self._package_envs[self._get_package_env()] + with package_env.display_context(self._has_display_suspended): + try: + packages = package_env.perform_packaging(self.conf) + except Skip as exception: + raise Skip(f"{exception.args[0]} for package environment {package_env.conf['env_name']}") + return packages + + def _get_package_env(self) -> str: + return "wheel" if self.pkg_type == "wheel" else "default" diff --git a/src/tox/tox_env/python/virtual_env/api.py b/src/tox/tox_env/python/virtual_env/api.py index bdf55a55..4f3c06f7 100644 --- a/src/tox/tox_env/python/virtual_env/api.py +++ b/src/tox/tox_env/python/virtual_env/api.py @@ -4,25 +4,23 @@ Declare the abstract base class for tox environments that handle the Python lang import sys from abc import ABC from pathlib import Path -from typing import Dict, List, Optional, Sequence, cast +from typing import Any, Dict, List, Optional, cast from virtualenv import __version__ as virtualenv_version from virtualenv import session_via_cli from virtualenv.create.creator import Creator from virtualenv.run.session import Session -from tox.config.cli.parser import DEFAULT_VERBOSITY, Parsed +from tox.config.cli.parser import Parsed from tox.config.loader.str_convert import StrConvert -from tox.config.main import Config from tox.config.sets import CoreConfigSet, EnvConfigSet -from tox.config.types import Command -from tox.execute.api import Execute, Outcome, StdinSource +from tox.execute.api import Execute from tox.execute.local_sub_process import LocalSubProcessExecutor from tox.journal import EnvJournal from tox.report import ToxHandler -from tox.tox_env.errors import Recreate +from tox.tox_env.python.pip.pip_install import Pip -from ..api import Python, PythonDeps, PythonInfo +from ..api import Python, PythonInfo class VirtualEnv(Python, ABC): @@ -32,6 +30,8 @@ class VirtualEnv(Python, ABC): self, conf: EnvConfigSet, core: CoreConfigSet, options: Parsed, journal: EnvJournal, log_handler: ToxHandler ) -> None: self._virtualenv_session: Optional[Session] = None + self._executor: Optional[Execute] = None + self._installer: Optional[Pip] = None super().__init__(conf, core, options, journal, log_handler) def register_config(self) -> None: @@ -40,7 +40,7 @@ class VirtualEnv(Python, ABC): keys=["system_site_packages", "sitepackages"], of_type=bool, default=lambda conf, name: StrConvert().to_bool( - self.environment_variables.get("VIRTUALENV_SYSTEM_SITE_PACKAGES", "False") + self._environment_variables.get("VIRTUALENV_SYSTEM_SITE_PACKAGES", "False") ), desc="create virtual environments that also have access to globally installed packages.", ) @@ -48,8 +48,8 @@ class VirtualEnv(Python, ABC): keys=["always_copy", "alwayscopy"], of_type=bool, default=lambda conf, name: StrConvert().to_bool( - self.environment_variables.get( - "VIRTUALENV_COPIES", self.environment_variables.get("VIRTUALENV_ALWAYS_COPY", "False") + self._environment_variables.get( + "VIRTUALENV_COPIES", self._environment_variables.get("VIRTUALENV_ALWAYS_COPY", "False") ) ), desc="force virtualenv to always copy rather than symlink", @@ -58,86 +58,54 @@ class VirtualEnv(Python, ABC): keys=["download"], of_type=bool, default=lambda conf, name: StrConvert().to_bool( - self.environment_variables.get("VIRTUALENV_DOWNLOAD", "False") + self._environment_variables.get("VIRTUALENV_DOWNLOAD", "False") ), desc="true if you want virtualenv to upgrade pip/wheel/setuptools to the latest version", ) - self.conf.add_config( - keys=["pip_pre"], - of_type=bool, - default=False, - desc="install the latest available pre-release (alpha/beta/rc) of dependencies without a specified version", - ) - self.conf.add_config( - keys=["install_command"], - of_type=Command, - default=self.default_install_command, - post_process=self.post_process_install_command, - desc="install the latest available pre-release (alpha/beta/rc) of dependencies without a specified version", - ) - self.conf.add_config( - keys=["list_dependencies_command"], - of_type=Command, - default=Command(["python", "-m", "pip", "freeze", "--all"]), - desc="install the latest available pre-release (alpha/beta/rc) of dependencies without a specified version", - ) - def post_process_install_command(self, cmd: Command) -> Command: - install_command = cmd.args - pip_pre: bool = self.conf["pip_pre"] - try: - opts_at = install_command.index("{opts}") - except ValueError: - if pip_pre: - install_command.append("--pre") - else: - if pip_pre: - install_command[opts_at] = "--pre" - else: - install_command.pop(opts_at) - return cmd - - def default_install_command(self, conf: Config, env_name: Optional[str]) -> Command: # noqa - isolated_flag = "-E" if self.base_python.version_info.major == 2 else "-I" - cmd = Command(["python", isolated_flag, "-m", "pip", "install", "{opts}", "{packages}"]) - return self.post_process_install_command(cmd) - - def setup(self) -> None: - with self._cache.compare({"version": virtualenv_version}, VirtualEnv.__name__) as (eq, old): - if eq is False and old is not None: # if changed create - raise Recreate - super().setup() - - def default_pass_env(self) -> List[str]: - env = super().default_pass_env() + @property + def executor(self) -> Execute: + if self._executor is None: + self._executor = LocalSubProcessExecutor(self.options.is_colored) + return self._executor + + @property + def installer(self) -> Pip: + if self._installer is None: + self._installer = Pip(self) + return self._installer + + def python_cache(self) -> Dict[str, Any]: + base = super().python_cache() + base["virtualenv version"] = virtualenv_version + return base + + 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() + def _default_set_env(self) -> Dict[str, str]: + env = super()._default_set_env() env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" return env - def build_executor(self) -> Execute: - return LocalSubProcessExecutor(self.options.is_colored) - @property def session(self) -> Session: if self._virtualenv_session is None: - env_dir = [str(cast(Path, self.conf["env_dir"]))] + env_dir = [str(self.env_dir)] env = self.virtualenv_env_vars() self._virtualenv_session = session_via_cli(env_dir, options=None, setup_logging=False, env=env) return self._virtualenv_session def virtualenv_env_vars(self) -> Dict[str, str]: - env = self.environment_variables.copy() + env = self._environment_variables.copy() base_python: List[str] = self.conf["base_python"] - if "VIRTUALENV_CLEAR" not in env: - env["VIRTUALENV_CLEAR"] = "True" if "VIRTUALENV_NO_PERIODIC_UPDATE" not in env: env["VIRTUALENV_NO_PERIODIC_UPDATE"] = "True" site = getattr(self.options, "site_packages", False) or self.conf["system_site_packages"] + env["VIRTUALENV_CLEAR"] = "False" env["VIRTUALENV_SYSTEM_SITE_PACKAGES"] = str(site) env["VIRTUALENV_COPIES"] = str(getattr(self.options, "always_copy", False) or self.conf["always_copy"]) env["VIRTUALENV_DOWNLOAD"] = str(self.conf["download"]) @@ -166,7 +134,7 @@ class VirtualEnv(Python, ABC): extra_version_info=None, ) - def python_env_paths(self) -> List[Path]: + def prepend_env_var_path(self) -> List[Path]: """Paths to add to the executable""" # we use the original executable as shims may be somewhere else return list(dict.fromkeys((self.creator.bin_dir, self.creator.script_dir))) @@ -180,62 +148,6 @@ class VirtualEnv(Python, ABC): def env_bin_dir(self) -> Path: return cast(Path, self.creator.script_dir) - def install_python_packages( - self, - packages: PythonDeps, - of_type: str, - no_deps: bool = False, - develop: bool = False, - force_reinstall: bool = False, - ) -> None: - if not packages: - return - - args: List[str] = [] - if no_deps: - args.append("--no-deps") - if force_reinstall: - args.append("--force-reinstall") - if develop is True: - args.extend(("--no-build-isolation", "-e")) - args.extend(str(i) for i in packages) - install_command = self.build_install_cmd(args) - - result = self.perform_install(install_command, f"install_{of_type}") - result.assert_success() - - def build_install_cmd(self, args: Sequence[str]) -> List[str]: - cmd: Command = self.conf["install_command"] - install_command = cmd.args - try: - opts_at = install_command.index("{packages}") - except ValueError: - opts_at = len(install_command) - result = install_command[:opts_at] - result.extend(args) - result.extend(install_command[opts_at + 1 :]) - return result - - def perform_install(self, install_command: Sequence[str], run_id: str) -> Outcome: - return self.execute( - cmd=install_command, - stdin=StdinSource.OFF, - cwd=self.core["tox_root"], - run_id=run_id, - show=self.options.verbosity > DEFAULT_VERBOSITY, - ) - - def get_installed_packages(self) -> List[str]: - cmd: Command = self.conf["list_dependencies_command"] - result = self.execute( - cmd=cmd.args, - stdin=StdinSource.OFF, - run_id="freeze", - show=self.options.verbosity > DEFAULT_VERBOSITY, - ) - result.assert_success() - return result.out.splitlines() - @property def runs_on_platform(self) -> str: return sys.platform diff --git a/src/tox/tox_env/python/virtual_env/package/api.py b/src/tox/tox_env/python/virtual_env/package/api.py index 60359df9..8e444244 100644 --- a/src/tox/tox_env/python/virtual_env/package/api.py +++ b/src/tox/tox_env/python/virtual_env/package/api.py @@ -2,11 +2,11 @@ import os import sys from contextlib import contextmanager from copy import deepcopy -from enum import Enum from pathlib import Path from threading import RLock -from typing import Any, Dict, Generator, Iterator, List, NoReturn, Optional, Sequence, Set, Tuple, Union, cast +from typing import Any, Dict, Iterator, List, NoReturn, Optional, Sequence, Set, Tuple, Union, cast +from cachetools import cached from packaging.markers import Variable from packaging.requirements import Requirement @@ -16,14 +16,13 @@ from tox.execute.api import ExecuteStatus from tox.execute.pep517_backend import LocalSubProcessPep517Executor from tox.execute.request import StdinSource from tox.journal import EnvJournal -from tox.plugin.impl import impl +from tox.plugin import impl from tox.report import ToxHandler from tox.tox_env.errors import Fail -from tox.tox_env.package import PackageToxEnv -from tox.tox_env.python.api import PythonDep -from tox.tox_env.python.package import PythonPackage +from tox.tox_env.package import Package +from tox.tox_env.python.package import DevLegacyPackage, PythonPackageToxEnv, SdistPackage, WheelPackage from tox.tox_env.register import ToxEnvRegister -from tox.util.pep517.frontend import BackendFailed, CmdStatus, ConfigSettings, Frontend, WheelResult +from tox.util.pep517.frontend import BackendFailed, CmdStatus, ConfigSettings, Frontend from ..api import VirtualEnv @@ -36,13 +35,6 @@ else: # pragma: no cover (<py38) TOX_PACKAGE_ENV_ID = "virtualenv-pep-517" -class PackageType(Enum): - sdist = 1 - wheel = 2 - dev = 3 - skip = 4 - - class ToxBackendFailed(Fail, BackendFailed): def __init__(self, backend_failed: BackendFailed) -> None: Fail.__init__(self) @@ -83,7 +75,7 @@ class ToxCmdStatus(CmdStatus): return status.outcome.out_err() -class Pep517VirtualEnvPackage(VirtualEnv, PythonPackage, Frontend): +class Pep517VirtualEnvPackage(PythonPackageToxEnv, VirtualEnv, Frontend): """local file system python virtual environment via the virtualenv package""" def __init__( @@ -92,17 +84,16 @@ class Pep517VirtualEnvPackage(VirtualEnv, PythonPackage, Frontend): VirtualEnv.__init__(self, conf, core, options, journal, log_handler) root: Path = self.conf["package_root"] Frontend.__init__(self, *Frontend.create_args_from_folder(root)) + + self._backend_executor_: Optional[LocalSubProcessPep517Executor] = None + self._builds: Set[str] = set() self._distribution_meta: Optional[PathDistribution] = None - self._build_requires: Optional[Tuple[Requirement]] = None - self._build_wheel_cache: Optional[WheelResult] = None - self._backend_executor: Optional[LocalSubProcessPep517Executor] = None self._package_dependencies: Optional[List[Requirement]] = None - self._package_dev_dependencies: Optional[List[Requirement]] = None - self._lock = RLock() # can build only one package at a time - self._package: Dict[Tuple[PackageType, str], Any] = {} - self._run_env_to_wheel_builder_env: Dict[str, PackageToxEnv] = {} - self._run_env_to_info: Dict[str, Tuple[PackageType, str]] = {} - self._teardown_done = False + self._pkg_lock = RLock() # can build only one package at a time + into: Dict[str, Any] = {} + pkg_cache = cached(into, key=lambda *args, **kwargs: "wheel" if "wheel_directory" in kwargs else "sdist") + self.build_wheel = pkg_cache(self.build_wheel) # type: ignore + self.build_sdist = pkg_cache(self.build_sdist) # type: ignore @staticmethod def id() -> str: @@ -113,20 +104,19 @@ class Pep517VirtualEnvPackage(VirtualEnv, PythonPackage, Frontend): self.conf.add_config( keys=["meta_dir"], of_type=Path, - default=lambda conf, name: cast(Path, self.conf["env_dir"]) / ".meta", + default=lambda conf, name: self.env_dir / ".meta", desc="directory assigned to the tox environment", ) self.conf.add_config( keys=["pkg_dir"], of_type=Path, - default=lambda conf, name: cast(Path, self.conf["env_dir"]) / "dist", + default=lambda conf, name: self.env_dir / "dist", desc="directory assigned to the tox environment", ) - def setup(self) -> None: - super().setup() - build_requires = [PythonDep(i) for i in self.get_requires_for_build_wheel().requires] - self.cached_install(build_requires, PythonPackage.__name__, "requires_for_build_wheel") + @property + def pkg_dir(self) -> Path: + return cast(Path, self.conf["pkg_dir"]) @property def meta_folder(self) -> Path: @@ -134,96 +124,70 @@ class Pep517VirtualEnvPackage(VirtualEnv, PythonPackage, Frontend): meta_folder.mkdir(exist_ok=True) return meta_folder - def _ensure_meta_present(self) -> None: - if self._distribution_meta is not None: # pragma: no branch - return # pragma: no cover - self.ensure_setup() - dist_info = self.prepare_metadata_for_build_wheel(self.meta_folder).metadata - self._distribution_meta = Distribution.at(str(dist_info)) # type: ignore[no-untyped-call] - - def perform_packaging(self, name: str) -> List[Path]: + def notify_of_run_env(self, conf: EnvConfigSet) -> None: + super().notify_of_run_env(conf) + self._builds.add(conf["package"]) + + def _setup_env(self) -> None: + super()._setup_env() + if "wheel" in self._builds: + build_requires = self.get_requires_for_build_wheel().requires + self.installer.install(build_requires, PythonPackageToxEnv.__name__, "requires_for_build_wheel") + if "sdist" in self._builds: + build_requires = self.get_requires_for_build_sdist().requires + self.installer.install(build_requires, PythonPackageToxEnv.__name__, "requires_for_build_sdist") + + def _teardown(self) -> None: + if self._backend_executor_ is not None: + try: + if self._backend_executor.is_alive: + self._send("_exit") # try first on amicable shutdown + except SystemExit: # if already has been interrupted ignore + pass + finally: + self._backend_executor_.close() + super()._teardown() + + def perform_packaging(self, for_env: EnvConfigSet) -> List[Package]: """build the package to install""" - content = self._run_env_to_info[name] - if content in self._package: - path: Path = self._package[content] - else: - pkg_type, build_env = content - if pkg_type is PackageType.dev: - path = self.core["tox_root"] # the folder itself is the package - elif (pkg_type is PackageType.sdist) or (pkg_type is PackageType.wheel and build_env == self.conf.name): - with self._lock: - self.ensure_setup() - if pkg_type is PackageType.sdist: - build_requires = [PythonDep(i) for i in self.get_requires_for_build_sdist().requires] - self.cached_install(build_requires, PythonPackage.__name__, "requires_for_build_sdist") - path = self.build_sdist(sdist_directory=self.pkg_dir).sdist - else: - path = self.build_wheel( - wheel_directory=self.pkg_dir, - metadata_directory=self.meta_folder, - config_settings={"--global-option": ["--bdist-dir", str(self.conf["env_dir"] / "build")]}, - ).wheel - elif pkg_type is PackageType.wheel: - wheel_pkg_env = self._run_env_to_wheel_builder_env[build_env] - with wheel_pkg_env.display_context(suspend=self.has_display_suspended): - wheel_pkg_env.ref_count.increment() - try: - path = wheel_pkg_env.perform_packaging(name)[0] - finally: - wheel_pkg_env.teardown() - else: # pragma: no cover # for when we introduce new packaging types and don't implement - raise TypeError(f"cannot handle package type {pkg_type}") # pragma: no cover - self._package[content] = path - return [path] - - def create_package_env(self, name: str, info: Tuple[Any, ...]) -> Generator[Tuple[str, str], "PackageToxEnv", None]: - if not ( # pragma: no branch - isinstance(info, tuple) - and len(info) == 2 - and isinstance(info[0], PackageType) - and isinstance(info[1], str) # ensure we can handle package info - ): - raise ValueError(f"{name} package info {info} is invalid by {self.conf.name}") # pragma: no cover - - pkg_type, wheel_build_env = info[0], info[1] - self._run_env_to_info[name] = pkg_type, wheel_build_env - - if pkg_type is not PackageType.wheel or wheel_build_env == self.conf.name: - return - - yield # type: ignore[misc] - wheel_pkg_tox_env = yield wheel_build_env, self.id() - if isinstance(wheel_pkg_tox_env, Pep517VirtualEnvPackage): # pragma: no branch - wheel_pkg_tox_env._run_env_to_info[name] = PackageType.wheel, wheel_build_env - self._run_env_to_wheel_builder_env[wheel_build_env] = wheel_pkg_tox_env - - def package_envs(self, name: str) -> Generator["PackageToxEnv", None, None]: - yield from super().package_envs(name) - if name in self._run_env_to_info: - _, env = self._run_env_to_info[name] - if env is not None and env != self.conf.name: - yield self._run_env_to_wheel_builder_env[env] - - def get_package_dependencies(self, for_env: EnvConfigSet) -> List[Requirement]: - env_name = for_env.name - with self._lock: + of_type: str = for_env["package"] + extras: Set[str] = for_env["extras"] + deps = self._dependencies_with_extras(self._get_package_dependencies(), extras) + if of_type == "dev-legacy": + deps = [*self.requires(), *self.get_requires_for_build_sdist().requires] + deps + package: Package = DevLegacyPackage(self.core["tox_root"], deps) # the folder itself is the package + elif of_type == "sdist": + with self._pkg_lock: + package = SdistPackage(self.build_sdist(sdist_directory=self.pkg_dir).sdist, deps) + elif of_type == "wheel": + with self._pkg_lock: + path = self.build_wheel( + wheel_directory=self.pkg_dir, + metadata_directory=self.meta_folder, + config_settings=self._wheel_config_settings, + ).wheel + package = WheelPackage(path, deps) + else: # pragma: no cover # for when we introduce new packaging types and don't implement + raise TypeError(f"cannot handle package type {of_type}") # pragma: no cover + return [package] + + def _get_package_dependencies(self) -> List[Requirement]: + with self._pkg_lock: if self._package_dependencies is None: # pragma: no branch self._ensure_meta_present() requires: List[str] = cast(PathDistribution, self._distribution_meta).requires or [] self._package_dependencies = [Requirement(i) for i in requires] - of_type, _ = self._run_env_to_info[env_name] - if of_type == PackageType.dev and self._package_dev_dependencies is None: - self._package_dev_dependencies = [*self.requires(), *self.get_requires_for_build_sdist().requires] - if of_type == PackageType.dev: - result: List[Requirement] = cast(List[Requirement], self._package_dev_dependencies).copy() - else: - result = [] - extras: Set[str] = for_env["extras"] - result.extend(self.dependencies_with_extras(self._package_dependencies, extras)) - return result + return self._package_dependencies + + def _ensure_meta_present(self) -> None: + if self._distribution_meta is not None: # pragma: no branch + return # pragma: no cover + self.setup() + dist_info = self.prepare_metadata_for_build_wheel(self.meta_folder, self._wheel_config_settings).metadata + self._distribution_meta = Distribution.at(str(dist_info)) # type: ignore[no-untyped-call] @staticmethod - def dependencies_with_extras(deps: List[Requirement], extras: Set[str]) -> List[Requirement]: + def _dependencies_with_extras(deps: List[Requirement], extras: Set[str]) -> List[Requirement]: result: List[Requirement] = [] for req in deps: req = deepcopy(req) @@ -250,47 +214,38 @@ class Pep517VirtualEnvPackage(VirtualEnv, PythonPackage, Frontend): result.append(req) return result + @contextmanager + def _wheel_directory(self) -> Iterator[Path]: + yield self.pkg_dir # use our local wheel directory for building wheel + + @property + def _wheel_config_settings(self) -> Optional[ConfigSettings]: + return {"--global-option": ["--bdist-dir", str(self.env_dir / "build")]} + @property - def backend_executor(self) -> LocalSubProcessPep517Executor: - if self._backend_executor is None: - self._backend_executor = LocalSubProcessPep517Executor( + def _backend_executor(self) -> LocalSubProcessPep517Executor: + if self._backend_executor_ is None: + self._backend_executor_ = LocalSubProcessPep517Executor( colored=self.options.is_colored, cmd=self.backend_cmd, - env=self.environment_variables, + env=self._environment_variables, cwd=self._root, ) - return self._backend_executor - - @property - def pkg_dir(self) -> Path: - return cast(Path, self.conf["pkg_dir"]) + return self._backend_executor_ @property def backend_cmd(self) -> Sequence[str]: return ["python"] + self.backend_args @property - def environment_variables(self) -> Dict[str, str]: - env = super().environment_variables + def _environment_variables(self) -> Dict[str, str]: + env = super()._environment_variables backend = os.pathsep.join(str(i) for i in self._backend_paths).strip() if backend: env["PYTHONPATH"] = backend return env - def teardown(self) -> None: - if not self._teardown_done: - self.ref_count.decrement() - if self.ref_count.value == 0 and self._backend_executor is not None and self._teardown_done is False: - self._teardown_done = True - try: - if self.backend_executor.is_alive: - self._send("_exit") # try first on amicable shutdown - except SystemExit: # if already has been interrupted ignore - pass - finally: - self._backend_executor.close() - @contextmanager def _send_msg( self, cmd: str, result_file: Path, msg: str # noqa: U100 @@ -301,7 +256,7 @@ class Pep517VirtualEnvPackage(VirtualEnv, PythonPackage, Frontend): stdin=StdinSource.API, show=None, run_id=cmd, - executor=self.backend_executor, + executor=self._backend_executor, ) as execute_status: execute_status.write_stdin(f"{msg}{os.linesep}") yield ToxCmdStatus(execute_status) @@ -309,34 +264,17 @@ class Pep517VirtualEnvPackage(VirtualEnv, PythonPackage, Frontend): if outcome is not None: # pragma: no branch outcome.assert_success() - @contextmanager - def _wheel_directory(self) -> Iterator[Path]: - yield self.pkg_dir # use our local wheel directory - - def build_wheel( - self, - wheel_directory: Path, - config_settings: Optional[ConfigSettings] = None, - metadata_directory: Optional[Path] = None, - ) -> WheelResult: - # only build once a wheel per session - might need more than once if the backend doesn't - # support prepare metadata for wheel - if self._build_wheel_cache is None: - self._build_wheel_cache = super().build_wheel(wheel_directory, config_settings, metadata_directory) - return self._build_wheel_cache - def _send(self, cmd: str, **kwargs: Any) -> Tuple[Any, str, str]: try: if cmd == "prepare_metadata_for_build_wheel": # given we'll build a wheel we might skip the prepare step - for pkg_type, pkg_name in self._run_env_to_info.values(): - if pkg_type is PackageType.wheel and pkg_name == self.conf.name: - result = { - "code": 1, - "exc_type": "AvoidRedundant", - "exc_msg": "will need to build wheel either way, avoid prepare", - } - raise BackendFailed(result, "", "") + if "wheel" in self._builds: + result = { + "code": 1, + "exc_type": "AvoidRedundant", + "exc_msg": "will need to build wheel either way, avoid prepare", + } + raise BackendFailed(result, "", "") return super()._send(cmd, **kwargs) except BackendFailed as exception: raise exception if isinstance(exception, ToxBackendFailed) else ToxBackendFailed(exception) from exception diff --git a/src/tox/tox_env/python/virtual_env/runner.py b/src/tox/tox_env/python/virtual_env/runner.py index c695daf6..2a557b99 100644 --- a/src/tox/tox_env/python/virtual_env/runner.py +++ b/src/tox/tox_env/python/virtual_env/runner.py @@ -1,128 +1,23 @@ """ A tox python environment runner that uses the virtualenv project. """ -from typing import Dict, Generator, Optional, Sequence, Set, Tuple - -from tox.config.cli.parser import Parsed -from tox.config.main import Config -from tox.config.sets import CoreConfigSet, EnvConfigSet -from tox.journal import EnvJournal -from tox.plugin.impl import impl -from tox.report import HandledError, ToxHandler -from tox.tox_env.package import PackageToxEnv +from tox.plugin import impl from tox.tox_env.register import ToxEnvRegister from ..runner import PythonRun from .api import VirtualEnv -from .package.api import PackageType class VirtualEnvRunner(VirtualEnv, PythonRun): """local file system python virtual environment via the virtualenv package""" - def __init__( - self, conf: EnvConfigSet, core: CoreConfigSet, options: Parsed, journal: EnvJournal, log_handler: ToxHandler - ) -> None: - super().__init__(conf, core, options, journal, log_handler) - @staticmethod def id() -> str: return "virtualenv" - def add_package_conf(self) -> bool: - desc = f"package installation mode - {' | '.join(i.name for i in PackageType)} " - if not super().add_package_conf(): - self.conf.add_constant(["package"], desc, PackageType.skip) - return False - self.conf.add_config(keys="usedevelop", desc="use develop mode", default=False, of_type=bool) - develop_mode = self.conf["usedevelop"] or getattr(self.options, "develop", False) - if develop_mode: - self.conf.add_constant(["package"], desc, PackageType.dev) - else: - self.conf.add_config(keys="package", of_type=PackageType, default=PackageType.sdist, desc=desc) - pkg_type = self.pkg_type - - if pkg_type == PackageType.skip: - return False - - self.conf.add_constant( - keys=["package_tox_env_type"], - desc="tox package type used to package", - value="virtualenv-pep-517", - ) - - self.conf.add_config( - keys=["package_env", "isolated_build_env"], - of_type=str, - default=".pkg", - desc="tox environment used to package", - ) - - if pkg_type == PackageType.wheel: - - def default_wheel_tag(conf: "Config", env_name: Optional[str]) -> str: - # https://www.python.org/dev/peps/pep-0427/#file-name-convention - # when building wheels we need to ensure that the built package is compatible with the target env - # compatibility is documented within https://www.python.org/dev/peps/pep-0427/#file-name-convention - # a wheel tag example: {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl - # python only code are often compatible at major level (unless universal wheel in which case both 2/3) - # c-extension codes are trickier, but as of today both poetry/setuptools uses pypa/wheels logic - # https://github.com/pypa/wheel/blob/master/src/wheel/bdist_wheel.py#L234-L280 - base: str = self.conf["package_env"] - run = self.base_python - if run is not None and self.package_env is not None and isinstance(self.package_env, VirtualEnv): - pkg = self.package_env.base_python - if pkg.version_no_dot == run.version_no_dot and pkg.impl_lower == run.impl_lower: - return base - if run is None: - raise ValueError(f"could not resolve base python for {self.conf.name}") - return f"{base}-{run.impl_lower}{run.version_no_dot}" - - self.conf.add_config( - keys=["wheel_build_env"], - of_type=str, - default=default_wheel_tag, - desc="wheel tag to use for building applications", - ) - self.conf.add_config( - keys=["extras"], - of_type=Set[str], - default=set(), - desc="extras to install of the target package", - ) - return True - - def create_package_env(self) -> Generator[Tuple[str, str], PackageToxEnv, None]: - yield from super().create_package_env() - if self.package_env is not None: - pkg_env = self.package_env - wheel_build_env = self.conf["wheel_build_env"] if self.pkg_type is PackageType.wheel else pkg_env.conf.name - yield from self.package_env.create_package_env(self.conf.name, (self.pkg_type, wheel_build_env)) - - def teardown(self) -> None: - super().teardown() - @property - def pkg_type(self) -> PackageType: - try: - pkg_type: PackageType = self.conf["package"] - except AttributeError as exc: - values = ", ".join(i.name for i in PackageType) - error = HandledError(f"invalid package config type {exc.args[0]!r} requested, must be one of {values}") - raise error from exc - return pkg_type - - def install_package_args(self) -> Dict[str, bool]: - return { - "no_deps": True, # dependencies are installed separately - "develop": self.pkg_type is PackageType.dev, # if package type is develop mode pass option through - "force_reinstall": True, # if is already installed reinstall - } - - def install_deps(self, args: Sequence[str]) -> None: # noqa: U100 - install_command = self.build_install_cmd(args) - result = self.perform_install(install_command, "install_deps") - result.assert_success() + def _default_package_tox_env_type(self) -> str: + return "virtualenv-pep-517" @impl diff --git a/src/tox/tox_env/register.py b/src/tox/tox_env/register.py index 11e15fde..2b8eefd5 100644 --- a/src/tox/tox_env/register.py +++ b/src/tox/tox_env/register.py @@ -1,3 +1,6 @@ +""" +Manages the tox environment registry. +""" from typing import TYPE_CHECKING, Dict, Iterable, Type from .package import PackageToxEnv @@ -8,40 +11,77 @@ if TYPE_CHECKING: class ToxEnvRegister: + """tox environment registry""" + def __init__(self) -> None: self._run_envs: Dict[str, Type[RunToxEnv]] = {} self._package_envs: Dict[str, Type[PackageToxEnv]] = {} self._default_run_env: str = "" - def populate(self, manager: "Plugin") -> None: + def _register_tox_env_types(self, manager: "Plugin") -> None: manager.tox_register_tox_env(register=self) - self._default_run_env = next(iter(self._run_envs.keys())) + if not self._default_run_env: + self._default_run_env = next(iter(self._run_envs.keys())) def add_run_env(self, of_type: Type[RunToxEnv]) -> None: + """ + Define a new run tox environment type. + + :param of_type: the new run environment type + """ self._run_envs[of_type.id()] = of_type def add_package_env(self, of_type: Type[PackageToxEnv]) -> None: + """ + Define a new packaging tox environment type. + + :param of_type: the new packaging environment type + """ self._package_envs[of_type.id()] = of_type @property def run_envs(self) -> Iterable[str]: + """:returns: run environment types currently defined""" return self._run_envs.keys() @property def default_run_env(self) -> str: + """:returns: the default run environment type""" return self._default_run_env @default_run_env.setter def default_run_env(self, value: str) -> None: + """ + Change the default run environment type. + + :param value: the new run environment type by name + """ if value not in self._run_envs: raise ValueError("run env must be registered before setting it as default") self._default_run_env = value def runner(self, name: str) -> Type[RunToxEnv]: + """ + Lookup a run tox environment type by name. + + :param name: the name of the runner type + :return: the type of the runner type + """ return self._run_envs[name] def package(self, name: str) -> Type[PackageToxEnv]: + """ + Lookup a packaging tox environment type by name. + + :param name: the name of the packaging type + :return: the type of the packaging type + """ return self._package_envs[name] -REGISTER = ToxEnvRegister() +REGISTER = ToxEnvRegister() #: the tox register + +__all__ = ( + "REGISTER", + "ToxEnvRegister", +) diff --git a/src/tox/tox_env/runner.py b/src/tox/tox_env/runner.py index eb9b9aa0..95276dd5 100644 --- a/src/tox/tox_env/runner.py +++ b/src/tox/tox_env/runner.py @@ -4,7 +4,7 @@ import re from abc import ABC, abstractmethod from hashlib import sha256 from pathlib import Path -from typing import TYPE_CHECKING, Dict, Generator, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, Tuple, cast from tox.config.sets import CoreConfigSet, EnvConfigSet from tox.config.types import Command, EnvList @@ -12,7 +12,7 @@ from tox.journal import EnvJournal from tox.report import ToxHandler from .api import ToxEnv -from .package import PackageToxEnv +from .package import Package, PackageToxEnv, PathPackage if TYPE_CHECKING: from tox.config.cli.parser import Parsed @@ -22,8 +22,8 @@ class RunToxEnv(ToxEnv, ABC): def __init__( self, conf: EnvConfigSet, core: CoreConfigSet, options: "Parsed", journal: EnvJournal, log_handler: ToxHandler ) -> None: - self.has_package = False - self.package_env: Optional[PackageToxEnv] = None + self._package_envs: Dict[str, PackageToxEnv] = {} + self._packages: List[Package] = [] super().__init__(conf, core, options, journal, log_handler) def register_config(self) -> None: @@ -80,45 +80,63 @@ class RunToxEnv(ToxEnv, ABC): default=False, desc="if set to true a failing result of this testenv will not make tox fail (instead just warn)", ) - self.has_package = self.add_package_conf() + if self._register_package_conf(): + self.conf.add_config( + keys=["package_env", "isolated_build_env"], + of_type=str, + default=self._default_package_env, + desc="tox environment used to package", + ) + self.conf.add_constant( + keys=["package_tox_env_type"], + desc="tox package type used to package", + value=self._default_package_tox_env_type, + ) + + def _teardown(self) -> None: + super()._teardown() + self._call_pkg_envs("teardown_env", self.conf) - def setup(self) -> None: - super().setup() - self.before_package_install() - self.handle_package() + def interrupt(self) -> None: + super().interrupt() + self._call_pkg_envs("interrupt") - def handle_package(self) -> None: - if self.package_env is None: - return - skip_pkg_install: bool = getattr(self.options, "skip_pkg_install", False) - if skip_pkg_install is True: - logging.warning("skip building and installing the package") - return - paths = self.install_package() - self.handle_journal_package(self.journal, paths) + def iter_package_env_types(self) -> Iterator[Tuple[str, str, str]]: + if "package_env" in self.conf: + name, pkg_env_type = self.conf["package_env"], self.conf["package_tox_env_type"] + yield "default", name, pkg_env_type - def before_package_install(self) -> None: - """logic to run before package install""" + def notify_of_package_env(self, tag: str, env: PackageToxEnv) -> None: + self._package_envs[tag] = env + env.notify_of_run_env(self.conf) + + def _call_pkg_envs(self, method_name: str, *args: Any) -> None: + for package_env in self.package_envs: + with package_env.display_context(suspend=self._has_display_suspended): + getattr(package_env, method_name)(*args) + + def _clean(self, force: bool = False) -> None: + super()._clean(force) + self._call_pkg_envs("_clean") # do not pass force along, allow package env to ignore if requested + + @property + def _default_package_env(self) -> str: + return ".pkg" + @property @abstractmethod - def install_package(self) -> List[Path]: + def _default_package_tox_env_type(self) -> str: raise NotImplementedError - @staticmethod - def handle_journal_package(journal: EnvJournal, package: List[Path]) -> None: - if not journal: - return - installed_meta = [] - for pkg in package: - of_type = "file" if pkg.is_file() else ("dir" if pkg.is_dir() else "N/A") - meta = {"basename": pkg.name, "type": of_type} - if of_type == "file": - meta["sha256"] = sha256(pkg.read_bytes()).hexdigest() - installed_meta.append(meta) - if installed_meta: - journal["installpkg"] = installed_meta[0] if len(installed_meta) == 1 else installed_meta + def _setup_with_env(self) -> None: + if self._package_envs: + skip_pkg_install: bool = getattr(self.options, "skip_pkg_install", False) + if skip_pkg_install is True: + logging.warning("skip building and installing the package") + else: + self._setup_pkg() - def add_package_conf(self) -> bool: + def _register_package_conf(self) -> bool: """If this returns True package_env and package_tox_env_type configurations must be defined""" self.core.add_config( keys=["no_package", "skipsdist"], @@ -138,46 +156,41 @@ class RunToxEnv(ToxEnv, ABC): skip_install: bool = self.conf["skip_install"] return not skip_install - def create_package_env(self) -> Generator[Tuple[str, str], PackageToxEnv, None]: - if not self.has_package: + def _setup_pkg(self) -> None: + self._packages = self._build_packages() + self.installer.install(self._packages, RunToxEnv.__name__, "package") + self._handle_journal_package(self.journal, self._packages) + + @staticmethod + def _handle_journal_package(journal: EnvJournal, packages: List[Package]) -> None: + if not journal: return - core_type = self.conf["package_tox_env_type"] - name = self.conf["package_env"] - package_tox_env = yield name, core_type - self.package_env = package_tox_env - self.package_env.ref_count.increment() - - def clean(self, force: bool = False) -> None: - super().clean(force) - if self.package_env is not None: # pragma: no cover branch - with self.package_env.display_context(suspend=self.has_display_suspended): - self.package_env.clean() # do not pass force along, allow package env to ignore if requested + installed_meta = [] + for package in packages: + if isinstance(package, PathPackage): + pkg = package.path + of_type = "file" if pkg.is_file() else ("dir" if pkg.is_dir() else "N/A") + meta = {"basename": pkg.name, "type": of_type} + if of_type == "file": + meta["sha256"] = sha256(pkg.read_bytes()).hexdigest() + else: + raise NotImplementedError + installed_meta.append(meta) + if installed_meta: + journal["installpkg"] = installed_meta[0] if len(installed_meta) == 1 else installed_meta @property - def environment_variables(self) -> Dict[str, str]: - environment_variables = super().environment_variables - if self.has_package: # if package(s) have been built insert them as environment variable - if self.packages: - environment_variables["TOX_PACKAGE"] = os.pathsep.join(self.packages) + def _environment_variables(self) -> Dict[str, str]: + environment_variables = super()._environment_variables + if self._package_envs and self._packages: # if package(s) have been built insert them as environment variable + environment_variables["TOX_PACKAGE"] = os.pathsep.join(str(i) for i in self._packages) return environment_variables - @property @abstractmethod - def packages(self) -> List[str]: + def _build_packages(self) -> List[Package]: """:returns: a list of packages installed in the environment""" raise NotImplementedError - def teardown(self) -> None: - super().teardown() - if self.package_env is not None: - with self.package_env.display_context(suspend=self.has_display_suspended): - self.package_env.teardown() - - def interrupt(self) -> None: - super().interrupt() - if self.package_env is not None: # pragma: no branch - self.package_env.interrupt() - - def package_envs(self) -> Generator[PackageToxEnv, None, None]: - if self.package_env is not None and self.conf.name is not None: - yield from self.package_env.package_envs(self.conf.name) + @property + def package_envs(self) -> Iterable[PackageToxEnv]: + yield from dict.fromkeys(self._package_envs.values()).keys() diff --git a/src/tox/util/threading.py b/src/tox/util/threading.py deleted file mode 100644 index 0e2db279..00000000 --- a/src/tox/util/threading.py +++ /dev/null @@ -1,15 +0,0 @@ -from threading import Lock - - -class AtomicCounter: - def __init__(self) -> None: - self.value: int = 0 - self._lock = Lock() - - def increment(self) -> None: - with self._lock: - self.value += 1 - - def decrement(self) -> None: - with self._lock: - self.value -= 1 |