natsio-testing 0.1.0__tar.gz
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.
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: natsio-testing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: nats-server process management for tests — start, stop, and fault-inject real servers
|
|
5
|
+
Keywords: nats,nats.io,testing,pytest,nats-server
|
|
6
|
+
Author: Corrupt Mane
|
|
7
|
+
Author-email: Corrupt Mane <corruptmane@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Dist: natsio
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Project-URL: Homepage, https://github.com/corruptmane/natsio
|
|
19
|
+
Project-URL: Repository, https://github.com/corruptmane/natsio
|
|
20
|
+
Project-URL: Changelog, https://github.com/corruptmane/natsio/blob/main/extensions/natsio-testing/CHANGELOG.md
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# natsio-testing
|
|
24
|
+
|
|
25
|
+
Run real `nats-server` processes from your test suite: start/stop, temporary
|
|
26
|
+
config files, free-port allocation, readiness probing (plaintext and
|
|
27
|
+
TLS-handshake-first), and fault injection (`kill()`).
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from natsio.testing import NatsServerProcess
|
|
31
|
+
|
|
32
|
+
async def test_against_a_real_server():
|
|
33
|
+
server = await NatsServerProcess(binary="nats-server").start()
|
|
34
|
+
try:
|
|
35
|
+
nc = await natsio.connect(server.url)
|
|
36
|
+
...
|
|
37
|
+
finally:
|
|
38
|
+
await server.stop()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Binary discovery: pass `binary=` explicitly, or use `find_server_binary()`
|
|
42
|
+
(checks `NATS_SERVER_BIN`, then `nats-server` on `PATH`).
|
|
43
|
+
|
|
44
|
+
Part of the natsio extension tier: distribution `natsio-testing`, imported as
|
|
45
|
+
`natsio.testing`. Pre-1.0, no API-stability promises.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# natsio-testing
|
|
2
|
+
|
|
3
|
+
Run real `nats-server` processes from your test suite: start/stop, temporary
|
|
4
|
+
config files, free-port allocation, readiness probing (plaintext and
|
|
5
|
+
TLS-handshake-first), and fault injection (`kill()`).
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from natsio.testing import NatsServerProcess
|
|
9
|
+
|
|
10
|
+
async def test_against_a_real_server():
|
|
11
|
+
server = await NatsServerProcess(binary="nats-server").start()
|
|
12
|
+
try:
|
|
13
|
+
nc = await natsio.connect(server.url)
|
|
14
|
+
...
|
|
15
|
+
finally:
|
|
16
|
+
await server.stop()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Binary discovery: pass `binary=` explicitly, or use `find_server_binary()`
|
|
20
|
+
(checks `NATS_SERVER_BIN`, then `nats-server` on `PATH`).
|
|
21
|
+
|
|
22
|
+
Part of the natsio extension tier: distribution `natsio-testing`, imported as
|
|
23
|
+
`natsio.testing`. Pre-1.0, no API-stability promises.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.10,<0.11"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "natsio-testing"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "nats-server process management for tests — start, stop, and fault-inject real servers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
authors = [{ name = "Corrupt Mane", email = "corruptmane@gmail.com" }]
|
|
13
|
+
keywords = ["nats", "nats.io", "testing", "pytest", "nats-server"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Framework :: Pytest",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["natsio"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/corruptmane/natsio"
|
|
27
|
+
Repository = "https://github.com/corruptmane/natsio"
|
|
28
|
+
Changelog = "https://github.com/corruptmane/natsio/blob/main/extensions/natsio-testing/CHANGELOG.md"
|
|
29
|
+
|
|
30
|
+
[tool.uv.sources]
|
|
31
|
+
natsio = { workspace = true }
|
|
32
|
+
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-name = "natsio.testing"
|
|
35
|
+
namespace = true
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Run real ``nats-server`` processes from a test suite.
|
|
2
|
+
|
|
3
|
+
Start/stop, temporary config files, free-port allocation, readiness probing
|
|
4
|
+
(plaintext and TLS-handshake-first servers), JetStream mode with an isolated
|
|
5
|
+
store directory, and fault injection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .server import NatsServerProcess, find_server_binary, free_port
|
|
9
|
+
|
|
10
|
+
__all__ = ["NatsServerProcess", "find_server_binary", "free_port"]
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import socket
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Self
|
|
8
|
+
|
|
9
|
+
__all__ = ["NatsServerProcess", "find_server_binary", "free_port"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_server_binary() -> str | None:
|
|
13
|
+
"""The ``NATS_SERVER_BIN`` env var if set, else ``nats-server`` on PATH."""
|
|
14
|
+
candidate = os.environ.get("NATS_SERVER_BIN")
|
|
15
|
+
if candidate and Path(candidate).is_file():
|
|
16
|
+
return candidate
|
|
17
|
+
return shutil.which("nats-server")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def free_port() -> int:
|
|
21
|
+
with socket.socket() as sock:
|
|
22
|
+
sock.bind(("127.0.0.1", 0))
|
|
23
|
+
return sock.getsockname()[1]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NatsServerProcess:
|
|
27
|
+
"""One managed ``nats-server`` subprocess.
|
|
28
|
+
|
|
29
|
+
``config`` is written to a temporary file and passed via ``-c``.
|
|
30
|
+
``jetstream=True`` enables JetStream with an isolated, auto-removed store
|
|
31
|
+
directory. ``kill()`` is SIGKILL — an abrupt loss with no FIN niceties —
|
|
32
|
+
for exercising reconnect and leadership-change paths.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
binary: str,
|
|
38
|
+
*,
|
|
39
|
+
port: int | None = None,
|
|
40
|
+
config: str | None = None,
|
|
41
|
+
jetstream: bool = False,
|
|
42
|
+
store_dir: str | os.PathLike[str] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.binary = binary
|
|
45
|
+
self.port = port if port is not None else free_port()
|
|
46
|
+
self.config = config
|
|
47
|
+
self.jetstream = jetstream
|
|
48
|
+
# An explicit store_dir survives this instance — restart a "new" server
|
|
49
|
+
# on the same directory to simulate a server bounce that keeps its data.
|
|
50
|
+
self.store_dir = Path(store_dir) if store_dir is not None else None
|
|
51
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
52
|
+
self._tmpdir: tempfile.TemporaryDirectory[str] | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def url(self) -> str:
|
|
56
|
+
return f"nats://127.0.0.1:{self.port}"
|
|
57
|
+
|
|
58
|
+
def _workdir(self) -> Path:
|
|
59
|
+
if self._tmpdir is None:
|
|
60
|
+
self._tmpdir = tempfile.TemporaryDirectory(prefix="natsio-test-server-")
|
|
61
|
+
return Path(self._tmpdir.name)
|
|
62
|
+
|
|
63
|
+
async def start(self, *, ready_timeout: float = 10.0) -> Self:
|
|
64
|
+
args = [self.binary, "-a", "127.0.0.1", "-p", str(self.port)]
|
|
65
|
+
if self.jetstream:
|
|
66
|
+
store = self.store_dir if self.store_dir is not None else self._workdir() / "jetstream"
|
|
67
|
+
args += ["-js", "-sd", str(store)]
|
|
68
|
+
if self.config is not None:
|
|
69
|
+
config_path = self._workdir() / "server.conf"
|
|
70
|
+
config_path.write_text(self.config)
|
|
71
|
+
args += ["-c", str(config_path)]
|
|
72
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
73
|
+
*args,
|
|
74
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
75
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
76
|
+
)
|
|
77
|
+
await self._wait_ready(deadline=ready_timeout)
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
async def _wait_ready(self, *, deadline: float) -> None:
|
|
81
|
+
async with asyncio.timeout(deadline):
|
|
82
|
+
while True:
|
|
83
|
+
assert self.process is not None
|
|
84
|
+
if self.process.returncode is not None:
|
|
85
|
+
raise RuntimeError(f"nats-server exited with code {self.process.returncode} during startup")
|
|
86
|
+
try:
|
|
87
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", self.port)
|
|
88
|
+
except OSError:
|
|
89
|
+
await asyncio.sleep(0.05)
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
# A TLS-handshake-first server accepts but stays silent until
|
|
93
|
+
# the ClientHello — an accepted, open connection is "ready".
|
|
94
|
+
banner = await asyncio.wait_for(reader.readline(), 0.25)
|
|
95
|
+
except TimeoutError:
|
|
96
|
+
writer.close()
|
|
97
|
+
return
|
|
98
|
+
except OSError:
|
|
99
|
+
# Kill-and-restart on the same port: Linux can accept the
|
|
100
|
+
# probe on the dead predecessor's socket and then RST it
|
|
101
|
+
# mid-read. Not ready yet — keep probing. (TimeoutError is
|
|
102
|
+
# an OSError subclass, so its clause must come first.)
|
|
103
|
+
writer.close()
|
|
104
|
+
await asyncio.sleep(0.05)
|
|
105
|
+
continue
|
|
106
|
+
finally:
|
|
107
|
+
writer.close()
|
|
108
|
+
if banner.startswith(b"INFO "):
|
|
109
|
+
return
|
|
110
|
+
await asyncio.sleep(0.05)
|
|
111
|
+
|
|
112
|
+
async def stop(self) -> None:
|
|
113
|
+
if self.process is not None and self.process.returncode is None:
|
|
114
|
+
self.process.terminate()
|
|
115
|
+
try:
|
|
116
|
+
async with asyncio.timeout(5):
|
|
117
|
+
await self.process.wait()
|
|
118
|
+
except TimeoutError:
|
|
119
|
+
self.process.kill()
|
|
120
|
+
await self.process.wait()
|
|
121
|
+
if self._tmpdir is not None:
|
|
122
|
+
self._tmpdir.cleanup()
|
|
123
|
+
self._tmpdir = None
|
|
124
|
+
|
|
125
|
+
def kill(self) -> None:
|
|
126
|
+
"""SIGKILL the server (abrupt loss; the store directory is kept)."""
|
|
127
|
+
if self.process is not None and self.process.returncode is None:
|
|
128
|
+
self.process.kill()
|