pytest-nats 0.0.2.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_nats/__init__.py +16 -0
- pytest_nats/_provisioning.py +496 -0
- pytest_nats/_runtime.py +422 -0
- pytest_nats/py.typed +0 -0
- pytest_nats-0.0.2.dev1.dist-info/METADATA +186 -0
- pytest_nats-0.0.2.dev1.dist-info/RECORD +9 -0
- pytest_nats-0.0.2.dev1.dist-info/WHEEL +4 -0
- pytest_nats-0.0.2.dev1.dist-info/entry_points.txt +4 -0
- pytest_nats-0.0.2.dev1.dist-info/licenses/LICENSE +21 -0
pytest_nats/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Pytest helpers for running test NATS servers."""
|
|
2
|
+
|
|
3
|
+
from ._provisioning import ExecutableErrorCategory, GitHub, Local, Mise, NatsExecutableError, Provision
|
|
4
|
+
from ._runtime import NatsServer, NatsServerError, nats_server_fixture
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ExecutableErrorCategory",
|
|
8
|
+
"GitHub",
|
|
9
|
+
"Local",
|
|
10
|
+
"Mise",
|
|
11
|
+
"NatsExecutableError",
|
|
12
|
+
"NatsServer",
|
|
13
|
+
"NatsServerError",
|
|
14
|
+
"Provision",
|
|
15
|
+
"nats_server_fixture",
|
|
16
|
+
]
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""NATS executable selection and provisioning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import zipfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from functools import cache
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import cast
|
|
22
|
+
|
|
23
|
+
import httpx2
|
|
24
|
+
from platformdirs import user_cache_path
|
|
25
|
+
|
|
26
|
+
_SEMANTIC_VERSION = (
|
|
27
|
+
r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
|
|
28
|
+
r"(?:-(?:(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)"
|
|
29
|
+
r"(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?"
|
|
30
|
+
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
|
|
31
|
+
)
|
|
32
|
+
_NATS_VERSION_PATTERN = re.compile(rf"nats-server: v(?P<version>{_SEMANTIC_VERSION})")
|
|
33
|
+
_SELECTOR_PATTERN = re.compile(r"^(?:latest|(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*))?(?:\.(?:0|[1-9]\d*))?)$")
|
|
34
|
+
_STABLE_VERSION_PATTERN = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
|
35
|
+
_RELEASE_DOWNLOAD_URL = "https://github.com/nats-io/nats-server/releases/download"
|
|
36
|
+
_RELEASE_API_URL = "https://api.github.com/repos/nats-io/nats-server/releases"
|
|
37
|
+
_LOGGER = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExecutableErrorCategory(str, Enum):
|
|
41
|
+
"""Stable categories for NATS executable acquisition failures."""
|
|
42
|
+
|
|
43
|
+
CONFIGURATION = "configuration"
|
|
44
|
+
LOCAL = "local"
|
|
45
|
+
VERSION_RESOLUTION = "version_resolution"
|
|
46
|
+
PROVISIONING = "provisioning"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NatsExecutableError(Exception):
|
|
50
|
+
"""Failure to acquire a NATS command."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, category: ExecutableErrorCategory, message: str) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.category = category
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _configuration_error(message: str) -> NatsExecutableError:
|
|
58
|
+
return NatsExecutableError(ExecutableErrorCategory.CONFIGURATION, message)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _local_value(value: object) -> str:
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
executable = value
|
|
64
|
+
elif isinstance(value, os.PathLike):
|
|
65
|
+
executable = cast(os.PathLike[str] | os.PathLike[bytes], value).__fspath__()
|
|
66
|
+
else:
|
|
67
|
+
raise _configuration_error("Local executable must be a string or string-compatible path")
|
|
68
|
+
if not isinstance(executable, str):
|
|
69
|
+
raise _configuration_error("Local executable must be a string or string-compatible path")
|
|
70
|
+
if executable == "" or Path(executable) == Path("."):
|
|
71
|
+
raise _configuration_error("Local executable cannot be empty or the current directory")
|
|
72
|
+
return executable
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _selector(value: object) -> str:
|
|
76
|
+
if not isinstance(value, str) or _SELECTOR_PATTERN.fullmatch(value) is None:
|
|
77
|
+
raise _configuration_error(f"invalid NATS version selector: {value!r}")
|
|
78
|
+
if value != "latest" and value.split(".", 1)[0] != "2":
|
|
79
|
+
raise _configuration_error("provisioning supports only NATS major version 2")
|
|
80
|
+
if value in ("2.0", "2.1") or value.startswith(("2.0.", "2.1.")):
|
|
81
|
+
raise _configuration_error("the selector cannot select a supported NATS release (>=2.2.0,<3)")
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cache_value(value: object | None) -> str | None:
|
|
86
|
+
if value is None:
|
|
87
|
+
return None
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
path = value
|
|
90
|
+
elif isinstance(value, os.PathLike):
|
|
91
|
+
path = cast(os.PathLike[str] | os.PathLike[bytes], value).__fspath__()
|
|
92
|
+
else:
|
|
93
|
+
raise _configuration_error("cache_dir must be a string-compatible path")
|
|
94
|
+
if not isinstance(path, str):
|
|
95
|
+
raise _configuration_error("cache_dir must be a string-compatible path")
|
|
96
|
+
return path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class Local:
|
|
101
|
+
"""Select a local NATS executable by command name or filesystem path."""
|
|
102
|
+
|
|
103
|
+
executable: str = "nats-server"
|
|
104
|
+
|
|
105
|
+
def __init__(self, executable: object = "nats-server") -> None:
|
|
106
|
+
object.__setattr__(self, "executable", _local_value(executable))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class Provision:
|
|
111
|
+
"""Provision NATS with Mise when available, otherwise GitHub."""
|
|
112
|
+
|
|
113
|
+
version: str = "latest"
|
|
114
|
+
cache_dir: str | None = None
|
|
115
|
+
|
|
116
|
+
def __init__(self, version: object = "latest", *, cache_dir: object | None = None) -> None:
|
|
117
|
+
object.__setattr__(self, "version", _selector(version))
|
|
118
|
+
object.__setattr__(self, "cache_dir", _cache_value(cache_dir))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class Mise:
|
|
123
|
+
"""Provision NATS through Mise."""
|
|
124
|
+
|
|
125
|
+
version: str = "latest"
|
|
126
|
+
|
|
127
|
+
def __init__(self, version: object = "latest") -> None:
|
|
128
|
+
object.__setattr__(self, "version", _selector(version))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class GitHub:
|
|
133
|
+
"""Provision NATS from an official GitHub release."""
|
|
134
|
+
|
|
135
|
+
version: str = "latest"
|
|
136
|
+
cache_dir: str | None = None
|
|
137
|
+
|
|
138
|
+
def __init__(self, version: object = "latest", *, cache_dir: object | None = None) -> None:
|
|
139
|
+
object.__setattr__(self, "version", _selector(version))
|
|
140
|
+
object.__setattr__(self, "cache_dir", _cache_value(cache_dir))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
NatsExecutableSource = Local | Provision | Mise | GitHub
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class AcquiredNats:
|
|
148
|
+
"""An acquired NATS command and its resolved NATS version."""
|
|
149
|
+
|
|
150
|
+
command: tuple[str, ...]
|
|
151
|
+
resolved_version: str
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class _GitHubPlatform:
|
|
156
|
+
cache_system: str
|
|
157
|
+
cache_architecture: str
|
|
158
|
+
archive_system: str
|
|
159
|
+
archive_architecture: str
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def executable_name(self) -> str:
|
|
163
|
+
return "nats-server.exe" if self.cache_system == "windows" else "nats-server"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def acquire_nats(source: NatsExecutableSource, root_path: Path) -> AcquiredNats:
|
|
167
|
+
"""Acquire one NATS command during fixture setup."""
|
|
168
|
+
if isinstance(source, Local):
|
|
169
|
+
return _acquire_local(source, root_path)
|
|
170
|
+
if isinstance(source, Provision):
|
|
171
|
+
mise = shutil.which("mise")
|
|
172
|
+
if mise is not None:
|
|
173
|
+
return _acquire_mise(Mise(source.version), Path(mise))
|
|
174
|
+
return _acquire_github(GitHub(source.version, cache_dir=source.cache_dir), root_path)
|
|
175
|
+
if isinstance(source, Mise):
|
|
176
|
+
mise = shutil.which("mise")
|
|
177
|
+
if mise is None:
|
|
178
|
+
raise NatsExecutableError(
|
|
179
|
+
ExecutableErrorCategory.PROVISIONING,
|
|
180
|
+
"Mise was requested but the mise command is not available on PATH",
|
|
181
|
+
)
|
|
182
|
+
return _acquire_mise(source, Path(mise))
|
|
183
|
+
return _acquire_github(source, root_path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _acquire_local(source: Local, root_path: Path) -> AcquiredNats:
|
|
187
|
+
value = os.path.expanduser(source.executable)
|
|
188
|
+
path = Path(value)
|
|
189
|
+
try:
|
|
190
|
+
if not path.is_absolute() and len(path.parts) == 1:
|
|
191
|
+
found = shutil.which(value)
|
|
192
|
+
if found is None:
|
|
193
|
+
raise FileNotFoundError(f"command not found on PATH: {value}")
|
|
194
|
+
path = Path(found)
|
|
195
|
+
elif not path.is_absolute():
|
|
196
|
+
path = root_path / path
|
|
197
|
+
executable = _resolved_path(path)
|
|
198
|
+
_validate_executable(executable)
|
|
199
|
+
version = _local_executable_version(executable)
|
|
200
|
+
if version.split(".", 1)[0] != "2":
|
|
201
|
+
raise ValueError(f"expected a NATS 2.x executable, got {version}")
|
|
202
|
+
return AcquiredNats((str(executable),), version)
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
raise NatsExecutableError(
|
|
205
|
+
ExecutableErrorCategory.LOCAL,
|
|
206
|
+
f"failed to acquire local NATS executable {source.executable!r}",
|
|
207
|
+
) from exc
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@cache
|
|
211
|
+
def _cached_mise(source: Mise, mise: Path) -> AcquiredNats:
|
|
212
|
+
backend = f"github:nats-io/nats-server@{source.version}"
|
|
213
|
+
try:
|
|
214
|
+
subprocess.run([str(mise), "install", backend], check=True, capture_output=True, text=True, timeout=300)
|
|
215
|
+
location = subprocess.run(
|
|
216
|
+
[str(mise), "where", backend], check=True, capture_output=True, text=True, timeout=300
|
|
217
|
+
)
|
|
218
|
+
installation = _resolved_path(Path(location.stdout.strip()))
|
|
219
|
+
executable = installation / _host_executable_name()
|
|
220
|
+
_validate_executable(executable)
|
|
221
|
+
resolved_version = (
|
|
222
|
+
source.version if source.version.count(".") == 2 else _mise_resolved_version(mise, installation)
|
|
223
|
+
)
|
|
224
|
+
return AcquiredNats((str(executable),), resolved_version)
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
details = ""
|
|
227
|
+
if isinstance(exc, subprocess.CalledProcessError):
|
|
228
|
+
output = (exc.stderr or exc.stdout or "").strip()
|
|
229
|
+
details = f": {output}" if output else ""
|
|
230
|
+
raise NatsExecutableError(
|
|
231
|
+
ExecutableErrorCategory.PROVISIONING,
|
|
232
|
+
f"Mise failed to provision NATS Server for selector {source.version!r}{details}",
|
|
233
|
+
) from exc
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _acquire_mise(source: Mise, mise: Path) -> AcquiredNats:
|
|
237
|
+
return _cached_mise(source, _resolved_path(mise))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _mise_resolved_version(mise: Path, installation: Path) -> str:
|
|
241
|
+
listing = subprocess.run([str(mise), "ls", "--json"], check=True, capture_output=True, text=True, timeout=30)
|
|
242
|
+
payload: object = json.loads(listing.stdout)
|
|
243
|
+
if not isinstance(payload, dict):
|
|
244
|
+
raise TypeError("Mise tool listing is not an object")
|
|
245
|
+
for installations in cast(dict[object, object], payload).values():
|
|
246
|
+
if not isinstance(installations, list):
|
|
247
|
+
continue
|
|
248
|
+
for item in cast(list[object], installations):
|
|
249
|
+
if not isinstance(item, dict):
|
|
250
|
+
continue
|
|
251
|
+
record = cast(dict[object, object], item)
|
|
252
|
+
version = record.get("version")
|
|
253
|
+
install_path = record.get("install_path")
|
|
254
|
+
if (
|
|
255
|
+
isinstance(version, str)
|
|
256
|
+
and isinstance(install_path, str)
|
|
257
|
+
and _resolved_path(Path(install_path)) == installation
|
|
258
|
+
):
|
|
259
|
+
return version
|
|
260
|
+
raise ValueError(f"Mise did not report the resolved version for {installation}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _acquire_github(source: GitHub, root_path: Path) -> AcquiredNats:
|
|
264
|
+
version = source.version if source.version.count(".") == 2 else _resolve_version(source.version)
|
|
265
|
+
platform_value = _github_platform()
|
|
266
|
+
cache_root = _cache_root(source.cache_dir, root_path)
|
|
267
|
+
return AcquiredNats(_provision_from_github(version, platform_value, cache_root), version)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@cache
|
|
271
|
+
def _resolve_version(selector: str) -> str:
|
|
272
|
+
matches: list[tuple[int, int, int]] = []
|
|
273
|
+
try:
|
|
274
|
+
with httpx2.Client(
|
|
275
|
+
timeout=30,
|
|
276
|
+
follow_redirects=True,
|
|
277
|
+
headers=_github_headers(os.environ.get("GITHUB_TOKEN")),
|
|
278
|
+
) as client:
|
|
279
|
+
page = 1
|
|
280
|
+
while True:
|
|
281
|
+
response = client.get(f"{_RELEASE_API_URL}?per_page=100&page={page}")
|
|
282
|
+
response.raise_for_status()
|
|
283
|
+
payload_object: object = response.json()
|
|
284
|
+
if not isinstance(payload_object, list):
|
|
285
|
+
raise TypeError("GitHub release response is not a list")
|
|
286
|
+
payload = cast(list[object], payload_object)
|
|
287
|
+
for item in payload:
|
|
288
|
+
if not isinstance(item, dict):
|
|
289
|
+
raise TypeError("GitHub release response contains a non-object")
|
|
290
|
+
release = cast(dict[object, object], item)
|
|
291
|
+
if release.get("draft") is not False or release.get("prerelease") is not False:
|
|
292
|
+
continue
|
|
293
|
+
tag_name = release.get("tag_name")
|
|
294
|
+
if not isinstance(tag_name, str):
|
|
295
|
+
raise TypeError("GitHub release has no string tag_name")
|
|
296
|
+
match = _STABLE_VERSION_PATTERN.fullmatch(tag_name)
|
|
297
|
+
if match is None:
|
|
298
|
+
continue
|
|
299
|
+
version = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
300
|
+
if version >= (2, 2, 0) and version[0] == 2 and _selector_matches(selector, version):
|
|
301
|
+
matches.append(version)
|
|
302
|
+
if len(payload) < 100:
|
|
303
|
+
break
|
|
304
|
+
page += 1
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
raise NatsExecutableError(
|
|
307
|
+
ExecutableErrorCategory.VERSION_RESOLUTION,
|
|
308
|
+
f"failed to resolve NATS version selector {selector!r}",
|
|
309
|
+
) from exc
|
|
310
|
+
if not matches:
|
|
311
|
+
raise NatsExecutableError(
|
|
312
|
+
ExecutableErrorCategory.VERSION_RESOLUTION,
|
|
313
|
+
f"no stable NATS 2.x release matches {selector!r}",
|
|
314
|
+
)
|
|
315
|
+
return ".".join(str(part) for part in max(matches))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _selector_matches(selector: str, version: tuple[int, int, int]) -> bool:
|
|
319
|
+
if selector == "latest":
|
|
320
|
+
return True
|
|
321
|
+
requested = tuple(int(part) for part in selector.split("."))
|
|
322
|
+
return version[: len(requested)] == requested
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _github_platform() -> _GitHubPlatform:
|
|
326
|
+
systems = {"Linux": ("linux", "linux"), "Darwin": ("macos", "darwin"), "Windows": ("windows", "windows")}
|
|
327
|
+
architectures = {
|
|
328
|
+
"x86_64": ("amd64", "amd64"),
|
|
329
|
+
"AMD64": ("amd64", "amd64"),
|
|
330
|
+
"aarch64": ("arm64", "arm64"),
|
|
331
|
+
"arm64": ("arm64", "arm64"),
|
|
332
|
+
"ARM64": ("arm64", "arm64"),
|
|
333
|
+
}
|
|
334
|
+
system_name = platform.system()
|
|
335
|
+
machine_name = platform.machine()
|
|
336
|
+
try:
|
|
337
|
+
system, archive_system = systems[system_name]
|
|
338
|
+
architecture, archive_architecture = architectures[machine_name]
|
|
339
|
+
except KeyError as exc:
|
|
340
|
+
raise NatsExecutableError(
|
|
341
|
+
ExecutableErrorCategory.PROVISIONING,
|
|
342
|
+
f"GitHub provisioning is unsupported on {system_name} {machine_name}",
|
|
343
|
+
) from exc
|
|
344
|
+
return _GitHubPlatform(system, architecture, archive_system, archive_architecture)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _cache_root(value: str | None, root_path: Path) -> Path:
|
|
348
|
+
if value is None:
|
|
349
|
+
return _resolved_path(user_cache_path("pytest-nats"))
|
|
350
|
+
path = Path(os.path.expanduser(value))
|
|
351
|
+
return _resolved_path(path if path.is_absolute() else root_path / path)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _provision_from_github(version: str, target_platform: _GitHubPlatform, cache_root: Path) -> tuple[str, ...]:
|
|
355
|
+
archive_path: Path | None = None
|
|
356
|
+
executable_path: Path | None = None
|
|
357
|
+
target = _resolved_path(
|
|
358
|
+
cache_root
|
|
359
|
+
/ version
|
|
360
|
+
/ target_platform.cache_system
|
|
361
|
+
/ target_platform.cache_architecture
|
|
362
|
+
/ target_platform.executable_name
|
|
363
|
+
)
|
|
364
|
+
try:
|
|
365
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
if target.exists():
|
|
367
|
+
if target.is_file() and os.access(target, os.X_OK):
|
|
368
|
+
return (str(target),)
|
|
369
|
+
if not target.is_dir():
|
|
370
|
+
target.unlink()
|
|
371
|
+
extension = "zip" if target_platform.cache_system == "windows" else "tar.gz"
|
|
372
|
+
archive_name = f"nats-server-v{version}-{target_platform.archive_system}-{target_platform.archive_architecture}.{extension}"
|
|
373
|
+
release_url = f"{_RELEASE_DOWNLOAD_URL}/v{version}"
|
|
374
|
+
archive_path = _temporary_path(target.parent, ".archive-")
|
|
375
|
+
executable_path = _temporary_path(target.parent, ".executable-")
|
|
376
|
+
with httpx2.Client(
|
|
377
|
+
timeout=30, follow_redirects=True, headers=_github_headers(os.environ.get("GITHUB_TOKEN"))
|
|
378
|
+
) as client:
|
|
379
|
+
_download(client, f"{release_url}/{archive_name}", archive_path)
|
|
380
|
+
checksum_response = client.get(f"{release_url}/SHA256SUMS")
|
|
381
|
+
checksum_response.raise_for_status()
|
|
382
|
+
_verify_checksum(archive_path, archive_name, checksum_response.content)
|
|
383
|
+
member_name = (
|
|
384
|
+
f"nats-server-v{version}-{target_platform.archive_system}-{target_platform.archive_architecture}/"
|
|
385
|
+
f"{target_platform.executable_name}"
|
|
386
|
+
)
|
|
387
|
+
_extract_executable(archive_path, member_name, executable_path, extension)
|
|
388
|
+
executable_path.chmod(executable_path.stat().st_mode | 0o755)
|
|
389
|
+
_validate_executable(executable_path)
|
|
390
|
+
_publish(executable_path, target)
|
|
391
|
+
return (str(target),)
|
|
392
|
+
except NatsExecutableError:
|
|
393
|
+
raise
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
raise NatsExecutableError(
|
|
396
|
+
ExecutableErrorCategory.PROVISIONING,
|
|
397
|
+
f"failed to provision NATS Server {version} from GitHub",
|
|
398
|
+
) from exc
|
|
399
|
+
finally:
|
|
400
|
+
if archive_path is not None:
|
|
401
|
+
archive_path.unlink(missing_ok=True)
|
|
402
|
+
if executable_path is not None:
|
|
403
|
+
executable_path.unlink(missing_ok=True)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _host_executable_name() -> str:
|
|
407
|
+
return "nats-server.exe" if os.name == "nt" else "nats-server"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _validate_executable(path: Path) -> None:
|
|
411
|
+
if not path.is_file():
|
|
412
|
+
raise FileNotFoundError(f"NATS executable is not a regular file: {path}")
|
|
413
|
+
if not os.access(path, os.X_OK):
|
|
414
|
+
raise PermissionError(f"NATS executable is not executable: {path}")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _local_executable_version(executable: Path) -> str:
|
|
418
|
+
result = subprocess.run([str(executable), "--version"], check=True, capture_output=True, text=True, timeout=5)
|
|
419
|
+
for line in (*result.stdout.splitlines(), *result.stderr.splitlines()):
|
|
420
|
+
match = _NATS_VERSION_PATTERN.fullmatch(line.strip())
|
|
421
|
+
if match is not None:
|
|
422
|
+
return match.group("version")
|
|
423
|
+
raise ValueError("unrecognized NATS Server version output")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _github_headers(token: str | None) -> dict[str, str]:
|
|
427
|
+
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _temporary_path(directory: Path, prefix: str) -> Path:
|
|
431
|
+
descriptor, name = tempfile.mkstemp(dir=directory, prefix=prefix)
|
|
432
|
+
os.close(descriptor)
|
|
433
|
+
return Path(name)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _resolved_path(path: Path) -> Path:
|
|
437
|
+
resolved = path.resolve()
|
|
438
|
+
text = str(resolved)
|
|
439
|
+
if text.startswith("\\\\?\\UNC\\"):
|
|
440
|
+
return Path("\\\\" + text[8:])
|
|
441
|
+
if text.startswith("\\\\?\\"):
|
|
442
|
+
return Path(text[4:])
|
|
443
|
+
return resolved
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _publish(source: Path, target: Path) -> None:
|
|
447
|
+
for attempt in range(5):
|
|
448
|
+
try:
|
|
449
|
+
os.replace(source, target)
|
|
450
|
+
return
|
|
451
|
+
except PermissionError:
|
|
452
|
+
if attempt == 4:
|
|
453
|
+
raise
|
|
454
|
+
time.sleep(0.05 * 2**attempt)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _download(client: httpx2.Client, url: str, destination: Path) -> None:
|
|
458
|
+
with client.stream("GET", url) as response:
|
|
459
|
+
response.raise_for_status()
|
|
460
|
+
with destination.open("wb") as stream:
|
|
461
|
+
for chunk in response.iter_bytes():
|
|
462
|
+
stream.write(chunk)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _verify_checksum(archive_path: Path, archive_name: str, checksums: bytes) -> None:
|
|
466
|
+
expected = None
|
|
467
|
+
for line in checksums.decode("utf-8").splitlines():
|
|
468
|
+
parts = line.split()
|
|
469
|
+
if len(parts) == 2 and parts[1] == archive_name and re.fullmatch(r"[0-9a-fA-F]{64}", parts[0]):
|
|
470
|
+
expected = parts[0].lower()
|
|
471
|
+
break
|
|
472
|
+
if expected is None:
|
|
473
|
+
raise ValueError(f"SHA256SUMS has no valid entry for {archive_name}")
|
|
474
|
+
actual = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
|
475
|
+
if actual != expected:
|
|
476
|
+
raise ValueError(f"checksum mismatch for {archive_name}")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _extract_executable(archive_path: Path, member_name: str, destination: Path, extension: str) -> None:
|
|
480
|
+
if extension == "zip":
|
|
481
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
482
|
+
member = archive.getinfo(member_name)
|
|
483
|
+
if member.is_dir():
|
|
484
|
+
raise ValueError(f"archive member is not a file: {member_name}")
|
|
485
|
+
with archive.open(member) as source, destination.open("wb") as target:
|
|
486
|
+
shutil.copyfileobj(source, target)
|
|
487
|
+
return
|
|
488
|
+
with tarfile.open(archive_path, mode="r:gz") as archive:
|
|
489
|
+
member = archive.getmember(member_name)
|
|
490
|
+
if not member.isfile():
|
|
491
|
+
raise ValueError(f"archive member is not a file: {member_name}")
|
|
492
|
+
source = archive.extractfile(member)
|
|
493
|
+
if source is None:
|
|
494
|
+
raise ValueError(f"could not read archive member: {member_name}")
|
|
495
|
+
with source, destination.open("wb") as target:
|
|
496
|
+
shutil.copyfileobj(source, target)
|
pytest_nats/_runtime.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Private lifecycle implementation for test NATS server fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from collections.abc import Callable, Iterator
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
from typing import Literal, cast
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
from ._provisioning import (
|
|
24
|
+
ExecutableErrorCategory,
|
|
25
|
+
GitHub,
|
|
26
|
+
Local,
|
|
27
|
+
Mise,
|
|
28
|
+
NatsExecutableError,
|
|
29
|
+
NatsExecutableSource,
|
|
30
|
+
Provision,
|
|
31
|
+
acquire_nats,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
FixtureScope = Literal["function", "module", "session"]
|
|
35
|
+
_HOST = "127.0.0.1"
|
|
36
|
+
_DEFAULT_MAX_MEMORY_STORE = 256 * 1024 * 1024
|
|
37
|
+
_DEFAULT_MAX_FILE_STORE = 1024 * 1024 * 1024
|
|
38
|
+
_SHUTDOWN_TIMEOUT = 5.0
|
|
39
|
+
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NatsServerError(Exception):
|
|
43
|
+
"""A test NATS server startup or lifecycle failure."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
message: str,
|
|
48
|
+
*,
|
|
49
|
+
returncode: int | None = None,
|
|
50
|
+
stdout: str = "",
|
|
51
|
+
stderr: str = "",
|
|
52
|
+
) -> None:
|
|
53
|
+
details = [message]
|
|
54
|
+
if returncode is not None:
|
|
55
|
+
details.append(f"return code: {returncode}")
|
|
56
|
+
if stdout:
|
|
57
|
+
details.append(f"stdout:\n{stdout}")
|
|
58
|
+
if stderr:
|
|
59
|
+
details.append(f"stderr:\n{stderr}")
|
|
60
|
+
super().__init__("\n".join(details))
|
|
61
|
+
self.returncode = returncode
|
|
62
|
+
self.stdout = stdout
|
|
63
|
+
self.stderr = stderr
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _OutputCapture:
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._chunks: list[str] = []
|
|
69
|
+
self._lock = threading.Lock()
|
|
70
|
+
|
|
71
|
+
def append(self, chunk: str) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._chunks.append(chunk)
|
|
74
|
+
|
|
75
|
+
def snapshot(self) -> str:
|
|
76
|
+
with self._lock:
|
|
77
|
+
return "".join(self._chunks)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NatsServer:
|
|
81
|
+
"""Read-only connection metadata and diagnostics for a test NATS server."""
|
|
82
|
+
|
|
83
|
+
__slots__ = ("_host", "_jetstream_enabled", "_port", "_resolved_version", "_stderr", "_stdout")
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
host: str,
|
|
89
|
+
port: int,
|
|
90
|
+
resolved_version: str,
|
|
91
|
+
jetstream_enabled: bool,
|
|
92
|
+
stdout: _OutputCapture,
|
|
93
|
+
stderr: _OutputCapture,
|
|
94
|
+
) -> None:
|
|
95
|
+
self._host = host
|
|
96
|
+
self._port = port
|
|
97
|
+
self._resolved_version = resolved_version
|
|
98
|
+
self._jetstream_enabled = jetstream_enabled
|
|
99
|
+
self._stdout = stdout
|
|
100
|
+
self._stderr = stderr
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def url(self) -> str:
|
|
104
|
+
"""Return the client connection URL."""
|
|
105
|
+
return f"nats://{self._host}:{self._port}"
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def host(self) -> str:
|
|
109
|
+
"""Return the IPv4 loopback host."""
|
|
110
|
+
return self._host
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def port(self) -> int:
|
|
114
|
+
"""Return the dynamically selected client port."""
|
|
115
|
+
return self._port
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def resolved_version(self) -> str:
|
|
119
|
+
"""Return the resolved NATS version."""
|
|
120
|
+
return self._resolved_version
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def jetstream_enabled(self) -> bool:
|
|
124
|
+
"""Return whether JetStream is enabled."""
|
|
125
|
+
return self._jetstream_enabled
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def stdout(self) -> str:
|
|
129
|
+
"""Return all server standard output captured so far."""
|
|
130
|
+
return self._stdout.snapshot()
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def stderr(self) -> str:
|
|
134
|
+
"""Return all server standard error captured so far."""
|
|
135
|
+
return self._stderr.snapshot()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _ServerProcess:
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
source: NatsExecutableSource,
|
|
142
|
+
root_path: Path,
|
|
143
|
+
*,
|
|
144
|
+
jetstream: bool,
|
|
145
|
+
max_memory_store: int,
|
|
146
|
+
max_file_store: int,
|
|
147
|
+
startup_timeout: float,
|
|
148
|
+
) -> None:
|
|
149
|
+
self._source = source
|
|
150
|
+
self._root_path = root_path
|
|
151
|
+
self._jetstream = jetstream
|
|
152
|
+
self._max_memory_store = max_memory_store
|
|
153
|
+
self._max_file_store = max_file_store
|
|
154
|
+
self._startup_timeout = startup_timeout
|
|
155
|
+
self._temporary_directory: tempfile.TemporaryDirectory[str] | None = None
|
|
156
|
+
self._process: subprocess.Popen[str] | None = None
|
|
157
|
+
self._readers: list[threading.Thread] = []
|
|
158
|
+
self._stdout = _OutputCapture()
|
|
159
|
+
self._stderr = _OutputCapture()
|
|
160
|
+
|
|
161
|
+
def __enter__(self) -> NatsServer:
|
|
162
|
+
try:
|
|
163
|
+
provisioned = acquire_nats(self._source, self._root_path)
|
|
164
|
+
self._temporary_directory = tempfile.TemporaryDirectory(prefix="pytest-nats-")
|
|
165
|
+
temporary_path = Path(self._temporary_directory.name)
|
|
166
|
+
config_path = temporary_path / "nats.conf"
|
|
167
|
+
config_path.write_text(
|
|
168
|
+
_server_config(
|
|
169
|
+
temporary_path / "jetstream",
|
|
170
|
+
self._jetstream,
|
|
171
|
+
self._max_memory_store,
|
|
172
|
+
self._max_file_store,
|
|
173
|
+
),
|
|
174
|
+
encoding="utf-8",
|
|
175
|
+
)
|
|
176
|
+
self._process = subprocess.Popen(
|
|
177
|
+
(*provisioned.command, "-c", str(config_path), "--ports_file_dir", str(temporary_path)),
|
|
178
|
+
stdin=subprocess.DEVNULL,
|
|
179
|
+
stdout=subprocess.PIPE,
|
|
180
|
+
stderr=subprocess.PIPE,
|
|
181
|
+
text=True,
|
|
182
|
+
encoding="utf-8",
|
|
183
|
+
errors="replace",
|
|
184
|
+
creationflags=int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)) if os.name == "nt" else 0,
|
|
185
|
+
)
|
|
186
|
+
self._start_readers()
|
|
187
|
+
client_port = self._wait_until_ready(temporary_path)
|
|
188
|
+
return NatsServer(
|
|
189
|
+
host=_HOST,
|
|
190
|
+
port=client_port,
|
|
191
|
+
resolved_version=provisioned.resolved_version,
|
|
192
|
+
jetstream_enabled=self._jetstream,
|
|
193
|
+
stdout=self._stdout,
|
|
194
|
+
stderr=self._stderr,
|
|
195
|
+
)
|
|
196
|
+
except NatsExecutableError:
|
|
197
|
+
self._cleanup()
|
|
198
|
+
raise
|
|
199
|
+
except NatsServerError:
|
|
200
|
+
self._stop()
|
|
201
|
+
self._cleanup()
|
|
202
|
+
raise
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
self._stop()
|
|
205
|
+
error = self._error("failed to start test NATS server")
|
|
206
|
+
self._cleanup()
|
|
207
|
+
raise error from exc
|
|
208
|
+
|
|
209
|
+
def __exit__(
|
|
210
|
+
self,
|
|
211
|
+
exc_type: type[BaseException] | None,
|
|
212
|
+
exc: BaseException | None,
|
|
213
|
+
traceback: TracebackType | None,
|
|
214
|
+
) -> bool:
|
|
215
|
+
del exc_type, exc, traceback
|
|
216
|
+
process = self._process
|
|
217
|
+
unexpected_returncode = process.poll() if process is not None else None
|
|
218
|
+
self._stop()
|
|
219
|
+
error = (
|
|
220
|
+
self._error("test NATS server exited unexpectedly", returncode=unexpected_returncode)
|
|
221
|
+
if unexpected_returncode is not None
|
|
222
|
+
else None
|
|
223
|
+
)
|
|
224
|
+
self._cleanup()
|
|
225
|
+
if error is not None:
|
|
226
|
+
raise error
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
def _start_readers(self) -> None:
|
|
230
|
+
assert self._process is not None
|
|
231
|
+
assert self._process.stdout is not None
|
|
232
|
+
assert self._process.stderr is not None
|
|
233
|
+
for stream, capture in ((self._process.stdout, self._stdout), (self._process.stderr, self._stderr)):
|
|
234
|
+
reader = threading.Thread(target=_drain, args=(stream.readline, capture), daemon=True)
|
|
235
|
+
reader.start()
|
|
236
|
+
self._readers.append(reader)
|
|
237
|
+
|
|
238
|
+
def _wait_until_ready(self, temporary_path: Path) -> int:
|
|
239
|
+
deadline = time.monotonic() + self._startup_timeout
|
|
240
|
+
last_error: Exception | None = None
|
|
241
|
+
while time.monotonic() < deadline:
|
|
242
|
+
assert self._process is not None
|
|
243
|
+
returncode = self._process.poll()
|
|
244
|
+
if returncode is not None:
|
|
245
|
+
self._join_readers()
|
|
246
|
+
raise self._error("test NATS server exited during startup", returncode=returncode)
|
|
247
|
+
try:
|
|
248
|
+
client_port, monitor_port = _read_ports(temporary_path)
|
|
249
|
+
_check_nats(client_port)
|
|
250
|
+
_check_health(monitor_port, self._jetstream)
|
|
251
|
+
return client_port
|
|
252
|
+
except (OSError, TypeError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc:
|
|
253
|
+
last_error = exc
|
|
254
|
+
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
255
|
+
raise self._error(
|
|
256
|
+
f"test NATS server did not become ready within {self._startup_timeout:g} seconds"
|
|
257
|
+
) from last_error
|
|
258
|
+
|
|
259
|
+
def _stop(self) -> None:
|
|
260
|
+
process = self._process
|
|
261
|
+
if process is not None and process.poll() is None:
|
|
262
|
+
if os.name == "nt":
|
|
263
|
+
process.send_signal(cast(int, getattr(signal, "CTRL_BREAK_EVENT"))) # noqa: B009 - Windows-only.
|
|
264
|
+
else:
|
|
265
|
+
process.terminate()
|
|
266
|
+
try:
|
|
267
|
+
process.wait(timeout=_SHUTDOWN_TIMEOUT)
|
|
268
|
+
except subprocess.TimeoutExpired:
|
|
269
|
+
process.kill()
|
|
270
|
+
process.wait()
|
|
271
|
+
self._join_readers()
|
|
272
|
+
|
|
273
|
+
def _join_readers(self) -> None:
|
|
274
|
+
for reader in self._readers:
|
|
275
|
+
reader.join(timeout=1)
|
|
276
|
+
|
|
277
|
+
def _cleanup(self) -> None:
|
|
278
|
+
if self._temporary_directory is not None:
|
|
279
|
+
for attempt in range(5):
|
|
280
|
+
try:
|
|
281
|
+
self._temporary_directory.cleanup()
|
|
282
|
+
break
|
|
283
|
+
except PermissionError:
|
|
284
|
+
if attempt == 4:
|
|
285
|
+
raise
|
|
286
|
+
time.sleep(0.05 * 2**attempt)
|
|
287
|
+
self._temporary_directory = None
|
|
288
|
+
|
|
289
|
+
def _error(self, message: str, *, returncode: int | None = None) -> NatsServerError:
|
|
290
|
+
if returncode is None and self._process is not None:
|
|
291
|
+
returncode = self._process.poll()
|
|
292
|
+
return NatsServerError(
|
|
293
|
+
message,
|
|
294
|
+
returncode=returncode,
|
|
295
|
+
stdout=self._stdout.snapshot(),
|
|
296
|
+
stderr=self._stderr.snapshot(),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _drain(readline: Callable[[], str], capture: _OutputCapture) -> None:
|
|
301
|
+
while chunk := readline():
|
|
302
|
+
capture.append(chunk)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _server_config(
|
|
306
|
+
store_directory: Path,
|
|
307
|
+
jetstream: bool,
|
|
308
|
+
max_memory_store: int,
|
|
309
|
+
max_file_store: int,
|
|
310
|
+
) -> str:
|
|
311
|
+
lines = [
|
|
312
|
+
f'host: "{_HOST}"',
|
|
313
|
+
"port: -1",
|
|
314
|
+
f'http: "{_HOST}:-1"',
|
|
315
|
+
]
|
|
316
|
+
if jetstream:
|
|
317
|
+
lines.extend(
|
|
318
|
+
[
|
|
319
|
+
"jetstream {",
|
|
320
|
+
f" store_dir: {json.dumps(str(store_directory))}",
|
|
321
|
+
f" max_mem: {max_memory_store}",
|
|
322
|
+
f" max_file: {max_file_store}",
|
|
323
|
+
"}",
|
|
324
|
+
]
|
|
325
|
+
)
|
|
326
|
+
return "\n".join(lines) + "\n"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _read_ports(directory: Path) -> tuple[int, int]:
|
|
330
|
+
ports_files = list(directory.glob("*.ports"))
|
|
331
|
+
if not ports_files:
|
|
332
|
+
raise FileNotFoundError("NATS ports file is not available")
|
|
333
|
+
payload: object = json.loads(ports_files[0].read_text(encoding="utf-8"))
|
|
334
|
+
if not isinstance(payload, dict):
|
|
335
|
+
raise TypeError("NATS ports file is not an object")
|
|
336
|
+
ports = cast(dict[object, object], payload)
|
|
337
|
+
return _port_from_urls(ports.get("nats")), _port_from_urls(ports.get("monitoring"))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _port_from_urls(value: object) -> int:
|
|
341
|
+
if not isinstance(value, list) or not value or not isinstance(value[0], str):
|
|
342
|
+
raise ValueError("NATS ports file has no listener URL")
|
|
343
|
+
parsed = urlparse(value[0])
|
|
344
|
+
if parsed.hostname != _HOST or parsed.port is None:
|
|
345
|
+
raise ValueError("NATS listener is not bound to IPv4 loopback")
|
|
346
|
+
return parsed.port
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _check_nats(port: int) -> None:
|
|
350
|
+
with socket.create_connection((_HOST, port), timeout=0.2) as connection:
|
|
351
|
+
connection.settimeout(0.2)
|
|
352
|
+
info = connection.recv(65536)
|
|
353
|
+
if not info.startswith(b"INFO "):
|
|
354
|
+
raise ValueError("NATS server did not send INFO")
|
|
355
|
+
connection.sendall(b"PING\r\n")
|
|
356
|
+
response = connection.recv(65536)
|
|
357
|
+
if b"PONG\r\n" not in response:
|
|
358
|
+
raise ValueError("NATS server did not answer PING")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _check_health(port: int, jetstream: bool) -> None:
|
|
362
|
+
query = "?js-enabled-only=true" if jetstream else ""
|
|
363
|
+
with _NO_PROXY_OPENER.open(f"http://{_HOST}:{port}/healthz{query}", timeout=0.2) as response:
|
|
364
|
+
payload: object = json.load(response)
|
|
365
|
+
if not isinstance(payload, dict) or cast(dict[object, object], payload).get("status") != "ok":
|
|
366
|
+
raise ValueError("NATS health endpoint did not report ok")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def nats_server_fixture(
|
|
370
|
+
binary: NatsExecutableSource | None = None,
|
|
371
|
+
*,
|
|
372
|
+
scope: FixtureScope = "function",
|
|
373
|
+
jetstream: bool = False,
|
|
374
|
+
max_memory_store: int = _DEFAULT_MAX_MEMORY_STORE,
|
|
375
|
+
max_file_store: int = _DEFAULT_MAX_FILE_STORE,
|
|
376
|
+
startup_timeout: float = 10.0,
|
|
377
|
+
) -> Callable[[], Iterator[NatsServer]]:
|
|
378
|
+
"""Return a pytest fixture definition owning one test NATS server."""
|
|
379
|
+
scope_value = cast(object, scope)
|
|
380
|
+
if scope_value not in ("function", "module", "session"):
|
|
381
|
+
raise ValueError(f"unsupported fixture scope: {scope!r}")
|
|
382
|
+
if not _is_boolean(jetstream):
|
|
383
|
+
raise ValueError("jetstream must be a boolean")
|
|
384
|
+
for name, value in cast(
|
|
385
|
+
tuple[tuple[str, object], ...],
|
|
386
|
+
(("max_memory_store", max_memory_store), ("max_file_store", max_file_store)),
|
|
387
|
+
):
|
|
388
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
389
|
+
raise ValueError(f"{name} must be a positive integer byte count")
|
|
390
|
+
if not _is_positive_number(startup_timeout):
|
|
391
|
+
raise ValueError("startup_timeout must be a positive duration in seconds")
|
|
392
|
+
if binary is None:
|
|
393
|
+
source: NatsExecutableSource = Local()
|
|
394
|
+
elif isinstance(cast(object, binary), (Local, Provision, Mise, GitHub)):
|
|
395
|
+
source = binary
|
|
396
|
+
else:
|
|
397
|
+
raise NatsExecutableError(
|
|
398
|
+
ExecutableErrorCategory.CONFIGURATION,
|
|
399
|
+
"binary must be Local, Provision, Mise, GitHub, or None",
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
@pytest.fixture(scope=scope)
|
|
403
|
+
def fixture_definition(request: pytest.FixtureRequest) -> Iterator[NatsServer]:
|
|
404
|
+
with _ServerProcess(
|
|
405
|
+
source,
|
|
406
|
+
request.config.rootpath,
|
|
407
|
+
jetstream=jetstream,
|
|
408
|
+
max_memory_store=max_memory_store,
|
|
409
|
+
max_file_store=max_file_store,
|
|
410
|
+
startup_timeout=float(startup_timeout),
|
|
411
|
+
) as server:
|
|
412
|
+
yield server
|
|
413
|
+
|
|
414
|
+
return fixture_definition
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _is_boolean(value: object) -> bool:
|
|
418
|
+
return isinstance(value, bool)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _is_positive_number(value: object) -> bool:
|
|
422
|
+
return not isinstance(value, bool) and isinstance(value, (int, float)) and value > 0
|
pytest_nats/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-nats
|
|
3
|
+
Version: 0.0.2.dev1
|
|
4
|
+
Summary: Pytest helpers for running ad-hoc NATS servers
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Framework :: Pytest
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Project-URL: Repository, https://github.com/m3nowak/pytest-nats
|
|
17
|
+
Project-URL: Issues, https://github.com/m3nowak/pytest-nats/issues
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx2>=2.12.0
|
|
20
|
+
Requires-Dist: platformdirs>=4.11.5
|
|
21
|
+
Requires-Dist: pytest>=8.4.2
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pytest-nats
|
|
25
|
+
|
|
26
|
+
Pytest helpers for starting isolated test NATS servers.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```shell
|
|
31
|
+
pip install pytest-nats
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
pytest-nats does not register a global pytest plugin. Declare each server fixture
|
|
35
|
+
explicitly in `conftest.py`; the variable name becomes the fixture name:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from pytest_nats import nats_server_fixture
|
|
39
|
+
|
|
40
|
+
nats_server = nats_server_fixture()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Tests receive a read-only `NatsServer` with the client URL, host, dynamic port,
|
|
44
|
+
resolved NATS version, JetStream state, and live `stdout` and `stderr` snapshots:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from pytest_nats import NatsServer
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_messaging(nats_server: NatsServer) -> None:
|
|
51
|
+
assert nats_server.url == f"nats://127.0.0.1:{nats_server.port}"
|
|
52
|
+
assert not nats_server.jetstream_enabled
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The fixture binds its unauthenticated client and internal monitoring listeners
|
|
56
|
+
only to `127.0.0.1`, selects dynamic ports, and waits for both a NATS protocol
|
|
57
|
+
exchange and the health endpoint before yielding. It terminates the server and
|
|
58
|
+
removes generated configuration and data at the end of the selected scope.
|
|
59
|
+
|
|
60
|
+
## JetStream
|
|
61
|
+
|
|
62
|
+
Enable JetStream when declaring the fixture. One server supports both memory-
|
|
63
|
+
and file-backed streams; client code remains responsible for creating streams
|
|
64
|
+
and consumers.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from pytest_nats import nats_server_fixture
|
|
68
|
+
|
|
69
|
+
jetstream_server = nats_server_fixture(
|
|
70
|
+
jetstream=True,
|
|
71
|
+
max_memory_store=512 * 1024 * 1024,
|
|
72
|
+
max_file_store=2 * 1024 * 1024 * 1024,
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The default aggregate limits are 256 MiB of memory and 1 GiB of file storage.
|
|
77
|
+
File data is isolated per server and removed during teardown.
|
|
78
|
+
|
|
79
|
+
## Fixture Scope
|
|
80
|
+
|
|
81
|
+
Function scope is the default. Module and session scopes retain server and
|
|
82
|
+
JetStream state for their normal pytest lifetime:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
module_nats = nats_server_fixture(scope="module")
|
|
86
|
+
session_nats = nats_server_fixture(scope="session", jetstream=True)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Supported scopes are `function`, `module`, and `session`.
|
|
90
|
+
|
|
91
|
+
## NATS executable selection
|
|
92
|
+
|
|
93
|
+
By default, pytest-nats finds `nats-server` on setup-time `PATH` and validates
|
|
94
|
+
that it reports a NATS 2.x semantic version. Use `Local` to select another
|
|
95
|
+
command name or path. Relative paths containing a directory are resolved from
|
|
96
|
+
pytest's root path.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
|
|
101
|
+
from pytest_nats import GitHub, Local, Mise, Provision, nats_server_fixture
|
|
102
|
+
|
|
103
|
+
default_local_nats = nats_server_fixture()
|
|
104
|
+
alternate_local_nats = nats_server_fixture(Local("nats-server-another"))
|
|
105
|
+
local_path_nats = nats_server_fixture(Local(Path("tools/nats-server")))
|
|
106
|
+
latest_nats = nats_server_fixture(Provision())
|
|
107
|
+
mise_nats = nats_server_fixture(Mise("2.12"))
|
|
108
|
+
github_nats = nats_server_fixture(GitHub("2.12.15", cache_dir=Path(".cache/nats")))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`Local`, `Provision`, `Mise`, and `GitHub` are immutable source values. Raw
|
|
112
|
+
strings and paths are not accepted as the fixture's `binary` argument.
|
|
113
|
+
`Provision` prefers Mise when it is available on setup-time `PATH` and uses
|
|
114
|
+
GitHub otherwise. Automatic provisioning accepts `latest`, major, major-minor,
|
|
115
|
+
and exact stable NATS 2.x selectors that can select releases starting at 2.2.0.
|
|
116
|
+
`startup_timeout` sets the positive setup deadline in seconds and defaults to
|
|
117
|
+
10 seconds.
|
|
118
|
+
|
|
119
|
+
### mise
|
|
120
|
+
|
|
121
|
+
[mise](https://mise.jdx.dev/) is a development-tool version manager. `Mise`
|
|
122
|
+
asks the `mise` executable on `PATH` to install and locate the selector through its
|
|
123
|
+
[GitHub backend](https://mise.jdx.dev/dev-tools/backends/github.html). The
|
|
124
|
+
selector is passed directly to Mise. Successful acquisition is reused for the
|
|
125
|
+
rest of the pytest process, while failures remain retryable.
|
|
126
|
+
|
|
127
|
+
### GitHub
|
|
128
|
+
|
|
129
|
+
`GitHub` downloads official
|
|
130
|
+
[NATS Server releases](https://github.com/nats-io/nats-server/releases),
|
|
131
|
+
verifies their published checksums, and atomically stores executables in the
|
|
132
|
+
selected cache directory. Existing regular executable cache entries are trusted
|
|
133
|
+
without running or rehashing them. Set the optional `GITHUB_TOKEN` environment variable
|
|
134
|
+
to authenticate GitHub API and download requests, which can avoid anonymous API
|
|
135
|
+
rate limits. See GitHub's
|
|
136
|
+
[personal access token documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
|
|
137
|
+
for token creation and handling guidance.
|
|
138
|
+
|
|
139
|
+
Executable lookup, version resolution, and provisioning failures raise
|
|
140
|
+
`NatsExecutableError`. Its `category` is an `ExecutableErrorCategory` value.
|
|
141
|
+
Server startup and lifecycle failures raise `NatsServerError`; its `returncode`,
|
|
142
|
+
`stdout`, and `stderr` attributes retain available diagnostics.
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
Install [mise](https://mise.jdx.dev/getting-started.html), then install the project
|
|
147
|
+
tools and dependencies:
|
|
148
|
+
|
|
149
|
+
```shell
|
|
150
|
+
mise run install
|
|
151
|
+
mise run setup
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Run tests and checks:
|
|
155
|
+
|
|
156
|
+
```shell
|
|
157
|
+
mise run test
|
|
158
|
+
mise run check
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Apply automatic lint and formatting fixes:
|
|
162
|
+
|
|
163
|
+
```shell
|
|
164
|
+
mise run fix
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Releasing
|
|
168
|
+
|
|
169
|
+
Releases run from `.github/workflows/release.yml`. The workflow accepts a stable,
|
|
170
|
+
canonical PEP 440 version, runs the complete CI workflow, builds and validates
|
|
171
|
+
the source distribution and wheel, publishes them to PyPI, then publishes the
|
|
172
|
+
draft GitHub Release. Run it from the `main` branch in the GitHub Actions UI.
|
|
173
|
+
The first intended version is `0.0.1`, which creates tag `v0.0.1`.
|
|
174
|
+
|
|
175
|
+
Before the first release, create a pending Trusted Publisher on PyPI with these
|
|
176
|
+
values:
|
|
177
|
+
|
|
178
|
+
- PyPI project: `pytest-nats`
|
|
179
|
+
- GitHub owner: `m3nowak`
|
|
180
|
+
- GitHub repository: `pytest-nats`
|
|
181
|
+
- Workflow filename: `release.yml`
|
|
182
|
+
- Environment: `pypi`
|
|
183
|
+
|
|
184
|
+
Create the `pypi` environment in the GitHub repository without required
|
|
185
|
+
reviewers. The workflow requests an OIDC token only in the PyPI publication job;
|
|
186
|
+
no PyPI API token is needed.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pytest_nats-0.0.2.dev1.dist-info/METADATA,sha256=P8m6LS0vqZ_WMrYDSojlMTaMiB7uJLZ0h7E31sPm8fY,6509
|
|
2
|
+
pytest_nats-0.0.2.dev1.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
|
|
3
|
+
pytest_nats-0.0.2.dev1.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
|
4
|
+
pytest_nats-0.0.2.dev1.dist-info/licenses/LICENSE,sha256=g3vPTnVy-mf3J728OL78KVUBAqsuaRoqNA_ZkGQa8Dk,1071
|
|
5
|
+
pytest_nats/__init__.py,sha256=4vnbo1LM2jzcOC1BPnvK2RCucZRHFYbVmP2svQhRyjA,425
|
|
6
|
+
pytest_nats/_provisioning.py,sha256=wcjXLs0_S4WKSu2y2UfnypRdKQL5gkUgFtzBvU0uGPE,19532
|
|
7
|
+
pytest_nats/_runtime.py,sha256=0-Arb7u7W5PVMYHyGk99HQmb8dllvS7JXFqOyw9x638,14399
|
|
8
|
+
pytest_nats/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
pytest_nats-0.0.2.dev1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikołaj Nowak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|