diff options
author | Bernát Gábor <bgabor8@bloomberg.net> | 2020-11-26 18:00:28 +0000 |
---|---|---|
committer | Bernát Gábor <bgabor8@bloomberg.net> | 2020-11-27 17:47:57 +0000 |
commit | 39c07bb676411f75adc1dab697c5917b6a803c49 (patch) | |
tree | c2b3ffe6efc7611dea154581dff5818377a46ccf | |
parent | 8b526dc6bd10b2bff87c667ce14fba6cc54c0d1f (diff) | |
download | tox-git-39c07bb676411f75adc1dab697c5917b6a803c49.tar.gz |
Fix provisioning support
Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>
43 files changed, 747 insertions, 394 deletions
@@ -1,36 +1,16 @@ -# python *.pyc *.pyo *.swp __pycache__ .eggs - - -# packaging folders /src/tox/version.py -/build/ -/dist/ -/src/tox.egg-info - -# tox working folder -/.tox -/.tox4 - -# IDEs -/.idea -/.vscode - -# tools +build +dist +*.egg-info +.tox +.tox4 /.*_cache .dmypy.json - -# documentation /docs/_draft.rst - -# release -credentials.json - pip-wheel-metadata -.DS_Store -.coverage.* Dockerfile @@ -61,6 +61,8 @@ docs = towncrier>=19.9.0rc1 testing = covdefaults>=1.2 + devpi-client>=5 + devpi-server>=5 freezegun>=1 psutil>=5.7 pytest>=5.4.1 diff --git a/src/tox/config/loader/convert.py b/src/tox/config/loader/convert.py index 27426651..0143e138 100644 --- a/src/tox/config/loader/convert.py +++ b/src/tox/config/loader/convert.py @@ -21,7 +21,6 @@ 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]) -> V: - from_module = getattr(of_type, "__module__", None) if from_module in ("typing", "typing_extensions"): return self._to_typing(raw, of_type) # type: ignore[return-value] @@ -35,6 +34,8 @@ class Convert(ABC, Generic[T]): return self.to_env_list(raw) # type: ignore[return-value] elif issubclass(of_type, str): 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) # type: ignore[call-arg] @@ -44,13 +45,15 @@ class Convert(ABC, Generic[T]): result: Any = _NO_MAPPING if origin in (list, List): entry_type = of_type.__args__[0] # type: ignore[attr-defined] - result = [self.to(i, entry_type) for i in self.to_list(raw)] + result = [self.to(i, entry_type) for i in self.to_list(raw, entry_type)] elif origin in (set, Set): entry_type = of_type.__args__[0] # type: ignore[attr-defined] - result = {self.to(i, entry_type) for i in self.to_set(raw)} + result = {self.to(i, entry_type) for i in self.to_set(raw, entry_type)} elif origin in (dict, Dict): key_type, value_type = of_type.__args__[0], of_type.__args__[1] # type: ignore[attr-defined] - result = OrderedDict((self.to(k, key_type), self.to(v, value_type)) for k, v in self.to_dict(raw)) + result = OrderedDict( + (self.to(k, key_type), self.to(v, value_type)) for k, v in self.to_dict(raw, (key_type, value_type)) + ) elif origin == Union: # handle Optional values args: List[Type[Any]] = of_type.__args__ # type: ignore[attr-defined] none = type(None) @@ -81,17 +84,17 @@ class Convert(ABC, Generic[T]): @staticmethod @abstractmethod - def to_list(value: T) -> Iterator[T]: + def to_list(value: T, of_type: Type[Any]) -> Iterator[T]: raise NotImplementedError @staticmethod @abstractmethod - def to_set(value: T) -> Iterator[T]: + def to_set(value: T, of_type: Type[Any]) -> Iterator[T]: raise NotImplementedError @staticmethod @abstractmethod - def to_dict(value: T) -> Iterator[Tuple[T, T]]: + def to_dict(value: T, of_type: Tuple[Type[Any], Type[Any]]) -> Iterator[Tuple[T, T]]: raise NotImplementedError @staticmethod diff --git a/src/tox/config/loader/memory.py b/src/tox/config/loader/memory.py index 874a2d76..2c5abe3c 100644 --- a/src/tox/config/loader/memory.py +++ b/src/tox/config/loader/memory.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Dict, Iterator, Optional, Set, Tuple, cast +from typing import Any, Dict, Iterator, Optional, Set, Tuple, Type, cast from tox.config.loader.convert import T from tox.config.main import Config @@ -28,15 +28,15 @@ class MemoryLoader(Loader[Any]): return value # type: ignore[no-any-return] @staticmethod - def to_list(value: Any) -> Iterator[T]: + def to_list(value: Any, of_type: Type[Any]) -> Iterator[T]: return value # type: ignore[no-any-return] @staticmethod - def to_set(value: Any) -> Iterator[T]: + def to_set(value: Any, of_type: Type[Any]) -> Iterator[T]: return iter(value) # type: ignore[no-any-return] @staticmethod - def to_dict(value: Any) -> Iterator[Tuple[T, T]]: + def to_dict(value: Any, of_type: Tuple[Type[Any], Type[Any]]) -> Iterator[Tuple[T, T]]: return value.items() # type: ignore[no-any-return] @staticmethod diff --git a/src/tox/config/loader/str_convert.py b/src/tox/config/loader/str_convert.py index 98ef5107..75960d7a 100644 --- a/src/tox/config/loader/str_convert.py +++ b/src/tox/config/loader/str_convert.py @@ -2,7 +2,7 @@ import shlex from itertools import chain from pathlib import Path -from typing import Iterator, Tuple +from typing import Any, Iterator, Tuple, Type from tox.config.loader.convert import Convert from tox.config.types import Command, EnvList @@ -20,8 +20,8 @@ class StrConvert(Convert[str]): return Path(value) @staticmethod - def to_list(value: str) -> Iterator[str]: - splitter = "\n" if "\n" in value else "," + def to_list(value: str, of_type: Type[Any]) -> Iterator[str]: + splitter = "\n" if issubclass(of_type, Command) or "\n" in value else "," splitter = splitter.replace("\r", "") for token in value.split(splitter): value = token.strip() @@ -29,12 +29,12 @@ class StrConvert(Convert[str]): yield value @staticmethod - def to_set(value: str) -> Iterator[str]: - for value in StrConvert.to_list(value): + def to_set(value: str, of_type: Type[Any]) -> Iterator[str]: + for value in StrConvert.to_list(value, of_type): yield value @staticmethod - def to_dict(value: str) -> Iterator[Tuple[str, str]]: + def to_dict(value: str, of_type: Tuple[Type[Any], Type[Any]]) -> Iterator[Tuple[str, str]]: for row in value.split("\n"): row = row.strip() if row: @@ -49,7 +49,8 @@ class StrConvert(Convert[str]): @staticmethod def to_command(value: str) -> Command: - return Command(shlex.split(value)) + args = shlex.split(value, comments=True) + return Command(args) @staticmethod def to_env_list(value: str) -> EnvList: diff --git a/src/tox/journal/__init__.py b/src/tox/journal/__init__.py index f292dd45..6ce9aa47 100644 --- a/src/tox/journal/__init__.py +++ b/src/tox/journal/__init__.py @@ -1,8 +1,21 @@ """This module handles collecting and persisting in json format a tox session""" +import json +from pathlib import Path +from typing import Optional + from .env import EnvJournal from .main import Journal + +def write_journal(path: Optional[Path], journal: Journal) -> None: + if path is None: + return + with open(path, "wt") as file_handler: + json.dump(journal.content, file_handler, indent=2, ensure_ascii=False) + + __all__ = ( "Journal", "EnvJournal", + "write_journal", ) diff --git a/src/tox/plugin/manager.py b/src/tox/plugin/manager.py index 9d7bb5ed..5cb3c93f 100644 --- a/src/tox/plugin/manager.py +++ b/src/tox/plugin/manager.py @@ -10,6 +10,7 @@ from tox.config.sets import ConfigSet from tox.session import state from tox.session.cmd import 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.artifact import dev, sdist, wheel @@ -38,6 +39,7 @@ class Plugin: list_env, parallel, sequential, + package_api, ) for plugin in internal_plugins: diff --git a/src/tox/provision.py b/src/tox/provision.py new file mode 100644 index 00000000..6c1a90a4 --- /dev/null +++ b/src/tox/provision.py @@ -0,0 +1,106 @@ +""" +This package handles provisioning an appropriate tox version per requirements. +""" +import logging +import sys +from argparse import ArgumentParser +from typing import List, Tuple, Union, cast + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version + +from tox.config.loader.memory import MemoryLoader +from tox.config.main import Config +from tox.config.sets import ConfigSet +from tox.plugin.impl import impl +from tox.session.state import State +from tox.tox_env.python.api import PythonDep +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+) + from importlib.metadata import PackageNotFoundError, distribution +else: # pragma: no cover (py38+) + from importlib_metadata import PackageNotFoundError, distribution # noqa + + +@impl +def tox_add_option(parser: ArgumentParser) -> None: + parser.add_argument( + "--no-recreate-provision", + dest="recreate", + help="if recreate is set do not recreate provision tox environment", + action="store_true", + ) + + +@impl +def tox_add_core_config(core: ConfigSet) -> None: + core.add_config( + keys=["min_version", "minversion"], + of_type=Version, + default=Version(current_version), + desc="Define the minimal tox version required to run", + ) + core.add_config( + keys="provision_tox_env", + of_type=str, + default=".tox", + desc="Name of the virtual environment used to provision a tox.", + ) + + def add_tox_requires_min_version(requires: List[Requirement], conf: Config) -> List[Requirement]: + min_version: Version = conf.core["min_version"] + requires.append(Requirement(f"tox >= {min_version}")) + return requires + + core.add_config( + keys="requires", + of_type=List[Requirement], + default=[], + desc="Name of the virtual environment used to provision a tox.", + post_process=add_tox_requires_min_version, + ) + + +def provision(state: State) -> Union[int, bool]: + requires: List[Requirement] = state.conf.core["requires"] + missing: List[Tuple[Requirement, str]] = [] + for package in requires: + package_name = canonicalize_name(package.name) + try: + dist = distribution(package_name) + if not package.specifier.contains(dist.version, prereleases=True): + missing.append((package, dist.version)) + except PackageNotFoundError: + missing.append((package, "N/A")) + 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 + ) + return run_provision(requires, state) + + +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 + deps=[PythonDep(d) for d in deps], # use our own dependency specification + pass_env=["*"], # do not filter environment variables, will be handled by provisioned tox + ) + provision_tox_env: str = state.conf.core["provision_tox_env"] + state.conf.get_env(provision_tox_env, loaders=[loader]) + tox_env = cast(PythonRun, state.tox_env(provision_tox_env)) + env_python = tox_env.env_python() + 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 + tox_env.ensure_setup(recreate=recreate) + args: List[str] = [str(env_python), "-m", "tox"] + args.extend(state.args) + outcome = tox_env.execute(cmd=args, allow_stdin=True, show_on_standard=True, run_id="provision") + return outcome.exit_code diff --git a/src/tox/provision/__init__.py b/src/tox/provision/__init__.py deleted file mode 100644 index 013558bc..00000000 --- a/src/tox/provision/__init__.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -This package handles provisioning an appropriate tox version per requirements. -""" -import sys -from typing import List - -from packaging.requirements import Requirement -from packaging.utils import canonicalize_name -from packaging.version import Version - -from tox.config.main import Config -from tox.config.sets import ConfigSet -from tox.plugin.impl import impl -from tox.session.state import State -from tox.tox_env.api import ToxEnv -from tox.version import __version__ as current_version - -if sys.version_info >= (3, 8): # pragma: no cover (py38+) - from importlib.metadata import distribution -else: # pragma: no cover (py38+) - from importlib_metadata import distribution # noqa - - -def add_tox_requires_min_version(requires: List[Requirement], conf: Config) -> List[Requirement]: - min_version = conf.core["min_version"] - requires.append(Requirement(f"tox >= {min_version}")) - return requires - - -def provision(state: State) -> None: - core = state.conf.core - provision_tox_env = core["provision_tox_env"] - requires = core["requires"] - - exists = set() - missing = [] - for package in requires: - package_name = canonicalize_name(package.name) - if package_name not in exists: - exists.add(package_name) - dist = distribution(package.name) - if not package.specifier.contains(dist.version, prereleases=True): - missing.append(package) - if missing: - for package in missing: - print(package) - run_provision(requires, state.tox_env(provision_tox_env)) - - -@impl -def tox_add_core_config(core: ConfigSet) -> None: - core.add_config( - keys=["min_version", "minversion"], - of_type=Version, - default=Version(current_version), - desc="Define the minimal tox version required to run", - ) - core.add_config( - keys="provision_tox_env", - of_type=str, - default=".tox", - desc="Name of the virtual environment used to provision a tox.", - ) - core.add_config( - keys="requires", - of_type=List[Requirement], - default=[], - desc="Name of the virtual environment used to provision a tox.", - post_process=add_tox_requires_min_version, - ) - core.add_config( - keys=["no_package", "skipsdist"], - of_type=bool, - default=False, - desc="Is there any packaging involved in this project.", - ) - - -def run_provision(deps: List[Requirement], tox_env: ToxEnv) -> None: # noqa - """""" diff --git a/src/tox/pytest.py b/src/tox/pytest.py index d6b0be86..22265062 100644 --- a/src/tox/pytest.py +++ b/src/tox/pytest.py @@ -3,13 +3,20 @@ A pytest plugin useful to test tox itself (and its plugins). """ import os +import random import re +import shutil +import socket +import string import sys import textwrap import warnings -from contextlib import contextmanager +from contextlib import closing, contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence +from subprocess import PIPE, Popen, check_call +from threading import Thread +from types import TracebackType +from typing import IO, TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Sequence, Tuple, Type, cast import pytest from _pytest.capture import CaptureFixture as _CaptureFixture @@ -18,6 +25,9 @@ from _pytest.config.argparsing import Parser from _pytest.logging import LogCaptureFixture from _pytest.monkeypatch import MonkeyPatch from _pytest.python import Function +from _pytest.tmpdir import TempPathFactory +from virtualenv.discovery.py_info import PythonInfo +from virtualenv.info import IS_WIN import tox.run from tox.execute.api import Outcome @@ -28,11 +38,20 @@ from tox.run import setup_state as previous_setup_state from tox.session.cmd.run.parallel import ENV_VAR_KEY from tox.session.state import State +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from typing import Protocol +else: # pragma: no cover (<py38) + from typing_extensions import Protocol # noqa + if TYPE_CHECKING: CaptureFixture = _CaptureFixture[str] else: CaptureFixture = _CaptureFixture +os.environ["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" +os.environ["PIP_NO_PYTHON_VERSION_WARNING"] = "1" +os.environ["PIP_USE_FEATURE"] = "2020-resolver" + @pytest.fixture(autouse=True) def ensure_logging_framework_not_altered() -> Iterator[None]: @@ -86,6 +105,7 @@ class ToxProject: def __init__( self, files: Dict[str, Any], + base: Optional[Path], path: Path, capsys: CaptureFixture, monkeypatch: MonkeyPatch, @@ -93,17 +113,20 @@ class ToxProject: self.path: Path = path self.monkeypatch: MonkeyPatch = monkeypatch self._capsys = capsys - self._setup_files(self.path, files) + self._setup_files(self.path, base, files) @staticmethod - def _setup_files(dest: Path, content: Dict[str, Any]) -> None: + def _setup_files(dest: Path, base: Optional[Path], content: Dict[str, Any]) -> None: + if base is not None: + shutil.copytree(str(base), str(dest)) + dest.mkdir(exist_ok=True) for key, value in content.items(): if not isinstance(key, str): raise TypeError(f"{key!r} at {dest}") # pragma: no cover at_path = dest / key if isinstance(value, dict): at_path.mkdir(exist_ok=True) - ToxProject._setup_files(at_path, value) + ToxProject._setup_files(at_path, None, value) elif isinstance(value, str): at_path.write_text(textwrap.dedent(value)) else: @@ -149,6 +172,9 @@ class ToxProject: m.setattr(sys, "argv", [sys.executable, "-m", "tox"] + list(args)) m.setenv("VIRTUALENV_SYMLINK_APP_DATA", "1") m.setenv("VIRTUALENV_SYMLINKS", "1") + m.setenv("VIRTUALENV_PIP", "embed") + m.setenv("VIRTUALENV_WHEEL", "embed") + m.setenv("VIRTUALENV_SETUPTOOLS", "embed") try: tox_run(args) except SystemExit as exception: @@ -186,6 +212,9 @@ class ToxRunOutcome: def assert_success(self) -> None: assert self.success, repr(self) + def assert_failed(self) -> None: + assert not self.success, repr(self) + def __repr__(self) -> str: return "\n".join( "{}{}{}".format(k, "\n" if "\n" in v else ": ", v) @@ -230,16 +259,18 @@ class ToxRunOutcome: assert Matches(pattern, flags=flags) == text -ToxProjectCreator = Callable[[Dict[str, Any]], ToxProject] +class ToxProjectCreator(Protocol): + def __call__(self, files: Dict[str, Any], base: Optional[Path] = None) -> ToxProject: + ... @pytest.fixture(name="tox_project") def init_fixture(tmp_path: Path, capsys: CaptureFixture, monkeypatch: MonkeyPatch) -> ToxProjectCreator: - def _init(files: Dict[str, Any]) -> ToxProject: + def _init(files: Dict[str, Any], base: Optional[Path] = None) -> ToxProject: """create tox projects""" - return ToxProject(files, tmp_path, capsys, monkeypatch) + return ToxProject(files, base, tmp_path / "p", capsys, monkeypatch) - return _init + return _init # noqa @pytest.fixture() @@ -277,12 +308,178 @@ def pytest_collection_modifyitems(config: PyTestConfig, items: List[Function]) - items.sort(key=lambda i: 1 if is_integration(i) else 0) +class Index: + def __init__(self, base_url: str, name: str, client_cmd_base: List[str]) -> None: + self._client_cmd_base = client_cmd_base + self._server_url = base_url + self.name = name + + @property + def url(self) -> str: + return f"{self._server_url}/{self.name}/+simple" + + def upload(self, files: Sequence[Path]) -> None: + check_call(self._client_cmd_base + ["upload", "--index", self.name] + [str(i) for i in files]) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(url={self.url})" # pragma: no cover + + def use(self, monkeypatch: MonkeyPatch) -> None: + enable_pypi_server(monkeypatch, self.url) + + +def enable_pypi_server(monkeypatch: MonkeyPatch, url: Optional[str]) -> None: + if url is None: # pragma: no cover # only one of the branches can be hit depending on env + monkeypatch.delenv("PIP_INDEX_URL", raising=False) + else: # pragma: no cover + monkeypatch.setenv("PIP_INDEX_URL", url) + monkeypatch.setenv("PIP_RETRIES", str(5)) + monkeypatch.setenv("PIP_TIMEOUT", str(2)) + + +def _find_free_port() -> int: + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as socket_handler: + socket_handler.bind(("", 0)) + return cast(int, socket_handler.getsockname()[1]) + + +class IndexServer: + def __init__(self, path: Path) -> None: + self.path = path + + self.host, self.port = "localhost", _find_free_port() + self._passwd = "".join(random.choice(string.ascii_letters) for _ in range(8)) + + def _exe(name: str) -> str: + return str(Path(scripts_dir) / f"{name}{'.exe' if IS_WIN else ''}") + + scripts_dir = PythonInfo.current().sysconfig_path("scripts") + self._init: str = _exe("devpi-init") + self._server: str = _exe("devpi-server") + self._client: str = _exe("devpi") + + self._server_dir = self.path / "server" + self._client_dir = self.path / "client" + self._indexes: Dict[str, Index] = {} + self._process: Optional["Popen[str]"] = None + self._has_use = False + self._stdout_drain: Optional[Thread] = None + + def __enter__(self) -> "IndexServer": + self._create_and_start_server() + self._setup_client() + return self + + def _create_and_start_server(self) -> None: + self._server_dir.mkdir(exist_ok=True) + server_at = str(self._server_dir) + # 1. create the server + cmd = [self._init, "--serverdir", server_at] + cmd.extend(("--no-root-pypi", "--role", "standalone", "--root-passwd", self._passwd)) + check_call(cmd, stdout=PIPE, stderr=PIPE) + # 2. start the server + cmd = [self._server, "--serverdir", server_at, "--port", str(self.port), "--offline-mode"] + self._process = Popen(cmd, stdout=PIPE, universal_newlines=True) + stdout = self._drain_stdout() + for line in stdout: # pragma: no branch # will always loop at least once + if "serving at url" in line: + + def _keep_draining() -> None: + for _ in stdout: + pass + + # important to keep draining the stdout, otherwise once the buffer is full Windows blocks the processg s + self._stdout_drain = Thread(target=_keep_draining) + self._stdout_drain.start() + break + + def _drain_stdout(self) -> Iterator[str]: + process = cast("Popen[str]", self._process) + stdout = cast(IO[str], process.stdout) + while True: + if process.poll() is not None: # pragma: no cover + raise RuntimeError(f"devpi server with pid {process.pid} at {self._server_dir} died") + yield stdout.readline() + + def _setup_client(self) -> None: + """create a user on the server and authenticate it""" + self._client_dir.mkdir(exist_ok=True) + base = ["--clientdir", str(self._client_dir)] + check_call([self._client, "use"] + base + [self.url], stdout=PIPE, stderr=PIPE) + check_call([self._client, "login"] + base + ["root", "--password", self._passwd], stdout=PIPE, stderr=PIPE) + + def create_index(self, name: str, *args: str) -> Index: + if name in self._indexes: # pragma: no cover + raise ValueError(f"index {name} already exists") + base = [self._client, "--clientdir", str(self._client_dir)] + check_call(base + ["index", "-c", name, *args], stdout=PIPE, stderr=PIPE) + index = Index(f"{self.url}/root", name, base) + if not self._has_use: + self._has_use = True + check_call(base + ["use", f"root/{name}"], stdout=PIPE, stderr=PIPE) + self._indexes[name] = index + return index + + def __exit__( + self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] + ) -> None: + if self._process is not None: # pragma: no cover # defend against devpi startup fail + self._process.terminate() + if self._stdout_drain is not None and self._stdout_drain.is_alive(): # pragma: no cover # devpi startup fail + self._stdout_drain.join() + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}" + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(url={self.url}, indexes={list(self._indexes)})" # pragma: no cover + + +@pytest.fixture(scope="session") +def pypi_server(tmp_path_factory: TempPathFactory) -> Iterator[IndexServer]: + # takes around 2.5s + path = tmp_path_factory.mktemp("pypi") + with IndexServer(path) as server: + server.create_index("empty", "volatile=False") + yield server + + +@pytest.fixture(scope="session") +def _invalid_index_fake_port() -> int: + return _find_free_port() + + +@pytest.fixture(autouse=True) +def disable_pip_pypi_access(_invalid_index_fake_port: int, monkeypatch: MonkeyPatch) -> Tuple[str, Optional[str]]: + """set a fake pip index url, tests that want to use a pypi server should create and overwrite this""" + previous_url = os.environ.get("PIP_INDEX_URL") + new_url = f"http://localhost:{_invalid_index_fake_port}/bad-pypi-server" + monkeypatch.setenv("PIP_INDEX_URL", new_url) + monkeypatch.setenv("PIP_RETRIES", str(0)) + monkeypatch.setenv("PIP_TIMEOUT", str(0.001)) + return new_url, previous_url + + +@pytest.fixture(name="enable_pip_pypi_access") +def enable_pip_pypi_access_fixture( + disable_pip_pypi_access: Tuple[str, Optional[str]], monkeypatch: MonkeyPatch +) -> Optional[str]: + """set a fake pip index url, tests that want to use a pypi server should create and overwrite this""" + _, previous_url = disable_pip_pypi_access + enable_pypi_server(monkeypatch, previous_url) + return previous_url + + __all__ = ( "CaptureFixture", "LogCaptureFixture", + "TempPathFactory", "MonkeyPatch", "ToxRunOutcome", "ToxProject", "ToxProjectCreator", "check_os_environ", + "IndexServer", + "Index", ) diff --git a/src/tox/run.py b/src/tox/run.py index 2f3788eb..69b6fdf6 100644 --- a/src/tox/run.py +++ b/src/tox/run.py @@ -8,6 +8,7 @@ from tox.config.cli.parse import get_options from tox.config.cli.parser import Parsed from tox.config.main import Config from tox.config.source.tox_ini import ToxIni +from tox.provision import provision from tox.report import HandledError from tox.session.state import State @@ -28,6 +29,9 @@ def run(args: Optional[Sequence[str]] = None) -> None: def main(args: Sequence[str]) -> int: state = setup_state(args) + result = provision(state) + if result is not False: + return result command = state.options.command handler = state.handlers[command] result = handler(state) diff --git a/src/tox/session/cmd/legacy.py b/src/tox/session/cmd/legacy.py index 706c9049..f2bbf57d 100644 --- a/src/tox/session/cmd/legacy.py +++ b/src/tox/session/cmd/legacy.py @@ -87,6 +87,8 @@ def tox_add_option(parser: ToxParser) -> None: def legacy(state: State) -> int: option = state.options if option.show_config: + state.options.list_keys_only = [] + state.options.show_core = True return show_config(state) if option.list_envs or option.list_envs_all: option.list_no_description = option.verbosity <= DEFAULT_VERBOSITY diff --git a/src/tox/session/cmd/run/sequential.py b/src/tox/session/cmd/run/sequential.py index 7738ac01..39d9ab35 100644 --- a/src/tox/session/cmd/run/sequential.py +++ b/src/tox/session/cmd/run/sequential.py @@ -1,7 +1,6 @@ """ Run tox environments in sequential order. """ -import json from datetime import datetime from typing import Dict, List, Tuple @@ -9,6 +8,7 @@ from colorama import Fore from tox.config.cli.parser import ToxParser from tox.execute.api import Outcome +from tox.journal import write_journal from tox.plugin.impl import impl from tox.session.common import env_list_flag from tox.session.state import State @@ -32,10 +32,7 @@ def run_sequential(state: State) -> int: code, outcomes = run_one(tox_env, state.options.recreate, state.options.no_test) duration = (datetime.now() - start_one).total_seconds() status_codes[name] = code, duration, [o.elapsed for o in outcomes] - result_json = getattr(state.options, "result_json", None) - if result_json is not None: - with open(result_json, "wt") as file_handler: - json.dump(state.journal.content, file_handler, indent=2, ensure_ascii=False) + write_journal(getattr(state.options, "result_json", None), state.journal) return report(state.options.start, status_codes, state.options.is_colored) diff --git a/src/tox/session/cmd/run/single.py b/src/tox/session/cmd/run/single.py index d83d8dbc..5ee6ac44 100644 --- a/src/tox/session/cmd/run/single.py +++ b/src/tox/session/cmd/run/single.py @@ -6,20 +6,11 @@ from typing import List, Tuple, cast from tox.config.types import Command from tox.execute.api import Outcome from tox.tox_env.api import ToxEnv -from tox.tox_env.errors import Recreate from tox.tox_env.runner import RunToxEnv def run_one(tox_env: RunToxEnv, recreate: bool, no_test: bool) -> Tuple[int, List[Outcome]]: - if recreate: - tox_env.clean(package_env=recreate) - try: - tox_env.setup() - except Recreate: - tox_env.clean(package_env=False) # restart creation once, no package please - tox_env.setup() - tox_env.setup_done() - + tox_env.ensure_setup(recreate=recreate) code, outcomes = run_commands(tox_env, no_test) return code, outcomes diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py index 7a506c85..07167c91 100644 --- a/src/tox/tox_env/api.py +++ b/src/tox/tox_env/api.py @@ -15,6 +15,7 @@ from tox.config.sets import ConfigSet from tox.execute.api import Execute, Outcome from tox.execute.request import ExecuteRequest from tox.journal import EnvJournal +from tox.tox_env.errors import Recreate from .info import Info @@ -34,6 +35,8 @@ class ToxEnv(ABC): self._paths: List[Path] = [] self.logger = logging.getLogger(self.conf["env_name"]) self._env_vars: Optional[Dict[str, str]] = None + self.setup_done = False + self.clean_done = False def __repr__(self) -> str: return f"{self.__class__.__name__}(name={self.conf['env_name']})" @@ -126,8 +129,22 @@ class ToxEnv(ABC): env_dir.mkdir(exist_ok=True, parents=True) finally: self._handle_env_tmp_dir() + self.setup_done, self.clean_done = True, False - def setup_done(self) -> None: + def ensure_setup(self, recreate: bool = False) -> None: + if self.setup_done is True: + return + if recreate: + self.clean() + try: + self.setup() + except Recreate: + if not recreate: + self.clean() + self.setup() + self.setup_has_been_done() + + def setup_has_been_done(self) -> None: """called when setup is done""" def _handle_env_tmp_dir(self) -> None: @@ -139,11 +156,14 @@ class ToxEnv(ABC): env_tmp_dir.mkdir(parents=True) def clean(self) -> None: + if self.clean_done is True: + return env_dir: Path = self.conf["env_dir"] if env_dir.exists(): logging.info("remove tox env folder %s", env_dir) shutil.rmtree(env_dir) self._cache.reset() + self.setup_done, self.clean_done = False, True @property def environment_variables(self) -> Dict[str, str]: diff --git a/src/tox/tox_env/package.py b/src/tox/tox_env/package.py index b6ac8181..ef151eb7 100644 --- a/src/tox/tox_env/package.py +++ b/src/tox/tox_env/package.py @@ -2,6 +2,7 @@ A tox environment that can build packages. """ from abc import ABC, abstractmethod +from argparse import ArgumentParser from pathlib import Path from typing import TYPE_CHECKING, List, Optional, Set @@ -9,23 +10,19 @@ from packaging.requirements import Requirement from tox.config.sets import ConfigSet from tox.journal import EnvJournal -from tox.tox_env.errors import Recreate +from tox.plugin.impl import impl from .api import ToxEnv if TYPE_CHECKING: from tox.config.cli.parser import Parsed - from tox.tox_env.python.api import Deps + from tox.tox_env.python.api import PythonDeps class PackageToxEnv(ToxEnv, ABC): def __init__(self, conf: ConfigSet, core: ConfigSet, options: "Parsed", journal: EnvJournal) -> None: super().__init__(conf, core, options, journal) - self._cleaned = False - self._setup_done = False - - def register_config(self) -> None: - super().register_config() + self.recreate_package = options.no_recreate_pkg is False if options.recreate else False @abstractmethod def get_package_dependencies(self, extras: Optional[Set[str]] = None) -> List[Requirement]: @@ -35,21 +32,19 @@ class PackageToxEnv(ToxEnv, ABC): def perform_packaging(self) -> List[Path]: raise NotImplementedError + def package_deps(self) -> "PythonDeps": + return [] + def clean(self) -> None: - # package environments may be shared clean only once - if self._cleaned is False: - self._cleaned = True + if self.recreate_package: # only recreate if user did not opt out super().clean() - def ensure_setup(self) -> None: - if self._setup_done is False: - try: - self.setup() - except Recreate: - self.clean() - self.setup() - self.setup_done() - self._setup_done = True - - def package_deps(self) -> "Deps": - return [] + +@impl +def tox_add_option(parser: ArgumentParser) -> None: + parser.add_argument( + "--no-recreate-pkg", + dest="no_recreate_pkg", + help="if recreate is set do not recreate packaging tox environment(s)", + action="store_true", + ) diff --git a/src/tox/tox_env/python/api.py b/src/tox/tox_env/python/api.py index cbb79765..c459130f 100644 --- a/src/tox/tox_env/python/api.py +++ b/src/tox/tox_env/python/api.py @@ -35,7 +35,7 @@ class PythonInfo(NamedTuple): extra_version_info: Optional[str] -class Dep: +class PythonDep: def __init__(self, value: Union[Path, Requirement]) -> None: self._value = value @@ -53,7 +53,7 @@ class Dep: return not (self == other) -Deps = Sequence[Dep] +PythonDeps = Sequence[PythonDep] class Python(ToxEnv, ABC): @@ -125,9 +125,9 @@ class Python(ToxEnv, ABC): self.create_python_env() self._paths = self.paths() - def setup_done(self) -> None: + def setup_has_been_done(self) -> None: """called when setup is done""" - super().setup_done() + super().setup_has_been_done() if self.journal: outcome = self.get_installed_packages() self.journal["installed_packages"] = outcome @@ -172,19 +172,19 @@ class Python(ToxEnv, ABC): def _get_python(self, base_python: List[str]) -> Optional[PythonInfo]: raise NotImplementedError - def cached_install(self, deps: Deps, section: str, of_type: str) -> bool: + 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 = [Dep(Requirement(i)) for i in (set(old) - set(conf_deps))] + 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 raise Recreate() new_deps_str = set(conf_deps) - set(old) - new_deps = [Dep(Requirement(i)) for i in new_deps_str] + new_deps = [PythonDep(Requirement(i)) for i in new_deps_str] self.install_python_packages(packages=new_deps) return False @@ -197,7 +197,7 @@ class Python(ToxEnv, ABC): raise NotImplementedError @abstractmethod - def install_python_packages(self, packages: Deps, no_deps: bool = False) -> None: + def install_python_packages(self, packages: PythonDeps, no_deps: bool = False) -> None: raise NotImplementedError diff --git a/src/tox/tox_env/python/package.py b/src/tox/tox_env/python/package.py index 21e32508..267b5ceb 100644 --- a/src/tox/tox_env/python/package.py +++ b/src/tox/tox_env/python/package.py @@ -10,20 +10,22 @@ from packaging.requirements import Requirement from tox.config.main import Config from ..package import PackageToxEnv -from .api import Dep, NoInterpreter, Python +from .api import NoInterpreter, Python, PythonDep class PythonPackage(Python, PackageToxEnv, ABC): def setup(self) -> None: """setup the tox environment""" super().setup() - fresh_requires = self.cached_install([Dep(i) for i in self.requires()], PythonPackage.__name__, "requires") + fresh_requires = self.cached_install( + [PythonDep(i) for i in self.requires()], PythonPackage.__name__, "requires" + ) if not fresh_requires: build_requirements: List[Union[str, Requirement]] = [] with self._cache.compare(build_requirements, PythonPackage.__name__, "build-requires") as (eq, old): if eq is False and old is None: build_requirements.extend(self.build_requires()) - new_deps = [Dep(Requirement(i) if isinstance(i, str) else i) for i in set(build_requirements)] + new_deps = [PythonDep(Requirement(i) if isinstance(i, str) else i) for i in set(build_requirements)] self.install_python_packages(packages=new_deps) def no_base_python_found(self, base_pythons: List[str]) -> NoReturn: diff --git a/src/tox/tox_env/python/runner.py b/src/tox/tox_env/python/runner.py index 1bfaae88..bc454a24 100644 --- a/src/tox/tox_env/python/runner.py +++ b/src/tox/tox_env/python/runner.py @@ -8,30 +8,40 @@ from typing import Any, Dict, List, NoReturn, Union, cast from packaging.requirements import Requirement +from tox.config.cli.parser import Parsed +from tox.config.sets import ConfigSet from tox.journal import EnvJournal from tox.tox_env.errors import Skip from ..runner import RunToxEnv -from .api import Dep, NoInterpreter, Python +from .api import NoInterpreter, Python, PythonDep class PythonRun(Python, RunToxEnv, ABC): + def __init__(self, conf: ConfigSet, core: ConfigSet, options: Parsed, journal: EnvJournal): + super().__init__(conf, core, options, journal) + self._packages: List[PythonDep] = [] + def register_config(self) -> None: super().register_config() outer_self = self - class _Dep(Dep): - def __init__(self, raw: Any) -> None: - if not raw.startswith("-r"): - val: Union[Path, Requirement] = Requirement(raw) + class _PythonDep(PythonDep): + def __init__(self, raw: Union[PythonDep, str]) -> None: + if isinstance(raw, str): + if raw.startswith("-r"): + val: Union[Path, Requirement] = Path(raw[2:]) + if not cast(Path, val).is_absolute(): + val = outer_self.core["toxinidir"] / val + else: + val = Requirement(raw) else: - path = Path(raw[2:]) - val = path if path.is_absolute() else cast(Path, outer_self.core["toxinidir"]) / path + val = raw.value super().__init__(val) self.conf.add_config( keys="deps", - of_type=List[_Dep], + of_type=List[_PythonDep], default=[], desc="Name of the python dependencies as specified by PEP-440", ) @@ -51,26 +61,38 @@ class PythonRun(Python, RunToxEnv, ABC): """setup the tox environment""" super().setup() self.install_deps() - if self.package_env is not None: package_deps = self.package_env.get_package_dependencies(self.conf["extras"]) - self.cached_install([Dep(p) for p in package_deps], PythonRun.__name__, "package_deps") + self.cached_install([PythonDep(p) for p in package_deps], PythonRun.__name__, "package_deps") self.install_package() def install_deps(self) -> None: self.cached_install(self.conf["deps"], PythonRun.__name__, "deps") def install_package(self) -> None: - if self.package_env is not None: - package: List[Dep] = [Dep(p) for p in self.package_env.perform_packaging()] - else: - package = [Dep(d) for d in self.get_pkg_no_env()] if self.has_package else [] + package = self.get_package() if package: self.install_python_packages(package, **self.install_package_args()) # type: ignore[no-untyped-call] self.handle_journal_package(self.journal, package) + def get_package(self) -> List[PythonDep]: + if self.package_env is not None: + package: List[PythonDep] = [PythonDep(p) for p in self.package_env.perform_packaging()] + else: + package = [PythonDep(d) for d in self.get_pkg_no_env()] if self.has_package else [] + self._packages = package + return package + + @abstractmethod + def install_package_args(self) -> Dict[str, Any]: + raise NotImplementedError + + @property + def packages(self) -> List[str]: + return [str(d.value) for d in self._packages] + @staticmethod - def handle_journal_package(journal: EnvJournal, package: List[Dep]) -> None: + def handle_journal_package(journal: EnvJournal, package: List[PythonDep]) -> None: if not journal: return installed_meta = [] @@ -88,7 +110,3 @@ class PythonRun(Python, RunToxEnv, ABC): def get_pkg_no_env(self) -> List[Path]: # by default in Python just forward the root folder to the installer return [cast(Path, self.core["tox_root"])] - - @abstractmethod - def install_package_args(self) -> Dict[str, Any]: - raise NotImplementedError diff --git a/src/tox/tox_env/python/virtual_env/api.py b/src/tox/tox_env/python/virtual_env/api.py index d6f865e5..27b0023d 100644 --- a/src/tox/tox_env/python/virtual_env/api.py +++ b/src/tox/tox_env/python/virtual_env/api.py @@ -15,7 +15,7 @@ from tox.execute.api import Execute, Outcome from tox.execute.local_sub_process import LocalSubProcessExecutor from tox.journal import EnvJournal -from ..api import Deps, Python, PythonInfo +from ..api import Python, PythonDeps, PythonInfo class VirtualEnv(Python, ABC): @@ -89,7 +89,7 @@ class VirtualEnv(Python, ABC): def install_python_packages( self, - packages: Deps, + packages: PythonDeps, no_deps: bool = False, develop: bool = False, force_reinstall: bool = False, diff --git a/src/tox/tox_env/python/virtual_env/package/artifact/dev.py b/src/tox/tox_env/python/virtual_env/package/artifact/dev.py index 60218da4..fc4aed26 100644 --- a/src/tox/tox_env/python/virtual_env/package/artifact/dev.py +++ b/src/tox/tox_env/python/virtual_env/package/artifact/dev.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import List, cast from tox.plugin.impl import impl -from tox.tox_env.python.api import Dep, Deps +from tox.tox_env.python.api import PythonDep, PythonDeps from tox.tox_env.register import ToxEnvRegister from ..api import Pep517VirtualEnvPackage @@ -19,9 +19,9 @@ class LegacyDevVirtualEnvPackage(Pep517VirtualEnvPackage): """The root folder itself is the package to install""" return [cast(Path, self.core["tox_root"])] - def package_deps(self) -> Deps: + def package_deps(self) -> PythonDeps: """Install requirement from pyproject.toml table""" - return [Dep(i) for i in self._requires] + return [PythonDep(i) for i in self._requires] @impl diff --git a/src/tox/tox_env/runner.py b/src/tox/tox_env/runner.py index f5a00e70..9abb1d48 100644 --- a/src/tox/tox_env/runner.py +++ b/src/tox/tox_env/runner.py @@ -1,6 +1,7 @@ -from abc import ABC +import os +from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Generator, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Dict, Generator, List, Optional, Tuple, cast from tox.config.sets import ConfigSet from tox.config.types import Command, EnvList @@ -67,6 +68,12 @@ class RunToxEnv(ToxEnv, ABC): def add_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"], + of_type=bool, + default=False, + desc="Is there any packaging involved in this project.", + ) core_no_package: bool = self.core["no_package"] if core_no_package is True: return False @@ -93,3 +100,18 @@ class RunToxEnv(ToxEnv, ABC): super().clean() if self.package_env: self.package_env.clean() + + @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 + packages = self.packages + if packages: + environment_variables["TOX_PACKAGE"] = os.pathsep.join(packages) + return environment_variables + + @property + @abstractmethod + def packages(self) -> List[str]: + """:returns: a list of packages installed in the environment""" + raise NotImplementedError diff --git a/tests/config/cli/test_cli_env_var.py b/tests/config/cli/test_cli_env_var.py index 957c983b..a6d989de 100644 --- a/tests/config/cli/test_cli_env_var.py +++ b/tests/config/cli/test_cli_env_var.py @@ -36,6 +36,7 @@ def test_verbose_no_test(monkeypatch: MonkeyPatch) -> None: "override": [], "show_config": False, "list_envs_all": False, + "no_recreate_pkg": False, "list_envs": False, "devenv_path": None, "config_file": "", @@ -86,6 +87,7 @@ def test_env_var_exhaustive_parallel_values( "installpkg": None, "list_envs": False, "list_envs_all": False, + "no_recreate_pkg": False, "no_test": True, "override": [Override("a=b"), Override("c=d")], "package_only": False, diff --git a/tests/config/cli/test_cli_ini.py b/tests/config/cli/test_cli_ini.py index 374db8e7..5d3afde4 100644 --- a/tests/config/cli/test_cli_ini.py +++ b/tests/config/cli/test_cli_ini.py @@ -89,6 +89,7 @@ def default_options() -> Dict[str, Any]: "result_json": None, "skip_missing_interpreters": "config", "verbose": 2, + "no_recreate_pkg": False, "work_dir": Path.cwd().absolute(), } @@ -107,6 +108,7 @@ def test_ini_exhaustive_parallel_values(exhaustive_ini: Path, core_handlers: Dic "no_test": True, "override": [Override("a=b"), Override("c=d")], "package_only": False, + "no_recreate_pkg": False, "parallel": 3, "parallel_live": True, "quiet": 1, diff --git a/tests/config/loader/ini/replace/test_replace_tox_env.py b/tests/config/loader/ini/replace/test_replace_tox_env.py index de3562a4..75791f59 100644 --- a/tests/config/loader/ini/replace/test_replace_tox_env.py +++ b/tests/config/loader/ini/replace/test_replace_tox_env.py @@ -135,4 +135,4 @@ def test_replace_from_section_bad_type(tox_ini_conf: ToxIniCreator) -> None: def test_replace_from_tox_section_registered(tox_ini_conf: ToxIniCreator, tmp_path: Path) -> None: conf_a = tox_ini_conf("[testenv:a]\nx = {[tox]tox_root}").get_env("a") conf_a.add_config(keys="x", of_type=Path, default=Path.cwd() / "magic", desc="d") - assert conf_a["x"] == tmp_path + assert conf_a["x"] == (tmp_path / "c") diff --git a/tests/config/test_main.py b/tests/config/test_main.py index 81ed88fa..8976d236 100644 --- a/tests/config/test_main.py +++ b/tests/config/test_main.py @@ -5,7 +5,6 @@ from tox.config.loader.api import Override from tox.config.loader.memory import MemoryLoader from tox.config.main import Config from tox.config.sets import ConfigSet -from tox.pytest import ToxProject @pytest.fixture @@ -13,13 +12,9 @@ def empty_config(tox_ini_conf: ToxIniCreator) -> Config: return tox_ini_conf("") -def test_empty_config_root(empty_config: Config, empty_project: ToxProject) -> None: - assert empty_config.core["tox_root"] == empty_project.path - - -def test_empty_config_repr(empty_config: Config, empty_project: ToxProject) -> None: +def test_empty_config_repr(empty_config: Config) -> None: text = repr(empty_config) - assert str(empty_project.path) in text + assert str(empty_config.core["tox_root"]) in text assert "config_source=ToxIni" in text diff --git a/tests/conftest.py b/tests/conftest.py index d445332a..1bdd429a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ from tox.config.main import Config from tox.run import make_config pytest_plugins = "tox.pytest" +HERE = Path(__file__).absolute().parent @pytest.fixture(scope="session") @@ -35,9 +36,21 @@ class ToxIniCreator(Protocol): @pytest.fixture def tox_ini_conf(tmp_path: Path, monkeypatch: MonkeyPatch) -> ToxIniCreator: def func(conf: str, override: Optional[Sequence[Override]] = None) -> Config: - (tmp_path / "tox.ini").write_bytes(conf.encode("utf-8")) + dest = tmp_path / "c" + dest.mkdir() + (dest / "tox.ini").write_bytes(conf.encode("utf-8")) with monkeypatch.context() as context: context.chdir(tmp_path) - return make_config(Parsed(work_dir=tmp_path, override=override or []), pos_args=[]) + return make_config(Parsed(work_dir=dest, override=override or []), pos_args=[]) return func + + +@pytest.fixture(scope="session") +def demo_pkg_setuptools() -> Path: + return HERE / "demo_pkg_setuptools" + + +@pytest.fixture(scope="session") +def demo_pkg_inline() -> Path: + return HERE / "demo_pkg_inline" diff --git a/tests/demo_pkg_inline/build.py b/tests/demo_pkg_inline/build.py new file mode 100644 index 00000000..7515fc0b --- /dev/null +++ b/tests/demo_pkg_inline/build.py @@ -0,0 +1,53 @@ +import sys +from pathlib import Path +from textwrap import dedent +from zipfile import ZipFile + +name = "demo_pkg_inline" +pkg_name = name.replace("_", "-") + +version = "1.0.0" +dist_info = f"{name}-{version}.dist-info" + +content = { + f"{name}/__init__.py": f"def do():\nprint('greetings from {name}')", + f"{dist_info}/METADATA": f""" + Metadata-Version: 2.1 + Name: {pkg_name} + Version: {version} + Summary: UNKNOWN + Home-page: UNKNOWN + Author: UNKNOWN + Author-email: UNKNOWN + License: UNKNOWN + Platform: UNKNOWN + + UNKNOWN + """, + f"{dist_info}/WHEEL": f""" + Wheel-Version: 1.0 + Generator: {name}-{version} + Root-Is-Purelib: true + Tag: py3-none-any + """, + f"{dist_info}/top_level.txt": name, + f"{dist_info}/RECORD": f""" + {name}/__init__.py,, + {dist_info}/METADATA,, + {dist_info}/WHEEL,, + {dist_info}/top_level.txt,, + {dist_info}/RECORD,, + """, +} + + +def build_wheel(wheel_directory, metadata_directory=None, config_settings=None): + path = Path(wheel_directory) / f"{name}-{version}-py{sys.version_info.major}-none-any.whl" + with ZipFile(str(path), "w") as zip_file_handler: + for arc_name, data in content.items(): + zip_file_handler.writestr(zinfo_or_arcname=arc_name, data=dedent(data).strip()) + print(f"created wheel {path}") + + +def get_requires_for_build_wheel(config_settings): + return [] diff --git a/tests/demo_pkg_inline/pyproject.toml b/tests/demo_pkg_inline/pyproject.toml new file mode 100644 index 00000000..a28fa49a --- /dev/null +++ b/tests/demo_pkg_inline/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires=[] +build-backend="build" +backend-path=["."] diff --git a/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py b/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py new file mode 100644 index 00000000..694740b1 --- /dev/null +++ b/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py @@ -0,0 +1,2 @@ +def do(): + print("greetings from demo_pkg_setuptools") diff --git a/tests/demo_pkg_setuptools/pyproject.toml b/tests/demo_pkg_setuptools/pyproject.toml new file mode 100644 index 00000000..7fd4fb51 --- /dev/null +++ b/tests/demo_pkg_setuptools/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=45", "wheel>=0.33"] +build-backend = 'setuptools.build_meta' diff --git a/tests/demo_pkg_setuptools/setup.cfg b/tests/demo_pkg_setuptools/setup.cfg new file mode 100644 index 00000000..80e42548 --- /dev/null +++ b/tests/demo_pkg_setuptools/setup.cfg @@ -0,0 +1,6 @@ +[metadata] +name = demo_pkg_setuptools +version = 1.2.3 + +[options] +packages = find: diff --git a/tests/pytest_/test_init.py b/tests/pytest_/test_init.py index b615c799..08e14165 100644 --- a/tests/pytest_/test_init.py +++ b/tests/pytest_/test_init.py @@ -1,6 +1,7 @@ import os import sys from itertools import chain, combinations +from pathlib import Path from textwrap import dedent from typing import List, Sequence @@ -12,7 +13,7 @@ from tox.pytest import MonkeyPatch, ToxProjectCreator, check_os_environ from tox.report import HandledError -def test_init_base(tox_project: ToxProjectCreator) -> None: +def test_tox_project_no_base(tox_project: ToxProjectCreator) -> None: project = tox_project( { "tox.ini": "[tox]", @@ -27,6 +28,14 @@ def test_init_base(tox_project: ToxProjectCreator) -> None: } +def test_tox_project_base(tmp_path: Path, tox_project: ToxProjectCreator) -> None: + base = tmp_path / "base" + base.mkdir() + (base / "out").write_text("a") + project = tox_project({"tox.ini": "[tox]"}, base=base) + assert project.structure + + COMB = list(chain.from_iterable(combinations(["DIFF", "MISS", "EXTRA"], i) for i in range(4))) @@ -98,7 +107,6 @@ def test_tox_run_outcome_repr(tox_project: ToxProjectCreator) -> None: min_version = {__version__} provision_tox_env = .tox requires = tox>={__version__} - no_package = False """ ).lstrip() assert repr(outcome) == exp diff --git a/tests/session/cmd/run/test_int_setuptools.py b/tests/session/cmd/run/test_int_setuptools.py deleted file mode 100644 index 2201284d..00000000 --- a/tests/session/cmd/run/test_int_setuptools.py +++ /dev/null @@ -1,52 +0,0 @@ -import os - -import pytest - -from tox.pytest import ToxProjectCreator - - -@pytest.mark.timeout(20) -@pytest.mark.integration -def test_setuptools_package_py_project(tox_project: ToxProjectCreator) -> None: - project = tox_project( - { - "tox.ini": """ - [tox] - env_list = py - - [testenv] - commands_pre = - python -c 'import sys; print("start", sys.executable)' - commands = - python -c 'import magic; print(magic.__version__)' - commands_post = - python -c 'import sys; print("end", sys.executable)' - package = wheel - """, - "setup.cfg": """ - [metadata] - name = magic - version = 1.2.3 - [options] - packages = find: - package_dir = - =src - [options.packages.find] - where = src - [bdist_wheel] - universal = 1 - """, - "pyproject.toml": """ - [build-system] - requires = [ - "setuptools >= 40.0.4", - "wheel >= 0.29.0", - ] - build-backend = 'setuptools.build_meta' - """, - "src": {"magic": {"__init__.py": """__version__ = "1.2.3" """}}, - }, - ) - outcome = project.run("-vv", "r", "-e", "py") - outcome.assert_success() - assert f"\n1.2.3{os.linesep}" in outcome.out diff --git a/tests/session/cmd/test_show_config.py b/tests/session/cmd/test_show_config.py index d8fd5ee2..610e05ee 100644 --- a/tests/session/cmd/test_show_config.py +++ b/tests/session/cmd/test_show_config.py @@ -24,7 +24,6 @@ def test_list_empty(tox_project: ToxProjectCreator) -> None: min_version = {__version__} provision_tox_env = .tox requires = tox>={__version__} - no_package = False """, ).lstrip() assert outcome.out == expected diff --git a/tests/test_provision.py b/tests/test_provision.py new file mode 100644 index 00000000..680927f5 --- /dev/null +++ b/tests/test_provision.py @@ -0,0 +1,106 @@ +import json +import os +import sys +from pathlib import Path +from subprocess import check_call +from typing import List, Optional +from zipfile import ZipFile + +import pytest +from packaging.requirements import Requirement + +from tox.pytest import Index, IndexServer, MonkeyPatch, TempPathFactory, ToxProjectCreator + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from importlib.metadata import Distribution # type: ignore[attr-defined] +else: # pragma: no cover (<py38) + from importlib_metadata import Distribution # noqa + +ROOT = Path(__file__).parents[1] + + +@pytest.fixture(scope="session") +def tox_wheel(tmp_path_factory: TempPathFactory) -> Path: + # takes around 3.2s + package: Optional[Path] = None + if "TOX_PACKAGE" in os.environ: + env_tox_pkg = Path(os.environ["TOX_PACKAGE"]) + if env_tox_pkg.exists() and env_tox_pkg.suffix == ".whl": + package = env_tox_pkg + if package is None: # pragma: no cover + # when we don't get a wheel path injected, build it (for example when running from an IDE) + package = build_wheel(tmp_path_factory.mktemp("dist"), Path(__file__).parents[1]) + return package + + +@pytest.fixture(scope="session") +def tox_wheels(tox_wheel: Path, tmp_path_factory: TempPathFactory) -> List[Path]: + # takes around 1.5s if already cached + result: List[Path] = [tox_wheel] + info = tmp_path_factory.mktemp("info") + with ZipFile(str(tox_wheel), "r") as zip_file: + zip_file.extractall(path=info) + dist_info = next((i for i in info.iterdir() if i.suffix == ".dist-info"), None) + if dist_info is None: # pragma: no cover + raise RuntimeError(f"no tox.dist-info inside {tox_wheel}") + distribution = Distribution.at(dist_info) + wheel_cache = ROOT / ".wheel_cache" / f"{sys.version_info.major}.{sys.version_info.minor}" + wheel_cache.mkdir(parents=True, exist_ok=True) + cmd = [sys.executable, "-I", "-m", "pip", "--disable-pip-version-check", "download", "-d", str(wheel_cache)] + for req in distribution.requires: + requirement = Requirement(req) + if not requirement.extras: # pragma: no branch # we don't need to install any extras (tests/docs/etc) + cmd.append(req) + check_call(cmd) + result.extend(wheel_cache.iterdir()) + return result + + +@pytest.fixture(scope="session") +def demo_pkg_inline_wheel(tmp_path_factory: TempPathFactory, demo_pkg_inline: Path) -> Path: + return build_wheel(tmp_path_factory.mktemp("dist"), demo_pkg_inline) + + +def build_wheel(dist_dir: Path, of: Path) -> Path: + from build.__main__ import build_package # noqa + + build_package(str(of), str(dist_dir), distributions=["wheel"]) + package = next(dist_dir.iterdir()) + return package + + +@pytest.fixture(scope="session") +def pypi_index_self(pypi_server: IndexServer, tox_wheels: List[Path], demo_pkg_inline_wheel: Path) -> Index: + # takes around 1s + self_index = pypi_server.create_index("self", "volatile=False") + self_index.upload(tox_wheels + [demo_pkg_inline_wheel]) + return self_index + + +def test_provision_requires_nok(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nrequires = pkg-does-not-exist\n setuptools==1\nskipsdist=true\n" + outcome = tox_project({"tox.ini": ini}).run("c", "-e", "py") + outcome.assert_failed() + outcome.assert_out_err( + r".*will run in automatically provisioned tox, host .* is missing \[requires \(has\)\]:" + r" pkg-does-not-exist \(N/A\), setuptools==1 \(.*\).*", + r".*", + regex=True, + ) + + +@pytest.mark.integration +@pytest.mark.timeout(60) +def test_provision_requires_ok( + tox_project: ToxProjectCreator, pypi_index_self: Index, monkeypatch: MonkeyPatch, tmp_path: Path +) -> None: + log = tmp_path / "out.log" + pypi_index_self.use(monkeypatch) + ini = "[tox]\nrequires = demo-pkg-inline\n setuptools \n[testenv]\npackage=skip" + + outcome = tox_project({"tox.ini": ini}).run("r", "-e", "py", "--result-json", str(log)) + + outcome.assert_success() + with log.open("rt") as file_handler: + log_report = json.load(file_handler) + assert "py" in log_report["testenvs"] diff --git a/tests/tox_env/python/test_python_api.py b/tests/tox_env/python/test_python_api.py index 8ce76e48..6f66756d 100644 --- a/tests/tox_env/python/test_python_api.py +++ b/tests/tox_env/python/test_python_api.py @@ -2,28 +2,28 @@ from pathlib import Path from packaging.requirements import Requirement -from tox.tox_env.python.api import Dep +from tox.tox_env.python.api import PythonDep def test_deps_path_eq() -> None: - dep_1 = Dep(Path.cwd()) - dep_2 = Dep(Path.cwd()) + dep_1 = PythonDep(Path.cwd()) + dep_2 = PythonDep(Path.cwd()) assert dep_1 == dep_2 def test_deps_path_ne() -> None: - dep_1 = Dep(Path.cwd()) - dep_2 = Dep(Path.cwd() / "a") + dep_1 = PythonDep(Path.cwd()) + dep_2 = PythonDep(Path.cwd() / "a") assert dep_1 != dep_2 def test_deps_req_eq() -> None: - dep_1 = Dep(Requirement("pytest")) - dep_2 = Dep(Requirement("pytest")) + dep_1 = PythonDep(Requirement("pytest")) + dep_2 = PythonDep(Requirement("pytest")) assert dep_1 == dep_2 def test_deps_req_ne() -> None: - dep_1 = Dep(Requirement("pytest")) - dep_2 = Dep(Requirement("tox")) + dep_1 = PythonDep(Requirement("pytest")) + dep_2 = PythonDep(Requirement("tox")) assert dep_1 != dep_2 diff --git a/tests/tox_env/python/test_python_runner.py b/tests/tox_env/python/test_python_runner.py index 3507f74d..0f3662a0 100644 --- a/tests/tox_env/python/test_python_runner.py +++ b/tests/tox_env/python/test_python_runner.py @@ -4,7 +4,7 @@ from packaging.requirements import Requirement from tox.journal import EnvJournal from tox.pytest import ToxProjectCreator -from tox.tox_env.python.api import Dep +from tox.tox_env.python.api import PythonDep from tox.tox_env.python.runner import PythonRun @@ -33,7 +33,7 @@ def test_journal_one_wheel_file(tmp_path: Path) -> None: wheel.write_bytes(b"magical") journal = EnvJournal(enabled=True, name="a") - PythonRun.handle_journal_package(journal, [Dep(wheel)]) + PythonRun.handle_journal_package(journal, [PythonDep(wheel)]) content = journal.content assert content == { @@ -52,7 +52,7 @@ def test_journal_multiple_wheel_file(tmp_path: Path) -> None: wheel_2.write_bytes(b"magic") journal = EnvJournal(enabled=True, name="a") - PythonRun.handle_journal_package(journal, [Dep(wheel_1), Dep(wheel_2)]) + PythonRun.handle_journal_package(journal, [PythonDep(wheel_1), PythonDep(wheel_2)]) content = journal.content assert content == { @@ -74,7 +74,7 @@ def test_journal_multiple_wheel_file(tmp_path: Path) -> None: def test_journal_packge_dir(tmp_path: Path) -> None: journal = EnvJournal(enabled=True, name="a") - PythonRun.handle_journal_package(journal, [Dep(tmp_path)]) + PythonRun.handle_journal_package(journal, [PythonDep(tmp_path)]) content = journal.content assert content == { @@ -88,7 +88,7 @@ def test_journal_packge_dir(tmp_path: Path) -> None: def test_journal_package_requirement(tmp_path: Path) -> None: journal = EnvJournal(enabled=True, name="a") - PythonRun.handle_journal_package(journal, [Dep(Requirement("pytest"))]) + PythonRun.handle_journal_package(journal, [PythonDep(Requirement("pytest"))]) content = journal.content assert content == {} diff --git a/tests/tox_env/python/virtual_env/setuptools/__init__.py b/tests/tox_env/python/virtual_env/setuptools/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/tests/tox_env/python/virtual_env/setuptools/__init__.py +++ /dev/null diff --git a/tests/tox_env/python/virtual_env/setuptools/package/__init__.py b/tests/tox_env/python/virtual_env/setuptools/package/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/tests/tox_env/python/virtual_env/setuptools/package/__init__.py +++ /dev/null diff --git a/tests/tox_env/python/virtual_env/setuptools/package/test_wheel_build.py b/tests/tox_env/python/virtual_env/setuptools/package/test_wheel_build.py deleted file mode 100644 index c147af23..00000000 --- a/tests/tox_env/python/virtual_env/setuptools/package/test_wheel_build.py +++ /dev/null @@ -1,87 +0,0 @@ -import sys -from pathlib import Path -from typing import List, Sequence - -import pytest -import setuptools -import wheel - -from tox.execute.api import Outcome -from tox.execute.request import ExecuteRequest -from tox.pytest import MonkeyPatch, ToxProjectCreator -from tox.tox_env.python.virtual_env.api import VirtualEnv -from tox.tox_env.python.virtual_env.package.artifact.wheel import Pep517VirtualEnvPackageWheel - - -@pytest.fixture() -def use_host_virtualenv(monkeypatch: MonkeyPatch) -> None: - # disable install - def perform_install(self: VirtualEnv, install_command: Sequence[str]) -> Outcome: - install_command = ("python", "-c", "import sys; print(sys.argv)") + tuple(install_command) - return old_cmd(self, install_command) - - old_cmd = VirtualEnv.perform_install - monkeypatch.setattr(VirtualEnv, "perform_install", perform_install) - - # return hots path - def paths(self: VirtualEnv) -> List[Path]: - return [Path(sys.executable).parent] - - monkeypatch.setattr(VirtualEnv, "paths", paths) - - # return hots path - def create_python_env(self: VirtualEnv) -> Outcome: - return Outcome(ExecuteRequest(["a"], Path(), {}, False), False, Outcome.OK, "", "", 0, 1.0, ["a"]) - - monkeypatch.setattr(VirtualEnv, "create_python_env", create_python_env) - - -def test_setuptools_package_wheel_universal(tox_project: ToxProjectCreator, use_host_virtualenv: None) -> None: - project = tox_project( - { - "tox.ini": """ - [tox] - env_list = py - - [testenv] - package = wheel - package_env = .package - """, - "setup.cfg": """ - [metadata] - name = magic - version = 1.2.3 - [options] - packages = find: - package_dir = - =src - [options.packages.find] - where = src - """, - "pyproject.toml": f""" - [build-system] - requires = [ - "setuptools >= {setuptools.__version__}", - "wheel >= {wheel.__version__}", - ] - build-backend = 'setuptools.build_meta' - """, - "src": {"magic": {"__init__.py": """__version__ = "1.2.3" """}}, - }, - ) - outcome = project.run("r") - tox_env = outcome.state.tox_env("py") - package_env = tox_env.package_env - assert isinstance(package_env, Pep517VirtualEnvPackageWheel) - packages = package_env.perform_packaging() - assert len(packages) == 1 - package = packages[0] - assert package.name == "magic-1.2.3-py3-none-any.whl" - - result = outcome.out.split("\n") - py_messages = [i for i in result if "py: " in i] - assert len(py_messages) == 2 # 1 install wheel + 1 report - - package_messages = [i for i in result if ".package: " in i] - # 1 install requires + 1 build requires + 1 build meta + 1 build isolated - assert len(package_messages) == 4 diff --git a/tests/tox_env/python/virtual_env/test_setuptools.py b/tests/tox_env/python/virtual_env/test_setuptools.py index 8dcd0022..b18035b1 100644 --- a/tests/tox_env/python/virtual_env/test_setuptools.py +++ b/tests/tox_env/python/virtual_env/test_setuptools.py @@ -1,24 +1,48 @@ -from tox.pytest import ToxProjectCreator +import os +import sys +from pathlib import Path +from typing import Optional + +import pytest +from tox.pytest import ToxProjectCreator +from tox.tox_env.python.virtual_env.package.artifact.wheel import Pep517VirtualEnvPackageWheel -def test_setuptools_project_no_package(tox_project: ToxProjectCreator) -> None: - project = tox_project( - { - "tox.ini": """ - [tox] - env_list = py - no_package = true +@pytest.mark.timeout(30) +@pytest.mark.integration +def test_setuptools_package( + tox_project: ToxProjectCreator, + demo_pkg_setuptools: Path, + enable_pip_pypi_access: Optional[str], # noqa +) -> None: + tox_ini = """ [testenv] - deps = pip - commands_pre = - python -c 'import sys; print("start", sys.executable)' - commands = - python -c 'import sys; print("do", sys.executable)' - commands_post = - python -c 'import sys; print("end", sys.executable)' - """, - }, - ) - outcome = project.run("-e", "py") + package = wheel + package_env = .package + commands_pre = python -c 'import sys; print("start", sys.executable)' + commands = python -c 'from demo_pkg_setuptools import do; do()' + commands_post = python -c 'import sys; print("end", sys.executable)' + """ + project = tox_project({"tox.ini": tox_ini}, base=demo_pkg_setuptools) + + outcome = project.run("r", "-e", "py") + outcome.assert_success() + assert f"\ngreetings from demo_pkg_setuptools{os.linesep}" in outcome.out + tox_env = outcome.state.tox_env("py") + + package_env = tox_env.package_env + assert isinstance(package_env, Pep517VirtualEnvPackageWheel) + packages = package_env.perform_packaging() + assert len(packages) == 1 + package = packages[0] + assert package.name == f"demo_pkg_setuptools-1.2.3-py{sys.version_info.major}-none-any.whl" + + result = outcome.out.split("\n") + py_messages = [i for i in result if "py: " in i] + assert len(py_messages) == 5, "\n".join(py_messages) # 1 install wheel + 3 command + 1 reports + + package_messages = [i for i in result if ".package: " in i] + # 1 install requires + 1 build requires + 1 build meta + 1 build isolated + assert len(package_messages) == 4, "\n".join(package_messages) @@ -11,7 +11,7 @@ envlist = pkg_meta isolated_build = true skip_missing_interpreters = true -minversion = 3.14.0 +minversion = 3.14 [testenv] description = run the tests with pytest @@ -32,7 +32,7 @@ commands = --cov-report html:{envtmpdir}/htmlcov \ --cov-report xml:{toxworkdir}/coverage.{envname}.xml \ -n={env:PYTEST_XDIST_PROC_NR:auto} \ - tests --timeout 10 --durations 5 --run-integration} + tests --timeout 20 --durations 5 --run-integration} [testenv:fix] description = format the code base to adhere to our styles, and complain about what we cannot do automatically |