summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorBernát Gábor <bgabor8@bloomberg.net>2021-04-05 00:59:13 +0100
committerGitHub <noreply@github.com>2021-04-05 00:59:13 +0100
commit18a95899444372822a4a6063a471e865f8c58edf (patch)
tree60c832841a5ab3ef64b673c5b337be9fc3181841 /src
parent54e6310f5376c7bd7e2b4871c700fe00cabf1c32 (diff)
downloadtox-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')
-rw-r--r--src/tox/config/cli/parse.py2
-rw-r--r--src/tox/config/cli/parser.py4
-rw-r--r--src/tox/config/loader/api.py19
-rw-r--r--src/tox/config/loader/convert.py70
-rw-r--r--src/tox/config/loader/ini/__init__.py17
-rw-r--r--src/tox/config/loader/stringify.py9
-rw-r--r--src/tox/config/main.py56
-rw-r--r--src/tox/config/sets.py48
-rw-r--r--src/tox/config/source/api.py24
-rw-r--r--src/tox/config/source/discover.py13
-rw-r--r--src/tox/config/source/legacy_toml.py2
-rw-r--r--src/tox/config/source/setup_cfg.py2
-rw-r--r--src/tox/config/types.py21
-rw-r--r--src/tox/execute/api.py39
-rw-r--r--src/tox/execute/local_sub_process/__init__.py2
-rw-r--r--src/tox/execute/request.py27
-rw-r--r--src/tox/journal/env.py26
-rw-r--r--src/tox/plugin/__init__.py30
-rw-r--r--src/tox/plugin/impl.py8
-rw-r--r--src/tox/plugin/inline.py27
-rw-r--r--src/tox/plugin/manager.py11
-rw-r--r--src/tox/plugin/spec.py66
-rw-r--r--src/tox/provision.py16
-rw-r--r--src/tox/pytest.py6
-rw-r--r--src/tox/report.py39
-rw-r--r--src/tox/session/cmd/depends.py4
-rw-r--r--src/tox/session/cmd/devenv.py2
-rw-r--r--src/tox/session/cmd/legacy.py2
-rw-r--r--src/tox/session/cmd/list_env.py2
-rw-r--r--src/tox/session/cmd/quickstart.py2
-rw-r--r--src/tox/session/cmd/run/common.py9
-rw-r--r--src/tox/session/cmd/run/parallel.py2
-rw-r--r--src/tox/session/cmd/run/sequential.py2
-rw-r--r--src/tox/session/cmd/run/single.py2
-rw-r--r--src/tox/session/cmd/show_config.py4
-rw-r--r--src/tox/session/cmd/version_flag.py2
-rw-r--r--src/tox/session/state.py19
-rw-r--r--src/tox/tox_env/api.py247
-rw-r--r--src/tox/tox_env/installer.py27
-rw-r--r--src/tox/tox_env/package.py41
-rw-r--r--src/tox/tox_env/python/api.py112
-rw-r--r--src/tox/tox_env/python/package.py38
-rw-r--r--src/tox/tox_env/python/pip/__init__.py0
-rw-r--r--src/tox/tox_env/python/pip/pip_install.py211
-rw-r--r--src/tox/tox_env/python/pip/req_file.py365
-rw-r--r--src/tox/tox_env/python/req_file.py209
-rw-r--r--src/tox/tox_env/python/runner.py158
-rw-r--r--src/tox/tox_env/python/virtual_env/api.py160
-rw-r--r--src/tox/tox_env/python/virtual_env/package/api.py262
-rw-r--r--src/tox/tox_env/python/virtual_env/runner.py111
-rw-r--r--src/tox/tox_env/register.py46
-rw-r--r--src/tox/tox_env/runner.py151
-rw-r--r--src/tox/util/threading.py15
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