static-http 0.1.1__tar.gz → 0.1.3__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.
Files changed (24) hide show
  1. {static_http-0.1.1 → static_http-0.1.3}/LICENSE +1 -1
  2. {static_http-0.1.1 → static_http-0.1.3}/PKG-INFO +4 -2
  3. {static_http-0.1.1 → static_http-0.1.3}/README.md +2 -0
  4. {static_http-0.1.1 → static_http-0.1.3}/pyproject.toml +2 -2
  5. {static_http-0.1.1 → static_http-0.1.3}/src/http_here/__init__.py +1 -1
  6. {static_http-0.1.1 → static_http-0.1.3}/src/http_here/cli.py +10 -1
  7. {static_http-0.1.1 → static_http-0.1.3}/src/http_here/server.py +86 -1
  8. {static_http-0.1.1 → static_http-0.1.3}/src/static_http.egg-info/PKG-INFO +4 -2
  9. {static_http-0.1.1 → static_http-0.1.3}/tests/test_server.py +26 -0
  10. {static_http-0.1.1 → static_http-0.1.3}/setup.cfg +0 -0
  11. {static_http-0.1.1 → static_http-0.1.3}/src/http_here/__main__.py +0 -0
  12. {static_http-0.1.1 → static_http-0.1.3}/src/http_here/keyboard.py +0 -0
  13. {static_http-0.1.1 → static_http-0.1.3}/src/http_here/qrcode.py +0 -0
  14. {static_http-0.1.1 → static_http-0.1.3}/src/http_here/ranges.py +0 -0
  15. {static_http-0.1.1 → static_http-0.1.3}/src/http_here/urls.py +0 -0
  16. {static_http-0.1.1 → static_http-0.1.3}/src/static_http.egg-info/SOURCES.txt +0 -0
  17. {static_http-0.1.1 → static_http-0.1.3}/src/static_http.egg-info/dependency_links.txt +0 -0
  18. {static_http-0.1.1 → static_http-0.1.3}/src/static_http.egg-info/entry_points.txt +0 -0
  19. {static_http-0.1.1 → static_http-0.1.3}/src/static_http.egg-info/requires.txt +0 -0
  20. {static_http-0.1.1 → static_http-0.1.3}/src/static_http.egg-info/top_level.txt +0 -0
  21. {static_http-0.1.1 → static_http-0.1.3}/tests/test_cli.py +0 -0
  22. {static_http-0.1.1 → static_http-0.1.3}/tests/test_qrcode.py +0 -0
  23. {static_http-0.1.1 → static_http-0.1.3}/tests/test_ranges.py +0 -0
  24. {static_http-0.1.1 → static_http-0.1.3}/tests/test_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 http-here contributors
3
+ Copyright (c) 2026 John Paul Ellis
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: static-http
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A temporary dependency-free static HTTP server with byte-range support.
5
- Author: static-http contributors
5
+ Author: John Paul Ellis
6
6
  License: MIT
7
7
  Keywords: http,server,static-http,static,range,cli
8
8
  Classifier: License :: OSI Approved :: MIT License
@@ -72,6 +72,7 @@ By default:
72
72
  - `--no-cache` — send `Cache-Control: no-store`.
73
73
  - `--quiet` — suppress per-request logs.
74
74
  - `--verbose` — print detailed startup/binding information.
75
+ - `--include-hidden` — include dot-prefixed files and directories in normal serving and directory listings.
75
76
  - `--version` — print package version and exit.
76
77
 
77
78
  ## Examples
@@ -82,6 +83,7 @@ static-http --qr
82
83
  static-http --no-cache
83
84
  static-http --quiet
84
85
  static-http --verbose
86
+ static-http --include-hidden
85
87
  static-http --port 9000 --cors
86
88
  static-http --no-dir-list
87
89
  ```
@@ -50,6 +50,7 @@ By default:
50
50
  - `--no-cache` — send `Cache-Control: no-store`.
51
51
  - `--quiet` — suppress per-request logs.
52
52
  - `--verbose` — print detailed startup/binding information.
53
+ - `--include-hidden` — include dot-prefixed files and directories in normal serving and directory listings.
53
54
  - `--version` — print package version and exit.
54
55
 
55
56
  ## Examples
@@ -60,6 +61,7 @@ static-http --qr
60
61
  static-http --no-cache
61
62
  static-http --quiet
62
63
  static-http --verbose
64
+ static-http --include-hidden
63
65
  static-http --port 9000 --cors
64
66
  static-http --no-dir-list
65
67
  ```
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "static-http"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "A temporary dependency-free static HTTP server with byte-range support."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { text = "MIT" }
12
- authors = [{ name = "static-http contributors" }]
12
+ authors = [{ name = "John Paul Ellis" }]
13
13
  dependencies = []
14
14
  keywords = ["http", "server", "static-http", "static", "range", "cli"]
15
15
  classifiers = [
@@ -1,3 +1,3 @@
1
1
  """Utilities for running a dependency-free local static HTTP server."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.3"
@@ -125,6 +125,11 @@ def _build_parser() -> argparse.ArgumentParser:
125
125
  action="store_true",
126
126
  help="Print extra startup and binding information.",
127
127
  )
128
+ parser.add_argument(
129
+ "--include-hidden",
130
+ action="store_true",
131
+ help="Include dot-prefixed files/directories when serving and listing directories.",
132
+ )
128
133
  parser.add_argument(
129
134
  "--version",
130
135
  action="version",
@@ -166,12 +171,14 @@ def main(argv: list[str] | None = None) -> int:
166
171
  bind_port = args.port
167
172
 
168
173
  response_headers = build_response_headers(cors=args.cors, no_cache=args.no_cache, headers=args.header)
174
+ effective_quiet = args.quiet or not args.verbose
169
175
 
170
176
  handler = make_handler(
171
177
  directory=root,
172
178
  extra_headers=response_headers,
173
179
  disable_dir_list=args.no_dir_list,
174
- quiet=args.quiet,
180
+ quiet=effective_quiet,
181
+ show_hidden=args.include_hidden,
175
182
  )
176
183
 
177
184
  try:
@@ -180,6 +187,8 @@ def main(argv: list[str] | None = None) -> int:
180
187
  print(f"Could not bind {bind_host}:{bind_port}: {exc}", file=sys.stderr)
181
188
  return 1
182
189
 
190
+ server.verbose = bool(args.verbose)
191
+
183
192
  actual_bind = server.server_address[0]
184
193
  actual_port = server.server_address[1]
185
194
  discovered_lan = urls.discover_lan_urls() if urls.is_all_interfaces_bind(actual_bind) else []
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import io
6
+ import html
6
7
  import os
8
+ import sys
7
9
  import posixpath
8
10
  import stat
9
11
  import urllib.parse
@@ -26,11 +28,13 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
26
28
  extra_headers: Mapping[str, str],
27
29
  disable_dir_list: bool,
28
30
  quiet: bool,
31
+ show_hidden: bool,
29
32
  **kwargs,
30
33
  ) -> None:
31
34
  self._extra_headers = dict(extra_headers)
32
35
  self._disable_dir_list = disable_dir_list
33
36
  self._quiet = quiet
37
+ self._show_hidden = show_hidden
34
38
  super().__init__(*args, directory=directory, **kwargs)
35
39
 
36
40
  def _add_custom_headers(self) -> None:
@@ -42,6 +46,16 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
42
46
  return
43
47
  super().log_message(format, *args)
44
48
 
49
+ def log_error(self, format: str, *args) -> None:
50
+ if self._quiet:
51
+ return
52
+ super().log_error(format, *args)
53
+
54
+ def _is_hidden(self, segment: str) -> bool:
55
+ if self._show_hidden:
56
+ return False
57
+ return segment.startswith(".")
58
+
45
59
  def translate_path(self, path: str) -> str | None:
46
60
  # Safe path mapping inspired by SimpleHTTPRequestHandler, with strict traversal defense.
47
61
  path = path.split("?", 1)[0]
@@ -61,6 +75,8 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
61
75
  continue
62
76
  if segment == "..":
63
77
  continue
78
+ if self._is_hidden(segment):
79
+ return None
64
80
  if ":" in segment:
65
81
  return None
66
82
  parts.append(segment)
@@ -183,6 +199,65 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
183
199
  f.close()
184
200
  raise
185
201
 
202
+ def list_directory(self, path: str) -> io.BytesIO | None:
203
+ if self._show_hidden:
204
+ return super().list_directory(path)
205
+
206
+ try:
207
+ listdir = os.listdir(path)
208
+ except OSError:
209
+ self.send_error(HTTPStatus.NOT_FOUND, "No permission to list directory")
210
+ return None
211
+
212
+ listdir = [entry for entry in listdir if not self._is_hidden(entry)]
213
+ if not listdir:
214
+ self.send_error(HTTPStatus.NOT_FOUND, "No visible files")
215
+ return None
216
+
217
+ listdir.sort(key=lambda a: a.lower())
218
+ r = []
219
+
220
+ try:
221
+ displaypath = urllib.parse.unquote(self.path, errors="surrogatepass")
222
+ except UnicodeDecodeError:
223
+ displaypath = urllib.parse.unquote(self.path)
224
+ displaypath = html.escape(displaypath)
225
+
226
+ enc = "UTF-8"
227
+ title = f"Directory listing for {displaypath}"
228
+ r.append("<!DOCTYPE html>\n")
229
+ r.append("<html>\n<head>\n")
230
+ r.append(f'<meta charset="{enc}">\n')
231
+ r.append(f"<title>{title}</title>\n")
232
+ r.append("</head>\n<body>\n")
233
+ r.append(f"<h1>{title}</h1>\n<hr>\n<ul>\n")
234
+
235
+ for name in listdir:
236
+ full_name = os.path.join(path, name)
237
+ displayname = linkname = name
238
+ if os.path.isdir(full_name):
239
+ displayname = name + "/"
240
+ linkname = name + "/"
241
+ if os.path.islink(full_name):
242
+ displayname = name + "@"
243
+
244
+ r.append(
245
+ f'<li><a href="{urllib.parse.quote(linkname, errors="surrogatepass")}">'
246
+ f"{html.escape(displayname)}</a></li>\n"
247
+ )
248
+
249
+ r.append("</ul>\n<hr>\n</body>\n</html>\n")
250
+ encoded = "".join(r).encode(enc, "surrogateescape")
251
+
252
+ f = io.BytesIO()
253
+ f.write(encoded)
254
+ f.seek(0)
255
+ self.send_response(HTTPStatus.OK)
256
+ self.send_header("Content-Type", f"text/html; charset={enc}")
257
+ self.send_header("Content-Length", str(len(encoded)))
258
+ self.end_headers()
259
+ return f
260
+
186
261
 
187
262
  class _RangeFile:
188
263
  def __init__(self, wrapped: io.BufferedReader, remaining: int) -> None:
@@ -206,8 +281,17 @@ class ThreadedHTTPServer(ThreadingHTTPServer):
206
281
  daemon_threads = True
207
282
  allow_reuse_address = True
208
283
 
284
+ def handle_error(self, request, client_address) -> None: # type: ignore[override]
285
+ exc = sys.exc_info()[1]
286
+ if (
287
+ not bool(getattr(self, "verbose", False))
288
+ and isinstance(exc, (BrokenPipeError, ConnectionAbortedError, ConnectionResetError))
289
+ ):
290
+ return
291
+ super().handle_error(request, client_address)
292
+
209
293
 
210
- def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_dir_list: bool, quiet: bool):
294
+ def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_dir_list: bool, quiet: bool, show_hidden: bool = False):
211
295
  def _factory(*args, **kwargs):
212
296
  return RangeAwareHTTPRequestHandler(
213
297
  *args,
@@ -215,6 +299,7 @@ def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_di
215
299
  extra_headers=extra_headers,
216
300
  disable_dir_list=disable_dir_list,
217
301
  quiet=quiet,
302
+ show_hidden=show_hidden,
218
303
  **kwargs,
219
304
  )
220
305
 
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: static-http
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A temporary dependency-free static HTTP server with byte-range support.
5
- Author: static-http contributors
5
+ Author: John Paul Ellis
6
6
  License: MIT
7
7
  Keywords: http,server,static-http,static,range,cli
8
8
  Classifier: License :: OSI Approved :: MIT License
@@ -72,6 +72,7 @@ By default:
72
72
  - `--no-cache` — send `Cache-Control: no-store`.
73
73
  - `--quiet` — suppress per-request logs.
74
74
  - `--verbose` — print detailed startup/binding information.
75
+ - `--include-hidden` — include dot-prefixed files and directories in normal serving and directory listings.
75
76
  - `--version` — print package version and exit.
76
77
 
77
78
  ## Examples
@@ -82,6 +83,7 @@ static-http --qr
82
83
  static-http --no-cache
83
84
  static-http --quiet
84
85
  static-http --verbose
86
+ static-http --include-hidden
85
87
  static-http --port 9000 --cors
86
88
  static-http --no-dir-list
87
89
  ```
@@ -89,6 +89,7 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
89
89
  root.mkdir()
90
90
  (root / "index.txt").write_bytes(b"index")
91
91
  (tmp_path / "a b.txt").write_bytes(b"space")
92
+ (tmp_path / ".hidden.txt").write_bytes(b"secret")
92
93
  (tmp_path / "rootdir").mkdir()
93
94
 
94
95
  with _running_server(
@@ -110,6 +111,10 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
110
111
  assert resp.headers["Access-Control-Allow-Origin"] == "*"
111
112
  assert resp.headers["Cache-Control"] == "no-store"
112
113
 
114
+ with pytest.raises(urllib.error.HTTPError) as exc:
115
+ urllib.request.urlopen(_http_url(srv, "/.hidden.txt"))
116
+ assert exc.value.code in {400, 404}
117
+
113
118
  with _running_server(
114
119
  tmp_path,
115
120
  extra_headers={},
@@ -121,6 +126,27 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
121
126
  assert exc.value.code == 403
122
127
 
123
128
 
129
+ def test_show_hidden_flag_exposes_hidden_entries(tmp_path: Path) -> None:
130
+ (tmp_path / ".hidden.txt").write_bytes(b"secret")
131
+
132
+ with _running_server(
133
+ tmp_path,
134
+ extra_headers={},
135
+ disable_dir_list=False,
136
+ quiet=True,
137
+ show_hidden=True,
138
+ ) as srv:
139
+ list_url = _http_url(srv, "/")
140
+ with urllib.request.urlopen(list_url) as resp:
141
+ assert resp.status == 200
142
+ body = resp.read()
143
+ assert b".hidden.txt" in body
144
+
145
+ with urllib.request.urlopen(_http_url(srv, "/.hidden.txt")) as resp:
146
+ assert resp.status == 200
147
+ assert resp.read() == b"secret"
148
+
149
+
124
150
  def test_path_traversal_cannot_escape_root(tmp_path: Path) -> None:
125
151
  (tmp_path / "inside.txt").write_bytes(b"inside")
126
152
  (tmp_path.parent / "outside.txt").write_bytes(b"outside")
File without changes