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.
singleserver/server.py ADDED
@@ -0,0 +1,432 @@
1
+ """
2
+ Main SingleServer class that ties together lock, process, and client.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import atexit
8
+ import logging
9
+ import signal
10
+ import threading
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from types import TracebackType
14
+ from typing import Any
15
+
16
+ from .client import ServerClient
17
+ from .lock import SocketLock
18
+ from .process import ProcessOwner
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class SingleServer:
24
+ """
25
+ Manages a singleton server process across multiple workers.
26
+
27
+ Uses atomic socket binding for coordination - only one process can become
28
+ the "owner" and run the server, others become clients.
29
+
30
+ Example:
31
+ # Define the server
32
+ datasette = SingleServer(
33
+ name="datasette",
34
+ command=["datasette", "serve", "data.db", "-p", "{port}"],
35
+ port=8765,
36
+ )
37
+
38
+ # Connect (starts if needed, connects if already running)
39
+ with datasette.connect() as client:
40
+ response = client.get("/data/my_table.json")
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ name: str,
46
+ command: list[str],
47
+ port: int | None = None,
48
+ socket: str | Path | None = None,
49
+ lock_port: int | None = None,
50
+ lock_socket: str | Path | None = None,
51
+ health_check_url: str = "/",
52
+ health_check_interval: float = 5.0,
53
+ startup_timeout: float = 30.0,
54
+ restart_on_failure: bool = True,
55
+ max_restarts: int = 3,
56
+ restart_delay: float = 1.0,
57
+ env: dict[str, str] | None = None,
58
+ cwd: str | Path | None = None,
59
+ stdout: str | Path | None = None,
60
+ stderr: str | Path | None = None,
61
+ shutdown_timeout: float = 10.0,
62
+ shutdown_signal: int = signal.SIGTERM,
63
+ ):
64
+ """
65
+ Initialize the SingleServer.
66
+
67
+ Args:
68
+ name: Identifier for logging/debugging.
69
+ command: Command to run. Can include {port} placeholder.
70
+ port: TCP port the server will listen on.
71
+ socket: Unix socket path the server will listen on.
72
+ lock_port: Separate port for the coordination lock (defaults to port - 1).
73
+ lock_socket: Separate socket path for coordination lock.
74
+ health_check_url: URL path to check for server readiness.
75
+ health_check_interval: Seconds between health checks when running.
76
+ startup_timeout: Max seconds to wait for server to become ready.
77
+ restart_on_failure: Whether to restart if server dies unexpectedly.
78
+ max_restarts: Maximum restart attempts before giving up.
79
+ restart_delay: Seconds to wait between restart attempts.
80
+ env: Additional environment variables for the server process.
81
+ cwd: Working directory for the server process.
82
+ stdout: Where to redirect stdout ("inherit", "null", or file path).
83
+ stderr: Where to redirect stderr ("inherit", "null", "stdout", or file path).
84
+ shutdown_timeout: Seconds to wait for graceful shutdown.
85
+ shutdown_signal: Signal to send for shutdown.
86
+ """
87
+ if port is None and socket is None:
88
+ raise ValueError("Must provide either port or socket")
89
+
90
+ self.name = name
91
+ self._command_template = command
92
+ self.port = port
93
+ self.socket_path = Path(socket) if socket else None
94
+
95
+ # Determine lock address (separate from server address)
96
+ if lock_port is not None or lock_socket is not None:
97
+ self._lock_port = lock_port
98
+ self._lock_socket = Path(lock_socket) if lock_socket else None
99
+ elif port is not None:
100
+ # Use a separate lock port by default (ensure it stays within valid range)
101
+ self._lock_port = port + 10000 if port + 10000 <= 65535 else port - 10000
102
+ self._lock_socket = None
103
+ else:
104
+ # Use a separate lock socket
105
+ self._lock_port = None
106
+ self._lock_socket = Path(str(socket) + ".lock")
107
+
108
+ self.health_check_url = health_check_url
109
+ self.health_check_interval = health_check_interval
110
+ self.startup_timeout = startup_timeout
111
+ self.restart_on_failure = restart_on_failure
112
+ self.max_restarts = max_restarts
113
+ self.restart_delay = restart_delay
114
+ self.env = env
115
+ self.cwd = cwd
116
+ self.stdout = stdout
117
+ self.stderr = stderr
118
+ self.shutdown_timeout = shutdown_timeout
119
+ self.shutdown_signal = shutdown_signal
120
+
121
+ self._lock: SocketLock | None = None
122
+ self._owner: ProcessOwner | None = None
123
+ self._is_owner = False
124
+ self._client: ServerClient | None = None
125
+ self._cleanup_registered = False
126
+ self._state_lock = threading.Lock()
127
+
128
+ @property
129
+ def command(self) -> list[str]:
130
+ """Build the command with placeholders replaced."""
131
+ result = []
132
+ for part in self._command_template:
133
+ if "{port}" in part:
134
+ part = part.replace("{port}", str(self.port))
135
+ if "{socket}" in part:
136
+ part = part.replace("{socket}", str(self.socket_path))
137
+ result.append(part)
138
+ return result
139
+
140
+ @property
141
+ def is_owner(self) -> bool:
142
+ """Return True if this process owns the server."""
143
+ return self._is_owner
144
+
145
+ @property
146
+ def is_connected(self) -> bool:
147
+ """Return True if connected to the server (as owner or client)."""
148
+ return self._client is not None and self._client.is_ready
149
+
150
+ def _create_health_check(self) -> Callable[[], bool]:
151
+ """Create a health check function for the process owner."""
152
+
153
+ def check() -> bool:
154
+ try:
155
+ client = ServerClient(
156
+ host="127.0.0.1",
157
+ port=self.port,
158
+ socket_path=self.socket_path,
159
+ health_check_url=self.health_check_url,
160
+ startup_timeout=2.0,
161
+ )
162
+ return bool(client.check_ready())
163
+ except Exception:
164
+ return False
165
+
166
+ return check
167
+
168
+ def _register_cleanup(self) -> None:
169
+ """Register cleanup handlers."""
170
+ if self._cleanup_registered:
171
+ return
172
+ self._cleanup_registered = True
173
+ atexit.register(self._cleanup)
174
+
175
+ # Also handle signals for clean shutdown
176
+ def signal_handler(signum: int, frame: Any) -> None:
177
+ self._cleanup()
178
+ # Re-raise signal for default handling
179
+ signal.signal(signum, signal.SIG_DFL)
180
+ signal.raise_signal(signum)
181
+
182
+ try:
183
+ signal.signal(signal.SIGTERM, signal_handler)
184
+ signal.signal(signal.SIGINT, signal_handler)
185
+ except ValueError:
186
+ # Can't set signal handlers from non-main thread
187
+ pass
188
+
189
+ def _cleanup(self) -> None:
190
+ """Clean up resources on exit."""
191
+ logger.debug(f"Cleaning up SingleServer {self.name}")
192
+
193
+ if self._owner:
194
+ try:
195
+ self._owner.stop()
196
+ except Exception as e:
197
+ logger.warning(f"Error stopping process: {e}")
198
+ self._owner = None
199
+
200
+ if self._lock:
201
+ try:
202
+ self._lock.release()
203
+ except Exception as e:
204
+ logger.warning(f"Error releasing lock: {e}")
205
+ self._lock = None
206
+
207
+ if self._client:
208
+ try:
209
+ self._client.close()
210
+ except Exception:
211
+ pass
212
+ self._client = None
213
+
214
+ self._is_owner = False
215
+
216
+ def connect(self, wait_ready: bool = True, timeout: float | None = None) -> ServerClient:
217
+ """
218
+ Connect to the server, starting it if necessary.
219
+
220
+ This is the main entry point. Multiple processes can call this concurrently:
221
+ - First process to acquire the lock becomes the owner and starts the server
222
+ - Other processes wait for the server to be ready and connect as clients
223
+
224
+ Args:
225
+ wait_ready: Whether to wait for the server to be ready.
226
+ timeout: Max seconds to wait for ready (uses startup_timeout if not specified).
227
+
228
+ Returns:
229
+ ServerClient instance for making requests to the server.
230
+
231
+ Raises:
232
+ ServerNotReady: If timeout is reached before server is ready.
233
+ """
234
+ with self._state_lock:
235
+ if self._client is not None:
236
+ return self._client
237
+
238
+ # Create the lock
239
+ self._lock = SocketLock(
240
+ port=self._lock_port,
241
+ socket_path=self._lock_socket,
242
+ )
243
+
244
+ # Try to become the owner
245
+ if self._lock.try_acquire():
246
+ logger.info(f"Acquired lock for {self.name}, becoming owner")
247
+ self._is_owner = True
248
+
249
+ # Start the server process
250
+ self._owner = ProcessOwner(
251
+ command=self.command,
252
+ env=self.env,
253
+ cwd=self.cwd,
254
+ stdout=self.stdout,
255
+ stderr=self.stderr,
256
+ health_check=self._create_health_check(),
257
+ health_check_interval=self.health_check_interval,
258
+ startup_timeout=self.startup_timeout,
259
+ restart_on_failure=self.restart_on_failure,
260
+ max_restarts=self.max_restarts,
261
+ restart_delay=self.restart_delay,
262
+ shutdown_timeout=self.shutdown_timeout,
263
+ shutdown_signal=self.shutdown_signal,
264
+ )
265
+ self._owner.start()
266
+ self._register_cleanup()
267
+ else:
268
+ logger.info(f"Lock already held for {self.name}, connecting as client")
269
+ # Release the lock object since we don't own it
270
+ self._lock = None
271
+
272
+ # Create client
273
+ self._client = ServerClient(
274
+ host="127.0.0.1",
275
+ port=self.port,
276
+ socket_path=self.socket_path,
277
+ health_check_url=self.health_check_url,
278
+ startup_timeout=timeout or self.startup_timeout,
279
+ )
280
+
281
+ # Wait for ready outside the lock
282
+ if wait_ready:
283
+ self._client.wait_ready(timeout=timeout)
284
+
285
+ return self._client
286
+
287
+ def disconnect(self) -> None:
288
+ """
289
+ Disconnect from the server.
290
+
291
+ If this process is the owner, stops the server process.
292
+ """
293
+ self._cleanup()
294
+
295
+ def stop(self) -> None:
296
+ """Alias for disconnect()."""
297
+ self.disconnect()
298
+
299
+ def get_client(self) -> ServerClient | None:
300
+ """
301
+ Get the client without connecting.
302
+
303
+ Returns:
304
+ ServerClient if connected, None otherwise.
305
+ """
306
+ return self._client
307
+
308
+ def __enter__(self) -> ServerClient:
309
+ """Context manager entry - connects to the server."""
310
+ return self.connect()
311
+
312
+ def __exit__(
313
+ self,
314
+ exc_type: type[BaseException] | None,
315
+ exc_val: BaseException | None,
316
+ exc_tb: TracebackType | None,
317
+ ) -> None:
318
+ """Context manager exit - disconnects from the server."""
319
+ self.disconnect()
320
+
321
+
322
+ class ManagedServer:
323
+ """
324
+ Alternative interface for servers that don't need the singleton pattern.
325
+
326
+ Use this when you just want process management (health checks, restarts)
327
+ without the distributed coordination.
328
+ """
329
+
330
+ def __init__(
331
+ self,
332
+ name: str,
333
+ command: list[str],
334
+ port: int | None = None,
335
+ socket: str | Path | None = None,
336
+ **kwargs: Any,
337
+ ):
338
+ """
339
+ Initialize the managed server.
340
+
341
+ Takes the same arguments as SingleServer.
342
+ """
343
+ self.name = name
344
+ self._command_template = command
345
+ self.port = port
346
+ self.socket_path = Path(socket) if socket else None
347
+ self._kwargs = kwargs
348
+ self._owner: ProcessOwner | None = None
349
+ self._client: ServerClient | None = None
350
+
351
+ @property
352
+ def command(self) -> list[str]:
353
+ """Build the command with placeholders replaced."""
354
+ result = []
355
+ for part in self._command_template:
356
+ if "{port}" in part:
357
+ part = part.replace("{port}", str(self.port))
358
+ if "{socket}" in part:
359
+ part = part.replace("{socket}", str(self.socket_path))
360
+ result.append(part)
361
+ return result
362
+
363
+ def start(self) -> ServerClient:
364
+ """Start the server and return a client."""
365
+ if self._owner is not None:
366
+ raise RuntimeError("Server already started")
367
+
368
+ health_check_url = self._kwargs.get("health_check_url", "/")
369
+
370
+ def create_health_check() -> Callable[[], bool]:
371
+ def check() -> bool:
372
+ try:
373
+ client = ServerClient(
374
+ host="127.0.0.1",
375
+ port=self.port,
376
+ socket_path=self.socket_path,
377
+ health_check_url=health_check_url,
378
+ startup_timeout=2.0,
379
+ )
380
+ return bool(client.check_ready())
381
+ except Exception:
382
+ return False
383
+
384
+ return check
385
+
386
+ self._owner = ProcessOwner(
387
+ command=self.command,
388
+ env=self._kwargs.get("env"),
389
+ cwd=self._kwargs.get("cwd"),
390
+ stdout=self._kwargs.get("stdout"),
391
+ stderr=self._kwargs.get("stderr"),
392
+ health_check=create_health_check(),
393
+ health_check_interval=self._kwargs.get("health_check_interval", 5.0),
394
+ startup_timeout=self._kwargs.get("startup_timeout", 30.0),
395
+ restart_on_failure=self._kwargs.get("restart_on_failure", True),
396
+ max_restarts=self._kwargs.get("max_restarts", 3),
397
+ restart_delay=self._kwargs.get("restart_delay", 1.0),
398
+ shutdown_timeout=self._kwargs.get("shutdown_timeout", 10.0),
399
+ shutdown_signal=self._kwargs.get("shutdown_signal", signal.SIGTERM),
400
+ )
401
+ self._owner.start()
402
+
403
+ self._client = ServerClient(
404
+ host="127.0.0.1",
405
+ port=self.port,
406
+ socket_path=self.socket_path,
407
+ health_check_url=health_check_url,
408
+ startup_timeout=self._kwargs.get("startup_timeout", 30.0),
409
+ )
410
+ self._client.wait_ready()
411
+
412
+ return self._client
413
+
414
+ def stop(self) -> None:
415
+ """Stop the server."""
416
+ if self._owner:
417
+ self._owner.stop()
418
+ self._owner = None
419
+ if self._client:
420
+ self._client.close()
421
+ self._client = None
422
+
423
+ def __enter__(self) -> ServerClient:
424
+ return self.start()
425
+
426
+ def __exit__(
427
+ self,
428
+ exc_type: type[BaseException] | None,
429
+ exc_val: BaseException | None,
430
+ exc_tb: TracebackType | None,
431
+ ) -> None:
432
+ self.stop()
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: singleserver
3
+ Version: 0.1.0
4
+ Summary: Manage singleton server processes across multiple workers using atomic socket binding
5
+ Project-URL: Homepage, https://github.com/Technology-Company/singleserver
6
+ Project-URL: Documentation, https://github.com/Technology-Company/singleserver#readme
7
+ Project-URL: Repository, https://github.com/Technology-Company/singleserver
8
+ Project-URL: Issues, https://github.com/Technology-Company/singleserver/issues
9
+ Author-email: Johanna Mae Dimayuga <johanna@techco.fi>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: coordination,django,gunicorn,multiprocess,process,server,singleton
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Topic :: System :: Distributed Computing
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: requests>=2.25.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
30
+ Requires-Dist: pytest-timeout>=2.0.0; extra == 'dev'
31
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
33
+ Requires-Dist: types-requests>=2.25.0; extra == 'dev'
34
+ Provides-Extra: unix-sockets
35
+ Requires-Dist: requests-unixsocket>=0.3.0; extra == 'unix-sockets'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # singleserver
39
+
40
+ Manage singleton server processes across multiple workers using atomic socket binding.
41
+
42
+ ## Problem
43
+
44
+ When running web applications with multiple workers (e.g., gunicorn), you often need auxiliary services (API servers, background processors, caches) that should only run as a **single instance** shared by all workers.
45
+
46
+ Current solutions like systemd unit files or separate containers require additional infrastructure and make local development different from production.
47
+
48
+ ## Solution
49
+
50
+ `singleserver` uses the OS-level guarantee that **only one process can bind a socket at a time**. When multiple workers call `connect()`:
51
+
52
+ 1. First worker acquires the lock and starts the server
53
+ 2. Other workers detect the lock is held and connect as clients
54
+ 3. If the owner dies, another worker can take over
55
+
56
+ ```
57
+ Worker 1: connect() → bind(:18765) → SUCCESS → starts server, returns client
58
+ Worker 2: connect() → bind(:18765) → EADDRINUSE → waits for ready, returns client
59
+ Worker 3: connect() → bind(:18765) → EADDRINUSE → waits for ready, returns client
60
+ ```
61
+
62
+ ## Installation
63
+
64
+ ```bash
65
+ pip install singleserver
66
+ ```
67
+
68
+ ## Quick Start
69
+
70
+ ```python
71
+ from singleserver import SingleServer
72
+
73
+ # Define the server
74
+ api_server = SingleServer(
75
+ name="api-server",
76
+ command=["python", "-m", "my_api", "--port", "{port}"],
77
+ port=8765,
78
+ )
79
+
80
+ # Connect (starts if needed, connects if already running)
81
+ with api_server.connect() as client:
82
+ response = client.get("/data.json")
83
+ print(response.json())
84
+ ```
85
+
86
+ ## Features
87
+
88
+ - **Atomic coordination**: Uses socket binding for distributed locking
89
+ - **Health monitoring**: Configurable health checks with automatic restarts
90
+ - **Output handling**: Redirect stdout/stderr to files
91
+ - **Graceful shutdown**: SIGTERM with timeout, then SIGKILL
92
+ - **No external dependencies**: Pure Python, no Redis/etcd/etc needed
93
+ - **Same code for dev and prod**: No systemd unit files required
94
+
95
+ ## Configuration
96
+
97
+ ```python
98
+ SingleServer(
99
+ name="my-service", # Identifier for logging
100
+ command=["python", "server.py", ...], # Command to run ({port} is replaced)
101
+
102
+ # Server address
103
+ port=8765, # TCP port
104
+ # OR
105
+ socket="/tmp/my-service.sock", # Unix socket (faster, more secure)
106
+
107
+ # Health checks
108
+ health_check_url="/", # URL to poll for readiness
109
+ health_check_interval=5.0, # Seconds between checks
110
+ startup_timeout=30.0, # Max seconds to wait for startup
111
+
112
+ # Restart behavior
113
+ restart_on_failure=True, # Auto-restart if process dies
114
+ max_restarts=3, # Give up after N restarts
115
+ restart_delay=1.0, # Seconds between restart attempts
116
+
117
+ # Process options
118
+ env={"DATABASE_URL": "..."}, # Environment variables
119
+ cwd="/app", # Working directory
120
+ stdout="/var/log/my-service.log", # Log file
121
+ stderr="stdout", # Redirect stderr to stdout
122
+
123
+ # Shutdown
124
+ shutdown_timeout=10.0, # Seconds for graceful stop
125
+ )
126
+ ```
127
+
128
+ ## Use Cases
129
+
130
+ ### Internal API server
131
+
132
+ ```python
133
+ from singleserver import SingleServer
134
+
135
+ api = SingleServer(
136
+ name="internal-api",
137
+ command=["python", "-m", "internal_api", "-p", "{port}"],
138
+ port=8765,
139
+ )
140
+
141
+ def my_view(request):
142
+ with api.connect() as client:
143
+ data = client.get("/query").json()
144
+ return JsonResponse(data)
145
+ ```
146
+
147
+ ### Background service
148
+
149
+ ```python
150
+ service = SingleServer(
151
+ name="background-service",
152
+ command=["python", "service.py", "--port", "{port}"],
153
+ port=8888,
154
+ health_check_url="/health",
155
+ restart_on_failure=True,
156
+ )
157
+ ```
158
+
159
+ ## How It Works
160
+
161
+ 1. **Lock acquisition**: Uses a separate socket (port + 10000 by default) for coordination
162
+ 2. **Process management**: Owner spawns subprocess in new process group
163
+ 3. **Health monitoring**: Background thread polls health endpoint
164
+ 4. **Restart logic**: Configurable restart attempts with delay
165
+ 5. **Cleanup**: atexit handler and signal handlers for clean shutdown
166
+
167
+ ## Development
168
+
169
+ ```bash
170
+ # Install dev dependencies
171
+ uv sync --all-extras
172
+
173
+ # Run tests
174
+ uv run pytest
175
+
176
+ # Type checking
177
+ uv run mypy singleserver
178
+
179
+ # Linting
180
+ uv run ruff check singleserver tests
181
+ ```
182
+
183
+ ## License
184
+
185
+ MIT
@@ -0,0 +1,9 @@
1
+ singleserver/__init__.py,sha256=EgSpurqe2RhhGvvKghb74xU9lOsGQexoXP0mMmP4fu8,1052
2
+ singleserver/client.py,sha256=6OG9l10xUXG_wLoJOVocy8jRM4f4JItm0A6yr1Rtd0Q,9560
3
+ singleserver/lock.py,sha256=e0AWDnYk3I6_4dYSCwxz4H2FCkb9ItJLinU3palZ2ak,9100
4
+ singleserver/process.py,sha256=GqUyGWZXy85biwiShuU95tMOQXAmbiBBDeXrTEikq-o,17062
5
+ singleserver/server.py,sha256=otrSQVQVNXEpVosG4GuCJk_Iozuqnbwmkt77c0D9cMI,14978
6
+ singleserver-0.1.0.dist-info/METADATA,sha256=oZeiUEhP_NpZXtRuaHkO1v0Cml6DJUCn9hXmI6b5KeY,6013
7
+ singleserver-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ singleserver-0.1.0.dist-info/licenses/LICENSE,sha256=x6w9qzvmsY6V3iRykNoiSWD0NqCZeV6VejFT4kVi6Fg,1075
9
+ singleserver-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Technology Company
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.