singleserver 0.1.0__py3-none-any.whl

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,42 @@
1
+ """
2
+ singleserver - Manage singleton server processes across multiple workers.
3
+
4
+ A Python library for coordinating a single instance of a server process
5
+ across multiple workers/processes using atomic socket binding.
6
+
7
+ Example:
8
+ from singleserver import SingleServer
9
+
10
+ # Define the server
11
+ api_server = SingleServer(
12
+ name="api-server",
13
+ command=["python", "-m", "my_api", "--port", "{port}"],
14
+ port=8765,
15
+ )
16
+
17
+ # Connect (starts if needed, connects if already running)
18
+ with api_server.connect() as client:
19
+ response = client.get("/data.json")
20
+ """
21
+
22
+ from .client import ServerClient, ServerNotReady
23
+ from .lock import LockFile, SocketLock
24
+ from .process import ProcessOwner, ProcessState
25
+ from .server import ManagedServer, SingleServer
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ # Main classes
31
+ "SingleServer",
32
+ "ManagedServer",
33
+ # Supporting classes
34
+ "ServerClient",
35
+ "ProcessOwner",
36
+ "ProcessState",
37
+ # Lock utilities
38
+ "SocketLock",
39
+ "LockFile",
40
+ # Exceptions
41
+ "ServerNotReady",
42
+ ]
singleserver/client.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ Client for connecting to managed servers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import socket
9
+ import time
10
+ from pathlib import Path
11
+ from types import TracebackType
12
+ from typing import Any
13
+ from urllib.parse import urljoin
14
+
15
+ try:
16
+ import requests
17
+ from requests.adapters import HTTPAdapter
18
+ from urllib3.util.retry import Retry
19
+
20
+ REQUESTS_AVAILABLE = True
21
+ except ImportError:
22
+ REQUESTS_AVAILABLE = False
23
+
24
+ try:
25
+ import httpx # noqa: F401
26
+
27
+ HTTPX_AVAILABLE = True
28
+ except ImportError:
29
+ HTTPX_AVAILABLE = False
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class ServerNotReady(Exception):
35
+ """Raised when the server is not ready within the timeout."""
36
+
37
+ pass
38
+
39
+
40
+ class ServerClient:
41
+ """
42
+ Client for connecting to a managed server.
43
+
44
+ Provides:
45
+ - wait_ready() to block until the server is accepting connections
46
+ - HTTP request methods (get, post, etc.) if requests library is available
47
+ - Base URL construction for manual HTTP client usage
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ host: str = "127.0.0.1",
53
+ port: int | None = None,
54
+ socket_path: str | Path | None = None,
55
+ health_check_url: str = "/",
56
+ startup_timeout: float = 30.0,
57
+ ):
58
+ """
59
+ Initialize the client.
60
+
61
+ Args:
62
+ host: Host to connect to (for TCP connections).
63
+ port: TCP port to connect to.
64
+ socket_path: Unix socket path to connect to.
65
+ health_check_url: URL path to use for readiness checks.
66
+ startup_timeout: Max seconds to wait for server to be ready.
67
+ """
68
+ if port is None and socket_path is None:
69
+ raise ValueError("Must provide either port or socket_path")
70
+
71
+ self.host = host
72
+ self.port = port
73
+ self.socket_path = Path(socket_path) if socket_path else None
74
+ self.health_check_url = health_check_url
75
+ self.startup_timeout = startup_timeout
76
+ self._session: Any | None = None # requests.Session or httpx.Client
77
+ self._ready = False
78
+
79
+ @property
80
+ def base_url(self) -> str:
81
+ """Base URL for making requests to the server."""
82
+ if self.socket_path:
83
+ # For Unix sockets, use http+unix:// scheme
84
+ return f"http+unix://{self.socket_path}"
85
+ return f"http://{self.host}:{self.port}"
86
+
87
+ @property
88
+ def is_ready(self) -> bool:
89
+ """Return True if the server is confirmed ready."""
90
+ return self._ready
91
+
92
+ def _check_tcp_connection(self) -> bool:
93
+ """Check if we can establish a TCP connection."""
94
+ try:
95
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
96
+ sock.settimeout(1.0)
97
+ result = sock.connect_ex((self.host, self.port))
98
+ sock.close()
99
+ return result == 0
100
+ except OSError:
101
+ return False
102
+
103
+ def _check_unix_connection(self) -> bool:
104
+ """Check if we can establish a Unix socket connection."""
105
+ if not self.socket_path or not self.socket_path.exists():
106
+ return False
107
+ try:
108
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
109
+ sock.settimeout(1.0)
110
+ sock.connect(str(self.socket_path))
111
+ sock.close()
112
+ return True
113
+ except OSError:
114
+ return False
115
+
116
+ def _check_http_health(self) -> bool:
117
+ """Check if the HTTP health endpoint is responding."""
118
+ if not REQUESTS_AVAILABLE:
119
+ # Fall back to socket check
120
+ if self.socket_path:
121
+ return self._check_unix_connection()
122
+ return self._check_tcp_connection()
123
+
124
+ try:
125
+ if self.socket_path:
126
+ # Use requests-unixsocket for Unix sockets if available
127
+ try:
128
+ import requests_unixsocket
129
+
130
+ session = requests_unixsocket.Session()
131
+ url = f"http+unix://{str(self.socket_path).replace('/', '%2F')}{self.health_check_url}"
132
+ response = session.get(url, timeout=2.0)
133
+ return bool(response.ok)
134
+ except ImportError:
135
+ # Fall back to socket check
136
+ return self._check_unix_connection()
137
+ else:
138
+ response = requests.get(
139
+ f"http://{self.host}:{self.port}{self.health_check_url}",
140
+ timeout=2.0,
141
+ )
142
+ return bool(response.ok)
143
+ except Exception:
144
+ return False
145
+
146
+ def wait_ready(self, timeout: float | None = None) -> bool:
147
+ """
148
+ Wait for the server to be ready.
149
+
150
+ Args:
151
+ timeout: Max seconds to wait. Uses startup_timeout if not specified.
152
+
153
+ Returns:
154
+ True if server is ready.
155
+
156
+ Raises:
157
+ ServerNotReady: If timeout is reached before server is ready.
158
+ """
159
+ if timeout is None:
160
+ timeout = self.startup_timeout
161
+
162
+ deadline = time.time() + timeout
163
+ check_interval = 0.1 # Start with fast checks
164
+ max_interval = 1.0
165
+
166
+ while time.time() < deadline:
167
+ if self._check_http_health():
168
+ self._ready = True
169
+ logger.debug("Server is ready")
170
+ return True
171
+
172
+ time.sleep(check_interval)
173
+ # Exponential backoff up to max_interval
174
+ check_interval = min(check_interval * 1.5, max_interval)
175
+
176
+ raise ServerNotReady(f"Server did not become ready within {timeout} seconds")
177
+
178
+ def check_ready(self) -> bool:
179
+ """
180
+ Check if the server is ready without waiting.
181
+
182
+ Returns:
183
+ True if server is ready, False otherwise.
184
+ """
185
+ ready = self._check_http_health()
186
+ self._ready = ready
187
+ return ready
188
+
189
+ def _get_session(self) -> Any:
190
+ """Get or create the HTTP session."""
191
+ if self._session is not None:
192
+ return self._session
193
+
194
+ if REQUESTS_AVAILABLE:
195
+ self._session = requests.Session()
196
+
197
+ # Configure retry logic
198
+ retry_strategy = Retry(
199
+ total=3,
200
+ backoff_factor=0.5,
201
+ status_forcelist=[500, 502, 503, 504],
202
+ )
203
+ adapter = HTTPAdapter(max_retries=retry_strategy)
204
+ self._session.mount("http://", adapter)
205
+ self._session.mount("https://", adapter)
206
+ return self._session
207
+
208
+ raise ImportError(
209
+ "requests library is required for HTTP methods. Install with: pip install requests"
210
+ )
211
+
212
+ def _make_url(self, path: str) -> str:
213
+ """Construct full URL from path."""
214
+ if self.socket_path:
215
+ # Can't use requests for Unix sockets without requests-unixsocket
216
+ raise NotImplementedError("HTTP methods with Unix sockets require requests-unixsocket")
217
+ return urljoin(f"http://{self.host}:{self.port}/", path.lstrip("/"))
218
+
219
+ def get(self, path: str, **kwargs: Any) -> Any:
220
+ """
221
+ Make a GET request to the server.
222
+
223
+ Args:
224
+ path: URL path (will be joined with base URL).
225
+ **kwargs: Additional arguments passed to requests.get().
226
+
227
+ Returns:
228
+ requests.Response object.
229
+ """
230
+ session = self._get_session()
231
+ return session.get(self._make_url(path), **kwargs)
232
+
233
+ def post(self, path: str, **kwargs: Any) -> Any:
234
+ """
235
+ Make a POST request to the server.
236
+
237
+ Args:
238
+ path: URL path (will be joined with base URL).
239
+ **kwargs: Additional arguments passed to requests.post().
240
+
241
+ Returns:
242
+ requests.Response object.
243
+ """
244
+ session = self._get_session()
245
+ return session.post(self._make_url(path), **kwargs)
246
+
247
+ def put(self, path: str, **kwargs: Any) -> Any:
248
+ """
249
+ Make a PUT request to the server.
250
+
251
+ Args:
252
+ path: URL path (will be joined with base URL).
253
+ **kwargs: Additional arguments passed to requests.put().
254
+
255
+ Returns:
256
+ requests.Response object.
257
+ """
258
+ session = self._get_session()
259
+ return session.put(self._make_url(path), **kwargs)
260
+
261
+ def delete(self, path: str, **kwargs: Any) -> Any:
262
+ """
263
+ Make a DELETE request to the server.
264
+
265
+ Args:
266
+ path: URL path (will be joined with base URL).
267
+ **kwargs: Additional arguments passed to requests.delete().
268
+
269
+ Returns:
270
+ requests.Response object.
271
+ """
272
+ session = self._get_session()
273
+ return session.delete(self._make_url(path), **kwargs)
274
+
275
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
276
+ """
277
+ Make an arbitrary HTTP request to the server.
278
+
279
+ Args:
280
+ method: HTTP method (GET, POST, etc.).
281
+ path: URL path (will be joined with base URL).
282
+ **kwargs: Additional arguments passed to requests.request().
283
+
284
+ Returns:
285
+ requests.Response object.
286
+ """
287
+ session = self._get_session()
288
+ return session.request(method, self._make_url(path), **kwargs)
289
+
290
+ def close(self) -> None:
291
+ """Close the client session."""
292
+ if self._session is not None:
293
+ self._session.close()
294
+ self._session = None
295
+
296
+ def __enter__(self) -> ServerClient:
297
+ return self
298
+
299
+ def __exit__(
300
+ self,
301
+ exc_type: type[BaseException] | None,
302
+ exc_val: BaseException | None,
303
+ exc_tb: TracebackType | None,
304
+ ) -> None:
305
+ self.close()
singleserver/lock.py ADDED
@@ -0,0 +1,282 @@
1
+ """
2
+ Atomic lock using socket binding.
3
+
4
+ Socket binding is atomic at the OS level - two processes cannot bind the same
5
+ port/socket simultaneously. This provides a distributed lock mechanism without
6
+ needing external coordination services.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import errno
12
+ import os
13
+ import socket
14
+ from pathlib import Path
15
+ from types import TracebackType
16
+
17
+
18
+ class SocketLock:
19
+ """
20
+ Atomic lock using socket binding.
21
+
22
+ Uses the OS-level guarantee that only one process can bind a socket at a time.
23
+ This works for both TCP ports and Unix domain sockets.
24
+
25
+ Example:
26
+ lock = SocketLock(port=8765)
27
+ if lock.try_acquire():
28
+ # We're the owner
29
+ try:
30
+ run_server()
31
+ finally:
32
+ lock.release()
33
+ else:
34
+ # Someone else owns it
35
+ connect_to_existing_server()
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ port: int | None = None,
41
+ socket_path: str | Path | None = None,
42
+ host: str = "127.0.0.1",
43
+ ):
44
+ """
45
+ Initialize the lock.
46
+
47
+ Args:
48
+ port: TCP port to bind for locking. Mutually exclusive with socket_path.
49
+ socket_path: Unix socket path for locking. Mutually exclusive with port.
50
+ host: Host to bind to when using TCP port. Defaults to localhost.
51
+
52
+ Raises:
53
+ ValueError: If neither port nor socket_path is provided, or both are.
54
+ """
55
+ # Initialize all attributes first so __del__ doesn't fail if validation raises
56
+ self._socket: socket.socket | None = None
57
+ self._acquired = False
58
+ self.port = port
59
+ self.socket_path = Path(socket_path) if socket_path else None
60
+ self.host = host
61
+
62
+ if port is None and socket_path is None:
63
+ raise ValueError("Must provide either port or socket_path")
64
+ if port is not None and socket_path is not None:
65
+ raise ValueError("Cannot provide both port and socket_path")
66
+
67
+ @property
68
+ def is_acquired(self) -> bool:
69
+ """Return True if this lock instance has acquired the lock."""
70
+ return self._acquired
71
+
72
+ @property
73
+ def address(self) -> tuple[str, int] | str:
74
+ """Return the address this lock binds to."""
75
+ if self.socket_path:
76
+ return str(self.socket_path)
77
+ assert self.port is not None # Guaranteed by __init__ validation
78
+ return (self.host, self.port)
79
+
80
+ def try_acquire(self) -> bool:
81
+ """
82
+ Try to acquire the lock.
83
+
84
+ Returns:
85
+ True if the lock was acquired, False if already held by another process.
86
+
87
+ Raises:
88
+ OSError: For socket errors other than address-in-use.
89
+ RuntimeError: If called when lock is already acquired.
90
+ """
91
+ if self._acquired:
92
+ raise RuntimeError("Lock already acquired by this instance")
93
+
94
+ try:
95
+ if self.socket_path:
96
+ # Clean up stale socket file if it exists
97
+ # This handles the case where a previous process crashed without cleanup
98
+ if self.socket_path.exists():
99
+ # Try to connect first to see if someone is actually listening
100
+ test_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
101
+ try:
102
+ test_sock.connect(str(self.socket_path))
103
+ # Connection succeeded, someone else has the lock
104
+ test_sock.close()
105
+ return False
106
+ except (ConnectionRefusedError, FileNotFoundError):
107
+ # No one is listening, safe to remove stale socket
108
+ try:
109
+ self.socket_path.unlink()
110
+ except FileNotFoundError:
111
+ pass
112
+ finally:
113
+ test_sock.close()
114
+
115
+ self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
116
+ self._socket.bind(str(self.socket_path))
117
+ # Start listening so others can detect the socket is in use
118
+ self._socket.listen(1)
119
+ else:
120
+ self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
121
+ self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
122
+ self._socket.bind((self.host, self.port))
123
+ # Start listening so others can detect the socket is in use
124
+ self._socket.listen(1)
125
+
126
+ self._acquired = True
127
+ return True
128
+
129
+ except OSError as e:
130
+ if e.errno in (errno.EADDRINUSE, errno.EADDRNOTAVAIL):
131
+ if self._socket:
132
+ self._socket.close()
133
+ self._socket = None
134
+ return False
135
+ # Re-raise unexpected errors
136
+ if self._socket:
137
+ self._socket.close()
138
+ self._socket = None
139
+ raise
140
+
141
+ def release(self) -> None:
142
+ """
143
+ Release the lock.
144
+
145
+ Safe to call even if lock was not acquired.
146
+ """
147
+ if self._socket:
148
+ try:
149
+ self._socket.close()
150
+ except OSError:
151
+ pass
152
+ self._socket = None
153
+
154
+ if self.socket_path and self.socket_path.exists():
155
+ try:
156
+ self.socket_path.unlink()
157
+ except (FileNotFoundError, OSError):
158
+ pass
159
+
160
+ self._acquired = False
161
+
162
+ def __enter__(self) -> SocketLock:
163
+ """Context manager entry - tries to acquire but doesn't raise if fails."""
164
+ self.try_acquire()
165
+ return self
166
+
167
+ def __exit__(
168
+ self,
169
+ exc_type: type[BaseException] | None,
170
+ exc_val: BaseException | None,
171
+ exc_tb: TracebackType | None,
172
+ ) -> None:
173
+ """Context manager exit - releases the lock."""
174
+ self.release()
175
+
176
+ def __del__(self) -> None:
177
+ """Destructor - ensure lock is released."""
178
+ self.release()
179
+
180
+
181
+ class LockFile:
182
+ """
183
+ Alternative lock implementation using a lock file with PID.
184
+
185
+ This is useful as a secondary coordination mechanism alongside SocketLock,
186
+ particularly for detecting stale locks and storing metadata about the lock owner.
187
+ """
188
+
189
+ def __init__(self, path: str | Path):
190
+ """
191
+ Initialize the lock file.
192
+
193
+ Args:
194
+ path: Path to the lock file.
195
+ """
196
+ self.path = Path(path)
197
+ self._acquired = False
198
+
199
+ @property
200
+ def is_acquired(self) -> bool:
201
+ """Return True if this instance holds the lock."""
202
+ return self._acquired
203
+
204
+ def try_acquire(self) -> bool:
205
+ """
206
+ Try to acquire the lock by creating a lock file with our PID.
207
+
208
+ Returns:
209
+ True if acquired, False if another process holds it.
210
+ """
211
+ if self._acquired:
212
+ raise RuntimeError("Lock already acquired by this instance")
213
+
214
+ try:
215
+ # Use O_EXCL for atomic file creation
216
+ fd = os.open(
217
+ self.path,
218
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
219
+ 0o644,
220
+ )
221
+ try:
222
+ os.write(fd, f"{os.getpid()}\n".encode())
223
+ finally:
224
+ os.close(fd)
225
+ self._acquired = True
226
+ return True
227
+
228
+ except FileExistsError:
229
+ # Lock file exists, check if the owning process is still alive
230
+ try:
231
+ with open(self.path) as f:
232
+ pid = int(f.read().strip())
233
+ # Check if process is alive
234
+ os.kill(pid, 0)
235
+ # Process is alive, lock is held
236
+ return False
237
+ except (ValueError, ProcessLookupError, PermissionError):
238
+ # Invalid PID or process is dead, try to clean up
239
+ try:
240
+ self.path.unlink()
241
+ return self.try_acquire()
242
+ except FileNotFoundError:
243
+ return self.try_acquire()
244
+ except OSError:
245
+ return False
246
+
247
+ def release(self) -> None:
248
+ """Release the lock by removing the lock file."""
249
+ if self._acquired:
250
+ try:
251
+ self.path.unlink()
252
+ except FileNotFoundError:
253
+ pass
254
+ self._acquired = False
255
+
256
+ def get_owner_pid(self) -> int | None:
257
+ """
258
+ Get the PID of the process that holds the lock.
259
+
260
+ Returns:
261
+ PID if lock file exists and is valid, None otherwise.
262
+ """
263
+ try:
264
+ with open(self.path) as f:
265
+ return int(f.read().strip())
266
+ except (FileNotFoundError, ValueError):
267
+ return None
268
+
269
+ def __enter__(self) -> LockFile:
270
+ self.try_acquire()
271
+ return self
272
+
273
+ def __exit__(
274
+ self,
275
+ exc_type: type[BaseException] | None,
276
+ exc_val: BaseException | None,
277
+ exc_tb: TracebackType | None,
278
+ ) -> None:
279
+ self.release()
280
+
281
+ def __del__(self) -> None:
282
+ self.release()