static-http 0.1.3__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.
- {static_http-0.1.3 → static_http-0.1.4}/PKG-INFO +4 -3
- {static_http-0.1.3 → static_http-0.1.4}/pyproject.toml +6 -4
- {static_http-0.1.3 → static_http-0.1.4}/src/http_here/__init__.py +1 -1
- static_http-0.1.4/src/http_here/qrcode.py +380 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/http_here/server.py +39 -29
- {static_http-0.1.3 → static_http-0.1.4}/src/static_http.egg-info/PKG-INFO +4 -3
- {static_http-0.1.3 → static_http-0.1.4}/tests/test_qrcode.py +25 -0
- {static_http-0.1.3 → static_http-0.1.4}/tests/test_server.py +35 -4
- static_http-0.1.3/src/http_here/qrcode.py +0 -81
- {static_http-0.1.3 → static_http-0.1.4}/LICENSE +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/README.md +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/setup.cfg +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/http_here/__main__.py +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/http_here/cli.py +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/http_here/keyboard.py +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/http_here/ranges.py +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/http_here/urls.py +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/static_http.egg-info/SOURCES.txt +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/static_http.egg-info/dependency_links.txt +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/static_http.egg-info/entry_points.txt +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/static_http.egg-info/requires.txt +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/src/static_http.egg-info/top_level.txt +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/tests/test_cli.py +0 -0
- {static_http-0.1.3 → static_http-0.1.4}/tests/test_ranges.py +0 -0
- {static_http-0.1.3 → 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.
|
|
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
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
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.
|
|
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 =
|
|
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
|
|
|
@@ -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,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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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.
|
|
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
|
+
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
|
|
@@ -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
|
|
@@ -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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|