static-http 0.1.3__tar.gz → 0.1.5__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 (26) hide show
  1. {static_http-0.1.3 → static_http-0.1.5}/PKG-INFO +4 -3
  2. {static_http-0.1.3 → static_http-0.1.5}/pyproject.toml +6 -4
  3. {static_http-0.1.3 → static_http-0.1.5}/src/http_here/__init__.py +1 -1
  4. static_http-0.1.5/src/http_here/qrcode.py +350 -0
  5. {static_http-0.1.3 → static_http-0.1.5}/src/http_here/server.py +39 -29
  6. {static_http-0.1.3 → static_http-0.1.5}/src/static_http.egg-info/PKG-INFO +4 -3
  7. static_http-0.1.5/tests/test_qrcode.py +116 -0
  8. {static_http-0.1.3 → static_http-0.1.5}/tests/test_server.py +35 -4
  9. static_http-0.1.3/src/http_here/qrcode.py +0 -81
  10. static_http-0.1.3/tests/test_qrcode.py +0 -32
  11. {static_http-0.1.3 → static_http-0.1.5}/LICENSE +0 -0
  12. {static_http-0.1.3 → static_http-0.1.5}/README.md +0 -0
  13. {static_http-0.1.3 → static_http-0.1.5}/setup.cfg +0 -0
  14. {static_http-0.1.3 → static_http-0.1.5}/src/http_here/__main__.py +0 -0
  15. {static_http-0.1.3 → static_http-0.1.5}/src/http_here/cli.py +0 -0
  16. {static_http-0.1.3 → static_http-0.1.5}/src/http_here/keyboard.py +0 -0
  17. {static_http-0.1.3 → static_http-0.1.5}/src/http_here/ranges.py +0 -0
  18. {static_http-0.1.3 → static_http-0.1.5}/src/http_here/urls.py +0 -0
  19. {static_http-0.1.3 → static_http-0.1.5}/src/static_http.egg-info/SOURCES.txt +0 -0
  20. {static_http-0.1.3 → static_http-0.1.5}/src/static_http.egg-info/dependency_links.txt +0 -0
  21. {static_http-0.1.3 → static_http-0.1.5}/src/static_http.egg-info/entry_points.txt +0 -0
  22. {static_http-0.1.3 → static_http-0.1.5}/src/static_http.egg-info/requires.txt +0 -0
  23. {static_http-0.1.3 → static_http-0.1.5}/src/static_http.egg-info/top_level.txt +0 -0
  24. {static_http-0.1.3 → static_http-0.1.5}/tests/test_cli.py +0 -0
  25. {static_http-0.1.3 → static_http-0.1.5}/tests/test_ranges.py +0 -0
  26. {static_http-0.1.3 → static_http-0.1.5}/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.3
3
+ Version: 0.1.5
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
@@ -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.3"
7
+ version = "0.1.5"
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.3"
3
+ __version__ = "0.1.5"
@@ -0,0 +1,350 @@
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 _generator_poly(degree: int) -> list[int]:
40
+ result = [0] * degree
41
+ result[-1] = 1
42
+ root = 1
43
+ for _ in range(degree):
44
+ for i in range(degree):
45
+ result[i] = _gf_mul(result[i], root)
46
+ if i + 1 < degree:
47
+ result[i] ^= result[i + 1]
48
+ root = _gf_mul(root, 0x02)
49
+ return result
50
+
51
+
52
+ def _reed_solomon(data: list[int], degree: int) -> list[int]:
53
+ divisor = _generator_poly(degree)
54
+ result = [0] * degree
55
+ for byte in data:
56
+ factor = byte ^ result.pop(0)
57
+ result.append(0)
58
+ for i, coefficient in enumerate(divisor):
59
+ result[i] ^= _gf_mul(coefficient, factor)
60
+ return result
61
+
62
+
63
+ def _append_bits(bits: list[int], value: int, length: int) -> None:
64
+ for i in range(length - 1, -1, -1):
65
+ bits.append((value >> i) & 1)
66
+
67
+
68
+ def _choose_version(data: bytes) -> int:
69
+ needed_bits = 4 + 8 + len(data) * 8
70
+ for version, data_codewords in _DATA_CODEWORDS.items():
71
+ if needed_bits <= data_codewords * 8:
72
+ return version
73
+ raise ValueError("URL is too long for the built-in QR renderer")
74
+
75
+
76
+ def _encode_data(data: bytes, version: int) -> list[int]:
77
+ bits: list[int] = []
78
+ _append_bits(bits, 0b0100, 4) # Byte mode.
79
+ _append_bits(bits, len(data), 8)
80
+ for byte in data:
81
+ _append_bits(bits, byte, 8)
82
+
83
+ capacity = _DATA_CODEWORDS[version] * 8
84
+ _append_bits(bits, 0, min(4, capacity - len(bits)))
85
+ while len(bits) % 8:
86
+ bits.append(0)
87
+
88
+ pad = [0xEC, 0x11]
89
+ pad_index = 0
90
+ while len(bits) < capacity:
91
+ _append_bits(bits, pad[pad_index % 2], 8)
92
+ pad_index += 1
93
+
94
+ return [int("".join(str(bit) for bit in bits[i : i + 8]), 2) for i in range(0, len(bits), 8)]
95
+
96
+
97
+ def _set_function(matrix: list[list[bool | None]], function: list[list[bool]], row: int, col: int, dark: bool) -> None:
98
+ matrix[row][col] = dark
99
+ function[row][col] = True
100
+
101
+
102
+ def _draw_finder(matrix: list[list[bool | None]], function: list[list[bool]], top: int, left: int) -> None:
103
+ size = len(matrix)
104
+ for r in range(-1, 8):
105
+ for c in range(-1, 8):
106
+ row = top + r
107
+ col = left + c
108
+ if not (0 <= row < size and 0 <= col < size):
109
+ continue
110
+ 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))
111
+ _set_function(matrix, function, row, col, dark)
112
+
113
+
114
+ def _draw_alignment(matrix: list[list[bool | None]], function: list[list[bool]], center: int) -> None:
115
+ for r in range(-2, 3):
116
+ for c in range(-2, 3):
117
+ dark = max(abs(r), abs(c)) != 1
118
+ _set_function(matrix, function, center + r, center + c, dark)
119
+
120
+
121
+ def _draw_function_patterns(matrix: list[list[bool | None]], function: list[list[bool]], version: int) -> None:
122
+ size = len(matrix)
123
+ _draw_finder(matrix, function, 0, 0)
124
+ _draw_finder(matrix, function, 0, size - 7)
125
+ _draw_finder(matrix, function, size - 7, 0)
126
+ if version > 1:
127
+ _draw_alignment(matrix, function, size - 7)
128
+
129
+ for i in range(8, size - 8):
130
+ _set_function(matrix, function, 6, i, i % 2 == 0)
131
+ _set_function(matrix, function, i, 6, i % 2 == 0)
132
+
133
+ _draw_format_bits(matrix, function, 0)
134
+
135
+
136
+ def _mask(mask: int, row: int, col: int) -> bool:
137
+ if mask == 0:
138
+ return (row + col) % 2 == 0
139
+ if mask == 1:
140
+ return row % 2 == 0
141
+ if mask == 2:
142
+ return col % 3 == 0
143
+ if mask == 3:
144
+ return (row + col) % 3 == 0
145
+ if mask == 4:
146
+ return (row // 2 + col // 3) % 2 == 0
147
+ if mask == 5:
148
+ return (row * col) % 2 + (row * col) % 3 == 0
149
+ if mask == 6:
150
+ return ((row * col) % 2 + (row * col) % 3) % 2 == 0
151
+ return ((row + col) % 2 + (row * col) % 3) % 2 == 0
152
+
153
+
154
+ def _draw_codewords(matrix: list[list[bool | None]], function: list[list[bool]], codewords: list[int], mask: int) -> None:
155
+ bits = [(codeword >> i) & 1 for codeword in codewords for i in range(7, -1, -1)]
156
+ bit_index = 0
157
+ size = len(matrix)
158
+ row = size - 1
159
+ direction = -1
160
+ col = size - 1
161
+
162
+ while col > 0:
163
+ if col == 6:
164
+ col -= 1
165
+ while 0 <= row < size:
166
+ for offset in range(2):
167
+ c = col - offset
168
+ if function[row][c]:
169
+ continue
170
+ bit = bit_index < len(bits) and bits[bit_index] == 1
171
+ bit_index += 1
172
+ matrix[row][c] = bit ^ _mask(mask, row, c)
173
+ row += direction
174
+ row -= direction
175
+ direction = -direction
176
+ col -= 2
177
+
178
+
179
+ def _format_bits(mask: int) -> int:
180
+ data = (0b01 << 3) | mask # Error correction level L.
181
+ value = data << 10
182
+ generator = 0b10100110111
183
+ for i in range(14, 9, -1):
184
+ if (value >> i) & 1:
185
+ value ^= generator << (i - 10)
186
+ return (((data << 10) | value) ^ 0b101010000010010) & 0x7FFF
187
+
188
+
189
+ def _draw_format_bits(matrix: list[list[bool | None]], function: list[list[bool]], mask: int) -> None:
190
+ bits = _format_bits(mask)
191
+ size = len(matrix)
192
+ for i in range(15):
193
+ dark = ((bits >> i) & 1) == 1
194
+
195
+ if i < 6:
196
+ _set_function(matrix, function, i, 8, dark)
197
+ elif i < 8:
198
+ _set_function(matrix, function, i + 1, 8, dark)
199
+ else:
200
+ _set_function(matrix, function, size - 15 + i, 8, dark)
201
+
202
+ if i < 8:
203
+ _set_function(matrix, function, 8, size - i - 1, dark)
204
+ elif i == 8:
205
+ _set_function(matrix, function, 8, 7, dark)
206
+ else:
207
+ _set_function(matrix, function, 8, 14 - i, dark)
208
+
209
+ _set_function(matrix, function, size - 8, 8, True)
210
+
211
+
212
+ def _penalty(matrix: list[list[bool | None]]) -> int:
213
+ size = len(matrix)
214
+ penalty = 0
215
+ for row in range(size):
216
+ run_color = matrix[row][0]
217
+ run_length = 1
218
+ for col in range(1, size):
219
+ if matrix[row][col] == run_color:
220
+ run_length += 1
221
+ else:
222
+ if run_length >= 5:
223
+ penalty += run_length - 2
224
+ run_color = matrix[row][col]
225
+ run_length = 1
226
+ if run_length >= 5:
227
+ penalty += run_length - 2
228
+
229
+ for col in range(size):
230
+ run_color = matrix[0][col]
231
+ run_length = 1
232
+ for row in range(1, size):
233
+ if matrix[row][col] == run_color:
234
+ run_length += 1
235
+ else:
236
+ if run_length >= 5:
237
+ penalty += run_length - 2
238
+ run_color = matrix[row][col]
239
+ run_length = 1
240
+ if run_length >= 5:
241
+ penalty += run_length - 2
242
+
243
+ for row in range(size - 1):
244
+ for col in range(size - 1):
245
+ color = matrix[row][col]
246
+ if color == matrix[row + 1][col] == matrix[row][col + 1] == matrix[row + 1][col + 1]:
247
+ penalty += 3
248
+
249
+ dark = sum(1 for row in matrix for cell in row if cell)
250
+ total = size * size
251
+ penalty += abs(dark * 20 - total * 10) // total * 10
252
+ return penalty
253
+
254
+
255
+ def _build_matrix(data: str) -> list[list[bool]]:
256
+ payload = data.encode("utf-8")
257
+ version = _choose_version(payload)
258
+ size = version * 4 + 17
259
+ data_codewords = _encode_data(payload, version)
260
+ codewords = data_codewords + _reed_solomon(data_codewords, _ECC_CODEWORDS[version])
261
+
262
+ best_matrix: list[list[bool | None]] | None = None
263
+ best_penalty: int | None = None
264
+ for mask in range(8):
265
+ matrix: list[list[bool | None]] = [[None for _ in range(size)] for _ in range(size)]
266
+ function = [[False for _ in range(size)] for _ in range(size)]
267
+ _draw_function_patterns(matrix, function, version)
268
+ _draw_codewords(matrix, function, codewords, mask)
269
+ _draw_format_bits(matrix, function, mask)
270
+ penalty = _penalty(matrix)
271
+ if best_penalty is None or penalty < best_penalty:
272
+ best_matrix = matrix
273
+ best_penalty = penalty
274
+
275
+ if best_matrix is None:
276
+ raise AssertionError("QR matrix was not generated")
277
+ return [[bool(cell) for cell in row] for row in best_matrix]
278
+
279
+
280
+ def _format_plain_block_matrix(matrix: list[list[bool]]) -> list[str]:
281
+ border = 4
282
+ width = len(matrix)
283
+ lines: list[str] = []
284
+ for _ in range(border):
285
+ lines.append(" " * ((width + border * 2) * 2))
286
+
287
+ for row in matrix:
288
+ line = " " * border
289
+ for cell in row:
290
+ line += "██" if cell else " "
291
+ line += " " * border
292
+ lines.append(line)
293
+
294
+ for _ in range(border):
295
+ lines.append(" " * ((width + border * 2) * 2))
296
+ return lines
297
+
298
+
299
+ def _format_ansi_block_matrix(matrix: list[list[bool]]) -> list[str]:
300
+ border = 4
301
+ light_row = _ANSI_WHITE + (" " * ((len(matrix) + border * 2) * 2)) + _ANSI_RESET
302
+ lines: list[str] = [light_row for _ in range(border)]
303
+
304
+ for row in matrix:
305
+ line = [_ANSI_WHITE + (" " * border)]
306
+ last_color = _ANSI_WHITE
307
+ for cell in row:
308
+ color = _ANSI_BLACK if cell else _ANSI_WHITE
309
+ if color != last_color:
310
+ line.append(color)
311
+ last_color = color
312
+ line.append(" ")
313
+ if last_color != _ANSI_WHITE:
314
+ line.append(_ANSI_WHITE)
315
+ line.append(" " * border)
316
+ line.append(_ANSI_RESET)
317
+ lines.append("".join(line))
318
+
319
+ lines.extend(light_row for _ in range(border))
320
+ return lines
321
+
322
+
323
+ def _supports_ansi(stream: TextIO) -> bool:
324
+ isatty = getattr(stream, "isatty", None)
325
+ return bool(isatty and isatty())
326
+
327
+
328
+ def render_qr(url: str, *, stream: TextIO | None = None) -> bool:
329
+ """Render a terminal QR code for ``url``."""
330
+
331
+ if stream is None:
332
+ stream = sys.stdout
333
+
334
+ try:
335
+ matrix = _build_matrix(url)
336
+ except ValueError as exc:
337
+ stream.write(f"{exc}\n")
338
+ return False
339
+
340
+ terminal = shutil.get_terminal_size((80, 24))
341
+ required_columns = (len(matrix) + 8) * 2
342
+ if terminal.columns < required_columns:
343
+ stream.write(f"Terminal too narrow to render QR code. Need at least {required_columns} columns.\n")
344
+ return False
345
+
346
+ formatter = _format_ansi_block_matrix if _supports_ansi(stream) else _format_plain_block_matrix
347
+ for line in formatter(matrix):
348
+ stream.write(f"{line}\n")
349
+ stream.write(f"{url}\n")
350
+ return True
@@ -2,12 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import io
6
5
  import html
6
+ import io
7
7
  import os
8
- import sys
9
- import posixpath
10
8
  import stat
9
+ import sys
11
10
  import urllib.parse
12
11
  from datetime import datetime, timezone
13
12
  from email.utils import parsedate_to_datetime
@@ -56,6 +55,20 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
56
55
  return False
57
56
  return segment.startswith(".")
58
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
+
59
72
  def translate_path(self, path: str) -> str | None:
60
73
  # Safe path mapping inspired by SimpleHTTPRequestHandler, with strict traversal defense.
61
74
  path = path.split("?", 1)[0]
@@ -65,25 +78,23 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
65
78
  if "\x00" in path:
66
79
  return None
67
80
 
68
- path = posixpath.normpath(path)
69
- if path == "/":
70
- parts: list[str] = []
71
- else:
72
- parts = []
73
- for segment in path.split("/"):
74
- if segment in ("", "."):
75
- continue
76
- if segment == "..":
77
- continue
78
- if self._is_hidden(segment):
79
- return None
80
- if ":" in segment:
81
- return None
82
- 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)
83
92
 
84
93
  candidate = self.directory
85
94
  for part in parts:
86
95
  candidate = os.path.join(candidate, part)
96
+ if self._has_hidden_attribute(candidate):
97
+ return None
87
98
 
88
99
  candidate = os.path.normpath(candidate)
89
100
  root = os.path.realpath(self.directory)
@@ -119,9 +130,11 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
119
130
  f = None
120
131
 
121
132
  if os.path.isdir(path):
122
- 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))
123
136
  self.send_response(HTTPStatus.MOVED_PERMANENTLY)
124
- self.send_header("Location", self.path + "/")
137
+ self.send_header("Location", location)
125
138
  self._add_custom_headers()
126
139
  self.end_headers()
127
140
  return None
@@ -156,7 +169,10 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
156
169
 
157
170
  if_modified_since = self.headers.get("If-Modified-Since")
158
171
  if if_modified_since:
159
- 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
160
176
  if parsed is not None:
161
177
  if parsed.tzinfo is None:
162
178
  parsed = parsed.replace(tzinfo=timezone.utc)
@@ -200,20 +216,13 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
200
216
  raise
201
217
 
202
218
  def list_directory(self, path: str) -> io.BytesIO | None:
203
- if self._show_hidden:
204
- return super().list_directory(path)
205
-
206
219
  try:
207
220
  listdir = os.listdir(path)
208
221
  except OSError:
209
222
  self.send_error(HTTPStatus.NOT_FOUND, "No permission to list directory")
210
223
  return None
211
224
 
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
-
225
+ listdir = [entry for entry in listdir if not self._is_hidden_entry(entry, os.path.join(path, entry))]
217
226
  listdir.sort(key=lambda a: a.lower())
218
227
  r = []
219
228
 
@@ -255,6 +264,7 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
255
264
  self.send_response(HTTPStatus.OK)
256
265
  self.send_header("Content-Type", f"text/html; charset={enc}")
257
266
  self.send_header("Content-Length", str(len(encoded)))
267
+ self._add_custom_headers()
258
268
  self.end_headers()
259
269
  return f
260
270
 
@@ -1,15 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: static-http
3
- Version: 0.1.3
3
+ Version: 0.1.5
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
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from collections import namedtuple
5
+
6
+ import pytest
7
+
8
+ from http_here import qrcode
9
+
10
+
11
+ class _TtyStringIO(io.StringIO):
12
+ def isatty(self) -> bool:
13
+ return True
14
+
15
+
16
+ def test_qr_renders_on_wide_terminal() -> None:
17
+ fake_size = namedtuple("Size", "columns lines")(80, 24)
18
+ monkeypatch = pytest.MonkeyPatch()
19
+ monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
20
+ out = io.StringIO()
21
+ try:
22
+ assert qrcode.render_qr("http://localhost:8080/", stream=out)
23
+ finally:
24
+ monkeypatch.undo()
25
+ assert out.getvalue().strip() != ""
26
+
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
+
48
+ def test_reed_solomon_matches_known_version_2_l_codewords() -> None:
49
+ data = qrcode._encode_data(b"http://localhost:8080/", 2)
50
+
51
+ assert data == [
52
+ 65,
53
+ 102,
54
+ 135,
55
+ 71,
56
+ 71,
57
+ 3,
58
+ 162,
59
+ 242,
60
+ 246,
61
+ 198,
62
+ 246,
63
+ 54,
64
+ 22,
65
+ 198,
66
+ 134,
67
+ 247,
68
+ 55,
69
+ 67,
70
+ 163,
71
+ 131,
72
+ 3,
73
+ 131,
74
+ 2,
75
+ 240,
76
+ 236,
77
+ 17,
78
+ 236,
79
+ 17,
80
+ 236,
81
+ 17,
82
+ 236,
83
+ 17,
84
+ 236,
85
+ 17,
86
+ ]
87
+ assert qrcode._reed_solomon(data, qrcode._ECC_CODEWORDS[2]) == [224, 235, 163, 25, 95, 161, 5, 47, 66, 94]
88
+
89
+
90
+ def test_format_bits_use_dark_module_and_second_copy_positions() -> None:
91
+ size = 25
92
+ matrix = [[None for _ in range(size)] for _ in range(size)]
93
+ function = [[False for _ in range(size)] for _ in range(size)]
94
+
95
+ qrcode._draw_function_patterns(matrix, function, 2)
96
+ qrcode._draw_format_bits(matrix, function, 4)
97
+ bits = qrcode._format_bits(4)
98
+
99
+ assert matrix[size - 8][8] is True
100
+ assert matrix[0][8] == bool(bits & 1)
101
+ assert matrix[8][size - 1] == bool(bits & 1)
102
+ assert matrix[8][7] == bool((bits >> 8) & 1)
103
+ assert matrix[size - 1][8] == bool(bits & 1)
104
+ assert function[size - 8][8] is True
105
+
106
+
107
+ def test_qr_warns_when_terminal_too_narrow() -> None:
108
+ fake_size = namedtuple("Size", "columns lines")(20, 24)
109
+ monkeypatch = pytest.MonkeyPatch()
110
+ monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
111
+ out = io.StringIO()
112
+ try:
113
+ assert not qrcode.render_qr("http://localhost:8080/", stream=out)
114
+ finally:
115
+ monkeypatch.undo()
116
+ assert "too narrow" in out.getvalue().lower()
@@ -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
@@ -101,8 +100,11 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
101
100
  list_url = _http_url(srv, "/")
102
101
  with urllib.request.urlopen(list_url) as resp:
103
102
  assert resp.status == 200
103
+ assert resp.headers["Access-Control-Allow-Origin"] == "*"
104
+ assert resp.headers["Cache-Control"] == "no-store"
104
105
  body = resp.read()
105
106
  assert b"a b.txt" in body or b"a+b.txt" in body
107
+ assert b".hidden.txt" not in body
106
108
 
107
109
  req = urllib.request.Request(_http_url(srv, "/a%20b.txt"))
108
110
  with urllib.request.urlopen(req) as resp:
@@ -115,6 +117,11 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
115
117
  urllib.request.urlopen(_http_url(srv, "/.hidden.txt"))
116
118
  assert exc.value.code in {400, 404}
117
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
+
118
125
  with _running_server(
119
126
  tmp_path,
120
127
  extra_headers={},
@@ -126,7 +133,7 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
126
133
  assert exc.value.code == 403
127
134
 
128
135
 
129
- def test_show_hidden_flag_exposes_hidden_entries(tmp_path: Path) -> None:
136
+ def test_show_hidden_handler_option_exposes_hidden_entries(tmp_path: Path) -> None:
130
137
  (tmp_path / ".hidden.txt").write_bytes(b"secret")
131
138
 
132
139
  with _running_server(
@@ -149,6 +156,7 @@ def test_show_hidden_flag_exposes_hidden_entries(tmp_path: Path) -> None:
149
156
 
150
157
  def test_path_traversal_cannot_escape_root(tmp_path: Path) -> None:
151
158
  (tmp_path / "inside.txt").write_bytes(b"inside")
159
+ (tmp_path / "outside.txt").write_bytes(b"fake-outside")
152
160
  (tmp_path.parent / "outside.txt").write_bytes(b"outside")
153
161
 
154
162
  with _running_server(
@@ -163,8 +171,31 @@ def test_path_traversal_cannot_escape_root(tmp_path: Path) -> None:
163
171
 
164
172
  with pytest.raises(urllib.error.HTTPError) as exc:
165
173
  urllib.request.urlopen(_http_url(srv, "/%2e%2e/outside.txt"))
166
- assert exc.value.code in {400, 404}
174
+ assert exc.value.code == 400
167
175
 
168
176
  with pytest.raises(urllib.error.HTTPError) as exc:
169
177
  urllib.request.urlopen(_http_url(srv, "/inside%5c.txt"))
170
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
@@ -1,32 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import io
4
- from collections import namedtuple
5
-
6
- import pytest
7
-
8
- from http_here import qrcode
9
-
10
-
11
- def test_qr_renders_on_wide_terminal() -> None:
12
- fake_size = namedtuple("Size", "columns lines")(80, 24)
13
- monkeypatch = pytest.MonkeyPatch()
14
- monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
15
- out = io.StringIO()
16
- try:
17
- assert qrcode.render_qr("http://localhost:8080/", stream=out)
18
- finally:
19
- monkeypatch.undo()
20
- assert out.getvalue().strip() != ""
21
-
22
-
23
- def test_qr_warns_when_terminal_too_narrow() -> None:
24
- fake_size = namedtuple("Size", "columns lines")(20, 24)
25
- monkeypatch = pytest.MonkeyPatch()
26
- monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
27
- out = io.StringIO()
28
- try:
29
- assert not qrcode.render_qr("http://localhost:8080/", stream=out)
30
- finally:
31
- monkeypatch.undo()
32
- assert "too narrow" in out.getvalue().lower()
File without changes
File without changes
File without changes