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 +3 -0
- http_here/__main__.py +4 -0
- http_here/cli.py +243 -0
- http_here/keyboard.py +86 -0
- http_here/qrcode.py +81 -0
- http_here/ranges.py +92 -0
- http_here/server.py +221 -0
- http_here/urls.py +96 -0
- static_http-0.1.1.dist-info/METADATA +111 -0
- static_http-0.1.1.dist-info/RECORD +14 -0
- static_http-0.1.1.dist-info/WHEEL +5 -0
- static_http-0.1.1.dist-info/entry_points.txt +2 -0
- static_http-0.1.1.dist-info/licenses/LICENSE +21 -0
- static_http-0.1.1.dist-info/top_level.txt +1 -0
http_here/__init__.py
ADDED
http_here/__main__.py
ADDED
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,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
|