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 ADDED
@@ -0,0 +1,7 @@
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
+ """Push notifications for Klipper printers, as a Moonraker component."""
6
+
7
+ __version__ = "0.1.0"
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())