pushraker 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.
- pushraker/__init__.py +7 -0
- pushraker/__main__.py +336 -0
- pushraker/component.py +4203 -0
- pushraker/discord.py +246 -0
- pushraker/email_html.py +121 -0
- pushraker/images.py +395 -0
- pushraker/klipper/pushraker.cfg +82 -0
- pushraker/printers/__init__.py +102 -0
- pushraker/printers/snapmaker_u1.py +51 -0
- pushraker/settings.py +1251 -0
- pushraker/web/index.html +1037 -0
- pushraker-0.1.0.dist-info/METADATA +111 -0
- pushraker-0.1.0.dist-info/RECORD +16 -0
- pushraker-0.1.0.dist-info/WHEEL +5 -0
- pushraker-0.1.0.dist-info/licenses/LICENSE +674 -0
- pushraker-0.1.0.dist-info/top_level.txt +1 -0
pushraker/__init__.py
ADDED
pushraker/__main__.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
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
|
+
"""python -m pushraker install|uninstall|status: wire the package into a
|
|
6
|
+
Moonraker + Klipper installation.
|
|
7
|
+
|
|
8
|
+
install put the component shim into Moonraker's components folder,
|
|
9
|
+
enable [push_notifications] in Moonraker's config, include
|
|
10
|
+
the Klipper macro file, and (once) move settings over from
|
|
11
|
+
an INI section such as the Snapmaker U1 extended firmware's
|
|
12
|
+
extended2.cfg [notifications]
|
|
13
|
+
uninstall take those out again (the settings and secrets stay unless
|
|
14
|
+
--purge)
|
|
15
|
+
status say what is in place
|
|
16
|
+
|
|
17
|
+
Paths default to a standard printer_data layout under the running user's
|
|
18
|
+
home (~/moonraker, ~/printer_data); the Snapmaker U1 extended firmware's
|
|
19
|
+
harness passes its own.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
import argparse
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from typing import List, Optional
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
from . import settings as cfgmod
|
|
31
|
+
|
|
32
|
+
SHIM_NAME = "push_notifications.py"
|
|
33
|
+
MOONRAKER_INCLUDE = "pushraker.cfg"
|
|
34
|
+
KLIPPER_INCLUDE = "pushraker.cfg"
|
|
35
|
+
SHIM = '''# pushraker: Moonraker loads components from this folder; this file hands
|
|
36
|
+
# over to the installed package. Written by `python -m pushraker install`.
|
|
37
|
+
import sys
|
|
38
|
+
for _p in {paths!r}:
|
|
39
|
+
if _p and _p not in sys.path:
|
|
40
|
+
sys.path.append(_p)
|
|
41
|
+
from pushraker.component import load_component # noqa: E402,F401
|
|
42
|
+
'''
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def home(sub: str) -> str:
|
|
46
|
+
return os.path.join(os.path.expanduser("~"), sub)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse(argv: List[str]) -> argparse.Namespace:
|
|
50
|
+
p = argparse.ArgumentParser(prog="python -m pushraker",
|
|
51
|
+
description=__doc__.split("\n\n")[0])
|
|
52
|
+
p.add_argument("command", choices=["install", "uninstall", "status"])
|
|
53
|
+
p.add_argument("--moonraker-dir", default=home("moonraker"),
|
|
54
|
+
help="Moonraker's source directory (holds moonraker/components)")
|
|
55
|
+
p.add_argument("--data-dir", default=home("printer_data"),
|
|
56
|
+
help="printer_data (config/, the secrets file lives here)")
|
|
57
|
+
p.add_argument("--moonraker-conf", default=None,
|
|
58
|
+
help="moonraker.conf (default: <data-dir>/config/moonraker.conf)")
|
|
59
|
+
p.add_argument("--moonraker-include-dir", default=None,
|
|
60
|
+
help="a directory moonraker.conf already includes: the "
|
|
61
|
+
"[push_notifications] section is written there as "
|
|
62
|
+
"pushraker.cfg instead of into moonraker.conf")
|
|
63
|
+
p.add_argument("--printer-cfg", default=None,
|
|
64
|
+
help="printer.cfg (default: <data-dir>/config/printer.cfg)")
|
|
65
|
+
p.add_argument("--klipper-include-dir", default=None,
|
|
66
|
+
help="a directory printer.cfg already includes: the macro "
|
|
67
|
+
"file is copied there instead of being included from "
|
|
68
|
+
"printer.cfg")
|
|
69
|
+
p.add_argument("--site-packages", action="append", default=[],
|
|
70
|
+
help="extra sys.path entries for the shim (the package's "
|
|
71
|
+
"venv, when Moonraker's Python can't see it)")
|
|
72
|
+
p.add_argument("--name", default="Printer",
|
|
73
|
+
help="the printer's default name in notifications")
|
|
74
|
+
p.add_argument("--error-index-url", default="",
|
|
75
|
+
help="the printer maker's error-code index, linked from "
|
|
76
|
+
"fault notifications")
|
|
77
|
+
p.add_argument("--import-ini", nargs=2, metavar=("FILE", "SECTION"),
|
|
78
|
+
help="settings to move into pushraker.conf when it doesn't "
|
|
79
|
+
"exist yet (e.g. extended2.cfg notifications)")
|
|
80
|
+
p.add_argument("--import-secrets", nargs=2, metavar=("FILE", "SECTION"),
|
|
81
|
+
help="the secrets that go with --import-ini (e.g. "
|
|
82
|
+
"moonraker.secrets notifications)")
|
|
83
|
+
p.add_argument("--purge", action="store_true",
|
|
84
|
+
help="uninstall: also remove pushraker.conf and the secrets")
|
|
85
|
+
p.add_argument("--owner", default="",
|
|
86
|
+
help="user[:group] the written files belong to (default: "
|
|
87
|
+
"whoever owns the directory they go into)")
|
|
88
|
+
return p.parse_args(argv)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write(path: str, text: str, mode: int = 0o644,
|
|
92
|
+
owner: str = "") -> bool:
|
|
93
|
+
"""Write if different; returns whether it changed. Files take the
|
|
94
|
+
owner of their directory unless --owner says otherwise."""
|
|
95
|
+
try:
|
|
96
|
+
with open(path, encoding="utf-8") as f:
|
|
97
|
+
if f.read() == text:
|
|
98
|
+
return False
|
|
99
|
+
except (OSError, UnicodeDecodeError):
|
|
100
|
+
pass
|
|
101
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
102
|
+
tmp = path + ".tmp"
|
|
103
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
104
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
105
|
+
f.write(text)
|
|
106
|
+
os.chmod(tmp, mode)
|
|
107
|
+
chown(tmp, owner, os.path.dirname(path))
|
|
108
|
+
os.replace(tmp, path)
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def chown(path: str, owner: str, like: str) -> None:
|
|
113
|
+
try:
|
|
114
|
+
if owner:
|
|
115
|
+
import pwd
|
|
116
|
+
import grp
|
|
117
|
+
user, _, group = owner.partition(":")
|
|
118
|
+
uid = pwd.getpwnam(user).pw_uid
|
|
119
|
+
gid = grp.getgrnam(group).gr_gid if group else \
|
|
120
|
+
pwd.getpwnam(user).pw_gid
|
|
121
|
+
else:
|
|
122
|
+
st = os.stat(like)
|
|
123
|
+
uid, gid = st.st_uid, st.st_gid
|
|
124
|
+
os.chown(path, uid, gid)
|
|
125
|
+
except (KeyError, OSError, ImportError):
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def remove(path: str) -> bool:
|
|
130
|
+
try:
|
|
131
|
+
os.remove(path)
|
|
132
|
+
return True
|
|
133
|
+
except OSError:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
SAVE_CONFIG_HEADER = "#*# <---------------------- SAVE_CONFIG"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def read_lines(conf: str) -> List[str]:
|
|
141
|
+
try:
|
|
142
|
+
with open(conf, encoding="utf-8") as f:
|
|
143
|
+
return f.read().splitlines(True)
|
|
144
|
+
except (OSError, UnicodeDecodeError):
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_header(line: str, section: str) -> bool:
|
|
149
|
+
s = line.strip()
|
|
150
|
+
return s.startswith("[") and s.endswith("]") and \
|
|
151
|
+
s.lower().replace(" ", "") == f"[{section}]".lower().replace(" ", "")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def ensure_section(conf: str, section: str, text: str, owner: str) -> bool:
|
|
155
|
+
"""Add a section to a config file if it has none: before Klipper's
|
|
156
|
+
SAVE_CONFIG block when there is one (Klipper refuses anything after
|
|
157
|
+
that header and drops the saved calibration), else at the end."""
|
|
158
|
+
lines = read_lines(conf)
|
|
159
|
+
if any(is_header(ln, section) for ln in lines):
|
|
160
|
+
return False
|
|
161
|
+
at = next((i for i, ln in enumerate(lines)
|
|
162
|
+
if ln.startswith(SAVE_CONFIG_HEADER)), len(lines))
|
|
163
|
+
block = text if text.endswith("\n") else text + "\n"
|
|
164
|
+
before = "".join(lines[:at])
|
|
165
|
+
if before and not before.endswith("\n"):
|
|
166
|
+
before += "\n"
|
|
167
|
+
if before and not before.endswith("\n\n"):
|
|
168
|
+
before += "\n"
|
|
169
|
+
after = "".join(lines[at:])
|
|
170
|
+
return write(conf, before + block + ("\n" + after if after else ""),
|
|
171
|
+
owner=owner)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def drop_section(conf: str, section: str, owner: str) -> bool:
|
|
175
|
+
"""Remove a section from a config file: an [include ...] is its one
|
|
176
|
+
line; anything else runs to the next header or Klipper's SAVE_CONFIG
|
|
177
|
+
block."""
|
|
178
|
+
lines = read_lines(conf)
|
|
179
|
+
if not lines:
|
|
180
|
+
return False
|
|
181
|
+
out: List[str] = []
|
|
182
|
+
skipping = False
|
|
183
|
+
one_line = section.lower().startswith("include ")
|
|
184
|
+
for ln in lines:
|
|
185
|
+
s = ln.strip()
|
|
186
|
+
if is_header(ln, section):
|
|
187
|
+
if one_line:
|
|
188
|
+
continue
|
|
189
|
+
skipping = True
|
|
190
|
+
continue
|
|
191
|
+
if skipping and ((s.startswith("[") and s.endswith("]"))
|
|
192
|
+
or ln.startswith(SAVE_CONFIG_HEADER)):
|
|
193
|
+
skipping = False
|
|
194
|
+
if not skipping:
|
|
195
|
+
out.append(ln)
|
|
196
|
+
text = "".join(out)
|
|
197
|
+
if text == "".join(lines):
|
|
198
|
+
return False
|
|
199
|
+
return write(conf, text, owner=owner)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def macro_file() -> str:
|
|
203
|
+
return os.path.join(os.path.dirname(__file__), "klipper", KLIPPER_INCLUDE)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def places(a: argparse.Namespace) -> dict:
|
|
207
|
+
config_dir = os.path.join(a.data_dir, "config")
|
|
208
|
+
return dict(
|
|
209
|
+
shim=os.path.join(a.moonraker_dir, "moonraker", "components",
|
|
210
|
+
SHIM_NAME),
|
|
211
|
+
moonraker_conf=a.moonraker_conf
|
|
212
|
+
or os.path.join(config_dir, "moonraker.conf"),
|
|
213
|
+
moonraker_include=os.path.join(a.moonraker_include_dir,
|
|
214
|
+
MOONRAKER_INCLUDE)
|
|
215
|
+
if a.moonraker_include_dir else "",
|
|
216
|
+
printer_cfg=a.printer_cfg or os.path.join(config_dir, "printer.cfg"),
|
|
217
|
+
klipper_include=os.path.join(a.klipper_include_dir, KLIPPER_INCLUDE)
|
|
218
|
+
if a.klipper_include_dir else os.path.join(config_dir,
|
|
219
|
+
KLIPPER_INCLUDE),
|
|
220
|
+
store=cfgmod.Store(config_dir, a.data_dir, name=a.name))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def section_text(a: argparse.Namespace) -> str:
|
|
224
|
+
lines = ["[push_notifications]",
|
|
225
|
+
"# pushraker (python -m pushraker install); settings are in "
|
|
226
|
+
"pushraker.conf",
|
|
227
|
+
f"name: {a.name}"]
|
|
228
|
+
if a.error_index_url:
|
|
229
|
+
lines.append(f"error_index_url: {a.error_index_url}")
|
|
230
|
+
return "\n".join(lines) + "\n"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cmd_install(a: argparse.Namespace) -> int:
|
|
234
|
+
p = places(a)
|
|
235
|
+
changed = []
|
|
236
|
+
if p["moonraker_include"] and os.path.realpath(p["moonraker_include"]) \
|
|
237
|
+
== os.path.realpath(p["klipper_include"]):
|
|
238
|
+
print("ERROR: --moonraker-include-dir and the Klipper include "
|
|
239
|
+
"directory are the same; both files are named pushraker.cfg")
|
|
240
|
+
return 1
|
|
241
|
+
if not os.path.isdir(os.path.dirname(p["shim"])):
|
|
242
|
+
print(f"ERROR: no Moonraker components directory at "
|
|
243
|
+
f"{os.path.dirname(p['shim'])} (--moonraker-dir)")
|
|
244
|
+
return 1
|
|
245
|
+
if write(p["shim"], SHIM.format(paths=list(a.site_packages)),
|
|
246
|
+
owner=a.owner):
|
|
247
|
+
changed.append(p["shim"])
|
|
248
|
+
if p["moonraker_include"]:
|
|
249
|
+
if write(p["moonraker_include"], section_text(a), owner=a.owner):
|
|
250
|
+
changed.append(p["moonraker_include"])
|
|
251
|
+
elif ensure_section(p["moonraker_conf"], "push_notifications",
|
|
252
|
+
section_text(a), a.owner):
|
|
253
|
+
changed.append(p["moonraker_conf"])
|
|
254
|
+
with open(macro_file()) as f:
|
|
255
|
+
macros = f.read()
|
|
256
|
+
if write(p["klipper_include"], macros, owner=a.owner):
|
|
257
|
+
changed.append(p["klipper_include"])
|
|
258
|
+
if not a.klipper_include_dir:
|
|
259
|
+
if ensure_section(p["printer_cfg"], f"include {KLIPPER_INCLUDE}",
|
|
260
|
+
f"[include {KLIPPER_INCLUDE}]\n", a.owner):
|
|
261
|
+
changed.append(p["printer_cfg"])
|
|
262
|
+
store = p["store"]
|
|
263
|
+
if not store.exists():
|
|
264
|
+
s = None
|
|
265
|
+
if a.import_ini:
|
|
266
|
+
s = cfgmod.import_ini(a.import_ini[0], a.import_ini[1],
|
|
267
|
+
*(a.import_secrets or ("", "")),
|
|
268
|
+
name=a.name)
|
|
269
|
+
if s["enabled"] or s["server"] or s["secret"]:
|
|
270
|
+
print(f"Moving settings from {a.import_ini[0]} "
|
|
271
|
+
f"[{a.import_ini[1]}] to {store.settings_path}")
|
|
272
|
+
else:
|
|
273
|
+
s = None
|
|
274
|
+
store.save(s or cfgmod.defaults(a.name))
|
|
275
|
+
chown(store.settings_path, a.owner,
|
|
276
|
+
os.path.dirname(store.settings_path))
|
|
277
|
+
if os.path.exists(store.secrets_path):
|
|
278
|
+
chown(store.secrets_path, a.owner, a.data_dir)
|
|
279
|
+
changed.append(store.settings_path)
|
|
280
|
+
for path in changed:
|
|
281
|
+
print(f"Wrote {path}")
|
|
282
|
+
if not changed:
|
|
283
|
+
print("Already installed; nothing changed.")
|
|
284
|
+
print(f"pushraker {__version__} is set up. Restart Moonraker and "
|
|
285
|
+
"Klipper to load it, then open the notifications page.")
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def cmd_uninstall(a: argparse.Namespace) -> int:
|
|
290
|
+
p = places(a)
|
|
291
|
+
removed = []
|
|
292
|
+
for path in (p["shim"], p["moonraker_include"], p["klipper_include"]):
|
|
293
|
+
if path and remove(path):
|
|
294
|
+
removed.append(path)
|
|
295
|
+
if not p["moonraker_include"] and drop_section(
|
|
296
|
+
p["moonraker_conf"], "push_notifications", a.owner):
|
|
297
|
+
removed.append(f"[push_notifications] in {p['moonraker_conf']}")
|
|
298
|
+
if not a.klipper_include_dir and drop_section(
|
|
299
|
+
p["printer_cfg"], f"include {KLIPPER_INCLUDE}", a.owner):
|
|
300
|
+
removed.append(f"[include {KLIPPER_INCLUDE}] in {p['printer_cfg']}")
|
|
301
|
+
if a.purge:
|
|
302
|
+
for path in (p["store"].settings_path, p["store"].secrets_path):
|
|
303
|
+
if remove(path):
|
|
304
|
+
removed.append(path)
|
|
305
|
+
for path in removed:
|
|
306
|
+
print(f"Removed {path}")
|
|
307
|
+
if not removed:
|
|
308
|
+
print("Nothing to remove.")
|
|
309
|
+
print("Restart Moonraker and Klipper to unload pushraker.")
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cmd_status(a: argparse.Namespace) -> int:
|
|
314
|
+
p = places(a)
|
|
315
|
+
print(f"pushraker {__version__}")
|
|
316
|
+
for what, path in (("shim", p["shim"]),
|
|
317
|
+
("Moonraker section", p["moonraker_include"]
|
|
318
|
+
or p["moonraker_conf"]),
|
|
319
|
+
("Klipper macros", p["klipper_include"]),
|
|
320
|
+
("settings", p["store"].settings_path),
|
|
321
|
+
("secrets", p["store"].secrets_path)):
|
|
322
|
+
print(f" {what}: {path} "
|
|
323
|
+
f"({'present' if os.path.exists(path) else 'missing'})")
|
|
324
|
+
if p["store"].exists():
|
|
325
|
+
print(" " + cfgmod.summary(p["store"].load()))
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
330
|
+
a = parse(sys.argv[1:] if argv is None else argv)
|
|
331
|
+
return {"install": cmd_install, "uninstall": cmd_uninstall,
|
|
332
|
+
"status": cmd_status}[a.command](a)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
sys.exit(main())
|