ext_http_server 1.0.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,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ext_http_server
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: An extended version of Python's SimpleHTTPServer that supports https, authentication, rate limiting, and download resuming.
|
|
5
|
+
Project-URL: Homepage, https://github.com/bboe/extended_http_server
|
|
6
|
+
Author-email: Bryce Boe <bbzbryce@gmail.com>
|
|
7
|
+
License-Expression: BSD-2-Clause
|
|
8
|
+
License-File: LICENSE.txt
|
|
9
|
+
Keywords: http authentication,http rate limit,http resume
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# ext_http_server
|
|
23
|
+
|
|
24
|
+
An extended version of Python's `http.server` that turns a simple static file
|
|
25
|
+
server into one supporting HTTPS, HTTP Basic authentication, server-to-client
|
|
26
|
+
rate limiting, and resumable downloads via HTTP `Range` requests.
|
|
27
|
+
|
|
28
|
+
### Requirements
|
|
29
|
+
|
|
30
|
+
`ext_http_server` supports Python 3.10 through 3.14.
|
|
31
|
+
|
|
32
|
+
### Installation
|
|
33
|
+
|
|
34
|
+
Install the `ext_http_server` command with [uv](https://docs.astral.sh/uv/):
|
|
35
|
+
|
|
36
|
+
uv tool install ext_http_server
|
|
37
|
+
|
|
38
|
+
### Generate a certificate
|
|
39
|
+
|
|
40
|
+
Generate a self-signed certificate, writing the private key and certificate
|
|
41
|
+
together into `cert.pem` (the single file `--cert` expects). This uses a modern
|
|
42
|
+
ECDSA P-256 key, which every common TLS client supports (Ed25519 keys are
|
|
43
|
+
newer but are rejected by some clients, including the LibreSSL-based `curl` that
|
|
44
|
+
ships with macOS as of June 2026):
|
|
45
|
+
|
|
46
|
+
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -noenc -keyout cert.pem -out cert.pem -days 365 -subj "/CN=localhost"
|
|
47
|
+
|
|
48
|
+
### Running ext_http_server
|
|
49
|
+
|
|
50
|
+
If you have files you want to serve in `/tmp/path/to/files/` run the following
|
|
51
|
+
to serve them up with a max outgoing throughput of 16KBps:
|
|
52
|
+
|
|
53
|
+
ext_http_server --cert cert.pem -d /tmp/path/to/files -r16 -a foo:bar
|
|
54
|
+
|
|
55
|
+
Or run it once without installing:
|
|
56
|
+
|
|
57
|
+
uvx ext_http_server --cert cert.pem -d /tmp/path/to/files -r16 -a foo:bar
|
|
58
|
+
|
|
59
|
+
By default, you will be able to access the webserver at
|
|
60
|
+
[https://localhost:8000](https://localhost:8000). To authenticate, use the
|
|
61
|
+
username `foo` and the password `bar` as indicated by the `-a foo:bar`
|
|
62
|
+
argument. Note that multiple `-a` arguments can be added to add more than one
|
|
63
|
+
user.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ext_http_server.py,sha256=3zYoYIH8plkGk59yY-IE8imIZQ_-9ds2W-H4ABMrTts,11492
|
|
2
|
+
ext_http_server-1.0.0.dist-info/METADATA,sha256=JCtAS_OAPwJPJrQHcYo8pJiRX73LFA2SR3XAQisR-yg,2532
|
|
3
|
+
ext_http_server-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
ext_http_server-1.0.0.dist-info/entry_points.txt,sha256=pwP3ctpOwcNIc1zWSXERQWG6Qke0WsLPeXAHqHWRgMY,57
|
|
5
|
+
ext_http_server-1.0.0.dist-info/licenses/LICENSE.txt,sha256=VtRw2c06n1S5lcOnvtPaueLhmsHzR_IpTcZOajSHFgg,1296
|
|
6
|
+
ext_http_server-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2012, Bryce Boe
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
8
|
+
list of conditions and the following disclaimer.
|
|
9
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer in the documentation
|
|
11
|
+
and/or other materials provided with the distribution.
|
|
12
|
+
|
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
14
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
15
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
16
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
17
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
18
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
19
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
20
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
21
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
22
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
ext_http_server.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""A small set of improvements upon the Simple and BaseHTTPServers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import base64
|
|
7
|
+
import errno
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import socketserver
|
|
11
|
+
import ssl
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from http import HTTPStatus
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer, SimpleHTTPRequestHandler
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, AnyStr, ClassVar, cast
|
|
20
|
+
from warnings import warn
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from socket import socket
|
|
24
|
+
|
|
25
|
+
from _typeshed import SupportsRead, SupportsWrite
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
__version__ = version("ext_http_server")
|
|
29
|
+
except PackageNotFoundError: # running from a source checkout without an install
|
|
30
|
+
__version__ = "unknown"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthHandler(SimpleHTTPRequestHandler):
|
|
34
|
+
"""A handler that supports basic HTTP authentication/authorization."""
|
|
35
|
+
|
|
36
|
+
message = "Authentication required."
|
|
37
|
+
realm = "Something"
|
|
38
|
+
users: ClassVar[set[str]] = set()
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def add_user(cls, username: str, password: str) -> None:
|
|
42
|
+
"""Add a set of credentials."""
|
|
43
|
+
token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
44
|
+
cls.users.add(token)
|
|
45
|
+
|
|
46
|
+
def do_GET(self) -> None:
|
|
47
|
+
"""Call the parent's do_GET function if the user is authorized."""
|
|
48
|
+
if self.handle_auth():
|
|
49
|
+
super().do_GET()
|
|
50
|
+
|
|
51
|
+
def do_HEAD(self) -> None:
|
|
52
|
+
"""Call the parent's do_HEAD function if the user is authorized."""
|
|
53
|
+
if self.handle_auth(head=True):
|
|
54
|
+
super().do_HEAD()
|
|
55
|
+
|
|
56
|
+
def handle_auth(self, *, head: bool = False) -> bool:
|
|
57
|
+
"""Output the authentication headers if the user is not valid.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
``True`` when the request carries valid credentials, ``False`` otherwise.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
if auth := self.headers.get("Authorization"):
|
|
64
|
+
try:
|
|
65
|
+
_, encoded = auth.split(" ", 1)
|
|
66
|
+
except ValueError:
|
|
67
|
+
encoded = None
|
|
68
|
+
# Verify the user
|
|
69
|
+
if encoded in AuthHandler.users:
|
|
70
|
+
return True
|
|
71
|
+
# Send authentication header information
|
|
72
|
+
self.send_response(HTTPStatus.UNAUTHORIZED)
|
|
73
|
+
self.send_header("WWW-Authenticate", f'Basic realm="{AuthHandler.realm}"')
|
|
74
|
+
self.send_header("Content-Type", "text/html")
|
|
75
|
+
self.send_header("Content-Length", str(len(AuthHandler.message)))
|
|
76
|
+
self.end_headers()
|
|
77
|
+
if not head:
|
|
78
|
+
self.wfile.write(AuthHandler.message.encode())
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RangeHandler(SimpleHTTPRequestHandler):
|
|
83
|
+
"""A handler that supports HTTP requests with the Range header.
|
|
84
|
+
|
|
85
|
+
The Range header allows for the resume download functionality.
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
is_ranged: bool
|
|
90
|
+
range_begin: int
|
|
91
|
+
range_end: int | None
|
|
92
|
+
|
|
93
|
+
def copyfile(self, source: SupportsRead[AnyStr], outputfile: SupportsWrite[AnyStr]) -> None:
|
|
94
|
+
"""Copy only the ranged part of the file when appropriate."""
|
|
95
|
+
if self.is_ranged and isinstance(source, io.IOBase):
|
|
96
|
+
source.seek(self.range_begin)
|
|
97
|
+
super().copyfile(source, outputfile)
|
|
98
|
+
|
|
99
|
+
def do_GET(self) -> None:
|
|
100
|
+
"""Set is_ranged flag if a valid Range header is sent."""
|
|
101
|
+
self.handle_range()
|
|
102
|
+
super().do_GET()
|
|
103
|
+
|
|
104
|
+
def do_HEAD(self) -> None:
|
|
105
|
+
"""Set is_ranged flag if a valid Range header is sent."""
|
|
106
|
+
self.handle_range()
|
|
107
|
+
super().do_HEAD()
|
|
108
|
+
|
|
109
|
+
def handle_range(self) -> None:
|
|
110
|
+
"""Parse the Range header if it exists."""
|
|
111
|
+
self.is_ranged = False
|
|
112
|
+
raw = self.headers.get("range")
|
|
113
|
+
if not raw or "=" not in raw:
|
|
114
|
+
return
|
|
115
|
+
range_unit, _, other = raw.partition("=")
|
|
116
|
+
if range_unit != "bytes" or "-" not in other:
|
|
117
|
+
return
|
|
118
|
+
if "," in other: # Handle only a single range
|
|
119
|
+
warn("Multiple ranges are not supported.", stacklevel=2)
|
|
120
|
+
return
|
|
121
|
+
begin, _, end = other.partition("-")
|
|
122
|
+
if end:
|
|
123
|
+
warn("Shortened ranges are not supported.", stacklevel=2)
|
|
124
|
+
return
|
|
125
|
+
if begin and not begin.isdigit():
|
|
126
|
+
return
|
|
127
|
+
self.range_begin = int(begin) if begin else 0
|
|
128
|
+
self.range_end = None
|
|
129
|
+
self.is_ranged = True
|
|
130
|
+
|
|
131
|
+
def send_header(self, keyword: str, value: str) -> None:
|
|
132
|
+
"""Modify Content-Length and add Content-Range when ranged."""
|
|
133
|
+
if keyword == "Content-Length" and self.is_ranged:
|
|
134
|
+
length = int(value)
|
|
135
|
+
end = length - 1 if self.range_end is None else min(self.range_end, length - 1)
|
|
136
|
+
value = str(1 + end - self.range_begin)
|
|
137
|
+
self.send_header("Content-Range", f"bytes {self.range_begin}-{end}/{length}")
|
|
138
|
+
super().send_header(keyword, value)
|
|
139
|
+
|
|
140
|
+
def send_response(self, code: int, message: str | None = None) -> None:
|
|
141
|
+
"""Send 206 status for ranged responses."""
|
|
142
|
+
if self.is_ranged and code == HTTPStatus.OK:
|
|
143
|
+
code = HTTPStatus.PARTIAL_CONTENT
|
|
144
|
+
super().send_response(code, message)
|
|
145
|
+
|
|
146
|
+
def setup(self) -> None:
|
|
147
|
+
"""Set HTTP/1.1 as Range is supported only on HTTP/1.1."""
|
|
148
|
+
super().setup()
|
|
149
|
+
self.protocol_version = "HTTP/1.1"
|
|
150
|
+
self.is_ranged = False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class RateLimitHandler(SimpleHTTPRequestHandler):
|
|
154
|
+
"""A handler that supports rate limiting from server to client.
|
|
155
|
+
|
|
156
|
+
This handler will not properly rate limit if a ForkingMixIn is used in the
|
|
157
|
+
HTTPServer object. However, it works great in combination with the ThreadingMixIn.
|
|
158
|
+
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def handle(self) -> None:
|
|
162
|
+
"""Set up rate limiting on the outgoing connection."""
|
|
163
|
+
# RateLimitWriter is a transparent write-proxy, not a BufferedIOBase subclass.
|
|
164
|
+
self.wfile = cast("io.BufferedIOBase", RateLimitWriter(self.wfile))
|
|
165
|
+
super().handle()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class MyHandler(AuthHandler, RangeHandler, RateLimitHandler):
|
|
169
|
+
"""A handler that supports auth, download resuming, and throttling."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class RateLimitWriter:
|
|
173
|
+
"""A class that rate limits writing to associated file streams.
|
|
174
|
+
|
|
175
|
+
This method only supports threading and not forking (multiprocessing).
|
|
176
|
+
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
INTERVAL_LEN: ClassVar[float] = 0.125
|
|
180
|
+
block_sent: ClassVar[int] = 0
|
|
181
|
+
block_size: ClassVar[int] = 16384
|
|
182
|
+
block_start: ClassVar[float] = 0.0
|
|
183
|
+
lock: ClassVar = threading.Lock()
|
|
184
|
+
|
|
185
|
+
wrapped: io.BufferedIOBase
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def bytes_to_write(cls, desired: int) -> int:
|
|
189
|
+
"""Determine how many bytes to write and sleep when over the limit.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The number of bytes the caller may write now.
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
to_send = 0
|
|
196
|
+
while not to_send:
|
|
197
|
+
with cls.lock:
|
|
198
|
+
now = time.time()
|
|
199
|
+
if not cls.block_start:
|
|
200
|
+
# First data of block, send it all
|
|
201
|
+
cls.block_start = now
|
|
202
|
+
to_send = min(desired, cls.block_size)
|
|
203
|
+
cls.block_sent = to_send
|
|
204
|
+
elif cls.block_sent < cls.block_size:
|
|
205
|
+
# Haven't sent a complete block, send remainder
|
|
206
|
+
to_send = min(desired, cls.block_size - cls.block_sent)
|
|
207
|
+
cls.block_sent += to_send
|
|
208
|
+
else:
|
|
209
|
+
# A complete block has been sent, sleep if necessary
|
|
210
|
+
sleep_time = cls.INTERVAL_LEN - (now - cls.block_start)
|
|
211
|
+
if sleep_time > 0:
|
|
212
|
+
time.sleep(sleep_time)
|
|
213
|
+
cls.block_start = 0.0
|
|
214
|
+
cls.block_sent = 0
|
|
215
|
+
return to_send
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def set_rate_limit(cls, limit: float) -> None:
|
|
219
|
+
"""Set the rate limit in kilobytes per second."""
|
|
220
|
+
cls.block_size = int(1024 * limit * cls.INTERVAL_LEN)
|
|
221
|
+
|
|
222
|
+
def __getattr__(self, attr: str) -> Any: # noqa: ANN401
|
|
223
|
+
"""Redirect all attribute access to the wrapped output stream.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The corresponding attribute of the wrapped stream.
|
|
227
|
+
|
|
228
|
+
"""
|
|
229
|
+
return getattr(self.wrapped, attr)
|
|
230
|
+
|
|
231
|
+
def __init__(self, to_wrap: io.BufferedIOBase) -> None:
|
|
232
|
+
"""Store the output stream we are wrapping."""
|
|
233
|
+
self.wrapped = to_wrap
|
|
234
|
+
|
|
235
|
+
def write(self, message: bytes) -> None:
|
|
236
|
+
"""Perform a throttled write to the wrapped output stream."""
|
|
237
|
+
while message:
|
|
238
|
+
to_send = RateLimitWriter.bytes_to_write(len(message))
|
|
239
|
+
self.wrapped.write(message[:to_send])
|
|
240
|
+
message = message[to_send:]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class SecureHTTPServer(HTTPServer):
|
|
244
|
+
"""A HTTP Server object that supports HTTPS."""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
address: tuple[str, int],
|
|
249
|
+
handler: type[BaseHTTPRequestHandler],
|
|
250
|
+
cert_file: str | os.PathLike[str],
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Support TLS/SSL by wrapping the socket."""
|
|
253
|
+
super().__init__(address, handler)
|
|
254
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
255
|
+
context.load_cert_chain(cert_file)
|
|
256
|
+
self.socket = context.wrap_socket(self.socket, server_side=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class MyServer(socketserver.ThreadingMixIn, SecureHTTPServer):
|
|
260
|
+
"""A threaded SecureHTTPServer with basic error filtering."""
|
|
261
|
+
|
|
262
|
+
def handle_error(self, request: socket | tuple[bytes, socket], client_address: Any) -> None: # noqa: ANN401
|
|
263
|
+
"""Disable tracebacks on connection close errors."""
|
|
264
|
+
_, exc_value, _ = sys.exc_info()
|
|
265
|
+
if isinstance(exc_value, OSError) and exc_value.errno == errno.EPIPE:
|
|
266
|
+
print(f"{client_address} closed connection")
|
|
267
|
+
elif isinstance(exc_value, ssl.SSLError) and exc_value.errno == 1:
|
|
268
|
+
print(f"{client_address} SSL Error: bad write retry")
|
|
269
|
+
else:
|
|
270
|
+
super().handle_error(request, client_address)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def main() -> int:
|
|
274
|
+
"""Run a secure threaded server with auth resume and rate limit support.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The process exit status.
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
parser = argparse.ArgumentParser()
|
|
281
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
282
|
+
parser.add_argument("-p", "--port", default=8000, type=int)
|
|
283
|
+
parser.add_argument("-c", "--cert", help="The TLS/SSL certificate file")
|
|
284
|
+
parser.add_argument("-d", "--directory", help="The directory to serve")
|
|
285
|
+
parser.add_argument("-r", "--ratelimit", default=128, help="The ratelimit in KBps", type=int)
|
|
286
|
+
parser.add_argument("-a", "--add-auth", action="append", help="Add user:password combination")
|
|
287
|
+
options = parser.parse_args()
|
|
288
|
+
|
|
289
|
+
# Configure Services
|
|
290
|
+
if not options.add_auth:
|
|
291
|
+
parser.error("At least one user must be added via --add-auth")
|
|
292
|
+
for auth in options.add_auth:
|
|
293
|
+
try:
|
|
294
|
+
username, password = auth.split(":", 1)
|
|
295
|
+
except ValueError:
|
|
296
|
+
parser.error(f"{auth!r} is not a valid username:password")
|
|
297
|
+
AuthHandler.add_user(username, password)
|
|
298
|
+
RateLimitWriter.set_rate_limit(options.ratelimit)
|
|
299
|
+
|
|
300
|
+
# Verify cert file
|
|
301
|
+
if not options.cert:
|
|
302
|
+
parser.error("--cert must be provided")
|
|
303
|
+
cert_path = Path(options.cert).resolve()
|
|
304
|
+
if not cert_path.is_file():
|
|
305
|
+
parser.error("Invalid cert file")
|
|
306
|
+
|
|
307
|
+
# Change into serving directory
|
|
308
|
+
if options.directory:
|
|
309
|
+
try:
|
|
310
|
+
os.chdir(options.directory)
|
|
311
|
+
except OSError:
|
|
312
|
+
parser.error("Invalid --directory")
|
|
313
|
+
|
|
314
|
+
server = MyServer(("", options.port), MyHandler, cert_path)
|
|
315
|
+
print(f"Server listening on port {options.port}")
|
|
316
|
+
try:
|
|
317
|
+
server.serve_forever()
|
|
318
|
+
except KeyboardInterrupt:
|
|
319
|
+
print("\nGoodbye")
|
|
320
|
+
return 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
sys.exit(main())
|