Skip to content

Commit 6f4cf48

Browse files
authored
Use devpi-process instead rolling our own (#2582)
1 parent d9de29d commit 6f4cf48

File tree

3 files changed

+22
-150
lines changed

3 files changed

+22
-150
lines changed

pyproject.toml

+1-2
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,7 @@ optional-dependencies.docs = [
4646
optional-dependencies.testing = [
4747
"build[virtualenv]>=0.9",
4848
"covdefaults>=2.2",
49-
"devpi-client>=6.0.2",
50-
"devpi-server>=6.7",
49+
"devpi-process>=0.2",
5150
"diff-cover>=7.2",
5251
"distlib>=0.3.6",
5352
"filelock>=3.8",

src/tox/pytest.py

+6-133
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,16 @@
55

66
import inspect
77
import os
8-
import random
98
import re
109
import shutil
1110
import socket
12-
import string
1311
import sys
1412
import textwrap
1513
import warnings
1614
from contextlib import closing, contextmanager
1715
from pathlib import Path
18-
from subprocess import PIPE, Popen, check_call
19-
from threading import Thread
2016
from types import ModuleType, TracebackType
21-
from typing import IO, TYPE_CHECKING, Any, Callable, Iterator, Sequence, cast
17+
from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, cast
2218
from unittest.mock import MagicMock
2319

2420
import pytest
@@ -30,9 +26,9 @@
3026
from _pytest.monkeypatch import MonkeyPatch
3127
from _pytest.python import Function
3228
from _pytest.tmpdir import TempPathFactory
29+
from devpi_process import IndexServer
3330
from pytest_mock import MockerFixture
34-
from virtualenv.discovery.py_info import PythonInfo
35-
from virtualenv.info import IS_WIN, fs_supports_symlink
31+
from virtualenv.info import fs_supports_symlink
3632

3733
import tox.run
3834
from tox.config.sets import EnvConfigSet
@@ -467,26 +463,6 @@ def is_integration(test_item: Function) -> bool:
467463
items.sort(key=is_integration)
468464

469465

470-
class Index:
471-
def __init__(self, base_url: str, name: str, client_cmd_base: list[str]) -> None:
472-
self._client_cmd_base = client_cmd_base
473-
self._server_url = base_url
474-
self.name = name
475-
476-
@property
477-
def url(self) -> str:
478-
return f"{self._server_url}/{self.name}/+simple"
479-
480-
def upload(self, files: Sequence[Path]) -> None:
481-
check_call(self._client_cmd_base + ["upload", "--index", self.name] + [str(i) for i in files])
482-
483-
def __repr__(self) -> str:
484-
return f"{self.__class__.__name__}(url={self.url})" # pragma: no cover
485-
486-
def use(self, monkeypatch: MonkeyPatch) -> None:
487-
enable_pypi_server(monkeypatch, self.url)
488-
489-
490466
def enable_pypi_server(monkeypatch: MonkeyPatch, url: str | None) -> None:
491467
if url is None: # pragma: no cover # only one of the branches can be hit depending on env
492468
monkeypatch.delenv("PIP_INDEX_URL", raising=False)
@@ -496,109 +472,6 @@ def enable_pypi_server(monkeypatch: MonkeyPatch, url: str | None) -> None:
496472
monkeypatch.setenv("PIP_TIMEOUT", str(2))
497473

498474

499-
def _find_free_port() -> int:
500-
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as socket_handler:
501-
socket_handler.bind(("", 0))
502-
return cast(int, socket_handler.getsockname()[1])
503-
504-
505-
class IndexServer:
506-
def __init__(self, path: Path) -> None:
507-
self.path = path
508-
509-
self.host, self.port = "localhost", _find_free_port()
510-
self._passwd = "".join(random.choices(string.ascii_letters, k=8))
511-
512-
def _exe(name: str) -> str:
513-
return str(Path(scripts_dir) / f"{name}{'.exe' if IS_WIN else ''}")
514-
515-
scripts_dir = PythonInfo.current().sysconfig_path("scripts")
516-
self._init: str = _exe("devpi-init")
517-
self._server: str = _exe("devpi-server")
518-
self._client: str = _exe("devpi")
519-
520-
self._server_dir = self.path / "server"
521-
self._client_dir = self.path / "client"
522-
self._indexes: dict[str, Index] = {}
523-
self._process: Popen[str] | None = None
524-
self._has_use = False
525-
self._stdout_drain: Thread | None = None
526-
527-
def __enter__(self) -> IndexServer:
528-
self._create_and_start_server()
529-
self._setup_client()
530-
return self
531-
532-
def _create_and_start_server(self) -> None:
533-
self._server_dir.mkdir(exist_ok=True)
534-
server_at = str(self._server_dir)
535-
# 1. create the server
536-
cmd = [self._init, "--serverdir", server_at]
537-
cmd.extend(("--no-root-pypi", "--role", "standalone", "--root-passwd", self._passwd))
538-
check_call(cmd, stdout=PIPE, stderr=PIPE)
539-
# 2. start the server
540-
cmd = [self._server, "--serverdir", server_at, "--port", str(self.port), "--offline-mode"]
541-
self._process = Popen(cmd, stdout=PIPE, universal_newlines=True)
542-
stdout = self._drain_stdout()
543-
for line in stdout: # pragma: no branch # will always loop at least once
544-
if "serving at url" in line:
545-
546-
def _keep_draining() -> None:
547-
for _ in stdout:
548-
pass
549-
550-
# important to keep draining the stdout, otherwise once the buffer is full Windows blocks the process
551-
self._stdout_drain = Thread(target=_keep_draining, name="tox-test-stdout-drain")
552-
self._stdout_drain.start()
553-
break
554-
555-
def _drain_stdout(self) -> Iterator[str]:
556-
process = cast("Popen[str]", self._process)
557-
stdout = cast(IO[str], process.stdout)
558-
while True:
559-
if process.poll() is not None: # pragma: no cover
560-
print(f"devpi server with pid {process.pid} at {self._server_dir} died")
561-
break
562-
yield stdout.readline()
563-
564-
def _setup_client(self) -> None:
565-
"""create a user on the server and authenticate it"""
566-
self._client_dir.mkdir(exist_ok=True)
567-
base = ["--clientdir", str(self._client_dir)]
568-
check_call([self._client, "use"] + base + [self.url], stdout=PIPE, stderr=PIPE)
569-
check_call([self._client, "login"] + base + ["root", "--password", self._passwd], stdout=PIPE, stderr=PIPE)
570-
571-
def create_index(self, name: str, *args: str) -> Index:
572-
if name in self._indexes: # pragma: no cover
573-
raise ValueError(f"index {name} already exists")
574-
base = [self._client, "--clientdir", str(self._client_dir)]
575-
check_call(base + ["index", "-c", name, *args], stdout=PIPE, stderr=PIPE)
576-
index = Index(f"{self.url}/root", name, base)
577-
if not self._has_use:
578-
self._has_use = True
579-
check_call(base + ["use", f"root/{name}"], stdout=PIPE, stderr=PIPE)
580-
self._indexes[name] = index
581-
return index
582-
583-
def __exit__(
584-
self,
585-
exc_type: type[BaseException] | None, # noqa: U100
586-
exc_val: BaseException | None, # noqa: U100
587-
exc_tb: TracebackType | None, # noqa: U100
588-
) -> None:
589-
if self._process is not None: # pragma: no cover # defend against devpi startup fail
590-
self._process.terminate()
591-
if self._stdout_drain is not None and self._stdout_drain.is_alive(): # pragma: no cover # devpi startup fail
592-
self._stdout_drain.join()
593-
594-
@property
595-
def url(self) -> str:
596-
return f"http://{self.host}:{self.port}"
597-
598-
def __repr__(self) -> str:
599-
return f"{self.__class__.__name__}(url={self.url}, indexes={list(self._indexes)})" # pragma: no cover
600-
601-
602475
@pytest.fixture(scope="session")
603476
def pypi_server(tmp_path_factory: TempPathFactory) -> Iterator[IndexServer]:
604477
# takes around 2.5s
@@ -610,7 +483,9 @@ def pypi_server(tmp_path_factory: TempPathFactory) -> Iterator[IndexServer]:
610483

611484
@pytest.fixture(scope="session")
612485
def _invalid_index_fake_port() -> int: # noqa: PT005
613-
return _find_free_port()
486+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as socket_handler:
487+
socket_handler.bind(("", 0))
488+
return cast(int, socket_handler.getsockname()[1])
614489

615490

616491
@pytest.fixture(autouse=True)
@@ -654,7 +529,5 @@ def register_inline_plugin(mocker: MockerFixture, *args: Callable[..., Any]) ->
654529
"ToxProject",
655530
"ToxProjectCreator",
656531
"check_os_environ",
657-
"IndexServer",
658-
"Index",
659532
"register_inline_plugin",
660533
)

tests/test_provision.py

+15-15
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
from zipfile import ZipFile
1212

1313
import pytest
14+
from devpi_process import Index, IndexServer
1415
from filelock import FileLock
1516
from packaging.requirements import Requirement
1617
from packaging.version import Version
1718

1819
from tox import __version__
19-
from tox.pytest import Index, IndexServer, MonkeyPatch, TempPathFactory, ToxProjectCreator
20+
from tox.pytest import MonkeyPatch, TempPathFactory, ToxProjectCreator
2021

2122
if sys.version_info >= (3, 8): # pragma: no cover (py38+)
2223
from importlib.metadata import Distribution
@@ -100,10 +101,18 @@ def pypi_index_self(pypi_server: IndexServer, tox_wheels: list[Path], demo_pkg_i
100101
with elapsed("start devpi and create index"): # takes around 1s
101102
self_index = pypi_server.create_index("self", "volatile=False")
102103
with elapsed("upload tox and its wheels to devpi"): # takes around 3.2s on build
103-
self_index.upload(tox_wheels + [demo_pkg_inline_wheel])
104+
self_index.upload(*tox_wheels, demo_pkg_inline_wheel)
104105
return self_index
105106

106107

108+
@pytest.fixture()
109+
def _pypi_index_self(pypi_index_self: Index, monkeypatch: MonkeyPatch) -> None:
110+
pypi_index_self.use()
111+
monkeypatch.setenv("PIP_INDEX_URL", pypi_index_self.url)
112+
monkeypatch.setenv("PIP_RETRIES", str(2))
113+
monkeypatch.setenv("PIP_TIMEOUT", str(5))
114+
115+
107116
def test_provision_requires_nok(tox_project: ToxProjectCreator) -> None:
108117
ini = "[tox]\nrequires = pkg-does-not-exist\n setuptools==1\nskipsdist=true\n"
109118
outcome = tox_project({"tox.ini": ini}).run("c", "-e", "py")
@@ -117,13 +126,8 @@ def test_provision_requires_nok(tox_project: ToxProjectCreator) -> None:
117126

118127

119128
@pytest.mark.integration()
120-
def test_provision_requires_ok(
121-
tox_project: ToxProjectCreator,
122-
pypi_index_self: Index,
123-
monkeypatch: MonkeyPatch,
124-
tmp_path: Path,
125-
) -> None:
126-
pypi_index_self.use(monkeypatch)
129+
@pytest.mark.usefixtures("_pypi_index_self")
130+
def test_provision_requires_ok(tox_project: ToxProjectCreator, tmp_path: Path) -> None:
127131
proj = tox_project({"tox.ini": "[tox]\nrequires=demo-pkg-inline\n[testenv]\npackage=skip"})
128132
log = tmp_path / "out.log"
129133

@@ -155,12 +159,8 @@ def test_provision_requires_ok(
155159

156160

157161
@pytest.mark.integration()
158-
def test_provision_platform_check(
159-
tox_project: ToxProjectCreator,
160-
pypi_index_self: Index,
161-
monkeypatch: MonkeyPatch,
162-
) -> None:
163-
pypi_index_self.use(monkeypatch)
162+
@pytest.mark.usefixtures("_pypi_index_self")
163+
def test_provision_platform_check(tox_project: ToxProjectCreator) -> None:
164164
ini = "[tox]\nrequires=demo-pkg-inline\n[testenv]\npackage=skip\n[testenv:.tox]\nplatform=wrong_platform"
165165
proj = tox_project({"tox.ini": ini})
166166

0 commit comments

Comments
 (0)