ext_http_server 0.2__tar.gz → 1.0.0__tar.gz

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,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions: {}
9
+
10
+ jobs:
11
+ lint:
12
+ name: Lint and type-check
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
16
+ with:
17
+ fetch-depth: 0 # hatch-vcs needs tags to build the project for pyright
18
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
19
+ - run: uvx pre-commit run --all-files
20
+
21
+ test:
22
+ name: Test on Python ${{ matrix.python-version }}
23
+ runs-on: ubuntu-latest
24
+ strategy:
25
+ fail-fast: false
26
+ matrix:
27
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
28
+ steps:
29
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
30
+ with:
31
+ fetch-depth: 0 # hatch-vcs needs tags to build the project
32
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
33
+ - run: uv run --python ${{ matrix.python-version }} --group test pytest
@@ -0,0 +1,24 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions: {}
8
+
9
+ jobs:
10
+ publish:
11
+ name: Build and publish
12
+ runs-on: ubuntu-latest
13
+ environment:
14
+ name: pypi
15
+ url: https://pypi.org/project/ext_http_server/
16
+ permissions:
17
+ id-token: write # trusted publishing to PyPI (no API token)
18
+ steps:
19
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
20
+ with:
21
+ fetch-depth: 0 # full history + tags so hatch-vcs can derive the version
22
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
23
+ - run: uv build
24
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,12 @@
1
+ *.egg-info/
2
+ *.pyc
3
+ *~
4
+ .coverage
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .venv/
8
+ __pycache__/
9
+ _build/
10
+ build/
11
+ dist/
12
+ htmlcov/
@@ -0,0 +1,55 @@
1
+ repos:
2
+ - hooks:
3
+ - id: docstrfmt
4
+ require_serial: true
5
+ repo: https://github.com/LilSpazJoekp/docstrfmt
6
+ rev: 8688ba6420d7b5ca95a8ba0edf8a9953babdc3da # frozen: v2.1.1
7
+
8
+ - hooks:
9
+ - id: codesorter
10
+ repo: https://github.com/praw-dev/CodeSorter
11
+ rev: 8aa6144b41e0f789124b2ca377d246ffd1fbb317 # frozen: v0.2.7
12
+
13
+ - hooks:
14
+ - id: auto-walrus
15
+ repo: https://github.com/MarcoGorelli/auto-walrus
16
+ rev: 1743edbed52f3d61886b59d98a27164a8af29c0d # frozen: 0.4.1
17
+
18
+ - hooks:
19
+ - args: [--fix]
20
+ id: ruff-check
21
+ - id: ruff-format
22
+ repo: https://github.com/astral-sh/ruff-pre-commit
23
+ rev: 3b3f7c3f57fe9925356faf5fe6230835138be230 # frozen: v0.15.17
24
+
25
+ - hooks:
26
+ - files: ^(.*\.toml)$
27
+ id: toml-sort-fix
28
+ repo: https://github.com/pappasam/toml-sort
29
+ rev: 2970ae9bb7124fe5117a27e10c10d2da051ce05a # frozen: v0.24.4
30
+
31
+ - hooks:
32
+ - id: check-added-large-files
33
+ - id: check-executables-have-shebangs
34
+ - id: check-shebang-scripts-are-executable
35
+ - id: check-toml
36
+ - id: check-yaml
37
+ - id: end-of-file-fixer
38
+ - args: [--fix=lf]
39
+ id: mixed-line-ending
40
+ - args: [--pytest-test-first]
41
+ files: ^tests/.*\.py$
42
+ id: name-tests-test
43
+ - id: no-commit-to-branch
44
+ - id: trailing-whitespace
45
+ repo: https://github.com/pre-commit/pre-commit-hooks
46
+ rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
47
+
48
+ - hooks:
49
+ - entry: uv run --group dev pyright
50
+ id: pyright
51
+ language: system
52
+ name: pyright
53
+ pass_filenames: false
54
+ types: [python]
55
+ repo: local
@@ -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.
@@ -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,42 @@
1
+ # ext_http_server
2
+
3
+ An extended version of Python's `http.server` that turns a simple static file
4
+ server into one supporting HTTPS, HTTP Basic authentication, server-to-client
5
+ rate limiting, and resumable downloads via HTTP `Range` requests.
6
+
7
+ ### Requirements
8
+
9
+ `ext_http_server` supports Python 3.10 through 3.14.
10
+
11
+ ### Installation
12
+
13
+ Install the `ext_http_server` command with [uv](https://docs.astral.sh/uv/):
14
+
15
+ uv tool install ext_http_server
16
+
17
+ ### Generate a certificate
18
+
19
+ Generate a self-signed certificate, writing the private key and certificate
20
+ together into `cert.pem` (the single file `--cert` expects). This uses a modern
21
+ ECDSA P-256 key, which every common TLS client supports (Ed25519 keys are
22
+ newer but are rejected by some clients, including the LibreSSL-based `curl` that
23
+ ships with macOS as of June 2026):
24
+
25
+ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -noenc -keyout cert.pem -out cert.pem -days 365 -subj "/CN=localhost"
26
+
27
+ ### Running ext_http_server
28
+
29
+ If you have files you want to serve in `/tmp/path/to/files/` run the following
30
+ to serve them up with a max outgoing throughput of 16KBps:
31
+
32
+ ext_http_server --cert cert.pem -d /tmp/path/to/files -r16 -a foo:bar
33
+
34
+ Or run it once without installing:
35
+
36
+ uvx ext_http_server --cert cert.pem -d /tmp/path/to/files -r16 -a foo:bar
37
+
38
+ By default, you will be able to access the webserver at
39
+ [https://localhost:8000](https://localhost:8000). To authenticate, use the
40
+ username `foo` and the password `bar` as indicated by the `-a foo:bar`
41
+ argument. Note that multiple `-a` arguments can be added to add more than one
42
+ user.
@@ -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())
@@ -0,0 +1,81 @@
1
+ [build-system]
2
+ build-backend = "hatchling.build"
3
+ requires = ["hatchling", "hatch-vcs"]
4
+
5
+ [dependency-groups]
6
+ dev = ["pyright"]
7
+ test = ["pytest", "trustme"]
8
+
9
+ [project]
10
+ authors = [
11
+ {name = "Bryce Boe", email = "bbzbryce@gmail.com"}
12
+ ]
13
+ dynamic = ["version"]
14
+ classifiers = [
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14"
24
+ ]
25
+ description = "An extended version of Python's SimpleHTTPServer that supports https, authentication, rate limiting, and download resuming."
26
+ keywords = ["http authentication", "http rate limit", "http resume"]
27
+ license = "BSD-2-Clause"
28
+ license-files = ["LICENSE.txt"]
29
+ name = "ext_http_server"
30
+ readme = "README.md"
31
+ requires-python = ">=3.10"
32
+
33
+ [project.scripts]
34
+ ext_http_server = "ext_http_server:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/bboe/extended_http_server"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ include = ["ext_http_server.py"]
41
+
42
+ [tool.hatch.version]
43
+ source = "vcs"
44
+
45
+ [tool.pyright]
46
+ include = ["ext_http_server.py"]
47
+ pythonVersion = "3.10"
48
+ typeCheckingMode = "strict"
49
+
50
+ [tool.pytest.ini_options]
51
+ pythonpath = ["."]
52
+ testpaths = ["tests"]
53
+
54
+ [tool.ruff]
55
+ line-length = 100
56
+ preview = true
57
+ target-version = "py310"
58
+
59
+ [tool.ruff.lint]
60
+ select = ["ALL"]
61
+ ignore = [
62
+ "CPY001", # file-level copyright notice; LICENSE.txt is the canonical copyright
63
+ "D203", # 1 blank line required before class docstring (conflicts with D211)
64
+ "D213", # multi-line docstring summary should start at the second line (conflicts with D212)
65
+ "T201" # `print` is the intended console output for this CLI server
66
+ ]
67
+
68
+ [tool.ruff.lint.per-file-ignores]
69
+ "tests/**" = [
70
+ "ANN", # annotations are optional in tests
71
+ "D", # docstrings are optional in tests
72
+ "INP001", # the tests directory is intentionally not a package
73
+ "PLR2004", # literal values are clearer than named constants in assertions
74
+ "RUF076", # an autouse fixture is the right tool for resetting global state
75
+ "S101", # asserts are how tests assert
76
+ "S106", # test credentials are not real secrets
77
+ "SLF001" # tests exercise private internals directly
78
+ ]
79
+
80
+ [tool.ruff.lint.pydocstyle]
81
+ convention = "google"