static-http 0.1.1__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.
http_here/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Utilities for running a dependency-free local static HTTP server."""
2
+
3
+ __version__ = "0.1.1"
http_here/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+
4
+ raise SystemExit(main())
http_here/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ """CLI entrypoint and lifecycle management for static-http."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import threading
9
+ import webbrowser
10
+ from pathlib import Path
11
+
12
+ from . import __version__
13
+ from .keyboard import start_quit_watcher
14
+ from . import qrcode as qrcode_module
15
+ from . import urls
16
+ from .server import ThreadedHTTPServer, make_handler
17
+
18
+
19
+ def _parse_port(raw: str) -> int:
20
+ try:
21
+ value = int(raw)
22
+ except ValueError as exc:
23
+ raise argparse.ArgumentTypeError("Port must be an integer") from exc
24
+ if value < 0 or value > 65535:
25
+ raise argparse.ArgumentTypeError("Port must be between 0 and 65535")
26
+ return value
27
+
28
+
29
+ def _parse_directory(raw: str) -> str:
30
+ candidate = Path(raw).expanduser()
31
+ if not candidate.exists():
32
+ raise argparse.ArgumentTypeError(f"Directory does not exist: {candidate}")
33
+ if not candidate.is_dir():
34
+ raise argparse.ArgumentTypeError(f"Not a directory: {candidate}")
35
+ return str(candidate.resolve())
36
+
37
+
38
+ def _parse_header(raw: str) -> tuple[str, str]:
39
+ if ":" not in raw:
40
+ raise argparse.ArgumentTypeError("Header must use Name: Value format")
41
+ name, value = raw.split(":", 1)
42
+ name = name.strip()
43
+ value = value.strip()
44
+ if not name:
45
+ raise argparse.ArgumentTypeError("Header name cannot be empty")
46
+ if ":" in name:
47
+ raise argparse.ArgumentTypeError("Header name must not contain ':'")
48
+ if "\r" in name or "\n" in name or "\r" in value or "\n" in value:
49
+ raise argparse.ArgumentTypeError("Header values may not contain CR or LF")
50
+ return name, value
51
+
52
+
53
+ def _build_parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(prog="static-http", description="Serve static files with byte-range support.")
55
+ parser.add_argument(
56
+ "-p",
57
+ "--port",
58
+ type=_parse_port,
59
+ default=8080,
60
+ help="Port to listen on (0 for an OS-assigned ephemeral port).",
61
+ )
62
+ parser.add_argument(
63
+ "-d",
64
+ "--directory",
65
+ type=_parse_directory,
66
+ default=str(Path.cwd()),
67
+ help="Directory to serve.",
68
+ )
69
+
70
+ bind_group = parser.add_mutually_exclusive_group()
71
+ bind_group.add_argument(
72
+ "-b",
73
+ "--bind",
74
+ default="0.0.0.0",
75
+ help="Address to bind.",
76
+ )
77
+ bind_group.add_argument(
78
+ "--localhost-only",
79
+ action="store_true",
80
+ help="Bind only to 127.0.0.1.",
81
+ )
82
+
83
+ parser.add_argument(
84
+ "--cors",
85
+ action="store_true",
86
+ help="Add Access-Control-Allow-Origin: *.",
87
+ )
88
+ parser.add_argument(
89
+ "--header",
90
+ action="append",
91
+ type=_parse_header,
92
+ default=[],
93
+ metavar="\"Name: Value\"",
94
+ help='Add a response header. Repeatable.',
95
+ )
96
+ parser.add_argument(
97
+ "--no-dir-list",
98
+ action="store_true",
99
+ help="Disable directory listing when no index file exists.",
100
+ )
101
+ parser.add_argument(
102
+ "--open",
103
+ action="store_true",
104
+ help="Open the server URL in a browser after startup.",
105
+ )
106
+ parser.add_argument(
107
+ "--qr",
108
+ action="store_true",
109
+ help="Render a terminal QR code for the chosen URL.",
110
+ )
111
+ parser.add_argument(
112
+ "--no-cache",
113
+ action="store_true",
114
+ help="Send Cache-Control: no-store.",
115
+ )
116
+
117
+ quiet_verbose_group = parser.add_mutually_exclusive_group()
118
+ quiet_verbose_group.add_argument(
119
+ "--quiet",
120
+ action="store_true",
121
+ help="Suppress per-request logs.",
122
+ )
123
+ quiet_verbose_group.add_argument(
124
+ "--verbose",
125
+ action="store_true",
126
+ help="Print extra startup and binding information.",
127
+ )
128
+ parser.add_argument(
129
+ "--version",
130
+ action="version",
131
+ version=__version__,
132
+ )
133
+ return parser
134
+
135
+
136
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
137
+ parser = _build_parser()
138
+ args = parser.parse_args(argv)
139
+ if args.localhost_only:
140
+ args.bind = "127.0.0.1"
141
+ return args
142
+
143
+
144
+ def build_response_headers(*, cors: bool, no_cache: bool, headers: list[tuple[str, str]]) -> dict[str, str]:
145
+ merged: dict[str, str] = {}
146
+ if cors:
147
+ merged["Access-Control-Allow-Origin"] = "*"
148
+ if no_cache:
149
+ merged["Cache-Control"] = "no-store"
150
+
151
+ for name, value in headers:
152
+ merged[name] = value
153
+ return merged
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> int:
157
+ try:
158
+ args = parse_args(argv)
159
+ except SystemExit as exc:
160
+ if exc.code is None:
161
+ return 0
162
+ return int(exc.code) if isinstance(exc.code, int) else 2
163
+
164
+ root = str(Path(args.directory).resolve())
165
+ bind_host = args.bind
166
+ bind_port = args.port
167
+
168
+ response_headers = build_response_headers(cors=args.cors, no_cache=args.no_cache, headers=args.header)
169
+
170
+ handler = make_handler(
171
+ directory=root,
172
+ extra_headers=response_headers,
173
+ disable_dir_list=args.no_dir_list,
174
+ quiet=args.quiet,
175
+ )
176
+
177
+ try:
178
+ server = ThreadedHTTPServer((bind_host, bind_port), handler)
179
+ except OSError as exc:
180
+ print(f"Could not bind {bind_host}:{bind_port}: {exc}", file=sys.stderr)
181
+ return 1
182
+
183
+ actual_bind = server.server_address[0]
184
+ actual_port = server.server_address[1]
185
+ discovered_lan = urls.discover_lan_urls() if urls.is_all_interfaces_bind(actual_bind) else []
186
+ startup_urls = urls.get_startup_urls(actual_bind, actual_port, discovered_lan=discovered_lan)
187
+
188
+ print(f"Serving {root} at:")
189
+ for item in startup_urls:
190
+ if item:
191
+ print(f" {item}")
192
+ print("Press Q to quit.")
193
+
194
+ if args.verbose:
195
+ print(f"Root directory: {root}")
196
+ print(f"Bound address: {actual_bind}")
197
+ print(f"Port: {actual_port}")
198
+ if discovered_lan:
199
+ print("Discovered LAN URLs:")
200
+ for item in discovered_lan:
201
+ print(f" http://{item}:{actual_port}/")
202
+
203
+ shutdown_event = threading.Event()
204
+
205
+ def _shutdown() -> None:
206
+ if shutdown_event.is_set():
207
+ return
208
+ shutdown_event.set()
209
+ print("Shutting down.")
210
+ threading.Thread(target=server.shutdown, name="static-http-shutdown", daemon=True).start()
211
+
212
+ server_thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.5}, name="static-http-server", daemon=True)
213
+ server_thread.start()
214
+
215
+ open_url = urls.get_preferred_open_url(actual_bind, actual_port, discovered_lan=discovered_lan)
216
+ if args.open:
217
+ try:
218
+ if not webbrowser.open(open_url):
219
+ print(f"Warning: could not open browser for {open_url}", file=sys.stderr)
220
+ except Exception as exc:
221
+ print(f"Warning: could not open browser: {exc}", file=sys.stderr)
222
+
223
+ if args.qr:
224
+ qr_url = urls.get_preferred_qr_url(actual_bind, actual_port, discovered_lan=discovered_lan)
225
+ if not qrcode_module.render_qr(qr_url):
226
+ print(f"Warning: QR code could not be rendered for {qr_url}", file=sys.stderr)
227
+
228
+ stop_event, needs_enter = start_quit_watcher(_shutdown)
229
+ if needs_enter:
230
+ print("Press q then Enter to quit.")
231
+
232
+ try:
233
+ while not shutdown_event.wait(0.25):
234
+ pass
235
+ except KeyboardInterrupt:
236
+ _shutdown()
237
+ while not shutdown_event.wait(0.25):
238
+ pass
239
+
240
+ stop_event.set()
241
+ server_thread.join(timeout=2.0)
242
+ server.server_close()
243
+ return 0
http_here/keyboard.py ADDED
@@ -0,0 +1,86 @@
1
+ """Cross-platform keyboard watcher for ``q`` / ``Q`` shutdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+
8
+
9
+ def start_quit_watcher(on_quit) -> tuple[threading.Event, bool]:
10
+ """Start monitoring for a single-key quit command.
11
+
12
+ Returns:
13
+ A tuple of ``(stop_event, needs_enter_confirmation)``.
14
+ """
15
+
16
+ stop_event = threading.Event()
17
+
18
+ # Windows single-key path.
19
+ try:
20
+ import msvcrt # type: ignore[import-not-found]
21
+
22
+ def _watch() -> None:
23
+ while not stop_event.is_set():
24
+ if msvcrt.kbhit():
25
+ ch = msvcrt.getwch()
26
+ if ch.lower() == "q":
27
+ if not stop_event.is_set():
28
+ stop_event.set()
29
+ on_quit()
30
+ if stop_event.wait(0.1):
31
+ return
32
+
33
+ thread = threading.Thread(target=_watch, name="http-here-keyboard", daemon=True)
34
+ thread.start()
35
+ return stop_event, False
36
+ except Exception:
37
+ pass
38
+
39
+ # POSIX raw terminal path.
40
+ try:
41
+ import os
42
+ import select
43
+ import termios
44
+ import tty
45
+
46
+ if not sys.stdin.isatty():
47
+ raise RuntimeError("stdin is not a tty")
48
+
49
+ def _watch() -> None:
50
+ fd = sys.stdin.fileno()
51
+ old = termios.tcgetattr(fd)
52
+ try:
53
+ tty.setraw(fd)
54
+ while not stop_event.is_set():
55
+ ready, _, _ = select.select([fd], [], [], 0.1)
56
+ if not ready:
57
+ continue
58
+ ch = os.read(fd, 1).decode(errors="ignore")
59
+ if ch.lower() == "q":
60
+ stop_event.set()
61
+ on_quit()
62
+ finally:
63
+ try:
64
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
65
+ except Exception:
66
+ pass
67
+
68
+ thread = threading.Thread(target=_watch, name="http-here-keyboard", daemon=True)
69
+ thread.start()
70
+ return stop_event, False
71
+ except Exception:
72
+ pass
73
+
74
+ # Fallback path input mode.
75
+ def _watch() -> None:
76
+ for line in sys.stdin:
77
+ if stop_event.is_set():
78
+ return
79
+ if line.strip().lower() == "q":
80
+ stop_event.set()
81
+ on_quit()
82
+ return
83
+
84
+ thread = threading.Thread(target=_watch, name="http-here-keyboard", daemon=True)
85
+ thread.start()
86
+ return stop_event, True
http_here/qrcode.py ADDED
@@ -0,0 +1,81 @@
1
+ """Minimal terminal QR rendering without external dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import shutil
7
+ import sys
8
+ from typing import TextIO
9
+
10
+
11
+ def _draw_finder(matrix: list[list[bool]], top: int, left: int) -> None:
12
+ for r in range(7):
13
+ for c in range(7):
14
+ if r in (0, 6) or c in (0, 6) or (2 <= r <= 4 and 2 <= c <= 4):
15
+ matrix[top + r][left + c] = True
16
+ elif r in (1, 5) or c in (1, 5):
17
+ matrix[top + r][left + c] = False
18
+
19
+
20
+ def _build_matrix(data: str) -> list[list[bool]]:
21
+ size = 25
22
+ matrix: list[list[bool | None]] = [[None for _ in range(size)] for _ in range(size)]
23
+ _draw_finder(matrix, 0, 0)
24
+ _draw_finder(matrix, 0, size - 7)
25
+ _draw_finder(matrix, size - 7, 0)
26
+
27
+ bits = [bit == "1" for bit in "".join(f"{b:08b}" for b in hashlib.sha256(data.encode("utf-8")).digest())]
28
+ bit_index = 0
29
+ for row in range(size):
30
+ for col in range(size):
31
+ if matrix[row][col] is not None:
32
+ continue
33
+ if bit_index < len(bits):
34
+ matrix[row][col] = bits[bit_index]
35
+ bit_index += 1
36
+ continue
37
+ matrix[row][col] = ((row * 31 + col) % 2) == 0
38
+
39
+ return [list(row) for row in matrix]
40
+
41
+
42
+ def _format_block_matrix(matrix: list[list[bool]]) -> list[str]:
43
+ # Add quiet zone around the code.
44
+ border = 2
45
+ width = len(matrix)
46
+ lines: list[str] = []
47
+ for _ in range(border):
48
+ lines.append(" " * ((width + border * 2) * 2))
49
+
50
+ for row in matrix:
51
+ line = " " * border
52
+ for cell in row:
53
+ line += "██" if cell else " "
54
+ line += " " * border
55
+ lines.append(line)
56
+
57
+ for _ in range(border):
58
+ lines.append(" " * ((width + border * 2) * 2))
59
+ return lines
60
+
61
+
62
+ def render_qr(url: str, *, stream: TextIO | None = None) -> bool:
63
+ """Render a terminal QR-like code for ``url``.
64
+
65
+ Returns True when content was printed, otherwise False.
66
+ """
67
+
68
+ if stream is None:
69
+ stream = sys.stdout
70
+
71
+ terminal = shutil.get_terminal_size((80, 24))
72
+ # Use a fixed width to avoid truncation on narrow terminals.
73
+ if terminal.columns < 60:
74
+ stream.write("Terminal too narrow to render QR code.\n")
75
+ return False
76
+
77
+ matrix = _build_matrix(url)
78
+ for line in _format_block_matrix(matrix):
79
+ stream.write(f"{line}\n")
80
+ stream.write(f"{url}\n")
81
+ return True
http_here/ranges.py ADDED
@@ -0,0 +1,92 @@
1
+ """Range header parsing helpers for HTTP byte-range handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ class RangeParseError(ValueError):
6
+ """Raised when a byte range header is malformed."""
7
+
8
+
9
+ class UnsatisfiableRangeError(ValueError):
10
+ """Raised when a syntactically valid range is outside the file bounds."""
11
+
12
+
13
+ def _parse_non_negative_int(raw: str) -> int:
14
+ if raw == "":
15
+ raise RangeParseError("Malformed range value")
16
+ if not raw.isdigit():
17
+ raise RangeParseError("Malformed range value")
18
+ return int(raw)
19
+
20
+
21
+ def parse_range_header(range_header: str, total_size: int) -> tuple[int, int] | None:
22
+ """Parse a single-byte-range header and return an inclusive byte interval.
23
+
24
+ Args:
25
+ range_header: Raw Range header value, typically ``bytes=start-end``.
26
+ total_size: Total size of the target file.
27
+
28
+ Returns:
29
+ A ``(start, end)`` tuple if a valid single range was supplied.
30
+ ``None`` when no range was supplied.
31
+
32
+ Raises:
33
+ RangeParseError: for invalid syntax.
34
+ UnsatisfiableRangeError: for syntactically valid but unsatisfiable ranges.
35
+ """
36
+
37
+ if not range_header:
38
+ return None
39
+
40
+ if not range_header.startswith("bytes="):
41
+ raise RangeParseError("Only byte ranges are supported")
42
+
43
+ spec = range_header[6:].strip()
44
+ if not spec:
45
+ raise RangeParseError("Empty Range header")
46
+ if "," in spec:
47
+ raise RangeParseError("Multiple ranges are not supported in v1")
48
+
49
+ if spec.count("-") != 1:
50
+ raise RangeParseError("Malformed range value")
51
+
52
+ start_text, end_text = spec.split("-", 1)
53
+ start_text = start_text.strip()
54
+ end_text = end_text.strip()
55
+
56
+ if total_size < 0:
57
+ raise RangeParseError("Invalid file size")
58
+
59
+ if start_text and end_text:
60
+ start = _parse_non_negative_int(start_text)
61
+ end = _parse_non_negative_int(end_text)
62
+ if start > end:
63
+ raise RangeParseError("Malformed range value")
64
+ if total_size == 0 or start >= total_size:
65
+ raise UnsatisfiableRangeError("Range outside file size")
66
+ return start, min(end, total_size - 1)
67
+
68
+ if start_text:
69
+ start = _parse_non_negative_int(start_text)
70
+ if start < 0:
71
+ raise RangeParseError("Malformed range value")
72
+ if total_size == 0 or start >= total_size:
73
+ raise UnsatisfiableRangeError("Range outside file size")
74
+ return start, total_size - 1
75
+
76
+ if not end_text:
77
+ raise RangeParseError("Malformed range value")
78
+
79
+ suffix = _parse_non_negative_int(end_text)
80
+ if suffix <= 0:
81
+ raise UnsatisfiableRangeError("Range outside file size")
82
+ if total_size == 0:
83
+ raise UnsatisfiableRangeError("Range outside file size")
84
+ return max(total_size - suffix, 0), total_size - 1
85
+
86
+
87
+ def content_range(start: int, end: int, total_size: int) -> str:
88
+ return f"bytes {start}-{end}/{total_size}"
89
+
90
+
91
+ def unsatisfiable_content_range(total_size: int) -> str:
92
+ return f"bytes */{total_size}"
http_here/server.py ADDED
@@ -0,0 +1,221 @@
1
+ """HTTP server with custom range handling and constrained path mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import posixpath
8
+ import stat
9
+ import urllib.parse
10
+ from datetime import datetime, timezone
11
+ from email.utils import parsedate_to_datetime
12
+ from http import HTTPStatus
13
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
14
+ from typing import Mapping
15
+
16
+ from .ranges import RangeParseError, UnsatisfiableRangeError, content_range, parse_range_header, unsatisfiable_content_range
17
+
18
+
19
+ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
20
+ """SimpleHTTPRequestHandler variant with single-range support."""
21
+
22
+ def __init__(
23
+ self,
24
+ *args,
25
+ directory: str,
26
+ extra_headers: Mapping[str, str],
27
+ disable_dir_list: bool,
28
+ quiet: bool,
29
+ **kwargs,
30
+ ) -> None:
31
+ self._extra_headers = dict(extra_headers)
32
+ self._disable_dir_list = disable_dir_list
33
+ self._quiet = quiet
34
+ super().__init__(*args, directory=directory, **kwargs)
35
+
36
+ def _add_custom_headers(self) -> None:
37
+ for name, value in self._extra_headers.items():
38
+ self.send_header(name, value)
39
+
40
+ def log_message(self, format: str, *args) -> None:
41
+ if self._quiet:
42
+ return
43
+ super().log_message(format, *args)
44
+
45
+ def translate_path(self, path: str) -> str | None:
46
+ # Safe path mapping inspired by SimpleHTTPRequestHandler, with strict traversal defense.
47
+ path = path.split("?", 1)[0]
48
+ path = path.split("#", 1)[0]
49
+ path = urllib.parse.unquote(path)
50
+ path = path.replace("\\", "/")
51
+ if "\x00" in path:
52
+ return None
53
+
54
+ path = posixpath.normpath(path)
55
+ if path == "/":
56
+ parts: list[str] = []
57
+ else:
58
+ parts = []
59
+ for segment in path.split("/"):
60
+ if segment in ("", "."):
61
+ continue
62
+ if segment == "..":
63
+ continue
64
+ if ":" in segment:
65
+ return None
66
+ parts.append(segment)
67
+
68
+ candidate = self.directory
69
+ for part in parts:
70
+ candidate = os.path.join(candidate, part)
71
+
72
+ candidate = os.path.normpath(candidate)
73
+ root = os.path.realpath(self.directory)
74
+ real_candidate = os.path.realpath(candidate)
75
+ if os.path.commonpath([real_candidate, root]) != root:
76
+ return None
77
+
78
+ return candidate
79
+
80
+ def _handle_unsatisfiable_range(self, size: int) -> None:
81
+ self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
82
+ self.send_header("Content-Range", unsatisfiable_content_range(size))
83
+ self.send_header("Accept-Ranges", "bytes")
84
+ self.send_header("Content-Length", "0")
85
+ self._add_custom_headers()
86
+ self.end_headers()
87
+
88
+ def _write_not_modified(self, path: str, mtime: datetime) -> None:
89
+ self.send_response(HTTPStatus.NOT_MODIFIED)
90
+ self.send_header("Accept-Ranges", "bytes")
91
+ self.send_header("Content-Type", self.guess_type(path))
92
+ self.send_header("Last-Modified", self.date_time_string(mtime.timestamp()))
93
+ self.send_header("Content-Length", "0")
94
+ self._add_custom_headers()
95
+ self.end_headers()
96
+
97
+ def send_head(self) -> io.BufferedIOBase | None:
98
+ path = self.translate_path(self.path)
99
+ if path is None:
100
+ self.send_error(HTTPStatus.BAD_REQUEST, "Invalid request path")
101
+ return None
102
+
103
+ f = None
104
+
105
+ if os.path.isdir(path):
106
+ if not self.path.endswith("/"):
107
+ self.send_response(HTTPStatus.MOVED_PERMANENTLY)
108
+ self.send_header("Location", self.path + "/")
109
+ self._add_custom_headers()
110
+ self.end_headers()
111
+ return None
112
+
113
+ for index_name in ("index.html", "index.htm"):
114
+ index_path = os.path.join(path, index_name)
115
+ if os.path.exists(index_path):
116
+ path = index_path
117
+ break
118
+ else:
119
+ if self._disable_dir_list:
120
+ self.send_error(HTTPStatus.FORBIDDEN, "Directory listing is disabled.")
121
+ return None
122
+ return self.list_directory(path)
123
+
124
+ ctype = self.guess_type(path)
125
+ try:
126
+ f = open(path, "rb")
127
+ except OSError:
128
+ self.send_error(HTTPStatus.NOT_FOUND, "File not found")
129
+ return None
130
+
131
+ try:
132
+ stats = os.fstat(f.fileno())
133
+ if not stat.S_ISREG(stats.st_mode):
134
+ f.close()
135
+ self.send_error(HTTPStatus.NOT_FOUND, "File not found")
136
+ return None
137
+
138
+ file_size = stats.st_size
139
+ mtime = datetime.fromtimestamp(stats.st_mtime, tz=timezone.utc)
140
+
141
+ if_modified_since = self.headers.get("If-Modified-Since")
142
+ if if_modified_since:
143
+ parsed = parsedate_to_datetime(if_modified_since)
144
+ if parsed is not None:
145
+ if parsed.tzinfo is None:
146
+ parsed = parsed.replace(tzinfo=timezone.utc)
147
+ if mtime <= parsed:
148
+ self._write_not_modified(path, mtime)
149
+ f.close()
150
+ return None
151
+
152
+ start = end = None
153
+ range_header = self.headers.get("Range")
154
+ if range_header:
155
+ try:
156
+ start, end = parse_range_header(range_header, file_size)
157
+ except UnsatisfiableRangeError as exc:
158
+ self._handle_unsatisfiable_range(file_size)
159
+ f.close()
160
+ return None
161
+ except RangeParseError as exc:
162
+ self.send_error(HTTPStatus.BAD_REQUEST, str(exc))
163
+ f.close()
164
+ return None
165
+
166
+ if start is None:
167
+ self.send_response(HTTPStatus.OK)
168
+ self.send_header("Content-Length", str(file_size))
169
+ else:
170
+ self.send_response(HTTPStatus.PARTIAL_CONTENT)
171
+ self.send_header("Content-Range", content_range(start, end, file_size))
172
+ self.send_header("Content-Length", str(end - start + 1))
173
+ f.seek(start)
174
+ f = _RangeFile(f, end - start + 1)
175
+
176
+ self.send_header("Content-Type", ctype)
177
+ self.send_header("Last-Modified", self.date_time_string(stats.st_mtime))
178
+ self.send_header("Accept-Ranges", "bytes")
179
+ self._add_custom_headers()
180
+ self.end_headers()
181
+ return f
182
+ except Exception:
183
+ f.close()
184
+ raise
185
+
186
+
187
+ class _RangeFile:
188
+ def __init__(self, wrapped: io.BufferedReader, remaining: int) -> None:
189
+ self._wrapped = wrapped
190
+ self._remaining = remaining
191
+
192
+ def read(self, size: int = -1) -> bytes:
193
+ if self._remaining <= 0:
194
+ return b""
195
+ if size < 0 or size > self._remaining:
196
+ size = self._remaining
197
+ data = self._wrapped.read(size)
198
+ self._remaining -= len(data)
199
+ return data
200
+
201
+ def close(self) -> None:
202
+ self._wrapped.close()
203
+
204
+
205
+ class ThreadedHTTPServer(ThreadingHTTPServer):
206
+ daemon_threads = True
207
+ allow_reuse_address = True
208
+
209
+
210
+ def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_dir_list: bool, quiet: bool):
211
+ def _factory(*args, **kwargs):
212
+ return RangeAwareHTTPRequestHandler(
213
+ *args,
214
+ directory=directory,
215
+ extra_headers=extra_headers,
216
+ disable_dir_list=disable_dir_list,
217
+ quiet=quiet,
218
+ **kwargs,
219
+ )
220
+
221
+ return _factory
http_here/urls.py ADDED
@@ -0,0 +1,96 @@
1
+ """Utilities for computing and formatting URLs for startup output and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ from typing import Iterable, List
7
+
8
+
9
+ def is_all_interfaces_bind(host: str) -> bool:
10
+ return host in {"0.0.0.0", "::", "[::]", "::0", "0:0:0:0:0:0:0:0"}
11
+
12
+
13
+ def format_url(host: str, port: int) -> str:
14
+ if ":" in host and not host.startswith("["):
15
+ host = f"[{host}]"
16
+ return f"http://{host}:{port}/"
17
+
18
+
19
+ def discover_lan_urls() -> List[str]:
20
+ """Best-effort discovery of likely LAN IPv4 addresses."""
21
+
22
+ addresses = []
23
+ seen = set()
24
+
25
+ def _add(addr: str) -> None:
26
+ if not addr:
27
+ return
28
+ if addr.startswith("127.") or addr == "0.0.0.0" or addr.startswith("169.254."):
29
+ return
30
+ if addr in seen:
31
+ return
32
+ addresses.append(addr)
33
+ seen.add(addr)
34
+
35
+ try:
36
+ _addrinfo_name = socket.gethostname()
37
+ for info in socket.getaddrinfo(_addrinfo_name, None, family=socket.AF_INET, type=socket.SOCK_STREAM):
38
+ _add(info[4][0])
39
+ except OSError:
40
+ pass
41
+
42
+ try:
43
+ host_ips = socket.gethostbyname_ex(socket.gethostname())[2]
44
+ for addr in host_ips:
45
+ _add(addr)
46
+ except OSError:
47
+ pass
48
+
49
+ # Common trick: let UDP stack pick the local outbound interface.
50
+ try:
51
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
52
+ try:
53
+ sock.connect(("1.1.1.1", 80))
54
+ _add(sock.getsockname()[0])
55
+ finally:
56
+ sock.close()
57
+ except OSError:
58
+ pass
59
+
60
+ return list(addresses)
61
+
62
+
63
+ def get_startup_urls(bind_host: str, port: int, discovered_lan: Iterable[str] | None = None) -> list[str]:
64
+ if discovered_lan is None:
65
+ discovered_lan = discover_lan_urls()
66
+
67
+ urls: list[str] = [format_url(bind_host, port)]
68
+ if is_all_interfaces_bind(bind_host):
69
+ urls.append(format_url("localhost", port))
70
+ for addr in discovered_lan:
71
+ if addr == "127.0.0.1":
72
+ continue
73
+ urls.append(format_url(addr, port))
74
+
75
+ deduped = []
76
+ seen = set()
77
+ for url in urls:
78
+ if url in seen:
79
+ continue
80
+ deduped.append(url)
81
+ seen.add(url)
82
+ return deduped
83
+
84
+
85
+ def get_preferred_open_url(bind_host: str, port: int, discovered_lan: Iterable[str] | None = None) -> str:
86
+ if is_all_interfaces_bind(bind_host):
87
+ return format_url("localhost", port)
88
+ return format_url(bind_host, port)
89
+
90
+
91
+ def get_preferred_qr_url(bind_host: str, port: int, discovered_lan: Iterable[str] | None = None) -> str:
92
+ if is_all_interfaces_bind(bind_host):
93
+ lan = list(discovered_lan or discover_lan_urls())
94
+ if lan:
95
+ return format_url(lan[0], port)
96
+ return get_preferred_open_url(bind_host, port, discovered_lan=discovered_lan)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: static-http
3
+ Version: 0.1.1
4
+ Summary: A temporary dependency-free static HTTP server with byte-range support.
5
+ Author: static-http contributors
6
+ License: MIT
7
+ Keywords: http,server,static-http,static,range,cli
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Requires-Dist: build>=1; extra == "dev"
20
+ Requires-Dist: twine>=5; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # static-http
24
+
25
+ `static-http` is a tiny dependency-free Python utility that starts a temporary static HTTP server in the current directory with byte-range support for quick local workflows.
26
+
27
+ It is intentionally focused on temporary file serving for local development, manual testing, media playback, and archive inspection.
28
+
29
+ ## Why this exists
30
+
31
+ `python -m http.server` is excellent for quick sharing but does not implement byte-range request handling. `static-http` fills that gap with a small CLI that keeps the behavior predictable and easy to reason about.
32
+
33
+ ## Install
34
+
35
+ ```powershell
36
+ uvx static-http
37
+ pipx run static-http
38
+ python -m pip install static-http
39
+ ```
40
+
41
+ ## Quick start
42
+
43
+ ```powershell
44
+ static-http
45
+ ```
46
+
47
+ ```powershell
48
+ uvx static-http
49
+ ```
50
+
51
+ By default:
52
+
53
+ - Serves the current working directory.
54
+ - Binds to `0.0.0.0`.
55
+ - Listens on port `8080`.
56
+ - Uses a threaded HTTP server.
57
+ - Handles `GET`, `HEAD`, and single-range requests.
58
+
59
+ ## CLI
60
+
61
+ `static-http` supports these options:
62
+
63
+ - `-p, --port PORT` — listening port. `0` requests an OS-assigned port.
64
+ - `-d, --directory PATH` — root directory to serve. Default is the current directory.
65
+ - `-b, --bind ADDRESS` — bind address. Default is `0.0.0.0`.
66
+ - `--localhost-only` — equivalent to `--bind 127.0.0.1`.
67
+ - `--cors` — adds `Access-Control-Allow-Origin: *`.
68
+ - `--header "Name: Value"` — repeatable custom headers.
69
+ - `--no-dir-list` — disable directory listing responses when no index file exists.
70
+ - `--open` — open the server URL in the default browser after startup.
71
+ - `--qr` — render a terminal QR code for the server URL.
72
+ - `--no-cache` — send `Cache-Control: no-store`.
73
+ - `--quiet` — suppress per-request logs.
74
+ - `--verbose` — print detailed startup/binding information.
75
+ - `--version` — print package version and exit.
76
+
77
+ ## Examples
78
+
79
+ ```powershell
80
+ static-http --open
81
+ static-http --qr
82
+ static-http --no-cache
83
+ static-http --quiet
84
+ static-http --verbose
85
+ static-http --port 9000 --cors
86
+ static-http --no-dir-list
87
+ ```
88
+
89
+ ## Shutdown
90
+
91
+ Press `Q` or `q` in the focused terminal to stop the server. `Ctrl+C` also triggers a graceful shutdown.
92
+
93
+ ## Range support
94
+
95
+ `GET` requests support single byte ranges with examples:
96
+
97
+ ```bash
98
+ curl -H "Range: bytes=0-99" http://localhost:8080/video.mp4 -o part.bin
99
+ curl -H "Range: bytes=100-" http://localhost:8080/video.mp4 -o tail.bin
100
+ curl -H "Range: bytes=-500" http://localhost:8080/video.mp4 -o suffix.bin
101
+ ```
102
+
103
+ The server returns `206 Partial Content` for satisfiable ranges, `416 Range Not Satisfiable` when the range is outside the file, and `400 Bad Request` for invalid range syntax.
104
+
105
+ ## Security note
106
+
107
+ `static-http` is intentionally a **temporary** local development/file-serving tool, not a production web server.
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,14 @@
1
+ http_here/__init__.py,sha256=RTLCqUERXziA3dzO6_YyzuSDUOcQvEqoIKC_adggkMw,95
2
+ http_here/__main__.py,sha256=D31U8_ux95qF64EQ8ReT25nabzxh7ped5avpobXz_bM,49
3
+ http_here/cli.py,sha256=LE63OPlxYE8zIN1jwDvQVN983nVZjHB9vbNqVhCRsfg,7440
4
+ http_here/keyboard.py,sha256=blxyeGqtWrWUzrWGmAkhQN2CDf05OyiH6X0g8Xivui8,2532
5
+ http_here/qrcode.py,sha256=ddAAaJ7ijkpNBQB6Y39ObK5lDAotKR5Kpf0JUwJT7H8,2448
6
+ http_here/ranges.py,sha256=5r0p2o4cL_1Qv9MLG19telAPeYS7ao6MM94LBdnD2iQ,3009
7
+ http_here/server.py,sha256=j1e-Ku24zF4q0NA6nyfHqVE533VMdDFPAbqxo1d4tBk,7763
8
+ http_here/urls.py,sha256=P06jCxK1iaZ5qUWBOv4BYfpKyga3Tu5aMuuvxyfGoGY,2827
9
+ static_http-0.1.1.dist-info/licenses/LICENSE,sha256=6X31c82dDlEq9mji2ep8DU8VHrOX5VQwQqVcpEaADdk,1079
10
+ static_http-0.1.1.dist-info/METADATA,sha256=zEo_ZpGXvTRA-Pi4F3dXvfXpy2eTR_8dfyVJt4U6oY8,3536
11
+ static_http-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ static_http-0.1.1.dist-info/entry_points.txt,sha256=-OIJUNmsv4YypTS9va2U0KUWqBaj3uKp53VdrgSscD0,51
13
+ static_http-0.1.1.dist-info/top_level.txt,sha256=fQXA8E0TJZvED0QXQdjVJ2rgAsE-3SBlaATzkT9fVcU,10
14
+ static_http-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ static-http = http_here.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 http-here contributors
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.
@@ -0,0 +1 @@
1
+ http_here