static-http 0.1.1__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.
- static_http-0.1.1/LICENSE +21 -0
- static_http-0.1.1/PKG-INFO +111 -0
- static_http-0.1.1/README.md +89 -0
- static_http-0.1.1/pyproject.toml +41 -0
- static_http-0.1.1/setup.cfg +4 -0
- static_http-0.1.1/src/http_here/__init__.py +3 -0
- static_http-0.1.1/src/http_here/__main__.py +4 -0
- static_http-0.1.1/src/http_here/cli.py +243 -0
- static_http-0.1.1/src/http_here/keyboard.py +86 -0
- static_http-0.1.1/src/http_here/qrcode.py +81 -0
- static_http-0.1.1/src/http_here/ranges.py +92 -0
- static_http-0.1.1/src/http_here/server.py +221 -0
- static_http-0.1.1/src/http_here/urls.py +96 -0
- static_http-0.1.1/src/static_http.egg-info/PKG-INFO +111 -0
- static_http-0.1.1/src/static_http.egg-info/SOURCES.txt +22 -0
- static_http-0.1.1/src/static_http.egg-info/dependency_links.txt +1 -0
- static_http-0.1.1/src/static_http.egg-info/entry_points.txt +2 -0
- static_http-0.1.1/src/static_http.egg-info/requires.txt +5 -0
- static_http-0.1.1/src/static_http.egg-info/top_level.txt +1 -0
- static_http-0.1.1/tests/test_cli.py +61 -0
- static_http-0.1.1/tests/test_qrcode.py +32 -0
- static_http-0.1.1/tests/test_ranges.py +55 -0
- static_http-0.1.1/tests/test_server.py +144 -0
- static_http-0.1.1/tests/test_urls.py +18 -0
|
@@ -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,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,89 @@
|
|
|
1
|
+
# static-http
|
|
2
|
+
|
|
3
|
+
`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.
|
|
4
|
+
|
|
5
|
+
It is intentionally focused on temporary file serving for local development, manual testing, media playback, and archive inspection.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
`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.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
uvx static-http
|
|
15
|
+
pipx run static-http
|
|
16
|
+
python -m pip install static-http
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
static-http
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
uvx static-http
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
By default:
|
|
30
|
+
|
|
31
|
+
- Serves the current working directory.
|
|
32
|
+
- Binds to `0.0.0.0`.
|
|
33
|
+
- Listens on port `8080`.
|
|
34
|
+
- Uses a threaded HTTP server.
|
|
35
|
+
- Handles `GET`, `HEAD`, and single-range requests.
|
|
36
|
+
|
|
37
|
+
## CLI
|
|
38
|
+
|
|
39
|
+
`static-http` supports these options:
|
|
40
|
+
|
|
41
|
+
- `-p, --port PORT` — listening port. `0` requests an OS-assigned port.
|
|
42
|
+
- `-d, --directory PATH` — root directory to serve. Default is the current directory.
|
|
43
|
+
- `-b, --bind ADDRESS` — bind address. Default is `0.0.0.0`.
|
|
44
|
+
- `--localhost-only` — equivalent to `--bind 127.0.0.1`.
|
|
45
|
+
- `--cors` — adds `Access-Control-Allow-Origin: *`.
|
|
46
|
+
- `--header "Name: Value"` — repeatable custom headers.
|
|
47
|
+
- `--no-dir-list` — disable directory listing responses when no index file exists.
|
|
48
|
+
- `--open` — open the server URL in the default browser after startup.
|
|
49
|
+
- `--qr` — render a terminal QR code for the server URL.
|
|
50
|
+
- `--no-cache` — send `Cache-Control: no-store`.
|
|
51
|
+
- `--quiet` — suppress per-request logs.
|
|
52
|
+
- `--verbose` — print detailed startup/binding information.
|
|
53
|
+
- `--version` — print package version and exit.
|
|
54
|
+
|
|
55
|
+
## Examples
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
static-http --open
|
|
59
|
+
static-http --qr
|
|
60
|
+
static-http --no-cache
|
|
61
|
+
static-http --quiet
|
|
62
|
+
static-http --verbose
|
|
63
|
+
static-http --port 9000 --cors
|
|
64
|
+
static-http --no-dir-list
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Shutdown
|
|
68
|
+
|
|
69
|
+
Press `Q` or `q` in the focused terminal to stop the server. `Ctrl+C` also triggers a graceful shutdown.
|
|
70
|
+
|
|
71
|
+
## Range support
|
|
72
|
+
|
|
73
|
+
`GET` requests support single byte ranges with examples:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
curl -H "Range: bytes=0-99" http://localhost:8080/video.mp4 -o part.bin
|
|
77
|
+
curl -H "Range: bytes=100-" http://localhost:8080/video.mp4 -o tail.bin
|
|
78
|
+
curl -H "Range: bytes=-500" http://localhost:8080/video.mp4 -o suffix.bin
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
## Security note
|
|
84
|
+
|
|
85
|
+
`static-http` is intentionally a **temporary** local development/file-serving tool, not a production web server.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "static-http"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "A temporary dependency-free static HTTP server with byte-range support."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "static-http contributors" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["http", "server", "static-http", "static", "range", "cli"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
static-http = "http_here.cli:main"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"build>=1",
|
|
31
|
+
"twine>=5",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
package-dir = {"" = "src"}
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
addopts = "-ra"
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|