|
| 1 | +import random |
| 2 | +import socket |
| 3 | +import string |
| 4 | +import sys |
| 5 | +import sysconfig |
| 6 | +from contextlib import closing |
| 7 | +from pathlib import Path |
| 8 | +from subprocess import PIPE, Popen, check_call |
| 9 | +from threading import Thread |
| 10 | +from types import TracebackType |
| 11 | +from typing import IO, Dict, Iterator, List, Optional, Sequence, Type, cast |
| 12 | + |
| 13 | +from .version import __version__ |
| 14 | + |
| 15 | + |
| 16 | +class Index: |
| 17 | + def __init__(self, base_url: str, name: str, user: str, client_cmd_base: List[str]) -> None: |
| 18 | + self._client_cmd_base = client_cmd_base |
| 19 | + self._server_url = base_url |
| 20 | + self.name = name |
| 21 | + self.user = user |
| 22 | + |
| 23 | + @property |
| 24 | + def url(self) -> str: |
| 25 | + return f"{self._server_url}/{self.name}/+simple" |
| 26 | + |
| 27 | + def use(self) -> None: |
| 28 | + check_call(self._client_cmd_base + ["use", f"{self.user}/{self.name}"], stdout=PIPE, stderr=PIPE) |
| 29 | + |
| 30 | + def upload(self, *files: Path) -> None: |
| 31 | + cmd = self._client_cmd_base + ["upload", "--index", self.name] + [str(i) for i in files] |
| 32 | + check_call(cmd) |
| 33 | + |
| 34 | + def __repr__(self) -> str: |
| 35 | + return f"{self.__class__.__name__}(url={self.url})" |
| 36 | + |
| 37 | + |
| 38 | +class IndexServer: |
| 39 | + def __init__(self, path: Path, with_root_pypi: bool = False, start_args: Optional[Sequence[str]] = None) -> None: |
| 40 | + self.path = path |
| 41 | + self._with_root_pypi = with_root_pypi |
| 42 | + self._start_args: Sequence[str] = [] if start_args is None else start_args |
| 43 | + |
| 44 | + self.host, self.port = "localhost", _find_free_port() |
| 45 | + self._passwd = "".join(random.choices(string.ascii_letters, k=8)) |
| 46 | + |
| 47 | + scripts_dir = sysconfig.get_path("scripts") |
| 48 | + if scripts_dir is None: |
| 49 | + raise RuntimeError("could not get scripts folder of host interpreter") # pragma: no cover |
| 50 | + |
| 51 | + def _exe(name: str) -> str: |
| 52 | + return str(Path(cast(str, scripts_dir)) / f"{name}{'.exe' if sys.platform == 'win32' else ''}") |
| 53 | + |
| 54 | + self._init: str = _exe("devpi-init") |
| 55 | + self._server: str = _exe("devpi-server") |
| 56 | + self._client: str = _exe("devpi") |
| 57 | + |
| 58 | + self._server_dir = self.path / "server" |
| 59 | + self._client_dir = self.path / "client" |
| 60 | + self._indexes: Dict[str, Index] = {} |
| 61 | + self._process: Optional["Popen[str]"] = None |
| 62 | + self._has_use = False |
| 63 | + self._stdout_drain: Optional[Thread] = None |
| 64 | + |
| 65 | + @property |
| 66 | + def user(self) -> str: |
| 67 | + return "root" |
| 68 | + |
| 69 | + def __enter__(self) -> "IndexServer": |
| 70 | + self._create_and_start_server() |
| 71 | + self._setup_client() |
| 72 | + return self |
| 73 | + |
| 74 | + def _create_and_start_server(self) -> None: |
| 75 | + self._server_dir.mkdir(exist_ok=True) |
| 76 | + server_at = str(self._server_dir) |
| 77 | + # 1. create the server |
| 78 | + cmd = [self._init, "--serverdir", server_at] |
| 79 | + cmd.extend(("--role", "standalone", "--root-passwd", self._passwd)) |
| 80 | + if self._with_root_pypi is False: |
| 81 | + cmd.append("--no-root-pypi") |
| 82 | + check_call(cmd, stdout=PIPE, stderr=PIPE) |
| 83 | + # 2. start the server |
| 84 | + cmd = [self._server, "--serverdir", server_at, "--port", str(self.port)] |
| 85 | + cmd.extend(self._start_args) |
| 86 | + self._process = Popen(cmd, stdout=PIPE, universal_newlines=True) |
| 87 | + stdout = self._drain_stdout() |
| 88 | + for line in stdout: # pragma: no branch # will always loop at least once |
| 89 | + if "serving at url" in line: |
| 90 | + |
| 91 | + def _keep_draining() -> None: |
| 92 | + for _ in stdout: |
| 93 | + pass |
| 94 | + |
| 95 | + # important to keep draining the stdout, otherwise once the buffer is full Windows blocks the process |
| 96 | + self._stdout_drain = Thread(target=_keep_draining, name="tox-test-stdout-drain") |
| 97 | + self._stdout_drain.start() |
| 98 | + break |
| 99 | + |
| 100 | + def _drain_stdout(self) -> Iterator[str]: |
| 101 | + process = cast("Popen[str]", self._process) |
| 102 | + stdout = cast(IO[str], process.stdout) |
| 103 | + while True: |
| 104 | + if process.poll() is not None: # pragma: no cover |
| 105 | + print(f"devpi server with pid {process.pid} at {self._server_dir} died") |
| 106 | + break |
| 107 | + yield stdout.readline() |
| 108 | + |
| 109 | + def _setup_client(self) -> None: |
| 110 | + """create a user on the server and authenticate it""" |
| 111 | + self._client_dir.mkdir(exist_ok=True) |
| 112 | + base = ["--clientdir", str(self._client_dir)] |
| 113 | + check_call([self._client, "use"] + base + [self.url], stdout=PIPE, stderr=PIPE) |
| 114 | + check_call([self._client, "login"] + base + [self.user, "--password", self._passwd], stdout=PIPE, stderr=PIPE) |
| 115 | + |
| 116 | + def create_index(self, name: str, *args: str) -> Index: |
| 117 | + if name in self._indexes: # pragma: no cover |
| 118 | + raise ValueError(f"index {name} already exists") |
| 119 | + base = [self._client, "--clientdir", str(self._client_dir)] |
| 120 | + check_call(base + ["index", "-c", name, *args], stdout=PIPE, stderr=PIPE) |
| 121 | + index = Index(f"{self.url}/{self.user}", name, self.user, base) |
| 122 | + self._indexes[name] = index |
| 123 | + return index |
| 124 | + |
| 125 | + def __exit__( |
| 126 | + self, |
| 127 | + exc_type: Optional[Type[BaseException]], # noqa: U100 |
| 128 | + exc_val: Optional[BaseException], # noqa: U100 |
| 129 | + exc_tb: Optional[TracebackType], # noqa: U100 |
| 130 | + ) -> None: |
| 131 | + if self._process is not None: # pragma: no cover # defend against devpi startup fail |
| 132 | + self._process.terminate() |
| 133 | + if self._stdout_drain is not None and self._stdout_drain.is_alive(): # pragma: no cover # devpi startup fail |
| 134 | + self._stdout_drain.join() |
| 135 | + |
| 136 | + @property |
| 137 | + def url(self) -> str: |
| 138 | + return f"http://{self.host}:{self.port}" |
| 139 | + |
| 140 | + def __repr__(self) -> str: |
| 141 | + return f"{self.__class__.__name__}(url={self.url}, indexes={list(self._indexes)})" |
| 142 | + |
| 143 | + |
| 144 | +def _find_free_port() -> int: |
| 145 | + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as socket_handler: |
| 146 | + socket_handler.bind(("", 0)) |
| 147 | + return cast(int, socket_handler.getsockname()[1]) |
| 148 | + |
| 149 | + |
| 150 | +__all__ = [ |
| 151 | + "__version__", |
| 152 | + "Index", |
| 153 | + "IndexServer", |
| 154 | +] |
0 commit comments