Skip to content

Commit 55ba1ea

Browse files
committed
feat(core): add TCP client/server modules (#6)
Signed-off-by: Ronan Abhamon <ronan.abhamon@vates.tech>
1 parent 224f841 commit 55ba1ea

11 files changed

Lines changed: 1118 additions & 1 deletion

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ disallow_untyped_defs = false
5757

5858
[tool.pyrefly]
5959
python-version = "3.14"
60-
search-path = ["stubs"]
60+
search-path = [".", "stubs"]
6161
project-includes = [
6262
"src",
6363
"tests"

‎src/xcp_storage/network/socket.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,12 @@ def socket_receive(sock: socket.socket, buffer: bytearray, size: Optional[int] =
302302
def get_socket_family_str(sock: socket.socket) -> str:
303303
return _FAMILY_TO_STR.get(sock.family, "Unknown")
304304

305+
def get_socket_port(sock: socket.socket) -> Optional[int]:
306+
try:
307+
return sock.getsockname()[1]
308+
except OSError:
309+
return None
310+
305311
# ------------------------------------------------------------------------------
306312

307313
class Socket(contextlib.AbstractContextManager):
@@ -346,6 +352,10 @@ def close(self) -> None:
346352
def family_str(self) -> str:
347353
return get_socket_family_str(self.sock)
348354

355+
@property
356+
def port(self) -> Optional[int]:
357+
return get_socket_port(self.sock)
358+
349359
@property
350360
def timeout(self) -> Optional[float]:
351361
return self.sock.gettimeout()
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Copyright (C) 2026 Vates SAS
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10+
# GNU General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU General Public License
13+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
14+
15+
import contextlib
16+
import ssl
17+
from types import TracebackType
18+
19+
from xcp_storage.network.socket import (
20+
create_client_sock,
21+
Socket,
22+
SocketDisconnectedError,
23+
)
24+
from xcp_storage.utils.sync import wait_for_condition
25+
26+
from xcp_storage.typing import (
27+
Optional,
28+
override,
29+
Type,
30+
)
31+
32+
# ==============================================================================
33+
34+
class TcpClientError(Exception):
35+
def __init__(self, message: str) -> None:
36+
super().__init__(message)
37+
38+
# ------------------------------------------------------------------------------
39+
40+
class TcpClient(contextlib.AbstractContextManager):
41+
def __init__(
42+
self,
43+
address: str,
44+
port: int,
45+
*,
46+
ssl_context: Optional[ssl.SSLContext] = None,
47+
client_timeout: float = 120
48+
) -> None:
49+
self._address = address
50+
self._port = port
51+
self._ssl_context = ssl_context
52+
self._client_timeout = client_timeout
53+
self._socket: Optional[Socket] = None
54+
55+
self._entered_count = 0
56+
57+
def __del__(self) -> None:
58+
# This test is required for a specific case:
59+
# rare but possible if `__new__` was executed without `__init__`.
60+
if getattr(self, "_socket", None):
61+
self.disconnect()
62+
63+
@override
64+
def __enter__(self) -> "TcpClient":
65+
self.connect()
66+
self._entered_count += 1
67+
return self
68+
69+
@override
70+
def __exit__(
71+
self,
72+
exc_type: Optional[Type[BaseException]],
73+
exc_value: Optional[BaseException],
74+
traceback: Optional[TracebackType]
75+
) -> None:
76+
self._entered_count -= 1
77+
if self._entered_count == 0:
78+
self.disconnect()
79+
80+
@property
81+
def socket(self) -> Optional[Socket]:
82+
return self._socket
83+
84+
@property
85+
def connected(self) -> bool:
86+
return self._socket is not None
87+
88+
def connect(self, timeout: Optional[float] = None) -> None:
89+
if self._socket:
90+
return
91+
92+
if timeout is None:
93+
# If the `connect` timeout is not set, we use the client one.
94+
timeout = self._client_timeout
95+
96+
connect_timeout = timeout if timeout is not None else self._client_timeout
97+
98+
error: Optional[Exception] = None
99+
def connect_impl() -> bool:
100+
nonlocal error
101+
try:
102+
client_socket = Socket(create_client_sock(
103+
self._address,
104+
self._port,
105+
reuse_address=True,
106+
keep_alive=True,
107+
timeout=connect_timeout,
108+
ssl_context=self._ssl_context
109+
))
110+
except Exception as e:
111+
error = e
112+
return False
113+
114+
# Ensure that client timeout is used after this point, not the connection timeout.
115+
client_socket.timeout = self._client_timeout
116+
self._socket = client_socket
117+
return True
118+
119+
if not wait_for_condition(connect_impl, timeout=timeout, interval=1):
120+
raise TcpClientError("Unable to connect to server.") from error
121+
122+
def disconnect(self) -> None:
123+
if self._socket:
124+
self._socket.close()
125+
self._socket = None
126+
127+
def send(self, buffer: bytes, size: Optional[int] = None) -> None:
128+
if not self._socket:
129+
raise TcpClientError("Cannot send. Not connected.")
130+
131+
try:
132+
self._socket.send(buffer, size)
133+
except SocketDisconnectedError:
134+
self.disconnect()
135+
raise
136+
137+
def receive(self, buffer: bytearray, size: Optional[int] = None) -> None:
138+
if not self._socket:
139+
raise TcpClientError("Cannot receive. Not connected.")
140+
141+
try:
142+
self._socket.receive(buffer, size)
143+
except SocketDisconnectedError:
144+
self.disconnect()
145+
raise

0 commit comments

Comments
 (0)