pendify 0.1.0__py3-none-any.whl

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.
pendify/qr.py ADDED
@@ -0,0 +1,433 @@
1
+ """A QR encoder written here (ISO/IEC 18004), and its inline SVG.
2
+
3
+ Byte mode, error correction level M, versions 1 to 6, the mask chosen by the standard's four penalty
4
+ rules. The pairing address, 41 bytes, is version 3. Whether a phone's camera reads the result is proven
5
+ on the bench, never claimed from these tests alone (design section 4).
6
+ """
7
+ from dataclasses import dataclass
8
+
9
+ from . import codes
10
+
11
+ PAIRING_ADDRESS = "https://pendiapp.com/link/pc#"
12
+ QUIET_ZONE = 4
13
+ # The drawn symbol's colours: ink on a white tile in both page themes, the finder rings in the app's violet.
14
+ _QR_INK, _QR_RING, _QR_TILE = "#1E1533", "#6D28D9", "#FFFFFF"
15
+
16
+ # Level M: version -> (EC codewords per block, blocks, data codewords per block). No short blocks up to 6.
17
+ _LEVEL_M = {1: (10, 1, 16), 2: (16, 1, 28), 3: (26, 1, 44), 4: (18, 2, 32), 5: (24, 2, 43), 6: (16, 4, 27)}
18
+ _ALIGNMENT = {1: (), 2: (6, 18), 3: (6, 22), 4: (6, 26), 5: (6, 30), 6: (6, 34)}
19
+ _LEVEL_M_BITS = 0b00
20
+ _FORMAT_GENERATOR = 0x537
21
+ _FORMAT_XOR = 0x5412
22
+ _MODE_BYTE = 0b0100
23
+ _PAD_BYTES = (0xEC, 0x11)
24
+ _FINDER_LIKE = (True, False, True, True, True, False, True)
25
+
26
+ # GF(256) over the polynomial 0x11D, as log and antilog tables.
27
+ _EXP = [0] * 512
28
+ _LOG = [0] * 256
29
+ _value = 1
30
+ for _i in range(255):
31
+ _EXP[_i], _LOG[_value] = _value, _i
32
+ _value <<= 1
33
+ if _value & 0x100:
34
+ _value ^= 0x11D
35
+ for _i in range(255, 512):
36
+ _EXP[_i] = _EXP[_i - 255]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class QrCode:
41
+ version: int
42
+ mask: int
43
+ modules: tuple # rows of booleans, True is dark
44
+
45
+
46
+ def pairing_address(secret):
47
+ """The one address the QR draws: the constant and the normalized secret."""
48
+ normal = codes.normalize(secret)
49
+ if normal is None:
50
+ raise ValueError("not a pairing value of twelve symbols")
51
+ return PAIRING_ADDRESS + normal
52
+
53
+
54
+ def _gf_mul(a, b):
55
+ return 0 if a == 0 or b == 0 else _EXP[_LOG[a] + _LOG[b]]
56
+
57
+
58
+ def reed_solomon_remainder(data, degree):
59
+ """The EC codewords: data times x^degree modulo the generator whose roots are alpha^0 .. alpha^(degree-1)."""
60
+ generator = [1]
61
+ for power in range(degree):
62
+ product = generator + [0]
63
+ for j, coefficient in enumerate(generator):
64
+ product[j + 1] ^= _gf_mul(coefficient, _EXP[power])
65
+ generator = product
66
+ remainder = [0] * degree
67
+ for byte in data:
68
+ factor = byte ^ remainder[0]
69
+ remainder = remainder[1:] + [0]
70
+ for j in range(degree):
71
+ remainder[j] ^= _gf_mul(generator[j + 1], factor)
72
+ return remainder
73
+
74
+
75
+ def _choose_version(length):
76
+ for version, (_, blocks, per_block) in _LEVEL_M.items():
77
+ if 4 + 8 + 8 * length <= blocks * per_block * 8:
78
+ return version
79
+ raise ValueError("the data does not fit a version 1 to 6 symbol at level M")
80
+
81
+
82
+ def _codewords(data, version):
83
+ ec, blocks, per_block = _LEVEL_M[version]
84
+ capacity = blocks * per_block * 8
85
+ bits = []
86
+ for value, width in [(_MODE_BYTE, 4), (len(data), 8)] + [(byte, 8) for byte in data]:
87
+ bits.extend((value >> shift) & 1 for shift in range(width - 1, -1, -1))
88
+ bits.extend([0] * min(4, capacity - len(bits)))
89
+ bits.extend([0] * (-len(bits) % 8))
90
+ words = [int("".join(map(str, bits[i:i + 8])), 2) for i in range(0, len(bits), 8)]
91
+ words += [_PAD_BYTES[i % 2] for i in range(blocks * per_block - len(words))]
92
+ data_blocks = [words[i * per_block:(i + 1) * per_block] for i in range(blocks)]
93
+ ec_blocks = [reed_solomon_remainder(block, ec) for block in data_blocks]
94
+ return ([block[i] for i in range(per_block) for block in data_blocks]
95
+ + [block[i] for i in range(ec) for block in ec_blocks])
96
+
97
+
98
+ def _draw_format(dark, function, mask):
99
+ size = len(dark)
100
+ data = (_LEVEL_M_BITS << 3) | mask
101
+ remainder = data
102
+ for _ in range(10):
103
+ remainder = (remainder << 1) ^ ((remainder >> 9) * _FORMAT_GENERATOR)
104
+ bits = ((data << 10) | remainder) ^ _FORMAT_XOR
105
+ places = ([(i, 8) for i in range(6)] + [(7, 8), (8, 8), (8, 7)] + [(8, 14 - i) for i in range(9, 15)],
106
+ [(8, size - 1 - i) for i in range(8)] + [(size - 15 + i, 8) for i in range(8, 15)])
107
+ for copy in places:
108
+ for i, (r, c) in enumerate(copy):
109
+ dark[r][c] = bool((bits >> i) & 1)
110
+ if function is not None:
111
+ function[r][c] = True
112
+ dark[size - 8][8] = True
113
+ if function is not None:
114
+ function[size - 8][8] = True
115
+
116
+
117
+ def _function_grid(version):
118
+ size = 17 + 4 * version
119
+ dark = [[False] * size for _ in range(size)]
120
+ function = [[False] * size for _ in range(size)]
121
+
122
+ def put(r, c, value):
123
+ dark[r][c], function[r][c] = value, True
124
+
125
+ for i in range(size):
126
+ put(6, i, i % 2 == 0)
127
+ put(i, 6, i % 2 == 0)
128
+ for top, left in ((0, 0), (0, size - 7), (size - 7, 0)):
129
+ for dr in range(-1, 8):
130
+ for dc in range(-1, 8):
131
+ if 0 <= top + dr < size and 0 <= left + dc < size:
132
+ distance = max(abs(dr - 3), abs(dc - 3))
133
+ put(top + dr, left + dc, distance not in (2, 4))
134
+ centers = _ALIGNMENT[version]
135
+ for cr in centers:
136
+ for cc in centers:
137
+ if function[cr][cc]: # the three corners a finder already holds
138
+ continue
139
+ for dr in range(-2, 3):
140
+ for dc in range(-2, 3):
141
+ put(cr + dr, cc + dc, max(abs(dr), abs(dc)) != 1)
142
+ _draw_format(dark, function, 0) # reserves both format areas and the dark module
143
+ return dark, function
144
+
145
+
146
+ def _place(dark, function, words):
147
+ size, i, total = len(dark), 0, len(words) * 8
148
+ right = size - 1
149
+ while right >= 1:
150
+ if right == 6:
151
+ right = 5
152
+ upward = ((right + 1) & 2) == 0
153
+ for vertical in range(size):
154
+ r = size - 1 - vertical if upward else vertical
155
+ for c in (right, right - 1):
156
+ if not function[r][c] and i < total:
157
+ dark[r][c] = bool((words[i >> 3] >> (7 - (i & 7))) & 1)
158
+ i += 1
159
+ right -= 2
160
+
161
+
162
+ def _mask_bit(mask, r, c):
163
+ return (
164
+ (r + c) % 2 == 0, r % 2 == 0, c % 3 == 0, (r + c) % 3 == 0, (r // 2 + c // 3) % 2 == 0,
165
+ (r * c) % 2 + (r * c) % 3 == 0, ((r * c) % 2 + (r * c) % 3) % 2 == 0,
166
+ ((r + c) % 2 + (r * c) % 3) % 2 == 0,
167
+ )[mask]
168
+
169
+
170
+ def encode(data, mask=None):
171
+ """The symbol for `data` (bytes). The mask is the first with the lowest penalty unless one is forced."""
172
+ data = bytes(data)
173
+ if mask is not None and mask not in range(8):
174
+ raise ValueError("a mask is 0 to 7")
175
+ version = _choose_version(len(data))
176
+ dark, function = _function_grid(version)
177
+ _place(dark, function, _codewords(data, version))
178
+ best = None
179
+ for k in range(8) if mask is None else (mask,):
180
+ candidate = [[module ^ (not function[r][c] and _mask_bit(k, r, c)) for c, module in enumerate(row)]
181
+ for r, row in enumerate(dark)]
182
+ _draw_format(candidate, None, k)
183
+ score = penalty(candidate)
184
+ if best is None or score < best[0]:
185
+ best = (score, k, candidate)
186
+ return QrCode(version, best[1], tuple(tuple(row) for row in best[2]))
187
+
188
+
189
+ def _runs_penalty(lines):
190
+ score = 0
191
+ for line in lines:
192
+ run, previous = 0, None
193
+ for module in list(line) + [None]:
194
+ if module == previous:
195
+ run += 1
196
+ continue
197
+ if run >= 5:
198
+ score += 3 + run - 5
199
+ run, previous = 1, module
200
+ return score
201
+
202
+
203
+ def _finder_like_penalty(lines):
204
+ count = 0
205
+ for line in lines:
206
+ line = list(line)
207
+ for x in range(len(line) - 6):
208
+ if tuple(line[x:x + 7]) == _FINDER_LIKE and (not any(line[max(x - 4, 0):x]) or not any(line[x + 7:x + 11])):
209
+ count += 1
210
+ return 40 * count
211
+
212
+
213
+ def penalty(modules):
214
+ """The four penalty rules (runs, 2 by 2 blocks, finder-like runs, dark balance); outside is light."""
215
+ rows = [[bool(module) for module in row] for row in modules]
216
+ columns = [list(column) for column in zip(*rows)]
217
+ blocks = sum(1 for r in range(len(rows) - 1) for c in range(len(rows[0]) - 1)
218
+ if rows[r][c] == rows[r][c + 1] == rows[r + 1][c] == rows[r + 1][c + 1])
219
+ total = sum(len(row) for row in rows)
220
+ dark = sum(sum(row) for row in rows)
221
+ return (_runs_penalty(rows) + _runs_penalty(columns) + 3 * blocks
222
+ + _finder_like_penalty(rows) + _finder_like_penalty(columns)
223
+ + 10 * (abs(dark * 2 - total) * 10 // total))
224
+
225
+
226
+ def svg(modules, labelledby=None):
227
+ """An inline SVG of the symbol on a white tile that keeps a four-module quiet zone, one unit per module.
228
+
229
+ The grid is the encoder's; only how a module is painted changes. A dark data module is a 0.88 dot with
230
+ 0.3 corners centred on its cell; each finder is a violet 7 by 7 ring, a white 5 by 5 separator and a 3 by 3
231
+ eye; each alignment pattern a 5 by 5 ring, its 3 by 3 gap and its centre. Every rounded corner is small
232
+ enough that each module's centre, where a reader samples, keeps the module's colour: a 7 by 7 ring keeps its
233
+ corner centre inside the arc only while its radius is under 0.5*sqrt(2)/(sqrt(2)-1), about 1.707.
234
+ """
235
+ n, q = len(modules), QUIET_ZONE
236
+ side = n + 2 * q
237
+ finders = ((0, 0), (0, n - 7), (n - 7, 0))
238
+
239
+ def in_finder(r, c):
240
+ return any(top <= r < top + 7 and left <= c < left + 7 for top, left in finders)
241
+
242
+ centres = _ALIGNMENT[(n - 17) // 4]
243
+ alignments = [(r, c) for r in centres for c in centres if not in_finder(r, c)]
244
+
245
+ def drawn_apart(r, c):
246
+ return in_finder(r, c) or any(abs(r - a) <= 2 and abs(c - b) <= 2 for a, b in alignments)
247
+
248
+ dots = "".join(f'<rect x="{c + q}.06" y="{r + q}.06" width=".88" height=".88" rx=".3"/>'
249
+ for r, row in enumerate(modules) for c, module in enumerate(row)
250
+ if module and not drawn_apart(r, c))
251
+ label = f' aria-labelledby="{labelledby}"' if labelledby else ""
252
+ out = [f'<svg class="qr" viewBox="0 0 {side} {side}" role="img"{label}>',
253
+ f'<rect width="{side}" height="{side}" rx="1.5" fill="{_QR_TILE}"/>',
254
+ f'<g fill="{_QR_INK}">{dots}</g>']
255
+ for top, left in finders:
256
+ x, y = left + q, top + q
257
+ out.append(f'<rect x="{x}" y="{y}" width="7" height="7" rx="1.5" fill="{_QR_RING}"/>'
258
+ f'<rect x="{x + 1}" y="{y + 1}" width="5" height="5" rx="1.4" fill="{_QR_TILE}"/>'
259
+ f'<rect x="{x + 2}" y="{y + 2}" width="3" height="3" rx="1" fill="{_QR_INK}"/>')
260
+ for a, b in alignments:
261
+ x, y = b - 2 + q, a - 2 + q
262
+ out.append(f'<rect x="{x}" y="{y}" width="5" height="5" rx="1.5" fill="{_QR_INK}"/>'
263
+ f'<rect x="{x + 1}" y="{y + 1}" width="3" height="3" rx=".9" fill="{_QR_TILE}"/>'
264
+ f'<rect x="{x + 2}" y="{y + 2}" width="1" height="1" rx=".3" fill="{_QR_INK}"/>')
265
+ out.append("</svg>")
266
+ return "".join(out)
267
+
268
+
269
+ # The pairing QR as the scene Almena (design lane pcpg round 3, pcpg_scene_svg.py, ported as it is): the modules drawn
270
+ # as SVG polygons over one baked plate image (plate_almena.PLATE_DATA_URI, a 576 px WebP made for ELEVATION). The
271
+ # plate holds everything the scene has that no key changes: the shadow, the apron with the moat, battlements, banners,
272
+ # braziers, the two figures, the kerb, the air, and the code field lying flat with its picture, its timing, finders
273
+ # and alignment pattern and its quiet zone colours. scene_svg() draws what the key changes, through the renderer's own
274
+ # affine camera: every light module raised as a paver (its top shows the plate's own texture, lifted), the side faces
275
+ # of the pavers where a dark module lies in front, the dark modules' beds darkened, and one centre dot per data module
276
+ # in the module's colour (one projected octagon in defs, placed by <use> at each module's projected centre).
277
+ # The numbers are render_diorama.py's (Pendi-evidence Findings/05_qr/v4-2026-09-06) for ELEVATION and a version 3
278
+ # symbol, in plate units: 1 unit = 1 px of the 576 px plate = 1/2 CSS px of the page's 288 px QR box.
279
+
280
+ VIEW = 576 # plate width in px; render_diorama.py:43-45 renders OUT_PX 1600 from CANVAS 3200, the plate is that
281
+ # render resized to 576 (2x the page's .qr max-width of 288 px)
282
+ FIELD = 45 # render_diorama.py:554-555 field = span + 2 * RING, span = 29 modules + 2 * 4 quiet zone
283
+ RING = 4 # render_diorama.py:48 RING = APRON (3) + KERB (1), the tiles between the field edge and the code
284
+ QUIET = 4 # the quiet zone, qr.QUIET_ZONE; the renderer's code["border"] (themes_halftone.json halftone.border)
285
+ MODULES = 29 # the one symbol size the plate is drawn for: version 3, which every pairing address takes
286
+ ELEVATION = 75 # the camera's pitch in degrees, the "75" entry of pcpg-r3-camera.json (Pendi-evidence
287
+ # mockups/pcpg, 1135 bytes) and its plate. render_diorama.py:609 RULED_CAMERA is 45, but at 45
288
+ # zxing-cpp reads this composition at no box from 288 to 720 px (pcpg-r3-probe.txt), and at 60 the
289
+ # phone's engine (ZXing core 3.5.4, QRCodeReader, HybridBinarizer) reads 34 of 48 crops
290
+ # (SceneDecodeTest). 75 reads 48 of 48: 288 px on a 1x screen, and full and half size on a 2x one.
291
+ AX = 5.888 # render_diorama.py:123 x = cx + (u - v) * INV_SQRT2 * S, S from :113 (3200 * 0.92 / (45 * sqrt 2))
292
+ AY = 5.6874 # render_diorama.py:124 y term INV_SQRT2 * S * sin(ELEVATION), per unit of u + v
293
+ AH = 2.1552 # render_diorama.py:125 and :127-128 rise per unit of height, S * cos(ELEVATION)
294
+ X0 = 288.0 # render_diorama.py:114 cx, the canvas centre
295
+ Y0 = 37.0467 # render_diorama.py:115-118 cy after the centring pass, minus FIELD * AY (so y = Y0 + (u + v) * AY)
296
+ RELIEF = 0.23 # render_diorama.py:50 RELIEF = PAVER_H (:52), the height of a light module; a dark one sits at
297
+ # SOIL_H 0 (:51)
298
+ KERB_H = 0.62 # render_diorama.py:54, the kerb's top; its near inner edges hide the field's near rows
299
+ PAVER_INSET = 0.0 # render_diorama.py:239-240 a paver's top is its whole tile; the renderer draws no inset or gap
300
+ LIP = 0.38 # render_diorama.py:57 share of a side face drawn as its lit top edge (_face, :223-231)
301
+ LIP_LIGHT = 1.30 # render_diorama.py:229 the lip is the wall colour at 1.30
302
+ WALL_LIT = 0.70 # render_diorama.py:65 and :217 wall of a light paver, used on its +v face (:241, side 0)
303
+ WALL_DARK = 0.52 # render_diorama.py:66 and :217 the other wall, used on its +u face (:241, side 1)
304
+ BED_DARK = 0.86 # render_diorama.py:269 a dark module's bed is its texture at 0.86: here black at 0.14 opacity
305
+ DOT = 0.42 # PendiQrStudio themes_halftone.json halftone.dot, halftone.py:229: centre dot diameter in modules
306
+ GROUND = "#F6F3FA" # the scene's code palette after halftone.palette_of (pcpg-r3-camera.json): pavers, light dots
307
+ INK = "#3A1D78" # the ink, dark dots (darkened with their bed, as the renderer's texture is)
308
+
309
+
310
+ def _shade(hex_colour, factor):
311
+ """render_diorama.py:135-136 shade(), on a #RRGGBB string."""
312
+ rgb = [int(hex_colour[i:i + 2], 16) for i in (1, 3, 5)]
313
+ return "#" + "".join("%02X" % max(0, min(255, int(round(c * factor)))) for c in rgb)
314
+
315
+
316
+ FACE_V = _shade(GROUND, WALL_LIT)
317
+ FACE_U = _shade(GROUND, WALL_DARK)
318
+ FACE_V_LIP = _shade(FACE_V, LIP_LIGHT)
319
+ FACE_U_LIP = _shade(FACE_U, LIP_LIGHT)
320
+ _OCTAGON = ((0.21, 0.087), (0.087, 0.21), (-0.087, 0.21), (-0.21, 0.087), (-0.21, -0.087), (-0.087, -0.21),
321
+ (0.087, -0.21), (0.21, -0.087)) # a circle of diameter DOT (0.42 modules) as 8 corners, in module units
322
+
323
+
324
+ def _pt(u, v, h):
325
+ """render_diorama.py:120-125 Camera.project, u the field column, v the field row, h the height."""
326
+ return X0 + (u - v) * AX, Y0 + (u + v) * AY - h * AH
327
+
328
+
329
+ def _poly(points):
330
+ """A closed polygon, first corner absolute, the rest relative, all on the 0.1 unit grid (no drift)."""
331
+ q = [(round(x * 10), round(y * 10)) for x, y in points]
332
+ out = "M%s %s" % (_num(q[0][0]), _num(q[0][1]))
333
+ for (x0, y0), (x1, y1) in zip(q, q[1:]):
334
+ out += "l%s %s" % (_num(x1 - x0), _num(y1 - y0))
335
+ return out + "z"
336
+
337
+
338
+ def _num(tenths):
339
+ """An integer count of tenths as the shortest SVG number: 12 -> 1.2, -3 -> -.3, 40 -> 4."""
340
+ sign, t = ("-" if tenths < 0 else ""), abs(tenths)
341
+ whole, frac = divmod(t, 10)
342
+ return sign + ((str(whole) if whole else "") + ("." + str(frac) if frac else "") or "0")
343
+
344
+
345
+ def _function(r, c, n):
346
+ """Finders with their separators, timing, the version 3 alignment pattern: drawn flat in the plate."""
347
+ if (r < 8 and c < 8) or (r < 8 and c >= n - 8) or (r >= n - 8 and c < 8):
348
+ return True
349
+ if r == 6 or c == 6:
350
+ return True
351
+ return abs(r - 22) <= 2 and abs(c - 22) <= 2
352
+
353
+
354
+ def _runs(cells):
355
+ """Sorted integers to (first, last) runs."""
356
+ out = []
357
+ for x in cells:
358
+ if out and x == out[-1][1] + 1:
359
+ out[-1][1] = x
360
+ else:
361
+ out.append([x, x])
362
+ return out
363
+
364
+
365
+ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q"):
366
+ """The pairing QR as the scene: the plate image under a group of polygons. None when the symbol is not version 3,
367
+ and the caller draws svg() instead.
368
+
369
+ Drawing order, the renderer's painter order folded into layers (draw_floor, render_diorama.py:234-274): the ink
370
+ centre dots of the dark data modules on the floor, the symbol's floor darkened to BED_DARK, the side faces of the
371
+ light modules that have a dark module in front, then the light modules' tops (the plate lifted by the relief,
372
+ clipped to the light modules) and their ground dots. A light module's floor is always covered by its top,
373
+ its faces or its near neighbours' tops, so only the dark floors stay darkened and dotted, as in the renderer. The
374
+ near kerb clips it all."""
375
+ n = len(modules)
376
+ if n != MODULES:
377
+ return None
378
+ lo, hi = RING, RING + n + 2 * QUIET # the code field, quiet zone included, in field tiles
379
+ s0, s1 = RING + QUIET, RING + QUIET + n # the symbol
380
+
381
+ def dark(u, v):
382
+ r, c = v - s0, u - s0
383
+ return 0 <= r < n and 0 <= c < n and bool(modules[r][c])
384
+
385
+ def area(c0, r0, c1, r1, h): # a block of whole modules, symbol coordinates, at height h
386
+ return _poly([_pt(s0 + c0, s0 + r0, h), _pt(s0 + c1, s0 + r0, h), _pt(s0 + c1, s0 + r1, h),
387
+ _pt(s0 + c0, s0 + r1, h)])
388
+
389
+ def data_region(h): # the symbol minus finders, separators, timing and alignment
390
+ holes = [(0, 0, 8, 8), (n - 8, 0, n, 8), (0, n - 8, 8, n), (20, 20, 25, 25), (8, 6, n - 8, 7), (6, 8, 7, n - 8)]
391
+ return area(0, 0, n, n, h) + "".join(area(*box, h) for box in holes)
392
+
393
+ tops, face_u, face_v, lips_u, lips_v = [], [], [], [], []
394
+ drop = RELIEF * AH
395
+ cut = drop * LIP
396
+ for v in range(lo, hi):
397
+ for u0, u1 in _runs([u for u in range(lo, hi) if not dark(u, v)]):
398
+ tops.append(_poly([_pt(u0 + PAVER_INSET, v, RELIEF), _pt(u1 + 1, v, RELIEF), _pt(u1 + 1, v + 1, RELIEF),
399
+ _pt(u0, v + 1, RELIEF)]))
400
+ for u0, u1 in _runs([u for u in range(lo, hi) if not dark(u, v) and dark(u, v + 1)]):
401
+ p0, p1 = _pt(u0, v + 1, RELIEF), _pt(u1 + 1, v + 1, RELIEF)
402
+ lips_v.append(_poly([p0, p1, (p1[0], p1[1] + cut), (p0[0], p0[1] + cut)]))
403
+ face_v.append(_poly([(p0[0], p0[1] + cut), (p1[0], p1[1] + cut), (p1[0], p1[1] + drop),
404
+ (p0[0], p0[1] + drop)]))
405
+ for u in range(lo, hi):
406
+ for v0, v1 in _runs([v for v in range(lo, hi) if not dark(u, v) and dark(u + 1, v)]):
407
+ p0, p1 = _pt(u + 1, v0, RELIEF), _pt(u + 1, v1 + 1, RELIEF)
408
+ lips_u.append(_poly([p0, p1, (p1[0], p1[1] + cut), (p0[0], p0[1] + cut)]))
409
+ face_u.append(_poly([(p0[0], p0[1] + cut), (p1[0], p1[1] + cut), (p1[0], p1[1] + drop),
410
+ (p0[0], p0[1] + drop)]))
411
+ edge = hi # the near kerb's inner edges, u = edge and v = edge, at KERB_H
412
+ a, b, c_ = _pt(edge, lo - 40, KERB_H), _pt(edge, edge, KERB_H), _pt(lo - 40, edge, KERB_H)
413
+ kerb = _poly([a, b, c_, (c_[0], c_[1] - 4 * VIEW), (a[0], a[1] - 4 * VIEW)])
414
+ label = f' aria-labelledby="{labelledby}"' if labelledby else ""
415
+ faces = "".join(f'<path fill="{fill}" d="{"".join(d)}"/>' for fill, d in
416
+ ((FACE_V_LIP, lips_v), (FACE_V, face_v), (FACE_U_LIP, lips_u), (FACE_U, face_u)) if d)
417
+
418
+ dot = _poly([((du - dv) * AX, (du + dv) * AY) for du, dv in _OCTAGON]) # one dot, centred on 0 0
419
+ dots = {True: [], False: []}
420
+ for r in range(n):
421
+ for c in range(n):
422
+ if not _function(r, c, n):
423
+ x, y = _pt(s0 + c + 0.5, s0 + r + 0.5, 0.0 if modules[r][c] else RELIEF)
424
+ dots[bool(modules[r][c])].append(f'<use href="#{uid}d" x="{x:.1f}" y="{y:.1f}"/>')
425
+ return (f'<svg class="qr" viewBox="0 0 {VIEW} {VIEW}" role="img"{label}>'
426
+ f'<defs><image id="{uid}p" width="{VIEW}" height="{VIEW}" href="{plate_data_uri}"/>'
427
+ f'<clipPath id="{uid}t"><path d="{"".join(tops)}"/></clipPath>'
428
+ f'<clipPath id="{uid}k"><path d="{kerb}"/></clipPath><path id="{uid}d" d="{dot}"/></defs>'
429
+ f'<use href="#{uid}p"/><g clip-path="url(#{uid}k)">'
430
+ f'<g fill="{INK}">{"".join(dots[True])}</g>'
431
+ f'<path fill-opacity="{1 - BED_DARK:.2f}" d="{area(0, 0, n, n, 0.0)}"/>{faces}'
432
+ f'<g clip-path="url(#{uid}t)"><use href="#{uid}p" y="{-drop:.2f}"/></g>'
433
+ f'<g fill="{GROUND}">{"".join(dots[False])}</g></g></svg>')
pendify/runfile.py ADDED
@@ -0,0 +1,246 @@
1
+ """One instance per config folder: the running process holds a run file with its pid and its page's port.
2
+
3
+ A start creates the file exclusively. When the file is already there and names a live process other than
4
+ this one, the start stops; a file whose pid is gone, or that names no pid, is taken over. A live pid is
5
+ asked of the system through ctypes on Windows (os.kill there would end the process) and os.kill elsewhere.
6
+ A refused remove or create is told apart before it is read as another start that won the race: a config
7
+ folder that takes no new file, or a run file still there with no live holder when the re-read window ends,
8
+ stops this start; only a live holder's record read within the window is that other start.
9
+
10
+ At its stop the holder marks its record closing, beside its pid and port, until it removes the file: a start that
11
+ meets a closing holder waits for the file to go instead of opening a page that is closing. A record with no mark,
12
+ as an older copy writes it, reads as not closing.
13
+ """
14
+ import errno
15
+ import json
16
+ import os
17
+ import secrets
18
+ import tempfile
19
+ import time
20
+ from pathlib import Path
21
+
22
+ FILE_NAME = "run.json"
23
+ # How long a start re-reads the other start's record: the winner of a race writes its pid right after its
24
+ # create and its port once its page listens.
25
+ REREAD_SECONDS = 1.0
26
+ REREAD_STEP = 0.05
27
+ # A start re-reading the file holds it for about a millisecond, and Windows refuses this holder's remove or replace
28
+ # meanwhile: each is tried REFUSED_TRIES times, REFUSED_STEP apart, 40 ms of waits in all.
29
+ REFUSED_TRIES = 5
30
+ REFUSED_STEP = 0.01
31
+ _STILL_ACTIVE = 259
32
+ _ERROR_ACCESS_DENIED = 5
33
+ _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
34
+
35
+
36
+ class FolderNotWritable(PermissionError):
37
+ """The config folder takes no new file, so no start can hold its run file there; `filename` names it."""
38
+
39
+
40
+ def pid_alive(pid):
41
+ """True when `pid` names a running process."""
42
+ if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
43
+ return False
44
+ if os.name == "nt":
45
+ return _windows_alive(pid)
46
+ try:
47
+ os.kill(pid, 0) # signal 0 only asks
48
+ except ProcessLookupError:
49
+ return False
50
+ except PermissionError:
51
+ return True
52
+ return True
53
+
54
+
55
+ def _windows_alive(pid):
56
+ import ctypes
57
+ from ctypes import wintypes
58
+
59
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
60
+ kernel32.OpenProcess.restype = wintypes.HANDLE
61
+ kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
62
+ kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
63
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
64
+ handle = kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
65
+ if not handle:
66
+ return ctypes.get_last_error() == _ERROR_ACCESS_DENIED # a process this user may not open still runs
67
+ try:
68
+ code = wintypes.DWORD()
69
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
70
+ return True
71
+ return code.value == _STILL_ACTIVE
72
+ finally:
73
+ kernel32.CloseHandle(handle)
74
+
75
+
76
+ class RunFile:
77
+ def __init__(self, folder, pid=None, alive=pid_alive, clock=time.monotonic, sleep=time.sleep):
78
+ self.path = Path(folder) / FILE_NAME
79
+ self._pid = os.getpid() if pid is None else pid
80
+ self._alive = alive
81
+ self._clock = clock
82
+ self._sleep = sleep
83
+ self._held = False
84
+ self._port = None
85
+
86
+ def claim(self):
87
+ """None when this process now holds the file; the live holder's record, {pid, port, closing}, when
88
+ another does. A refused remove or create (PermissionError on Windows: the file held open, or pending its
89
+ removal, by another start) is that other start only when its live record shows within
90
+ REREAD_SECONDS. FolderNotWritable when the config folder takes no new file; OSError when the file
91
+ can neither be created nor taken over."""
92
+ try:
93
+ self.path.parent.mkdir(parents=True, exist_ok=True)
94
+ except PermissionError:
95
+ raise FolderNotWritable(errno.EACCES, "the config folder cannot be made", str(self.path.parent)) from None
96
+ except FileExistsError: # what Windows raises when a regular file stands where the folder or a parent goes
97
+ raise FolderNotWritable(errno.EACCES, "the config folder cannot be made", str(self.path.parent)) from None
98
+ for _ in range(3):
99
+ try:
100
+ handle = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
101
+ except FileExistsError:
102
+ holder = self._live_holder()
103
+ if holder is not None:
104
+ return holder
105
+ try:
106
+ os.remove(self.path) # a holder that is gone, or a file that names none: taken over
107
+ except FileNotFoundError:
108
+ pass
109
+ except PermissionError: # held open by another start, or a file this user cannot remove
110
+ holder = self._refused()
111
+ if holder is not None:
112
+ return holder
113
+ continue
114
+ except PermissionError: # pending its removal under another handle, or a folder that takes no file
115
+ holder = self._refused()
116
+ if holder is not None:
117
+ return holder
118
+ continue
119
+ with os.fdopen(handle, "w", encoding="utf-8") as out:
120
+ json.dump({"pid": self._pid, "port": None}, out)
121
+ self._held = True
122
+ return None
123
+ raise FileExistsError("the run file came back after every take-over")
124
+
125
+ def _refused(self):
126
+ """A refused remove or create, told apart: the live holder's record when it shows within the window;
127
+ None when the file is gone by then, so the claim tries again. A folder in the file's place, a config
128
+ folder that takes no new file, or a file still there naming no live holder: this start cannot go on."""
129
+ if self.path.is_dir():
130
+ raise IsADirectoryError(errno.EISDIR, "the run file is a folder", str(self.path))
131
+ self._probe_folder()
132
+ deadline = self._clock() + REREAD_SECONDS
133
+ while True:
134
+ holder = self._live_holder()
135
+ if holder is not None:
136
+ return holder
137
+ if not os.path.lexists(self.path):
138
+ return None
139
+ if self._clock() >= deadline:
140
+ raise PermissionError(errno.EACCES, "the run file names no live holder and cannot be replaced",
141
+ str(self.path))
142
+ self._sleep(REREAD_STEP)
143
+
144
+ def _probe_folder(self):
145
+ """FolderNotWritable when the config folder takes no new file: a probe name, made and removed at once."""
146
+ probe = self.path.parent / f".probe-{self._pid}-{secrets.token_hex(4)}.tmp"
147
+ try:
148
+ handle = os.open(probe, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
149
+ except PermissionError:
150
+ raise FolderNotWritable(errno.EACCES, "the config folder takes no new file",
151
+ str(self.path.parent)) from None
152
+ os.close(handle)
153
+ os.remove(probe)
154
+
155
+ def holder_port(self, holder):
156
+ """The port of `holder`, a live holder claim() returned, re-read from its record for up to
157
+ REREAD_SECONDS; None when it has published none by then."""
158
+ deadline = self._clock() + REREAD_SECONDS
159
+ while True:
160
+ record = self._read()
161
+ if record is not None and record["pid"] == holder["pid"] and record["port"] is not None:
162
+ return record["port"]
163
+ if self._clock() >= deadline:
164
+ return None
165
+ self._sleep(REREAD_STEP)
166
+
167
+ def wait_released(self, holder, seconds):
168
+ """True once the run file no longer names `holder`, a live holder claim() returned closing, as a live
169
+ process: the file gone, its process gone, or another start's record in its place; re-read every
170
+ REREAD_STEP. False when it still does after `seconds`."""
171
+ deadline = self._clock() + seconds
172
+ while True:
173
+ record = self._live_holder()
174
+ if record is None or record["pid"] != holder["pid"]:
175
+ return True
176
+ if self._clock() >= deadline:
177
+ return False
178
+ self._sleep(REREAD_STEP)
179
+
180
+ def publish(self, port):
181
+ """This process's pid and its page's port, written over the claim in one move; PermissionError when the
182
+ move is still refused after its retries."""
183
+ self._write({"pid": self._pid, "port": port})
184
+ self._port = port
185
+
186
+ def mark_closing(self):
187
+ """This process's record marked closing, in the same one move: a start that meets it waits for the
188
+ file to go. Nothing when this process does not hold the file."""
189
+ if self._held:
190
+ self._write({"pid": self._pid, "port": self._port, "closing": True})
191
+
192
+ def _write(self, record):
193
+ handle, temp = tempfile.mkstemp(dir=self.path.parent, prefix=".run-", suffix=".tmp")
194
+ try:
195
+ with os.fdopen(handle, "w", encoding="utf-8") as out:
196
+ json.dump(record, out)
197
+ self._retried(os.replace, temp, self.path)
198
+ except BaseException:
199
+ if os.path.exists(temp):
200
+ os.remove(temp)
201
+ raise
202
+
203
+ def release(self):
204
+ """Removes the file when this process holds it and it still names this process; a remove still refused
205
+ after its retries leaves the file and raises nothing."""
206
+ if not self._held:
207
+ return
208
+ self._held = False
209
+ holder = self._read()
210
+ if holder is not None and holder["pid"] == self._pid:
211
+ try:
212
+ self._retried(os.remove, self.path)
213
+ except FileNotFoundError:
214
+ pass
215
+ except PermissionError:
216
+ pass # refused to the end: the file stays, and a start waiting on it takes it over once this pid dies
217
+
218
+ def _retried(self, move, *args):
219
+ """`move(*args)`, tried again REFUSED_STEP later while a reader makes the system refuse it, REFUSED_TRIES
220
+ times in all; the last refusal raises."""
221
+ for attempt in range(1, REFUSED_TRIES + 1):
222
+ try:
223
+ return move(*args)
224
+ except PermissionError:
225
+ if attempt == REFUSED_TRIES:
226
+ raise
227
+ self._sleep(REFUSED_STEP)
228
+
229
+ def _live_holder(self):
230
+ """The record of a live process other than this one, or None."""
231
+ holder = self._read()
232
+ if holder is not None and holder["pid"] != self._pid and self._alive(holder["pid"]):
233
+ return holder
234
+ return None
235
+
236
+ def _read(self):
237
+ try:
238
+ data = json.loads(self.path.read_text(encoding="utf-8"))
239
+ except (OSError, ValueError):
240
+ return None
241
+ pid = data.get("pid") if isinstance(data, dict) else None
242
+ if isinstance(pid, bool) or not isinstance(pid, int):
243
+ return None
244
+ port = data.get("port")
245
+ valid = isinstance(port, int) and not isinstance(port, bool) and 0 < port < 65536
246
+ return {"pid": pid, "port": port if valid else None, "closing": data.get("closing") is True}