Skip to content

Commit 4a08aa1

Browse files
authored
TestServer: Server, but partial'd to run on a test socket (#565)
#### New `Server` params `Server` now accepts 2 new optional params, `socket_name_factory` and `on_init` callbacks (#565): - `socket_name_factory`: Callable that generates unique socket names for new servers - `on_init`: Callback that runs after server initialization - Useful for creating multiple servers with unique names and tracking server instances - Socket name factory is tried after socket_name, maintaining backward compatibility #### New test fixture: `TestServer` Add `TestServer` pytest fixture for creating temporary tmux servers (#565): - Creates servers with unique socket names that clean up after themselves - Useful for testing interactions between multiple tmux servers - Includes comprehensive test coverage and documentation - Available in doctest namespace
2 parents 44fe656 + f79993a commit 4a08aa1

File tree

7 files changed

+267
-1
lines changed

7 files changed

+267
-1
lines changed

CHANGES

+20-1
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,27 @@ $ pip install --user --upgrade --pre libtmux
1111

1212
## libtmux 0.43.x (Yet to be released)
1313

14+
<!-- To maintainers and contributors: Please add notes for the forthcoming version below -->
15+
1416
- _Future release notes will be placed here_
1517

16-
<!-- To maintainers and contributors: Please add notes for the forthcoming version above -->
18+
### Features
19+
20+
Server now accepts 2 new optional params, `socket_name_factory` and `on_init` callbacks (#565):
21+
22+
- `socket_name_factory`: Callable that generates unique socket names for new servers
23+
- `on_init`: Callback that runs after server initialization
24+
- Useful for creating multiple servers with unique names and tracking server instances
25+
- Socket name factory is tried after socket_name, maintaining backward compatibility
26+
27+
#### New test fixture: `TestServer`
28+
29+
Add `TestServer` pytest fixture for creating temporary tmux servers (#565):
30+
31+
- Creates servers with unique socket names that clean up after themselves
32+
- Useful for testing interactions between multiple tmux servers
33+
- Includes comprehensive test coverage and documentation
34+
- Available in doctest namespace
1735

1836
## libtmux 0.42.1 (2024-02-15)
1937

@@ -88,6 +106,7 @@ _Maintenance only, no bug fixes or new features_
88106
```sh
89107
ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format .
90108
```
109+
91110
- Tests: Stability fixes for legacy `test_select_pane` test (#552)
92111

93112
## libtmux 0.39.0 (2024-11-26)

conftest.py

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def add_doctest_fixtures(
4141
doctest_namespace["Window"] = Window
4242
doctest_namespace["Pane"] = Pane
4343
doctest_namespace["server"] = request.getfixturevalue("server")
44+
doctest_namespace["Server"] = request.getfixturevalue("TestServer")
4445
session: Session = request.getfixturevalue("session")
4546
doctest_namespace["session"] = session
4647
doctest_namespace["window"] = session.active_window

docs/pytest-plugin/index.md

+28
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,34 @@ def test_something(session):
9393

9494
The above will assure the libtmux session launches with `-x 800 -y 600`.
9595

96+
(temp_server)=
97+
98+
### Creating temporary servers
99+
100+
If you need multiple independent tmux servers in your tests, the {func}`TestServer fixture <libtmux.pytest_plugin.TestServer>` provides a factory that creates servers with unique socket names. Each server is automatically cleaned up when the test completes.
101+
102+
```python
103+
def test_something(TestServer):
104+
Server = TestServer() # Get unique partial'd Server
105+
server = Server() # Create server instance
106+
107+
session = server.new_session()
108+
assert server.is_alive()
109+
```
110+
111+
You can also use it with custom configurations, similar to the {ref}`server fixture <Setting a tmux configuration>`:
112+
113+
```python
114+
def test_with_config(TestServer, tmp_path):
115+
config_file = tmp_path / "tmux.conf"
116+
config_file.write_text("set -g status off")
117+
118+
Server = TestServer()
119+
server = Server(config_file=str(config_file))
120+
```
121+
122+
This is particularly useful when testing interactions between multiple tmux servers or when you need to verify behavior across server restarts.
123+
96124
(set_home)=
97125

98126
### Setting a temporary home directory

src/libtmux/pytest_plugin.py

+56
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import contextlib
6+
import functools
67
import getpass
78
import logging
89
import os
@@ -256,3 +257,58 @@ def session(
256257
assert TEST_SESSION_NAME != "tmuxp"
257258

258259
return session
260+
261+
262+
@pytest.fixture
263+
def TestServer(
264+
request: pytest.FixtureRequest,
265+
) -> type[Server]:
266+
"""Create a temporary tmux server that cleans up after itself.
267+
268+
This is similar to the server pytest fixture, but can be used outside of pytest.
269+
The server will be killed when the test completes.
270+
271+
Returns
272+
-------
273+
type[Server]
274+
A factory function that returns a Server with a unique socket_name
275+
276+
Examples
277+
--------
278+
>>> server = Server() # Create server instance
279+
>>> server.new_session()
280+
Session($... ...)
281+
>>> server.is_alive()
282+
True
283+
>>> # Each call creates a new server with unique socket
284+
>>> server2 = Server()
285+
>>> server2.socket_name != server.socket_name
286+
True
287+
"""
288+
created_sockets: list[str] = []
289+
290+
def on_init(server: Server) -> None:
291+
"""Track created servers for cleanup."""
292+
created_sockets.append(server.socket_name or "default")
293+
294+
def socket_name_factory() -> str:
295+
"""Generate unique socket names."""
296+
return f"libtmux_test{next(namer)}"
297+
298+
def fin() -> None:
299+
"""Kill all servers created with these sockets."""
300+
for socket_name in created_sockets:
301+
server = Server(socket_name=socket_name)
302+
if server.is_alive():
303+
server.kill()
304+
305+
request.addfinalizer(fin)
306+
307+
return t.cast(
308+
"type[Server]",
309+
functools.partial(
310+
Server,
311+
on_init=on_init,
312+
socket_name_factory=socket_name_factory,
313+
),
314+
)

src/libtmux/server.py

+9
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ class Server(EnvironmentMixin):
5959
socket_path : str, optional
6060
config_file : str, optional
6161
colors : str, optional
62+
on_init : callable, optional
63+
socket_name_factory : callable, optional
6264
6365
Examples
6466
--------
@@ -110,6 +112,8 @@ def __init__(
110112
socket_path: str | pathlib.Path | None = None,
111113
config_file: str | None = None,
112114
colors: int | None = None,
115+
on_init: t.Callable[[Server], None] | None = None,
116+
socket_name_factory: t.Callable[[], str] | None = None,
113117
**kwargs: t.Any,
114118
) -> None:
115119
EnvironmentMixin.__init__(self, "-g")
@@ -120,6 +124,8 @@ def __init__(
120124
self.socket_path = socket_path
121125
elif socket_name is not None:
122126
self.socket_name = socket_name
127+
elif socket_name_factory is not None:
128+
self.socket_name = socket_name_factory()
123129

124130
tmux_tmpdir = pathlib.Path(os.getenv("TMUX_TMPDIR", "/tmp"))
125131
socket_name = self.socket_name or "default"
@@ -137,6 +143,9 @@ def __init__(
137143
if colors:
138144
self.colors = colors
139145

146+
if on_init is not None:
147+
on_init(self)
148+
140149
def is_alive(self) -> bool:
141150
"""Return True if tmux server alive.
142151

tests/test_pytest_plugin.py

+85
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
from __future__ import annotations
44

55
import textwrap
6+
import time
67
import typing as t
78

89
if t.TYPE_CHECKING:
10+
import pathlib
11+
912
import pytest
1013

14+
from libtmux.server import Server
15+
1116

1217
def test_plugin(
1318
pytester: pytest.Pytester,
@@ -71,3 +76,83 @@ def test_repo_git_remote_checkout(
7176
# Test
7277
result = pytester.runpytest(str(first_test_filename))
7378
result.assert_outcomes(passed=1)
79+
80+
81+
def test_test_server(TestServer: t.Callable[..., Server]) -> None:
82+
"""Test TestServer creates and cleans up server."""
83+
server = TestServer()
84+
assert server.is_alive() is False # Server not started yet
85+
86+
session = server.new_session()
87+
assert server.is_alive() is True
88+
assert len(server.sessions) == 1
89+
assert session.session_name is not None
90+
91+
# Test socket name is unique
92+
assert server.socket_name is not None
93+
assert server.socket_name.startswith("libtmux_test")
94+
95+
# Each call creates a new server with unique socket
96+
server2 = TestServer()
97+
assert server2.socket_name is not None
98+
assert server2.socket_name.startswith("libtmux_test")
99+
assert server2.socket_name != server.socket_name
100+
101+
102+
def test_test_server_with_config(
103+
TestServer: t.Callable[..., Server],
104+
tmp_path: pathlib.Path,
105+
) -> None:
106+
"""Test TestServer with config file."""
107+
config_file = tmp_path / "tmux.conf"
108+
config_file.write_text("set -g status off", encoding="utf-8")
109+
110+
server = TestServer(config_file=str(config_file))
111+
session = server.new_session()
112+
113+
# Verify config was loaded
114+
assert session.cmd("show-options", "-g", "status").stdout[0] == "status off"
115+
116+
117+
def test_test_server_cleanup(TestServer: t.Callable[..., Server]) -> None:
118+
"""Test TestServer properly cleans up after itself."""
119+
server = TestServer()
120+
socket_name = server.socket_name
121+
assert socket_name is not None
122+
123+
# Create multiple sessions
124+
server.new_session(session_name="test1")
125+
server.new_session(session_name="test2")
126+
assert len(server.sessions) == 2
127+
128+
# Verify server is alive
129+
assert server.is_alive() is True
130+
131+
# Delete server and verify cleanup
132+
server.kill()
133+
time.sleep(0.1) # Give time for cleanup
134+
135+
# Create new server to verify old one was cleaned up
136+
new_server = TestServer()
137+
assert new_server.is_alive() is False # Server not started yet
138+
new_server.new_session() # This should work if old server was cleaned up
139+
assert new_server.is_alive() is True
140+
141+
142+
def test_test_server_multiple(TestServer: t.Callable[..., Server]) -> None:
143+
"""Test multiple TestServer instances can coexist."""
144+
server1 = TestServer()
145+
server2 = TestServer()
146+
147+
# Each server should have a unique socket
148+
assert server1.socket_name != server2.socket_name
149+
150+
# Create sessions in each server
151+
server1.new_session(session_name="test1")
152+
server2.new_session(session_name="test2")
153+
154+
# Verify sessions are in correct servers
155+
assert any(s.session_name == "test1" for s in server1.sessions)
156+
assert any(s.session_name == "test2" for s in server2.sessions)
157+
assert not any(s.session_name == "test1" for s in server2.sessions)
158+
assert not any(s.session_name == "test2" for s in server1.sessions)

tests/test_server.py

+68
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,71 @@ def test_raise_if_dead_does_not_raise_if_alive(server: Server) -> None:
228228
"""Verify new_session() does not raise if tmux server is alive."""
229229
server.new_session()
230230
server.raise_if_dead()
231+
232+
233+
def test_on_init(server: Server) -> None:
234+
"""Verify on_init callback is called during Server initialization."""
235+
called_with: list[Server] = []
236+
237+
def on_init(server: Server) -> None:
238+
called_with.append(server)
239+
240+
myserver = Server(socket_name="test_on_init", on_init=on_init)
241+
try:
242+
assert len(called_with) == 1
243+
assert called_with[0] is myserver
244+
finally:
245+
if myserver.is_alive():
246+
myserver.kill()
247+
248+
249+
def test_socket_name_factory(server: Server) -> None:
250+
"""Verify socket_name_factory generates socket names."""
251+
socket_names: list[str] = []
252+
253+
def socket_name_factory() -> str:
254+
name = f"test_socket_{len(socket_names)}"
255+
socket_names.append(name)
256+
return name
257+
258+
myserver = Server(socket_name_factory=socket_name_factory)
259+
try:
260+
assert myserver.socket_name == "test_socket_0"
261+
assert socket_names == ["test_socket_0"]
262+
263+
# Creating another server should use factory again
264+
myserver2 = Server(socket_name_factory=socket_name_factory)
265+
try:
266+
assert myserver2.socket_name == "test_socket_1"
267+
assert socket_names == ["test_socket_0", "test_socket_1"]
268+
finally:
269+
if myserver2.is_alive():
270+
myserver2.kill()
271+
finally:
272+
if myserver.is_alive():
273+
myserver.kill()
274+
if myserver2.is_alive():
275+
myserver2.kill()
276+
277+
278+
def test_socket_name_precedence(server: Server) -> None:
279+
"""Verify socket_name takes precedence over socket_name_factory."""
280+
281+
def socket_name_factory() -> str:
282+
return "from_factory"
283+
284+
myserver = Server(
285+
socket_name="explicit_name",
286+
socket_name_factory=socket_name_factory,
287+
)
288+
myserver2 = Server(socket_name_factory=socket_name_factory)
289+
try:
290+
assert myserver.socket_name == "explicit_name"
291+
292+
# Without socket_name, factory is used
293+
assert myserver2.socket_name == "from_factory"
294+
finally:
295+
if myserver.is_alive():
296+
myserver.kill()
297+
if myserver2.is_alive():
298+
myserver2.kill()

0 commit comments

Comments
 (0)