pushraker 0.1.0.dev0__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.
pushraker/images.py ADDED
@@ -0,0 +1,395 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # SPDX-PackageHomePage: https://github.com/noon92/pushraker
3
+ # SPDX-FileCopyrightText: Copyright (c) 2026 @noon92
4
+
5
+ # Spool pictures for the Spoolman notifications of push_notifications.
6
+ #
7
+ # One row per toolhead: its spool, drawn in the filament's colour (or
8
+ # colours: wedges and stripes for a multi-colour filament) and filled
9
+ # to how much is left (10% steps of a full spool; under 10% shows a few
10
+ # wraps), with arrows to the spools the printer refills from, in the order
11
+ # it would use them. The toolhead's own spool has the left column to
12
+ # itself; a chain longer than two backups wraps to a second line, joined
13
+ # by an elbow arrow (down, left, down).
14
+ # Colours: red = not enough, orange = may be insufficient / running low,
15
+ # green = enough. Uses Pillow only (no numpy).
16
+ #
17
+ # This file may be distributed under the terms of the GNU GPLv3 license.
18
+
19
+ from __future__ import annotations
20
+ import math
21
+ import os
22
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
23
+
24
+ from PIL import Image, ImageDraw, ImageFilter, ImageFont
25
+
26
+ RGB = Tuple[int, int, int]
27
+ RED: RGB = (176, 38, 38)
28
+ ORANGE: RGB = (196, 104, 0)
29
+ GREEN: RGB = (36, 116, 58)
30
+ INK: RGB = (32, 34, 38)
31
+ GREY: RGB = (95, 98, 105)
32
+ STATUS_COLOURS = {"red": RED, "orange": ORANGE, "green": GREEN, "ink": INK}
33
+
34
+ WIDTH = 900
35
+ ROW = 330 # one line of spools
36
+ LINE_GAP = 44 # between the lines of a wrapped chain (the elbow arrow)
37
+ NOTE_H = 30 # the "order may differ" note under a chain
38
+ COLUMN = 300 # width of each spool's column
39
+ COLUMNS = (150, 450, 750) # centres: toolhead, then its refill spools
40
+ NOTE_X = 600 # centre of the no-refill note (the two right columns)
41
+ SPOOL = 170 # spool height in px
42
+ SQUASH = 0.5 # side view at ~60 degrees
43
+ DEPTH = 0.32 # spool width between flanges, fraction of its height
44
+ R_HUB = 0.34 # hub radius, fraction of the flange radius
45
+ R_FULL = 0.90 # radius of a full spool's windings
46
+ SS = 2 # supersampling for smooth edges
47
+ UNKNOWN_FILL = 0.5 # Spoolman says empty but it's loaded: drawn half full
48
+
49
+ FONT_DIRS = ("/usr/share/fonts/truetype/dejavu", "/usr/share/fonts/dejavu",
50
+ "/usr/share/fonts/TTF", "/usr/share/fonts")
51
+ _fonts: Dict[Tuple[bool, int], Any] = {}
52
+ # The printer has no font files: Pillow's built-in font (one weight) is
53
+ # used, and bold is drawn with an outline
54
+ _fake_bold = False
55
+
56
+
57
+ def font(bold: bool, size: int) -> Any:
58
+ key = (bold, size)
59
+ if key in _fonts:
60
+ return _fonts[key]
61
+ name = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
62
+ f = None
63
+ for d in FONT_DIRS:
64
+ try:
65
+ f = ImageFont.truetype(os.path.join(d, name), size)
66
+ break
67
+ except OSError:
68
+ continue
69
+ if f is None:
70
+ global _fake_bold
71
+ _fake_bold = True
72
+ try:
73
+ f = ImageFont.load_default(size=size) # Pillow >= 10.1
74
+ except TypeError:
75
+ f = ImageFont.load_default()
76
+ _fonts[key] = f
77
+ return f
78
+
79
+
80
+ def parse_hex(value: Any) -> Optional[RGB]:
81
+ s = str(value or "").strip().lstrip("#")
82
+ if len(s) >= 6:
83
+ try:
84
+ return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
85
+ except ValueError:
86
+ return None
87
+ return None
88
+
89
+
90
+ def level(remaining: float, full: Optional[float]) -> float:
91
+ """Drawn fill: 10% steps of a full spool; under 10% (not empty) shows
92
+ a few wraps. Unknown full weight: drawn full."""
93
+ if remaining <= 0:
94
+ return 0.
95
+ if not full or full <= 0:
96
+ return 1.
97
+ f = remaining / full
98
+ return 0.06 if f < 0.1 else min(1., round(f * 10) / 10)
99
+
100
+
101
+ def _scale(c: Sequence[float], k: float) -> RGB:
102
+ return tuple(max(0, min(255, int(v * k))) for v in c[:3]) # type: ignore
103
+
104
+
105
+ def _ellipse(d: ImageDraw.ImageDraw, cx: float, cy: float, rx: float,
106
+ ry: float, **kw: Any) -> None:
107
+ d.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], **kw)
108
+
109
+
110
+ def _shades(colour: RGB) -> Tuple[RGB, RGB, RGB]:
111
+ """(face, side, groove) shades of a winding colour."""
112
+ lum = sum(colour) / 3.
113
+ face = colour
114
+ side = _scale(colour, 0.78)
115
+ groove = _scale(colour, 0.9)
116
+ if lum < 45: # lift black so the windings show
117
+ face = tuple(min(255, v + 18) for v in colour) # type: ignore
118
+ side = tuple(min(255, v + 10) for v in colour) # type: ignore
119
+ groove = tuple(min(255, v + 34) for v in colour) # type: ignore
120
+ elif lum > 225: # white: keep shading visible
121
+ side = _scale(colour, 0.84)
122
+ groove = _scale(colour, 0.93)
123
+ return face, side, groove
124
+
125
+
126
+ def spool(fill: float, colour: Any) -> Image.Image:
127
+ """An angled spool, front flange on the left, smoked clear plastic.
128
+ `colour` is one RGB, or a list of them for a multi-colour filament:
129
+ the face is split into wedges and the barrel into stripes, one per
130
+ colour, in the order given (coextruded or sequential alike)."""
131
+ colours: List[RGB] = [tuple(c) for c in colour] \
132
+ if colour and isinstance(colour[0], (tuple, list)) else [tuple(colour)]
133
+ colours = colours[:8] or [(200, 200, 200)]
134
+ h = SPOOL * SS
135
+ r = h / 2.
136
+ rx = r * SQUASH
137
+ depth = int(h * DEPTH)
138
+ w = int(2 * rx) + depth + 4 * SS
139
+ img = Image.new("RGBA", (w, h + 4 * SS), (0, 0, 0, 0))
140
+ d = ImageDraw.Draw(img)
141
+ cy = h / 2. + 2 * SS
142
+ fx = rx + 2 * SS # front face centre
143
+ r_fill = math.sqrt(R_HUB ** 2 + fill * (R_FULL ** 2 - R_HUB ** 2)) \
144
+ if fill > 0 else 0.
145
+ n = len(colours)
146
+ shades = [_shades(c) for c in colours]
147
+ # back flange
148
+ _ellipse(d, fx + depth, cy, rx, r, fill=(85, 89, 96, 235))
149
+ # barrel: hub, then windings, stacked from the back; a multi-colour
150
+ # filament's barrel is striped along its depth
151
+ for i in range(depth, 0, -SS):
152
+ _ellipse(d, fx + i, cy, rx * R_HUB, r * R_HUB, fill=(96, 100, 108))
153
+ if r_fill:
154
+ k = min(n - 1, int((depth - i) / depth * n))
155
+ _ellipse(d, fx + i, cy, rx * r_fill, r * r_fill,
156
+ fill=shades[k][1])
157
+ if r_fill:
158
+ box = [fx - rx * r_fill, cy - r * r_fill, fx + rx * r_fill,
159
+ cy + r * r_fill]
160
+ if n == 1:
161
+ d.ellipse(box, fill=shades[0][0])
162
+ else:
163
+ for k in range(n):
164
+ d.pieslice(box, -90 + 360. * k / n, -90 + 360. * (k + 1) / n,
165
+ fill=shades[k][0])
166
+ # winding grooves on the front face, in each wedge's own shade
167
+ kk = r_fill
168
+ step = 0.035
169
+ while kk > R_HUB + step:
170
+ gbox = [fx - rx * kk, cy - r * kk, fx + rx * kk, cy + r * kk]
171
+ if n == 1:
172
+ d.ellipse(gbox, outline=shades[0][2], width=SS)
173
+ else:
174
+ for k in range(n):
175
+ d.arc(gbox, -90 + 360. * k / n, -90 + 360. * (k + 1) / n,
176
+ fill=shades[k][2], width=SS)
177
+ kk -= step
178
+ # hub and centre hole
179
+ _ellipse(d, fx, cy, rx * R_HUB, r * R_HUB, fill=(132, 136, 145))
180
+ _ellipse(d, fx - rx * 0.04, cy - r * 0.04, rx * R_HUB * 0.86,
181
+ r * R_HUB * 0.86, fill=(146, 150, 159))
182
+ _ellipse(d, fx, cy, rx * 0.12, r * 0.12, fill=(52, 54, 60))
183
+ # front flange: smoked clear plastic, opaque rim, soft glare
184
+ fl = Image.new("RGBA", img.size, (0, 0, 0, 0))
185
+ fd = ImageDraw.Draw(fl)
186
+ _ellipse(fd, fx, cy, rx, r, fill=(150, 158, 170, 72))
187
+ glare = Image.new("RGBA", img.size, (0, 0, 0, 0))
188
+ gd = ImageDraw.Draw(glare)
189
+ _ellipse(gd, fx - rx * 0.45, cy - r * 0.42, rx * 0.22, r * 0.3,
190
+ fill=(255, 255, 255, 120))
191
+ glare = glare.filter(ImageFilter.GaussianBlur(10 * SS))
192
+ fl = Image.alpha_composite(fl, glare)
193
+ fd = ImageDraw.Draw(fl)
194
+ _ellipse(fd, fx, cy, rx, r, outline=(80, 84, 94, 245), width=4 * SS)
195
+ img = Image.alpha_composite(img, fl)
196
+ return img.resize((img.width // SS, img.height // SS), Image.LANCZOS)
197
+
198
+
199
+ def _text(d: ImageDraw.ImageDraw, xy: Tuple[float, float], text: str,
200
+ bold: bool, size: int, fill: RGB, center: bool = False) -> None:
201
+ f = font(bold, size)
202
+ x, y = xy
203
+ if bold and _fake_bold:
204
+ # Outlined letters need a little more space between them
205
+ gap = max(1., size * 0.07)
206
+ widths = [d.textlength(c, font=f) for c in text]
207
+ if center:
208
+ x -= (sum(widths) + gap * (len(text) - 1)) / 2
209
+ for c, w in zip(text, widths):
210
+ d.text((x, y), c, font=f, fill=fill, stroke_width=1,
211
+ stroke_fill=fill)
212
+ x += w + gap
213
+ return
214
+ if center:
215
+ x -= d.textlength(text, font=f) / 2
216
+ d.text((x, y), text, font=f, fill=fill)
217
+
218
+
219
+ def _fit(d: ImageDraw.ImageDraw, text: str, f: Any, width: float) -> str:
220
+ if d.textlength(text, font=f) <= width:
221
+ return text
222
+ while text and d.textlength(text + "…", font=f) > width:
223
+ text = text[:-1]
224
+ return text.rstrip() + "…"
225
+
226
+
227
+ def _put(bg: Image.Image, cx: float, y: int, sp: Image.Image) -> Image.Image:
228
+ x = int(cx - sp.width / 2)
229
+ sh = Image.new("RGBA", bg.size, (0, 0, 0, 0))
230
+ sd = ImageDraw.Draw(sh)
231
+ sd.ellipse([x + 8, y + SPOOL - 14, x + sp.width + 8, y + SPOOL + 16],
232
+ fill=(0, 0, 0, 80))
233
+ bg = Image.alpha_composite(bg, sh.filter(ImageFilter.GaussianBlur(9)))
234
+ bg.alpha_composite(sp, (x, y))
235
+ return bg
236
+
237
+
238
+ def _background(height: int) -> Image.Image:
239
+ bg = Image.new("RGBA", (WIDTH, height))
240
+ d = ImageDraw.Draw(bg)
241
+ for y in range(height):
242
+ g = int(238 - 18 * y / max(1, height - 1))
243
+ d.line([(0, y), (WIDTH, y)], fill=(g, g + 1, g + 5, 255))
244
+ return bg
245
+
246
+
247
+ def _layout(row: Dict[str, Any]) -> List[Tuple[int, int]]:
248
+ """(line, column) of each refill spool: the first line holds up to
249
+ two after the toolhead's own; later lines up to two, never in the
250
+ toolhead's (left) column."""
251
+ return [(0, 1 + k) if k < 2 else (1 + (k - 2) // 2, 1 + (k - 2) % 2)
252
+ for k in range(len(row.get("refills") or []))]
253
+
254
+
255
+ def _row_height(row: Dict[str, Any]) -> int:
256
+ places = _layout(row)
257
+ lines = 1 + max((ln for ln, _c in places), default=0)
258
+ note = NOTE_H if len(places) >= 2 else 0
259
+ return ROW * lines + LINE_GAP * (lines - 1) + note
260
+
261
+
262
+ def _warning(d: ImageDraw.ImageDraw, x: float, y: float, size: int) -> None:
263
+ """A warning sign (as the emoji): yellow triangle, dark "!"."""
264
+ top = y + 1
265
+ d.polygon([(x + size / 2, top), (x + size, top + size - 1),
266
+ (x, top + size - 1)], fill=(242, 184, 0),
267
+ outline=(150, 110, 0))
268
+ cx = x + size / 2
269
+ d.line([cx, top + size * 0.32, cx, top + size * 0.66], fill=INK, width=2)
270
+ d.ellipse([cx - 1.2, top + size * 0.75, cx + 1.2, top + size * 0.88],
271
+ fill=INK)
272
+
273
+
274
+ def _arrow_right(d: ImageDraw.ImageDraw, x0: float, x1: float,
275
+ y: float) -> None:
276
+ d.line([x0, y, x1 - 22, y], fill=(45, 48, 54), width=8)
277
+ d.polygon([(x1, y), (x1 - 28, y - 21), (x1 - 28, y + 21)],
278
+ fill=(45, 48, 54))
279
+
280
+
281
+ def _arrow_elbow(d: ImageDraw.ImageDraw, x0: float, y0: float, x1: float,
282
+ y1: float) -> None:
283
+ """Down from (x0, y0), left to x1, down to (x1, y1)."""
284
+ mid = y0 + (y1 - y0) * 0.45
285
+ d.line([(x0, y0), (x0, mid), (x1, mid), (x1, y1 - 18)],
286
+ fill=(45, 48, 54), width=6, joint="curve")
287
+ d.polygon([(x1, y1), (x1 - 15, y1 - 22), (x1 + 15, y1 - 22)],
288
+ fill=(45, 48, 54))
289
+
290
+
291
+ def render(rows: List[Dict[str, Any]], path: str) -> str:
292
+ """Draw the rows and save a JPEG at `path`. Each row:
293
+ toolhead, word, status, needs (str or ""), remaining (None: not
294
+ known, drawn full with no amount line), full, colour,
295
+ name, and optionally refills = [{toolhead, remaining, full, colour,
296
+ name, status, match ("Exact match" / "Inexact match")}, ...] (in the order the printer uses them) or
297
+ note = [lines].
298
+ Every spool is drawn as the same stack: toolhead above, amount left,
299
+ what it needs (the toolhead's own spool only) and the filament's name
300
+ below. `word` is not drawn: the colour and the amounts say it.
301
+ """
302
+ heights = [_row_height(r) for r in rows]
303
+ height = sum(heights) + 12
304
+ bg = _background(height)
305
+
306
+ def stack(d: ImageDraw.ImageDraw, cx: float, y0: int, sp: Dict[str, Any],
307
+ col: RGB, needs: str = "") -> None:
308
+ _text(d, (cx, y0 + 2), sp["toolhead"], True, 24, col, center=True)
309
+ y = y0 + SPOOL + 66
310
+ if sp.get("remaining") is not None:
311
+ # No Spoolman: no amount, the spool is drawn full
312
+ _text(d, (cx, y), "Amount left unknown" if sp.get("unknown")
313
+ else grams_text(sp["remaining"], sp.get("full")),
314
+ True, 20, col, center=True)
315
+ y += 27
316
+ if needs:
317
+ _text(d, (cx, y), needs.rstrip("."), False, 20, INK, center=True)
318
+ y += 27
319
+ _text(d, (cx, y), _fit(d, sp.get("name") or "", font(False, 16),
320
+ COLUMN - 20), False, 16, GREY, center=True)
321
+ if sp.get("match"):
322
+ # A refill spool: the same colour as the toolhead's, or not
323
+ # (with a warning sign: drawn, the printer has no emoji font)
324
+ m = sp["match"]
325
+ if m.startswith("Inexact"):
326
+ w = d.textlength(m, font=font(False, 16))
327
+ size = 16
328
+ gap = 6
329
+ x = cx - (w + size + gap) / 2
330
+ _warning(d, x, y + 22, size)
331
+ _text(d, (x + size + gap, y + 22), m, False, 16, GREY)
332
+ else:
333
+ _text(d, (cx, y + 22), m, False, 16, GREY, center=True)
334
+
335
+ def draw(sp: Dict[str, Any], cx: float, y: int) -> None:
336
+ nonlocal bg
337
+ bg = _put(bg, cx, y + 40, spool(
338
+ UNKNOWN_FILL if sp.get("unknown")
339
+ else 1. if sp.get("remaining") is None
340
+ else level(sp["remaining"], sp.get("full")),
341
+ sp.get("colour") or (200, 200, 200)))
342
+
343
+ y0 = 6
344
+ for i, row in enumerate(rows):
345
+ col = STATUS_COLOURS.get(row["status"], INK)
346
+ refills = row.get("refills") or []
347
+ places = _layout(row)
348
+ line_y = lambda ln: y0 + ln * (ROW + LINE_GAP)
349
+ draw(row, COLUMNS[0], y0)
350
+ for sp, (ln, c) in zip(refills, places):
351
+ draw(sp, COLUMNS[c], line_y(ln))
352
+ d = ImageDraw.Draw(bg)
353
+ if i:
354
+ d.line([40, y0 - 4, WIDTH - 40, y0 - 4], fill=(190, 193, 200),
355
+ width=2)
356
+ stack(d, COLUMNS[0], y0, row, col, row.get("needs") or "")
357
+ prev = (0, 0)
358
+ for k, (sp, (ln, c)) in enumerate(zip(refills, places)):
359
+ if ln == prev[0]:
360
+ ay = line_y(ln) + 40 + SPOOL // 2
361
+ x0 = COLUMNS[prev[1]] + COLUMN / 2 - 100
362
+ x1 = COLUMNS[c] - COLUMN / 2 + 100
363
+ _arrow_right(d, x0, x1, ay)
364
+ _text(d, ((x0 + x1) / 2, ay - 52), "refill", False, 18,
365
+ GREY, center=True)
366
+ else:
367
+ # From under the last spool of the line above, down to the
368
+ # first of this one
369
+ ey0, ey1 = line_y(prev[0]) + ROW - 18, line_y(ln) - 2
370
+ _arrow_elbow(d, COLUMNS[prev[1]], ey0, COLUMNS[c], ey1)
371
+ _text(d, ((COLUMNS[prev[1]] + COLUMNS[c]) / 2,
372
+ ey0 + (ey1 - ey0) * 0.45 - 25), "refill", False,
373
+ 18, GREY, center=True)
374
+ stack(d, COLUMNS[c], line_y(ln), sp,
375
+ STATUS_COLOURS.get(sp.get("status"), INK))
376
+ prev = (ln, c)
377
+ if not refills:
378
+ lines = row.get("note") or []
379
+ y = y0 + 40 + SPOOL // 2 - 15 * len(lines)
380
+ for k, line in enumerate(lines):
381
+ _text(d, (NOTE_X, y + k * 30), line, False, 20, GREY,
382
+ center=True)
383
+ elif len(refills) >= 2:
384
+ _text(d, (WIDTH / 2, y0 + heights[i] - NOTE_H + 2),
385
+ "Backup spool order may differ from what's shown",
386
+ False, 16, GREY, center=True)
387
+ y0 += heights[i]
388
+ bg.convert("RGB").save(path, quality=88)
389
+ return path
390
+
391
+
392
+ def grams_text(remaining: float, full: Optional[float]) -> str:
393
+ if full and full > 0:
394
+ return f"{remaining:.0f} of {full:.0f} g left"
395
+ return f"{remaining:.0f} g left"
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: pushraker
3
+ Version: 0.1.0.dev0
4
+ Summary: Push notifications for Klipper printers: a Moonraker component with pictures, sent through Apprise (ntfy, Pushover, Telegram, Discord, webhooks, email)
5
+ Author: noon92
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/noon92/pushraker
8
+ Project-URL: Issues, https://github.com/noon92/pushraker/issues
9
+ Keywords: klipper,moonraker,notifications,ntfy,apprise,3d-printing
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Topic :: System :: Monitoring
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: apprise>=1.9
17
+ Requires-Dist: requests
18
+ Requires-Dist: Pillow>=10
19
+ Dynamic: license-file
20
+
21
+ # pushraker
22
+
23
+ Push notifications for Klipper printers, as a Moonraker component: print
24
+ started, paused, resumed, complete, cancelled, progress, first layer, faults
25
+ and shutdowns, power-loss recovery, chamber temperature, filament (Spoolman
26
+ and MMU backup spools), with pictures (camera snapshot, model preview, spool
27
+ drawings). Sent through [Apprise](https://github.com/caronc/apprise): ntfy,
28
+ Pushover, Gotify, Telegram, Discord, a webhook, email.
29
+
30
+ Grew out of the Push Notifications feature of the
31
+ [Snapmaker U1 Extended Firmware](https://github.com/paxx12-snapmaker-u1/SnapmakerU1-Extended-Firmware);
32
+ it is being split out so it works on any Moonraker printer and is configured
33
+ from its own web page, live, without editing `moonraker.conf`.
34
+
35
+ **Status: work in progress — not yet usable on its own.** The component is
36
+ here and tested; the settings API, the web page and the installer are next.
37
+
38
+ ## Layout
39
+
40
+ - `src/pushraker/component.py` — the Moonraker component
41
+ - `src/pushraker/images.py` — spool pictures (Pillow)
42
+ - `src/pushraker/email_html.py`, `src/pushraker/discord.py` — email card, Discord plugin
43
+ - `shims/push_notifications.py` — the one file that goes into Moonraker's
44
+ `components/` directory to load the package
45
+ - `tests/test_pushraker.py` — the test suite (`python tests/test_pushraker.py`)
46
+
47
+ ## License
48
+
49
+ GPL-3.0-or-later.
@@ -0,0 +1,10 @@
1
+ pushraker/__init__.py,sha256=C71xg-cEQxmjP3-a6Bcw00-1vKjUD5dDupY_hkpdhMY,259
2
+ pushraker/component.py,sha256=_eIJEa6k5reScoXLl2FQZVPF0nAMGvMICgRzjEarj_o,174434
3
+ pushraker/discord.py,sha256=29UUZC81w1uqVEoG_89x2rihaRDvN258X1jFKQJjqf4,9547
4
+ pushraker/email_html.py,sha256=tQ44rTB5pEshg3azFRTIPG1Qhi15ZHHCY4iE8Dmd4Jw,5450
5
+ pushraker/images.py,sha256=_VeEBRwJ5OU0YME3NuYwTl_qNqj1zvC9kU_qGUJv2Sw,16285
6
+ pushraker-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
7
+ pushraker-0.1.0.dev0.dist-info/METADATA,sha256=HhKkTXWYcOnu53Nvd8Y4c2gtF0hY5ABgO_69xC5PYeo,2133
8
+ pushraker-0.1.0.dev0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ pushraker-0.1.0.dev0.dist-info/top_level.txt,sha256=fscrjWibYf9xECHe72Ro-7ruj9tz6CjIiQ7k2Z6prJE,10
10
+ pushraker-0.1.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+