Skip to content

Commit d17497d

Browse files
committed
context manager: provide a context manager for the whole client
in this way we don't need to create a ClientSession context, and we keep control of the websocket closing. this is backwards compatible.
1 parent 12464b2 commit d17497d

3 files changed

Lines changed: 136 additions & 8 deletions

File tree

README.md

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,54 @@ Async Python client for [NanoKVM](https://github.com/sipeed/NanoKVM).
44

55
## Usage
66

7-
```python
7+
### Simple Usage (Recommended)
88

9-
from aiohttp import ClientSession
10-
from nanokvm.models import GpioType
11-
from nanokvm.client import NanoKVMClient
9+
The client automatically manages the HTTP session:
1210

11+
```python
12+
from nanokvm.client import NanoKVMClient
13+
from nanokvm.models import GpioType, MouseButton
1314

14-
async with ClientSession() as session:
15-
client = NanoKVMClient("http://kvm-8b76.local/api/", session)
15+
async with NanoKVMClient("http://kvm-8b76.local/api/") as client:
1616
await client.authenticate("username", "password")
1717

18+
# Get device information
1819
dev = await client.get_info()
1920
hw = await client.get_hardware()
2021
gpio = await client.get_gpio()
2122

23+
# Keyboard input
2224
await client.paste_text("Hello\nworld!")
2325

26+
# Mouse control
27+
await client.mouse_click(MouseButton.LEFT, 0.5, 0.5)
28+
await client.mouse_move_abs(0.25, 0.75)
29+
await client.mouse_scroll(0, -3)
30+
31+
# Stream video
2432
async for frame in client.mjpeg_stream():
2533
print(frame)
2634

35+
# Control GPIO
36+
await client.push_button(GpioType.POWER, duration_ms=1000)
37+
```
38+
39+
### Backwards compatible custom Session)
40+
41+
For more control, you can provide your own `ClientSession`:
42+
43+
```python
44+
from aiohttp import ClientSession
45+
from nanokvm.client import NanoKVMClient
46+
from nanokvm.models import GpioType
47+
48+
async with ClientSession() as session:
49+
client = NanoKVMClient("http://kvm-8b76.local/api/", session)
50+
await client.authenticate("username", "password")
51+
52+
dev = await client.get_info()
53+
hw = await client.get_hardware()
54+
2755
await client.push_button(GpioType.POWER, duration_ms=1000)
2856
```
2957

nanokvm/client.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,23 +109,51 @@ class NanoKVMClient:
109109
def __init__(
110110
self,
111111
url: str,
112-
session: ClientSession,
112+
session: ClientSession | None = None,
113113
*,
114114
token: str | None = None,
115115
request_timeout: int = 10,
116116
) -> None:
117-
"""Initialize the NanoKVM client."""
117+
"""
118+
Initialize the NanoKVM client.
119+
120+
Args:
121+
url: Base URL of the NanoKVM API (e.g., "http://192.168.1.1/api/")
122+
session: Optional aiohttp ClientSession. If not provided, one will
123+
be created and managed automatically when using the client as
124+
an async context manager.
125+
token: Optional pre-existing authentication token
126+
request_timeout: Request timeout in seconds (default: 10)
127+
"""
118128
self.url = yarl.URL(url)
119129
self.session = session
120130
self._token = token
121131
self._request_timeout = request_timeout
122132
self._ws: aiohttp.ClientWebSocketResponse | None = None
133+
self._owns_session = session is None
123134

124135
@property
125136
def token(self) -> str | None:
126137
"""Return the current auth token."""
127138
return self._token
128139

140+
async def __aenter__(self) -> NanoKVMClient:
141+
"""Async context manager entry."""
142+
if self._owns_session:
143+
self.session = ClientSession()
144+
return self
145+
146+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
147+
"""Async context manager exit - cleanup resources."""
148+
await self.close()
149+
150+
async def close(self) -> None:
151+
"""Close all connections and cleanup resources."""
152+
await self.close_ws()
153+
if self._owns_session and self.session is not None:
154+
await self.session.close()
155+
self.session = None
156+
129157
@contextlib.asynccontextmanager
130158
async def _request(
131159
self,
@@ -136,6 +164,12 @@ async def _request(
136164
**kwargs: Any,
137165
) -> AsyncIterator[ClientResponse]:
138166
"""Make an API request."""
167+
if self.session is None:
168+
raise RuntimeError(
169+
"Client session not initialized. Use the client as an async "
170+
"context manager: 'async with NanoKVMClient(url) as client:'"
171+
)
172+
139173
cookies = {}
140174
if authenticate:
141175
if not self._token:
@@ -660,6 +694,12 @@ async def set_mouse_jiggler_state(
660694
async def _get_ws(self) -> aiohttp.ClientWebSocketResponse:
661695
"""Get or create WebSocket connection for mouse events."""
662696
if self._ws is None or self._ws.closed:
697+
if self.session is None:
698+
raise RuntimeError(
699+
"Client session not initialized. Use the client as an async "
700+
"context manager: 'async with NanoKVMClient(url) as client:'"
701+
)
702+
663703
if not self._token:
664704
raise NanoKVMNotAuthenticatedError("Client is not authenticated")
665705

tests/test_client.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest.mock import AsyncMock
2+
13
from aiohttp import ClientSession
24

35
from nanokvm.client import NanoKVMClient
@@ -8,3 +10,61 @@ async def test_client() -> None:
810
async with ClientSession() as session:
911
client = NanoKVMClient("http://localhost:8888/api/", session)
1012
assert client is not None
13+
14+
15+
async def test_client_context_manager_with_session() -> None:
16+
"""Test the NanoKVMClient as async context manager with provided session."""
17+
async with ClientSession() as session:
18+
async with NanoKVMClient(
19+
"http://localhost:8888/api/", session, token="test-token"
20+
) as client:
21+
assert client is not None
22+
assert client.session is session
23+
# Mock WebSocket to test cleanup
24+
mock_ws = AsyncMock()
25+
mock_ws.closed = False
26+
client._ws = mock_ws
27+
28+
# After exiting context, WebSocket should be closed
29+
mock_ws.close.assert_called_once()
30+
# Session should not be closed (we don't own it)
31+
assert not session.closed
32+
33+
34+
async def test_client_context_manager_without_session() -> None:
35+
"""Test the NanoKVMClient creates and manages session automatically."""
36+
async with NanoKVMClient(
37+
"http://localhost:8888/api/", token="test-token"
38+
) as client:
39+
assert client is not None
40+
assert client.session is not None
41+
assert isinstance(client.session, ClientSession)
42+
session = client.session
43+
# Mock WebSocket to test cleanup
44+
mock_ws = AsyncMock()
45+
mock_ws.closed = False
46+
client._ws = mock_ws
47+
48+
# After exiting context, both WebSocket and session should be closed
49+
mock_ws.close.assert_called_once()
50+
assert session.closed
51+
52+
53+
async def test_client_without_context_manager_requires_session() -> None:
54+
"""Test that using client without context manager requires explicit session."""
55+
# This should work with explicit session (backward compatible)
56+
async with ClientSession() as session:
57+
client = NanoKVMClient("http://localhost:8888/api/", session)
58+
assert client is not None
59+
assert client.session is session
60+
61+
# Using without session and without context manager should fail on first use
62+
client_no_session = NanoKVMClient("http://localhost:8888/api/")
63+
assert client_no_session.session is None
64+
65+
# Should raise RuntimeError when trying to make a request
66+
try:
67+
await client_no_session.get_info()
68+
raise AssertionError("Expected RuntimeError")
69+
except RuntimeError as e:
70+
assert "context manager" in str(e)

0 commit comments

Comments
 (0)