static-http 0.1.2__tar.gz → 0.1.4__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 (25) hide show
  1. {static_http-0.1.2/src/static_http.egg-info → static_http-0.1.4}/PKG-INFO +6 -3
  2. {static_http-0.1.2 → static_http-0.1.4}/README.md +2 -0
  3. {static_http-0.1.2 → static_http-0.1.4}/pyproject.toml +6 -4
  4. {static_http-0.1.2 → static_http-0.1.4}/src/http_here/__init__.py +1 -1
  5. {static_http-0.1.2 → static_http-0.1.4}/src/http_here/cli.py +10 -1
  6. static_http-0.1.4/src/http_here/qrcode.py +380 -0
  7. {static_http-0.1.2 → static_http-0.1.4}/src/http_here/server.py +113 -18
  8. {static_http-0.1.2 → static_http-0.1.4/src/static_http.egg-info}/PKG-INFO +6 -3
  9. {static_http-0.1.2 → static_http-0.1.4}/tests/test_qrcode.py +25 -0
  10. {static_http-0.1.2 → static_http-0.1.4}/tests/test_server.py +60 -3
  11. static_http-0.1.2/src/http_here/qrcode.py +0 -81
  12. {static_http-0.1.2 → static_http-0.1.4}/LICENSE +0 -0
  13. {static_http-0.1.2 → static_http-0.1.4}/setup.cfg +0 -0
  14. {static_http-0.1.2 → static_http-0.1.4}/src/http_here/__main__.py +0 -0
  15. {static_http-0.1.2 → static_http-0.1.4}/src/http_here/keyboard.py +0 -0
  16. {static_http-0.1.2 → static_http-0.1.4}/src/http_here/ranges.py +0 -0
  17. {static_http-0.1.2 → static_http-0.1.4}/src/http_here/urls.py +0 -0
  18. {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/SOURCES.txt +0 -0
  19. {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/dependency_links.txt +0 -0
  20. {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/entry_points.txt +0 -0
  21. {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/requires.txt +0 -0
  22. {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/top_level.txt +0 -0
  23. {static_http-0.1.2 → static_http-0.1.4}/tests/test_cli.py +0 -0
  24. {static_http-0.1.2 → static_http-0.1.4}/tests/test_ranges.py +0 -0
  25. {static_http-0.1.2 → static_http-0.1.4}/tests/test_urls.py +0 -0
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: static-http
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A temporary dependency-free static HTTP server with byte-range support.
5
5
  Author: John Paul Ellis
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Keywords: http,server,static-http,static,range,cli
8
- Classifier: License :: OSI Approved :: MIT License
9
8
  Classifier: Programming Language :: Python :: 3
10
9
  Classifier: Programming Language :: Python :: 3 :: Only
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
13
14
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
14
15
  Requires-Python: >=3.10
15
16
  Description-Content-Type: text/markdown
@@ -72,6 +73,7 @@ By default:
72
73
  - `--no-cache` — send `Cache-Control: no-store`.
73
74
  - `--quiet` — suppress per-request logs.
74
75
  - `--verbose` — print detailed startup/binding information.
76
+ - `--include-hidden` — include dot-prefixed files and directories in normal serving and directory listings.
75
77
  - `--version` — print package version and exit.
76
78
 
77
79
  ## Examples
@@ -82,6 +84,7 @@ static-http --qr
82
84
  static-http --no-cache
83
85
  static-http --quiet
84
86
  static-http --verbose
87
+ static-http --include-hidden
85
88
  static-http --port 9000 --cors
86
89
  static-http --no-dir-list
87
90
  ```
@@ -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
  ```
@@ -1,23 +1,25 @@
1
1
  [build-system]
2
- requires = ["setuptools>=68", "wheel"]
2
+ requires = ["setuptools>=77", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "static-http"
7
- version = "0.1.2"
7
+ version = "0.1.4"
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
- license = { text = "MIT" }
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
12
13
  authors = [{ name = "John Paul Ellis" }]
13
14
  dependencies = []
14
15
  keywords = ["http", "server", "static-http", "static", "range", "cli"]
15
16
  classifiers = [
16
- "License :: OSI Approved :: MIT License",
17
17
  "Programming Language :: Python :: 3",
18
18
  "Programming Language :: Python :: 3 :: Only",
19
19
  "Programming Language :: Python :: 3.10",
20
20
  "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
21
23
  "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
22
24
  ]
23
25
 
@@ -1,3 +1,3 @@
1
1
  """Utilities for running a dependency-free local static HTTP server."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
@@ -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 []
@@ -0,0 +1,380 @@
1
+ """Minimal terminal QR rendering without external dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import sys
7
+ from typing import TextIO
8
+
9
+
10
+ _ECC_CODEWORDS = {
11
+ 1: 7,
12
+ 2: 10,
13
+ 3: 15,
14
+ 4: 20,
15
+ }
16
+ _DATA_CODEWORDS = {
17
+ 1: 19,
18
+ 2: 34,
19
+ 3: 55,
20
+ 4: 80,
21
+ }
22
+ _ANSI_BLACK = "\x1b[40m"
23
+ _ANSI_WHITE = "\x1b[107m"
24
+ _ANSI_RESET = "\x1b[0m"
25
+
26
+
27
+ def _gf_mul(x: int, y: int) -> int:
28
+ result = 0
29
+ while y:
30
+ if y & 1:
31
+ result ^= x
32
+ y >>= 1
33
+ x <<= 1
34
+ if x & 0x100:
35
+ x ^= 0x11D
36
+ return result
37
+
38
+
39
+ def _gf_pow(x: int, power: int) -> int:
40
+ result = 1
41
+ for _ in range(power):
42
+ result = _gf_mul(result, x)
43
+ return result
44
+
45
+
46
+ def _generator_poly(degree: int) -> list[int]:
47
+ result = [1]
48
+ for i in range(degree):
49
+ result = _poly_mul(result, [1, _gf_pow(2, i)])
50
+ return result
51
+
52
+
53
+ def _poly_mul(left: list[int], right: list[int]) -> list[int]:
54
+ result = [0] * (len(left) + len(right) - 1)
55
+ for i, left_value in enumerate(left):
56
+ for j, right_value in enumerate(right):
57
+ result[i + j] ^= _gf_mul(left_value, right_value)
58
+ return result
59
+
60
+
61
+ def _reed_solomon(data: list[int], degree: int) -> list[int]:
62
+ generator = _generator_poly(degree)
63
+ result = data + [0] * degree
64
+ for i, value in enumerate(data):
65
+ if value == 0:
66
+ continue
67
+ for j, coefficient in enumerate(generator):
68
+ result[i + j] ^= _gf_mul(coefficient, value)
69
+ return result[-degree:]
70
+
71
+
72
+ def _append_bits(bits: list[int], value: int, length: int) -> None:
73
+ for i in range(length - 1, -1, -1):
74
+ bits.append((value >> i) & 1)
75
+
76
+
77
+ def _choose_version(data: bytes) -> int:
78
+ needed_bits = 4 + 8 + len(data) * 8
79
+ for version, data_codewords in _DATA_CODEWORDS.items():
80
+ if needed_bits <= data_codewords * 8:
81
+ return version
82
+ raise ValueError("URL is too long for the built-in QR renderer")
83
+
84
+
85
+ def _encode_data(data: bytes, version: int) -> list[int]:
86
+ bits: list[int] = []
87
+ _append_bits(bits, 0b0100, 4) # Byte mode.
88
+ _append_bits(bits, len(data), 8)
89
+ for byte in data:
90
+ _append_bits(bits, byte, 8)
91
+
92
+ capacity = _DATA_CODEWORDS[version] * 8
93
+ _append_bits(bits, 0, min(4, capacity - len(bits)))
94
+ while len(bits) % 8:
95
+ bits.append(0)
96
+
97
+ pad = [0xEC, 0x11]
98
+ pad_index = 0
99
+ while len(bits) < capacity:
100
+ _append_bits(bits, pad[pad_index % 2], 8)
101
+ pad_index += 1
102
+
103
+ return [int("".join(str(bit) for bit in bits[i : i + 8]), 2) for i in range(0, len(bits), 8)]
104
+
105
+
106
+ def _set_function(matrix: list[list[bool | None]], function: list[list[bool]], row: int, col: int, dark: bool) -> None:
107
+ matrix[row][col] = dark
108
+ function[row][col] = True
109
+
110
+
111
+ def _draw_finder(matrix: list[list[bool | None]], function: list[list[bool]], top: int, left: int) -> None:
112
+ size = len(matrix)
113
+ for r in range(-1, 8):
114
+ for c in range(-1, 8):
115
+ row = top + r
116
+ col = left + c
117
+ if not (0 <= row < size and 0 <= col < size):
118
+ continue
119
+ dark = 0 <= r <= 6 and 0 <= c <= 6 and (r in (0, 6) or c in (0, 6) or (2 <= r <= 4 and 2 <= c <= 4))
120
+ _set_function(matrix, function, row, col, dark)
121
+
122
+
123
+ def _draw_alignment(matrix: list[list[bool | None]], function: list[list[bool]], center: int) -> None:
124
+ for r in range(-2, 3):
125
+ for c in range(-2, 3):
126
+ dark = max(abs(r), abs(c)) != 1
127
+ _set_function(matrix, function, center + r, center + c, dark)
128
+
129
+
130
+ def _draw_function_patterns(matrix: list[list[bool | None]], function: list[list[bool]], version: int) -> None:
131
+ size = len(matrix)
132
+ _draw_finder(matrix, function, 0, 0)
133
+ _draw_finder(matrix, function, 0, size - 7)
134
+ _draw_finder(matrix, function, size - 7, 0)
135
+ if version > 1:
136
+ _draw_alignment(matrix, function, size - 7)
137
+
138
+ for i in range(8, size - 8):
139
+ _set_function(matrix, function, 6, i, i % 2 == 0)
140
+ _set_function(matrix, function, i, 6, i % 2 == 0)
141
+
142
+ _set_function(matrix, function, size - 8, 8, True)
143
+ _draw_format_bits(matrix, function, 0)
144
+
145
+
146
+ def _mask(mask: int, row: int, col: int) -> bool:
147
+ if mask == 0:
148
+ return (row + col) % 2 == 0
149
+ if mask == 1:
150
+ return row % 2 == 0
151
+ if mask == 2:
152
+ return col % 3 == 0
153
+ if mask == 3:
154
+ return (row + col) % 3 == 0
155
+ if mask == 4:
156
+ return (row // 2 + col // 3) % 2 == 0
157
+ if mask == 5:
158
+ return (row * col) % 2 + (row * col) % 3 == 0
159
+ if mask == 6:
160
+ return ((row * col) % 2 + (row * col) % 3) % 2 == 0
161
+ return ((row + col) % 2 + (row * col) % 3) % 2 == 0
162
+
163
+
164
+ def _draw_codewords(matrix: list[list[bool | None]], function: list[list[bool]], codewords: list[int], mask: int) -> None:
165
+ bits = [(codeword >> i) & 1 for codeword in codewords for i in range(7, -1, -1)]
166
+ bit_index = 0
167
+ size = len(matrix)
168
+ row = size - 1
169
+ direction = -1
170
+ col = size - 1
171
+
172
+ while col > 0:
173
+ if col == 6:
174
+ col -= 1
175
+ while 0 <= row < size:
176
+ for offset in range(2):
177
+ c = col - offset
178
+ if function[row][c]:
179
+ continue
180
+ bit = bit_index < len(bits) and bits[bit_index] == 1
181
+ bit_index += 1
182
+ matrix[row][c] = bit ^ _mask(mask, row, c)
183
+ row += direction
184
+ row -= direction
185
+ direction = -direction
186
+ col -= 2
187
+
188
+
189
+ def _format_bits(mask: int) -> int:
190
+ data = (0b01 << 3) | mask # Error correction level L.
191
+ value = data << 10
192
+ generator = 0b10100110111
193
+ for i in range(14, 9, -1):
194
+ if (value >> i) & 1:
195
+ value ^= generator << (i - 10)
196
+ return (((data << 10) | value) ^ 0b101010000010010) & 0x7FFF
197
+
198
+
199
+ def _draw_format_bits(matrix: list[list[bool | None]], function: list[list[bool]], mask: int) -> None:
200
+ bits = _format_bits(mask)
201
+ size = len(matrix)
202
+ positions_a = [
203
+ (8, 0),
204
+ (8, 1),
205
+ (8, 2),
206
+ (8, 3),
207
+ (8, 4),
208
+ (8, 5),
209
+ (8, 7),
210
+ (8, 8),
211
+ (7, 8),
212
+ (5, 8),
213
+ (4, 8),
214
+ (3, 8),
215
+ (2, 8),
216
+ (1, 8),
217
+ (0, 8),
218
+ ]
219
+ positions_b = [
220
+ (size - 1, 8),
221
+ (size - 2, 8),
222
+ (size - 3, 8),
223
+ (size - 4, 8),
224
+ (size - 5, 8),
225
+ (size - 6, 8),
226
+ (size - 7, 8),
227
+ (8, size - 8),
228
+ (8, size - 7),
229
+ (8, size - 6),
230
+ (8, size - 5),
231
+ (8, size - 4),
232
+ (8, size - 3),
233
+ (8, size - 2),
234
+ (8, size - 1),
235
+ ]
236
+ for i, (row, col) in enumerate(positions_a):
237
+ _set_function(matrix, function, row, col, ((bits >> i) & 1) == 1)
238
+ for i, (row, col) in enumerate(positions_b):
239
+ _set_function(matrix, function, row, col, ((bits >> i) & 1) == 1)
240
+
241
+
242
+ def _penalty(matrix: list[list[bool | None]]) -> int:
243
+ size = len(matrix)
244
+ penalty = 0
245
+ for row in range(size):
246
+ run_color = matrix[row][0]
247
+ run_length = 1
248
+ for col in range(1, size):
249
+ if matrix[row][col] == run_color:
250
+ run_length += 1
251
+ else:
252
+ if run_length >= 5:
253
+ penalty += run_length - 2
254
+ run_color = matrix[row][col]
255
+ run_length = 1
256
+ if run_length >= 5:
257
+ penalty += run_length - 2
258
+
259
+ for col in range(size):
260
+ run_color = matrix[0][col]
261
+ run_length = 1
262
+ for row in range(1, size):
263
+ if matrix[row][col] == run_color:
264
+ run_length += 1
265
+ else:
266
+ if run_length >= 5:
267
+ penalty += run_length - 2
268
+ run_color = matrix[row][col]
269
+ run_length = 1
270
+ if run_length >= 5:
271
+ penalty += run_length - 2
272
+
273
+ for row in range(size - 1):
274
+ for col in range(size - 1):
275
+ color = matrix[row][col]
276
+ if color == matrix[row + 1][col] == matrix[row][col + 1] == matrix[row + 1][col + 1]:
277
+ penalty += 3
278
+
279
+ dark = sum(1 for row in matrix for cell in row if cell)
280
+ total = size * size
281
+ penalty += abs(dark * 20 - total * 10) // total * 10
282
+ return penalty
283
+
284
+
285
+ def _build_matrix(data: str) -> list[list[bool]]:
286
+ payload = data.encode("utf-8")
287
+ version = _choose_version(payload)
288
+ size = version * 4 + 17
289
+ data_codewords = _encode_data(payload, version)
290
+ codewords = data_codewords + _reed_solomon(data_codewords, _ECC_CODEWORDS[version])
291
+
292
+ best_matrix: list[list[bool | None]] | None = None
293
+ best_penalty: int | None = None
294
+ for mask in range(8):
295
+ matrix: list[list[bool | None]] = [[None for _ in range(size)] for _ in range(size)]
296
+ function = [[False for _ in range(size)] for _ in range(size)]
297
+ _draw_function_patterns(matrix, function, version)
298
+ _draw_codewords(matrix, function, codewords, mask)
299
+ _draw_format_bits(matrix, function, mask)
300
+ penalty = _penalty(matrix)
301
+ if best_penalty is None or penalty < best_penalty:
302
+ best_matrix = matrix
303
+ best_penalty = penalty
304
+
305
+ if best_matrix is None:
306
+ raise AssertionError("QR matrix was not generated")
307
+ return [[bool(cell) for cell in row] for row in best_matrix]
308
+
309
+
310
+ def _format_plain_block_matrix(matrix: list[list[bool]]) -> list[str]:
311
+ border = 4
312
+ width = len(matrix)
313
+ lines: list[str] = []
314
+ for _ in range(border):
315
+ lines.append(" " * ((width + border * 2) * 2))
316
+
317
+ for row in matrix:
318
+ line = " " * border
319
+ for cell in row:
320
+ line += "██" if cell else " "
321
+ line += " " * border
322
+ lines.append(line)
323
+
324
+ for _ in range(border):
325
+ lines.append(" " * ((width + border * 2) * 2))
326
+ return lines
327
+
328
+
329
+ def _format_ansi_block_matrix(matrix: list[list[bool]]) -> list[str]:
330
+ border = 4
331
+ light_row = _ANSI_WHITE + (" " * ((len(matrix) + border * 2) * 2)) + _ANSI_RESET
332
+ lines: list[str] = [light_row for _ in range(border)]
333
+
334
+ for row in matrix:
335
+ line = [_ANSI_WHITE + (" " * border)]
336
+ last_color = _ANSI_WHITE
337
+ for cell in row:
338
+ color = _ANSI_BLACK if cell else _ANSI_WHITE
339
+ if color != last_color:
340
+ line.append(color)
341
+ last_color = color
342
+ line.append(" ")
343
+ if last_color != _ANSI_WHITE:
344
+ line.append(_ANSI_WHITE)
345
+ line.append(" " * border)
346
+ line.append(_ANSI_RESET)
347
+ lines.append("".join(line))
348
+
349
+ lines.extend(light_row for _ in range(border))
350
+ return lines
351
+
352
+
353
+ def _supports_ansi(stream: TextIO) -> bool:
354
+ isatty = getattr(stream, "isatty", None)
355
+ return bool(isatty and isatty())
356
+
357
+
358
+ def render_qr(url: str, *, stream: TextIO | None = None) -> bool:
359
+ """Render a terminal QR code for ``url``."""
360
+
361
+ if stream is None:
362
+ stream = sys.stdout
363
+
364
+ try:
365
+ matrix = _build_matrix(url)
366
+ except ValueError as exc:
367
+ stream.write(f"{exc}\n")
368
+ return False
369
+
370
+ terminal = shutil.get_terminal_size((80, 24))
371
+ required_columns = (len(matrix) + 8) * 2
372
+ if terminal.columns < required_columns:
373
+ stream.write(f"Terminal too narrow to render QR code. Need at least {required_columns} columns.\n")
374
+ return False
375
+
376
+ formatter = _format_ansi_block_matrix if _supports_ansi(stream) else _format_plain_block_matrix
377
+ for line in formatter(matrix):
378
+ stream.write(f"{line}\n")
379
+ stream.write(f"{url}\n")
380
+ return True
@@ -2,10 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import html
5
6
  import io
6
7
  import os
7
- import posixpath
8
8
  import stat
9
+ import sys
9
10
  import urllib.parse
10
11
  from datetime import datetime, timezone
11
12
  from email.utils import parsedate_to_datetime
@@ -26,11 +27,13 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
26
27
  extra_headers: Mapping[str, str],
27
28
  disable_dir_list: bool,
28
29
  quiet: bool,
30
+ show_hidden: bool,
29
31
  **kwargs,
30
32
  ) -> None:
31
33
  self._extra_headers = dict(extra_headers)
32
34
  self._disable_dir_list = disable_dir_list
33
35
  self._quiet = quiet
36
+ self._show_hidden = show_hidden
34
37
  super().__init__(*args, directory=directory, **kwargs)
35
38
 
36
39
  def _add_custom_headers(self) -> None:
@@ -42,6 +45,30 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
42
45
  return
43
46
  super().log_message(format, *args)
44
47
 
48
+ def log_error(self, format: str, *args) -> None:
49
+ if self._quiet:
50
+ return
51
+ super().log_error(format, *args)
52
+
53
+ def _is_hidden(self, segment: str) -> bool:
54
+ if self._show_hidden:
55
+ return False
56
+ return segment.startswith(".")
57
+
58
+ def _has_hidden_attribute(self, path: str) -> bool:
59
+ if self._show_hidden:
60
+ return False
61
+ hidden_flag = getattr(stat, "FILE_ATTRIBUTE_HIDDEN", 0)
62
+ if not hidden_flag:
63
+ return False
64
+ try:
65
+ return bool(os.stat(path).st_file_attributes & hidden_flag)
66
+ except (AttributeError, OSError):
67
+ return False
68
+
69
+ def _is_hidden_entry(self, name: str, path: str) -> bool:
70
+ return self._is_hidden(name) or self._has_hidden_attribute(path)
71
+
45
72
  def translate_path(self, path: str) -> str | None:
46
73
  # Safe path mapping inspired by SimpleHTTPRequestHandler, with strict traversal defense.
47
74
  path = path.split("?", 1)[0]
@@ -51,23 +78,23 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
51
78
  if "\x00" in path:
52
79
  return None
53
80
 
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)
81
+ parts = []
82
+ for segment in path.split("/"):
83
+ if segment in ("", "."):
84
+ continue
85
+ if segment == "..":
86
+ return None
87
+ if self._is_hidden(segment):
88
+ return None
89
+ if ":" in segment:
90
+ return None
91
+ parts.append(segment)
67
92
 
68
93
  candidate = self.directory
69
94
  for part in parts:
70
95
  candidate = os.path.join(candidate, part)
96
+ if self._has_hidden_attribute(candidate):
97
+ return None
71
98
 
72
99
  candidate = os.path.normpath(candidate)
73
100
  root = os.path.realpath(self.directory)
@@ -103,9 +130,11 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
103
130
  f = None
104
131
 
105
132
  if os.path.isdir(path):
106
- if not self.path.endswith("/"):
133
+ parts = urllib.parse.urlsplit(self.path)
134
+ if not parts.path.endswith("/"):
135
+ location = urllib.parse.urlunsplit(("", "", parts.path + "/", parts.query, parts.fragment))
107
136
  self.send_response(HTTPStatus.MOVED_PERMANENTLY)
108
- self.send_header("Location", self.path + "/")
137
+ self.send_header("Location", location)
109
138
  self._add_custom_headers()
110
139
  self.end_headers()
111
140
  return None
@@ -140,7 +169,10 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
140
169
 
141
170
  if_modified_since = self.headers.get("If-Modified-Since")
142
171
  if if_modified_since:
143
- parsed = parsedate_to_datetime(if_modified_since)
172
+ try:
173
+ parsed = parsedate_to_datetime(if_modified_since)
174
+ except (IndexError, OverflowError, TypeError, ValueError):
175
+ parsed = None
144
176
  if parsed is not None:
145
177
  if parsed.tzinfo is None:
146
178
  parsed = parsed.replace(tzinfo=timezone.utc)
@@ -183,6 +215,59 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
183
215
  f.close()
184
216
  raise
185
217
 
218
+ def list_directory(self, path: str) -> io.BytesIO | None:
219
+ try:
220
+ listdir = os.listdir(path)
221
+ except OSError:
222
+ self.send_error(HTTPStatus.NOT_FOUND, "No permission to list directory")
223
+ return None
224
+
225
+ listdir = [entry for entry in listdir if not self._is_hidden_entry(entry, os.path.join(path, entry))]
226
+ listdir.sort(key=lambda a: a.lower())
227
+ r = []
228
+
229
+ try:
230
+ displaypath = urllib.parse.unquote(self.path, errors="surrogatepass")
231
+ except UnicodeDecodeError:
232
+ displaypath = urllib.parse.unquote(self.path)
233
+ displaypath = html.escape(displaypath)
234
+
235
+ enc = "UTF-8"
236
+ title = f"Directory listing for {displaypath}"
237
+ r.append("<!DOCTYPE html>\n")
238
+ r.append("<html>\n<head>\n")
239
+ r.append(f'<meta charset="{enc}">\n')
240
+ r.append(f"<title>{title}</title>\n")
241
+ r.append("</head>\n<body>\n")
242
+ r.append(f"<h1>{title}</h1>\n<hr>\n<ul>\n")
243
+
244
+ for name in listdir:
245
+ full_name = os.path.join(path, name)
246
+ displayname = linkname = name
247
+ if os.path.isdir(full_name):
248
+ displayname = name + "/"
249
+ linkname = name + "/"
250
+ if os.path.islink(full_name):
251
+ displayname = name + "@"
252
+
253
+ r.append(
254
+ f'<li><a href="{urllib.parse.quote(linkname, errors="surrogatepass")}">'
255
+ f"{html.escape(displayname)}</a></li>\n"
256
+ )
257
+
258
+ r.append("</ul>\n<hr>\n</body>\n</html>\n")
259
+ encoded = "".join(r).encode(enc, "surrogateescape")
260
+
261
+ f = io.BytesIO()
262
+ f.write(encoded)
263
+ f.seek(0)
264
+ self.send_response(HTTPStatus.OK)
265
+ self.send_header("Content-Type", f"text/html; charset={enc}")
266
+ self.send_header("Content-Length", str(len(encoded)))
267
+ self._add_custom_headers()
268
+ self.end_headers()
269
+ return f
270
+
186
271
 
187
272
  class _RangeFile:
188
273
  def __init__(self, wrapped: io.BufferedReader, remaining: int) -> None:
@@ -206,8 +291,17 @@ class ThreadedHTTPServer(ThreadingHTTPServer):
206
291
  daemon_threads = True
207
292
  allow_reuse_address = True
208
293
 
294
+ def handle_error(self, request, client_address) -> None: # type: ignore[override]
295
+ exc = sys.exc_info()[1]
296
+ if (
297
+ not bool(getattr(self, "verbose", False))
298
+ and isinstance(exc, (BrokenPipeError, ConnectionAbortedError, ConnectionResetError))
299
+ ):
300
+ return
301
+ super().handle_error(request, client_address)
302
+
209
303
 
210
- def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_dir_list: bool, quiet: bool):
304
+ def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_dir_list: bool, quiet: bool, show_hidden: bool = False):
211
305
  def _factory(*args, **kwargs):
212
306
  return RangeAwareHTTPRequestHandler(
213
307
  *args,
@@ -215,6 +309,7 @@ def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_di
215
309
  extra_headers=extra_headers,
216
310
  disable_dir_list=disable_dir_list,
217
311
  quiet=quiet,
312
+ show_hidden=show_hidden,
218
313
  **kwargs,
219
314
  )
220
315
 
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: static-http
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A temporary dependency-free static HTTP server with byte-range support.
5
5
  Author: John Paul Ellis
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Keywords: http,server,static-http,static,range,cli
8
- Classifier: License :: OSI Approved :: MIT License
9
8
  Classifier: Programming Language :: Python :: 3
10
9
  Classifier: Programming Language :: Python :: 3 :: Only
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
13
14
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
14
15
  Requires-Python: >=3.10
15
16
  Description-Content-Type: text/markdown
@@ -72,6 +73,7 @@ By default:
72
73
  - `--no-cache` — send `Cache-Control: no-store`.
73
74
  - `--quiet` — suppress per-request logs.
74
75
  - `--verbose` — print detailed startup/binding information.
76
+ - `--include-hidden` — include dot-prefixed files and directories in normal serving and directory listings.
75
77
  - `--version` — print package version and exit.
76
78
 
77
79
  ## Examples
@@ -82,6 +84,7 @@ static-http --qr
82
84
  static-http --no-cache
83
85
  static-http --quiet
84
86
  static-http --verbose
87
+ static-http --include-hidden
85
88
  static-http --port 9000 --cors
86
89
  static-http --no-dir-list
87
90
  ```
@@ -8,6 +8,11 @@ import pytest
8
8
  from http_here import qrcode
9
9
 
10
10
 
11
+ class _TtyStringIO(io.StringIO):
12
+ def isatty(self) -> bool:
13
+ return True
14
+
15
+
11
16
  def test_qr_renders_on_wide_terminal() -> None:
12
17
  fake_size = namedtuple("Size", "columns lines")(80, 24)
13
18
  monkeypatch = pytest.MonkeyPatch()
@@ -20,6 +25,26 @@ def test_qr_renders_on_wide_terminal() -> None:
20
25
  assert out.getvalue().strip() != ""
21
26
 
22
27
 
28
+ def test_qr_uses_forced_terminal_contrast_on_tty() -> None:
29
+ fake_size = namedtuple("Size", "columns lines")(80, 24)
30
+ monkeypatch = pytest.MonkeyPatch()
31
+ monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
32
+ out = _TtyStringIO()
33
+ try:
34
+ assert qrcode.render_qr("http://localhost:8080/", stream=out)
35
+ finally:
36
+ monkeypatch.undo()
37
+ rendered = out.getvalue()
38
+ assert "\x1b[40m" in rendered
39
+ assert "\x1b[107m" in rendered
40
+
41
+
42
+ def test_qr_known_format_bits_and_version_selection() -> None:
43
+ assert f"{qrcode._format_bits(0):015b}" == "111011111000100"
44
+ assert len(qrcode._build_matrix("http://localhost:8080/")) == 25
45
+ assert len(qrcode._build_matrix("http://192.168.1.205:8080/")) == 25
46
+
47
+
23
48
  def test_qr_warns_when_terminal_too_narrow() -> None:
24
49
  fake_size = namedtuple("Size", "columns lines")(20, 24)
25
50
  monkeypatch = pytest.MonkeyPatch()
@@ -1,13 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import contextlib
4
+ from http.client import HTTPResponse
4
5
  import threading
5
6
  import urllib.error
6
7
  import urllib.request
7
8
  from pathlib import Path
8
9
 
9
- from http.client import HTTPResponse
10
-
11
10
  import pytest
12
11
 
13
12
  from http_here import server
@@ -89,6 +88,7 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
89
88
  root.mkdir()
90
89
  (root / "index.txt").write_bytes(b"index")
91
90
  (tmp_path / "a b.txt").write_bytes(b"space")
91
+ (tmp_path / ".hidden.txt").write_bytes(b"secret")
92
92
  (tmp_path / "rootdir").mkdir()
93
93
 
94
94
  with _running_server(
@@ -100,8 +100,11 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
100
100
  list_url = _http_url(srv, "/")
101
101
  with urllib.request.urlopen(list_url) as resp:
102
102
  assert resp.status == 200
103
+ assert resp.headers["Access-Control-Allow-Origin"] == "*"
104
+ assert resp.headers["Cache-Control"] == "no-store"
103
105
  body = resp.read()
104
106
  assert b"a b.txt" in body or b"a+b.txt" in body
107
+ assert b".hidden.txt" not in body
105
108
 
106
109
  req = urllib.request.Request(_http_url(srv, "/a%20b.txt"))
107
110
  with urllib.request.urlopen(req) as resp:
@@ -110,6 +113,15 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
110
113
  assert resp.headers["Access-Control-Allow-Origin"] == "*"
111
114
  assert resp.headers["Cache-Control"] == "no-store"
112
115
 
116
+ with pytest.raises(urllib.error.HTTPError) as exc:
117
+ urllib.request.urlopen(_http_url(srv, "/.hidden.txt"))
118
+ assert exc.value.code in {400, 404}
119
+
120
+ invalid_date = urllib.request.Request(_http_url(srv, "/a%20b.txt"), headers={"If-Modified-Since": "not a date"})
121
+ with urllib.request.urlopen(invalid_date) as resp:
122
+ assert resp.status == 200
123
+ assert resp.read() == b"space"
124
+
113
125
  with _running_server(
114
126
  tmp_path,
115
127
  extra_headers={},
@@ -121,8 +133,30 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
121
133
  assert exc.value.code == 403
122
134
 
123
135
 
136
+ def test_show_hidden_handler_option_exposes_hidden_entries(tmp_path: Path) -> None:
137
+ (tmp_path / ".hidden.txt").write_bytes(b"secret")
138
+
139
+ with _running_server(
140
+ tmp_path,
141
+ extra_headers={},
142
+ disable_dir_list=False,
143
+ quiet=True,
144
+ show_hidden=True,
145
+ ) as srv:
146
+ list_url = _http_url(srv, "/")
147
+ with urllib.request.urlopen(list_url) as resp:
148
+ assert resp.status == 200
149
+ body = resp.read()
150
+ assert b".hidden.txt" in body
151
+
152
+ with urllib.request.urlopen(_http_url(srv, "/.hidden.txt")) as resp:
153
+ assert resp.status == 200
154
+ assert resp.read() == b"secret"
155
+
156
+
124
157
  def test_path_traversal_cannot_escape_root(tmp_path: Path) -> None:
125
158
  (tmp_path / "inside.txt").write_bytes(b"inside")
159
+ (tmp_path / "outside.txt").write_bytes(b"fake-outside")
126
160
  (tmp_path.parent / "outside.txt").write_bytes(b"outside")
127
161
 
128
162
  with _running_server(
@@ -137,8 +171,31 @@ def test_path_traversal_cannot_escape_root(tmp_path: Path) -> None:
137
171
 
138
172
  with pytest.raises(urllib.error.HTTPError) as exc:
139
173
  urllib.request.urlopen(_http_url(srv, "/%2e%2e/outside.txt"))
140
- assert exc.value.code in {400, 404}
174
+ assert exc.value.code == 400
141
175
 
142
176
  with pytest.raises(urllib.error.HTTPError) as exc:
143
177
  urllib.request.urlopen(_http_url(srv, "/inside%5c.txt"))
144
178
  assert exc.value.code in {400, 404, 200}
179
+
180
+
181
+ def test_directory_redirect_preserves_query_string(tmp_path: Path) -> None:
182
+ (tmp_path / "dir").mkdir()
183
+
184
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
185
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
186
+ return None
187
+
188
+ opener = urllib.request.build_opener(NoRedirect)
189
+ with _running_server(
190
+ tmp_path,
191
+ extra_headers={},
192
+ disable_dir_list=False,
193
+ quiet=True,
194
+ ) as srv:
195
+ with pytest.raises(urllib.error.HTTPError) as exc:
196
+ opener.open(_http_url(srv, "/dir?download=1"))
197
+ assert exc.value.code == 301
198
+ assert exc.value.headers["Location"] == "/dir/?download=1"
199
+
200
+ with urllib.request.urlopen(_http_url(srv, "/dir/?download=1")) as resp:
201
+ assert resp.status == 200
@@ -1,81 +0,0 @@
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
File without changes
File without changes