pushraker 0.1.0.dev0__tar.gz → 0.2.1__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.
Files changed (26) hide show
  1. pushraker-0.2.1/PKG-INFO +115 -0
  2. pushraker-0.2.1/README.md +95 -0
  3. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/pyproject.toml +1 -1
  4. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker/__init__.py +1 -1
  5. pushraker-0.2.1/src/pushraker/__main__.py +354 -0
  6. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker/component.py +747 -195
  7. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker/discord.py +9 -3
  8. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker/email_html.py +1 -1
  9. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker/images.py +1 -1
  10. pushraker-0.2.1/src/pushraker/klipper/pushraker.cfg +82 -0
  11. pushraker-0.2.1/src/pushraker/printers/__init__.py +102 -0
  12. pushraker-0.2.1/src/pushraker/printers/snapmaker_u1.py +51 -0
  13. pushraker-0.2.1/src/pushraker/settings.py +1273 -0
  14. pushraker-0.2.1/src/pushraker/web/index.html +1039 -0
  15. pushraker-0.2.1/src/pushraker.egg-info/PKG-INFO +115 -0
  16. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker.egg-info/SOURCES.txt +8 -1
  17. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/tests/test_pushraker.py +371 -12
  18. pushraker-0.2.1/tests/test_settings.py +471 -0
  19. pushraker-0.1.0.dev0/PKG-INFO +0 -49
  20. pushraker-0.1.0.dev0/README.md +0 -29
  21. pushraker-0.1.0.dev0/src/pushraker.egg-info/PKG-INFO +0 -49
  22. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/LICENSE +0 -0
  23. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/setup.cfg +0 -0
  24. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker.egg-info/dependency_links.txt +0 -0
  25. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker.egg-info/requires.txt +0 -0
  26. {pushraker-0.1.0.dev0 → pushraker-0.2.1}/src/pushraker.egg-info/top_level.txt +0 -0
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: pushraker
3
+ Version: 0.2.1
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
+ backup spools, MMUs), 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. Configured from its
29
+ own web page, live, with no Moonraker or Klipper restart.
30
+
31
+ It grew out of the Push Notifications feature of the
32
+ [Snapmaker U1 Extended Firmware](https://github.com/paxx12-snapmaker-u1/SnapmakerU1-Extended-Firmware),
33
+ where it is built in; the user documentation lives there
34
+ for now: [notifications](https://snapmakeru1-extended-firmware.pages.dev/notifications).
35
+
36
+ **Status: 0.2.** The component has been in
37
+ use on the Snapmaker U1 for a while; the page, the settings file and the
38
+ installer are new. Other printers: the generic parts work (Klipper's
39
+ standard objects: print_stats, exclude_object, heater_generic, tmc
40
+ drivers, Moonraker's webcams and Spoolman); printer-specific parts are
41
+ behind `printers/`, one module per printer; `snapmaker_u1.py` is the
42
+ first. Notifications a printer can't produce (the U1's touchscreen
43
+ calibrations, coded warnings, power-loss recovery, toolhead-map refills,
44
+ MMU slots) start off on other printers and are left off the page; turn
45
+ one on in `pushraker.conf` and it shows on the page and is sent like any
46
+ other.
47
+
48
+ ## Install (any Moonraker printer)
49
+
50
+ ```sh
51
+ pip install pushraker # into the Python Moonraker runs with
52
+ python -m pushraker install # ~/moonraker and ~/printer_data by default
53
+ ```
54
+
55
+ `install` puts a one-file shim into Moonraker's `components/` folder, adds
56
+ `[pushraker]` to `moonraker.conf`, includes the Klipper macro
57
+ file from `printer.cfg`, and creates `printer_data/config/pushraker.conf`.
58
+ Restart Moonraker and Klipper. Then serve the settings page
59
+ (`src/pushraker/web/index.html`, or `python -c "import pushraker, os;
60
+ print(os.path.dirname(pushraker.__file__) + '/web')"`) from the same nginx
61
+ that serves Fluidd or Mainsail, e.g.
62
+
63
+ ```nginx
64
+ location /notifications/ {
65
+ alias /path/to/site-packages/pushraker/web/;
66
+ index index.html;
67
+ }
68
+ ```
69
+
70
+ and open `http://<printer>/notifications/`. The page talks to Moonraker
71
+ through the `/server/` proxy of that nginx; without one it falls back to
72
+ Moonraker's own port, which needs the page's origin in `cors_domains`
73
+ under `[authorization]` in `moonraker.conf`. With Moonraker's
74
+ authorization on, the page asks for an API key the first time.
75
+
76
+ Call `_PRINT_END_NTFY` at the start of your `PRINT_END` macro and
77
+ `_CANCEL_PRINT_NTFY` at the start of `CANCEL_PRINT` for the print-end and
78
+ cancel snapshots to be taken before the printer parks (the Snapmaker U1
79
+ extended firmware does that through its hook system).
80
+
81
+ `python -m pushraker install --help` lists the paths and options (include
82
+ directories, the printer's default name, the error-code index link, moving
83
+ settings over from an INI section). `python -m pushraker uninstall` takes
84
+ it out again; `--purge` also removes the settings and secrets.
85
+
86
+ ## Where things live on the printer
87
+
88
+ - `printer_data/config/pushraker.conf`: the settings, a plain INI file
89
+ (also editable by hand; the component re-reads it when it changes).
90
+ - `printer_data/pushraker.secrets`: tokens and passwords, mode 0600,
91
+ outside the config folder so Moonraker never serves it.
92
+ - Moonraker endpoints: `GET/POST /server/pushraker/config`,
93
+ `POST /server/pushraker/test`, `GET /server/pushraker/status` (also
94
+ under `/server/push_notifications/`, the 0.1 names).
95
+
96
+ ## Layout
97
+
98
+ - `src/pushraker/component.py` — the Moonraker component
99
+ - `src/pushraker/settings.py` — the settings: validation, storage, the
100
+ Apprise URLs built from them
101
+ - `src/pushraker/printers/` — what differs between printers (the harness
102
+ side of the package), one module per printer; `snapmaker_u1.py` is the
103
+ first
104
+ - `src/pushraker/images.py` — spool pictures (Pillow)
105
+ - `src/pushraker/email_html.py`, `src/pushraker/discord.py` — email card,
106
+ Discord senders
107
+ - `src/pushraker/web/index.html` — the settings page
108
+ - `src/pushraker/klipper/pushraker.cfg` — the Klipper macro file
109
+ - `src/pushraker/__main__.py` — `python -m pushraker install|uninstall|status`
110
+ - `tests/` — `python tests/test_pushraker.py` (the component against a
111
+ stand-in Moonraker; also runs `test_settings.py`)
112
+
113
+ ## License
114
+
115
+ GPL-3.0-or-later.
@@ -0,0 +1,95 @@
1
+ # pushraker
2
+
3
+ Push notifications for Klipper printers, as a Moonraker component: print
4
+ started, paused, resumed, complete, cancelled, progress, first layer, faults
5
+ and shutdowns, power-loss recovery, chamber temperature, filament (Spoolman,
6
+ backup spools, MMUs), with pictures (camera snapshot, model preview, spool
7
+ drawings). Sent through [Apprise](https://github.com/caronc/apprise): ntfy,
8
+ Pushover, Gotify, Telegram, Discord, a webhook, email. Configured from its
9
+ own web page, live, with no Moonraker or Klipper restart.
10
+
11
+ It grew out of the Push Notifications feature of the
12
+ [Snapmaker U1 Extended Firmware](https://github.com/paxx12-snapmaker-u1/SnapmakerU1-Extended-Firmware),
13
+ where it is built in; the user documentation lives there
14
+ for now: [notifications](https://snapmakeru1-extended-firmware.pages.dev/notifications).
15
+
16
+ **Status: 0.2.** The component has been in
17
+ use on the Snapmaker U1 for a while; the page, the settings file and the
18
+ installer are new. Other printers: the generic parts work (Klipper's
19
+ standard objects: print_stats, exclude_object, heater_generic, tmc
20
+ drivers, Moonraker's webcams and Spoolman); printer-specific parts are
21
+ behind `printers/`, one module per printer; `snapmaker_u1.py` is the
22
+ first. Notifications a printer can't produce (the U1's touchscreen
23
+ calibrations, coded warnings, power-loss recovery, toolhead-map refills,
24
+ MMU slots) start off on other printers and are left off the page; turn
25
+ one on in `pushraker.conf` and it shows on the page and is sent like any
26
+ other.
27
+
28
+ ## Install (any Moonraker printer)
29
+
30
+ ```sh
31
+ pip install pushraker # into the Python Moonraker runs with
32
+ python -m pushraker install # ~/moonraker and ~/printer_data by default
33
+ ```
34
+
35
+ `install` puts a one-file shim into Moonraker's `components/` folder, adds
36
+ `[pushraker]` to `moonraker.conf`, includes the Klipper macro
37
+ file from `printer.cfg`, and creates `printer_data/config/pushraker.conf`.
38
+ Restart Moonraker and Klipper. Then serve the settings page
39
+ (`src/pushraker/web/index.html`, or `python -c "import pushraker, os;
40
+ print(os.path.dirname(pushraker.__file__) + '/web')"`) from the same nginx
41
+ that serves Fluidd or Mainsail, e.g.
42
+
43
+ ```nginx
44
+ location /notifications/ {
45
+ alias /path/to/site-packages/pushraker/web/;
46
+ index index.html;
47
+ }
48
+ ```
49
+
50
+ and open `http://<printer>/notifications/`. The page talks to Moonraker
51
+ through the `/server/` proxy of that nginx; without one it falls back to
52
+ Moonraker's own port, which needs the page's origin in `cors_domains`
53
+ under `[authorization]` in `moonraker.conf`. With Moonraker's
54
+ authorization on, the page asks for an API key the first time.
55
+
56
+ Call `_PRINT_END_NTFY` at the start of your `PRINT_END` macro and
57
+ `_CANCEL_PRINT_NTFY` at the start of `CANCEL_PRINT` for the print-end and
58
+ cancel snapshots to be taken before the printer parks (the Snapmaker U1
59
+ extended firmware does that through its hook system).
60
+
61
+ `python -m pushraker install --help` lists the paths and options (include
62
+ directories, the printer's default name, the error-code index link, moving
63
+ settings over from an INI section). `python -m pushraker uninstall` takes
64
+ it out again; `--purge` also removes the settings and secrets.
65
+
66
+ ## Where things live on the printer
67
+
68
+ - `printer_data/config/pushraker.conf`: the settings, a plain INI file
69
+ (also editable by hand; the component re-reads it when it changes).
70
+ - `printer_data/pushraker.secrets`: tokens and passwords, mode 0600,
71
+ outside the config folder so Moonraker never serves it.
72
+ - Moonraker endpoints: `GET/POST /server/pushraker/config`,
73
+ `POST /server/pushraker/test`, `GET /server/pushraker/status` (also
74
+ under `/server/push_notifications/`, the 0.1 names).
75
+
76
+ ## Layout
77
+
78
+ - `src/pushraker/component.py` — the Moonraker component
79
+ - `src/pushraker/settings.py` — the settings: validation, storage, the
80
+ Apprise URLs built from them
81
+ - `src/pushraker/printers/` — what differs between printers (the harness
82
+ side of the package), one module per printer; `snapmaker_u1.py` is the
83
+ first
84
+ - `src/pushraker/images.py` — spool pictures (Pillow)
85
+ - `src/pushraker/email_html.py`, `src/pushraker/discord.py` — email card,
86
+ Discord senders
87
+ - `src/pushraker/web/index.html` — the settings page
88
+ - `src/pushraker/klipper/pushraker.cfg` — the Klipper macro file
89
+ - `src/pushraker/__main__.py` — `python -m pushraker install|uninstall|status`
90
+ - `tests/` — `python tests/test_pushraker.py` (the component against a
91
+ stand-in Moonraker; also runs `test_settings.py`)
92
+
93
+ ## License
94
+
95
+ GPL-3.0-or-later.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pushraker"
7
- version = "0.1.0.dev0"
7
+ version = "0.2.1"
8
8
  description = "Push notifications for Klipper printers: a Moonraker component with pictures, sent through Apprise (ntfy, Pushover, Telegram, Discord, webhooks, email)"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -4,4 +4,4 @@
4
4
 
5
5
  """Push notifications for Klipper printers, as a Moonraker component."""
6
6
 
7
- __version__ = "0.1.0.dev0"
7
+ __version__ = "0.2.1"
@@ -0,0 +1,354 @@
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 [pushraker] 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 = "pushraker.py"
33
+ SECTION = "pushraker"
34
+ # What 0.1.x installed under the old component name: replaced on install
35
+ OLD_SHIM_NAME = "push_notifications.py"
36
+ OLD_SECTION = "push_notifications"
37
+ MOONRAKER_INCLUDE = "pushraker.cfg"
38
+ KLIPPER_INCLUDE = "pushraker.cfg"
39
+ SHIM = '''# pushraker: Moonraker loads components from this folder; this file hands
40
+ # over to the installed package. Written by `python -m pushraker install`.
41
+ import sys
42
+ for _p in {paths!r}:
43
+ if _p and _p not in sys.path:
44
+ sys.path.append(_p)
45
+ from pushraker.component import load_component # noqa: E402,F401
46
+ '''
47
+
48
+
49
+ def home(sub: str) -> str:
50
+ return os.path.join(os.path.expanduser("~"), sub)
51
+
52
+
53
+ def parse(argv: List[str]) -> argparse.Namespace:
54
+ p = argparse.ArgumentParser(prog="python -m pushraker",
55
+ description=__doc__.split("\n\n")[0])
56
+ p.add_argument("command", choices=["install", "uninstall", "status"])
57
+ p.add_argument("--moonraker-dir", default=home("moonraker"),
58
+ help="Moonraker's source directory (holds moonraker/components)")
59
+ p.add_argument("--data-dir", default=home("printer_data"),
60
+ help="printer_data (config/, the secrets file lives here)")
61
+ p.add_argument("--moonraker-conf", default=None,
62
+ help="moonraker.conf (default: <data-dir>/config/moonraker.conf)")
63
+ p.add_argument("--moonraker-include-dir", default=None,
64
+ help="a directory moonraker.conf already includes: the "
65
+ "[pushraker] section is written there as "
66
+ "pushraker.cfg instead of into moonraker.conf")
67
+ p.add_argument("--printer-cfg", default=None,
68
+ help="printer.cfg (default: <data-dir>/config/printer.cfg)")
69
+ p.add_argument("--klipper-include-dir", default=None,
70
+ help="a directory printer.cfg already includes: the macro "
71
+ "file is copied there instead of being included from "
72
+ "printer.cfg")
73
+ p.add_argument("--site-packages", action="append", default=[],
74
+ help="extra sys.path entries for the shim (the package's "
75
+ "venv, when Moonraker's Python can't see it)")
76
+ p.add_argument("--name", default="Printer",
77
+ help="the printer's default name in notifications")
78
+ p.add_argument("--error-index-url", default="",
79
+ help="the printer maker's error-code index, linked from "
80
+ "fault notifications")
81
+ p.add_argument("--import-ini", nargs=2, metavar=("FILE", "SECTION"),
82
+ help="settings to move into pushraker.conf when it doesn't "
83
+ "exist yet (e.g. extended2.cfg notifications)")
84
+ p.add_argument("--import-secrets", nargs=2, metavar=("FILE", "SECTION"),
85
+ help="the secrets that go with --import-ini (e.g. "
86
+ "moonraker.secrets notifications)")
87
+ p.add_argument("--purge", action="store_true",
88
+ help="uninstall: also remove pushraker.conf and the secrets")
89
+ p.add_argument("--owner", default="",
90
+ help="user[:group] the written files belong to (default: "
91
+ "whoever owns the directory they go into)")
92
+ return p.parse_args(argv)
93
+
94
+
95
+ def write(path: str, text: str, mode: int = 0o644,
96
+ owner: str = "") -> bool:
97
+ """Write if different; returns whether it changed. Files take the
98
+ owner of their directory unless --owner says otherwise."""
99
+ try:
100
+ with open(path, encoding="utf-8") as f:
101
+ if f.read() == text:
102
+ return False
103
+ except (OSError, UnicodeDecodeError):
104
+ pass
105
+ os.makedirs(os.path.dirname(path), exist_ok=True)
106
+ tmp = path + ".tmp"
107
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
108
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
109
+ f.write(text)
110
+ os.chmod(tmp, mode)
111
+ chown(tmp, owner, os.path.dirname(path))
112
+ os.replace(tmp, path)
113
+ return True
114
+
115
+
116
+ def chown(path: str, owner: str, like: str) -> None:
117
+ try:
118
+ if owner:
119
+ import pwd
120
+ import grp
121
+ user, _, group = owner.partition(":")
122
+ uid = pwd.getpwnam(user).pw_uid
123
+ gid = grp.getgrnam(group).gr_gid if group else \
124
+ pwd.getpwnam(user).pw_gid
125
+ else:
126
+ st = os.stat(like)
127
+ uid, gid = st.st_uid, st.st_gid
128
+ os.chown(path, uid, gid)
129
+ except (KeyError, OSError, ImportError):
130
+ pass
131
+
132
+
133
+ def remove(path: str) -> bool:
134
+ try:
135
+ os.remove(path)
136
+ return True
137
+ except OSError:
138
+ return False
139
+
140
+
141
+ SAVE_CONFIG_HEADER = "#*# <---------------------- SAVE_CONFIG"
142
+
143
+
144
+ def read_lines(conf: str) -> List[str]:
145
+ try:
146
+ with open(conf, encoding="utf-8") as f:
147
+ return f.read().splitlines(True)
148
+ except (OSError, UnicodeDecodeError):
149
+ return []
150
+
151
+
152
+ def is_header(line: str, section: str) -> bool:
153
+ s = line.strip()
154
+ return s.startswith("[") and s.endswith("]") and \
155
+ s.lower().replace(" ", "") == f"[{section}]".lower().replace(" ", "")
156
+
157
+
158
+ def ensure_section(conf: str, section: str, text: str, owner: str) -> bool:
159
+ """Add a section to a config file if it has none: before Klipper's
160
+ SAVE_CONFIG block when there is one (Klipper refuses anything after
161
+ that header and drops the saved calibration), else at the end."""
162
+ lines = read_lines(conf)
163
+ if any(is_header(ln, section) for ln in lines):
164
+ return False
165
+ at = next((i for i, ln in enumerate(lines)
166
+ if ln.startswith(SAVE_CONFIG_HEADER)), len(lines))
167
+ block = text if text.endswith("\n") else text + "\n"
168
+ before = "".join(lines[:at])
169
+ if before and not before.endswith("\n"):
170
+ before += "\n"
171
+ if before and not before.endswith("\n\n"):
172
+ before += "\n"
173
+ after = "".join(lines[at:])
174
+ return write(conf, before + block + ("\n" + after if after else ""),
175
+ owner=owner)
176
+
177
+
178
+ def drop_section(conf: str, section: str, owner: str) -> bool:
179
+ """Remove a section from a config file: an [include ...] is its one
180
+ line; anything else runs to the next header or Klipper's SAVE_CONFIG
181
+ block."""
182
+ lines = read_lines(conf)
183
+ if not lines:
184
+ return False
185
+ out: List[str] = []
186
+ skipping = False
187
+ one_line = section.lower().startswith("include ")
188
+ for ln in lines:
189
+ s = ln.strip()
190
+ if is_header(ln, section):
191
+ if one_line:
192
+ continue
193
+ skipping = True
194
+ continue
195
+ if skipping and ((s.startswith("[") and s.endswith("]"))
196
+ or ln.startswith(SAVE_CONFIG_HEADER)):
197
+ skipping = False
198
+ if not skipping:
199
+ out.append(ln)
200
+ text = "".join(out)
201
+ if text == "".join(lines):
202
+ return False
203
+ return write(conf, text, owner=owner)
204
+
205
+
206
+ def macro_file() -> str:
207
+ return os.path.join(os.path.dirname(__file__), "klipper", KLIPPER_INCLUDE)
208
+
209
+
210
+ def places(a: argparse.Namespace) -> dict:
211
+ config_dir = os.path.join(a.data_dir, "config")
212
+ return dict(
213
+ shim=os.path.join(a.moonraker_dir, "moonraker", "components",
214
+ SHIM_NAME),
215
+ old_shim=os.path.join(a.moonraker_dir, "moonraker", "components",
216
+ OLD_SHIM_NAME),
217
+ moonraker_conf=a.moonraker_conf
218
+ or os.path.join(config_dir, "moonraker.conf"),
219
+ moonraker_include=os.path.join(a.moonraker_include_dir,
220
+ MOONRAKER_INCLUDE)
221
+ if a.moonraker_include_dir else "",
222
+ printer_cfg=a.printer_cfg or os.path.join(config_dir, "printer.cfg"),
223
+ klipper_include=os.path.join(a.klipper_include_dir, KLIPPER_INCLUDE)
224
+ if a.klipper_include_dir else os.path.join(config_dir,
225
+ KLIPPER_INCLUDE),
226
+ store=cfgmod.Store(config_dir, a.data_dir, name=a.name))
227
+
228
+
229
+ def section_text(a: argparse.Namespace) -> str:
230
+ lines = [f"[{SECTION}]",
231
+ "# pushraker (python -m pushraker install); settings are in "
232
+ "pushraker.conf",
233
+ f"name: {a.name}"]
234
+ if a.error_index_url:
235
+ lines.append(f"error_index_url: {a.error_index_url}")
236
+ return "\n".join(lines) + "\n"
237
+
238
+
239
+ def cmd_install(a: argparse.Namespace) -> int:
240
+ p = places(a)
241
+ changed = []
242
+ if p["moonraker_include"] and os.path.realpath(p["moonraker_include"]) \
243
+ == os.path.realpath(p["klipper_include"]):
244
+ print("ERROR: --moonraker-include-dir and the Klipper include "
245
+ "directory are the same; both files are named pushraker.cfg")
246
+ return 1
247
+ if not os.path.isdir(os.path.dirname(p["shim"])):
248
+ print(f"ERROR: no Moonraker components directory at "
249
+ f"{os.path.dirname(p['shim'])} (--moonraker-dir)")
250
+ return 1
251
+ if write(p["shim"], SHIM.format(paths=list(a.site_packages)),
252
+ owner=a.owner):
253
+ changed.append(p["shim"])
254
+ # 0.1.x's names: the old shim and section would load the component a
255
+ # second time
256
+ if remove(p["old_shim"]):
257
+ changed.append(f"removed {p['old_shim']}")
258
+ if p["moonraker_include"]:
259
+ if write(p["moonraker_include"], section_text(a), owner=a.owner):
260
+ changed.append(p["moonraker_include"])
261
+ else:
262
+ if drop_section(p["moonraker_conf"], OLD_SECTION, a.owner):
263
+ changed.append(f"[{OLD_SECTION}] removed from {p['moonraker_conf']}")
264
+ if ensure_section(p["moonraker_conf"], SECTION, section_text(a),
265
+ a.owner):
266
+ changed.append(p["moonraker_conf"])
267
+ with open(macro_file()) as f:
268
+ macros = f.read()
269
+ if write(p["klipper_include"], macros, owner=a.owner):
270
+ changed.append(p["klipper_include"])
271
+ if not a.klipper_include_dir:
272
+ if ensure_section(p["printer_cfg"], f"include {KLIPPER_INCLUDE}",
273
+ f"[include {KLIPPER_INCLUDE}]\n", a.owner):
274
+ changed.append(p["printer_cfg"])
275
+ store = p["store"]
276
+ if not store.exists():
277
+ s = None
278
+ if a.import_ini:
279
+ s = cfgmod.import_ini(a.import_ini[0], a.import_ini[1],
280
+ *(a.import_secrets or ("", "")),
281
+ name=a.name)
282
+ if s["enabled"] or s["server"] or s["secret"]:
283
+ print(f"Moving settings from {a.import_ini[0]} "
284
+ f"[{a.import_ini[1]}] to {store.settings_path}")
285
+ else:
286
+ s = None
287
+ if s is not None:
288
+ store.save(s)
289
+ chown(store.settings_path, a.owner,
290
+ os.path.dirname(store.settings_path))
291
+ if os.path.exists(store.secrets_path):
292
+ chown(store.secrets_path, a.owner, a.data_dir)
293
+ changed.append(store.settings_path)
294
+ # Otherwise the component writes it at its first start, with the
295
+ # defaults for the printer it finds
296
+ for path in changed:
297
+ print(f"Updated: {path}")
298
+ if not changed:
299
+ print("Already installed; nothing changed.")
300
+ print(f"pushraker {__version__} is set up. Restart Moonraker and "
301
+ "Klipper to load it, then open the notifications page.")
302
+ return 0
303
+
304
+
305
+ def cmd_uninstall(a: argparse.Namespace) -> int:
306
+ p = places(a)
307
+ removed = []
308
+ for path in (p["shim"], p["old_shim"], p["moonraker_include"],
309
+ p["klipper_include"]):
310
+ if path and remove(path):
311
+ removed.append(path)
312
+ if not p["moonraker_include"]:
313
+ for section in (SECTION, OLD_SECTION):
314
+ if drop_section(p["moonraker_conf"], section, a.owner):
315
+ removed.append(f"[{section}] in {p['moonraker_conf']}")
316
+ if not a.klipper_include_dir and drop_section(
317
+ p["printer_cfg"], f"include {KLIPPER_INCLUDE}", a.owner):
318
+ removed.append(f"[include {KLIPPER_INCLUDE}] in {p['printer_cfg']}")
319
+ if a.purge:
320
+ for path in (p["store"].settings_path, p["store"].secrets_path):
321
+ if remove(path):
322
+ removed.append(path)
323
+ for path in removed:
324
+ print(f"Removed {path}")
325
+ if not removed:
326
+ print("Nothing to remove.")
327
+ print("Restart Moonraker and Klipper to unload pushraker.")
328
+ return 0
329
+
330
+
331
+ def cmd_status(a: argparse.Namespace) -> int:
332
+ p = places(a)
333
+ print(f"pushraker {__version__}")
334
+ for what, path in (("shim", p["shim"]),
335
+ ("Moonraker section", p["moonraker_include"]
336
+ or p["moonraker_conf"]),
337
+ ("Klipper macros", p["klipper_include"]),
338
+ ("settings", p["store"].settings_path),
339
+ ("secrets", p["store"].secrets_path)):
340
+ print(f" {what}: {path} "
341
+ f"({'present' if os.path.exists(path) else 'missing'})")
342
+ if p["store"].exists():
343
+ print(" " + cfgmod.summary(p["store"].load()))
344
+ return 0
345
+
346
+
347
+ def main(argv: Optional[List[str]] = None) -> int:
348
+ a = parse(sys.argv[1:] if argv is None else argv)
349
+ return {"install": cmd_install, "uninstall": cmd_uninstall,
350
+ "status": cmd_status}[a.command](a)
351
+
352
+
353
+ if __name__ == "__main__":
354
+ sys.exit(main())