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()