cronboard-service 1.0.4__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.
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: cronboard-service
3
+ Version: 1.0.4
4
+ Summary: Session D-Bus service that lets the Cronboard GNOME Shell extension read and change your crontab
5
+ License-Expression: GPL-2.0-or-later
6
+ Project-URL: Homepage, https://github.com/seniru-dilmith/cronboard
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: dbus-fast>=2
10
+
11
+ # Cronboard service
12
+
13
+ The companion service for the [Cronboard](https://github.com/seniru-dilmith/cronboard) GNOME Shell extension. It runs in your user session and does the work the extension is not allowed to do itself: reading and writing your crontab (with a backup of the previous one), running a job on demand, and checking that the cron service is running. The extension talks to it over the session D-Bus.
14
+
15
+ ```bash
16
+ pipx install cronboard-service
17
+ cronboard-service install
18
+ ```
19
+
20
+ `install` registers the service with the session bus so it starts on demand. Without it you can run `cronboard-service` in a terminal instead.
@@ -0,0 +1,10 @@
1
+ # Cronboard service
2
+
3
+ The companion service for the [Cronboard](https://github.com/seniru-dilmith/cronboard) GNOME Shell extension. It runs in your user session and does the work the extension is not allowed to do itself: reading and writing your crontab (with a backup of the previous one), running a job on demand, and checking that the cron service is running. The extension talks to it over the session D-Bus.
4
+
5
+ ```bash
6
+ pipx install cronboard-service
7
+ cronboard-service install
8
+ ```
9
+
10
+ `install` registers the service with the session bus so it starts on demand. Without it you can run `cronboard-service` in a terminal instead.
File without changes
@@ -0,0 +1,83 @@
1
+ """The Cronboard session D-Bus service."""
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from dbus_fast import BusType, DBusError
9
+ from dbus_fast.aio import MessageBus
10
+ from dbus_fast.constants import RequestNameReply
11
+ from dbus_fast.service import ServiceInterface, method
12
+
13
+ from . import core
14
+
15
+ NAME = "io.github.seniru_dilmith.Cronboard"
16
+ PATH = "/io/github/seniru_dilmith/Cronboard"
17
+
18
+
19
+ class Cronboard(ServiceInterface):
20
+ def __init__(self):
21
+ super().__init__(NAME)
22
+
23
+ @method()
24
+ async def Read(self) -> "s":
25
+ return await self._guard(core.read())
26
+
27
+ @method()
28
+ async def Write(self, text: "s", snapshot: "s"):
29
+ await self._guard(core.write(text, snapshot))
30
+
31
+ @method()
32
+ async def Run(self, argv: "as", env: "a{ss}") -> "(is)":
33
+ return list(await self._guard(core.run_job(argv, env)))
34
+
35
+ @method()
36
+ async def CronActive(self) -> "b":
37
+ return await core.cron_active()
38
+
39
+ @staticmethod
40
+ async def _guard(awaitable):
41
+ """Turns the core's errors into D-Bus errors the extension tells apart by name."""
42
+ try:
43
+ return await awaitable
44
+ except core.NotInstalled:
45
+ raise DBusError(f"{NAME}.Error.NotInstalled", "cron is not installed: the crontab command was not found.")
46
+ except core.Conflict:
47
+ raise DBusError(f"{NAME}.Error.Conflict", "The crontab changed outside Cronboard, so nothing was saved. Check the list and try again.")
48
+ except Exception as e:
49
+ raise DBusError(f"{NAME}.Error.Failed", str(e))
50
+
51
+
52
+ async def serve():
53
+ bus = await MessageBus(bus_type=BusType.SESSION).connect()
54
+ bus.export(PATH, Cronboard())
55
+ if await bus.request_name(NAME) != RequestNameReply.PRIMARY_OWNER:
56
+ sys.exit(f"{NAME} is already running")
57
+ try:
58
+ await bus.wait_for_disconnect()
59
+ except EOFError:
60
+ pass # the session bus went away (logout)
61
+
62
+
63
+ def install():
64
+ """Registers the service with the session bus so it starts when the extension asks for it."""
65
+ data = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
66
+ target = data / "dbus-1" / "services" / f"{NAME}.service"
67
+ target.parent.mkdir(parents=True, exist_ok=True)
68
+ exe = Path(sys.argv[0]).resolve()
69
+ target.write_text(f"[D-BUS Service]\nName={NAME}\nExec={exe}\n")
70
+ print(f"Registered {target}")
71
+
72
+
73
+ def main():
74
+ if sys.argv[1:] == ["install"]:
75
+ install()
76
+ elif len(sys.argv) == 1:
77
+ asyncio.run(serve())
78
+ else:
79
+ sys.exit("usage: cronboard-service [install]")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
@@ -0,0 +1,105 @@
1
+ """Crontab access and job running. No D-Bus in here, so it can be tested directly."""
2
+
3
+ import asyncio
4
+ import os
5
+ import re
6
+ import time
7
+ from pathlib import Path
8
+
9
+ BACKUPS_KEPT = 20
10
+
11
+
12
+ class NotInstalled(Exception):
13
+ pass
14
+
15
+
16
+ class Conflict(Exception):
17
+ pass
18
+
19
+
20
+ async def _run(argv, stdin=None, env=None):
21
+ """Runs a command and returns (exit status, stdout, stderr)."""
22
+ try:
23
+ proc = await asyncio.create_subprocess_exec(
24
+ *argv,
25
+ stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
26
+ stdout=asyncio.subprocess.PIPE,
27
+ stderr=asyncio.subprocess.PIPE,
28
+ env={**os.environ, "LC_ALL": "C", **(env or {})}, # stable messages to match on
29
+ )
30
+ except FileNotFoundError as e:
31
+ raise NotInstalled(argv[0]) from e
32
+ out, err = await proc.communicate(stdin.encode() if stdin is not None else None)
33
+ return proc.returncode, out.decode(errors="replace"), err.decode(errors="replace")
34
+
35
+
36
+ async def read():
37
+ """The current crontab text; '' when the user has none."""
38
+ status, out, err = await _run(["crontab", "-l"])
39
+ if status == 0:
40
+ return out
41
+ if err.startswith("no crontab for "):
42
+ return ""
43
+ raise RuntimeError(err.strip() or f"crontab -l exited with status {status}")
44
+
45
+
46
+ def _backup_dir():
47
+ data = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
48
+ return Path(data) / "cronboard" / "backups"
49
+
50
+
51
+ def _backup(text):
52
+ folder = _backup_dir()
53
+ folder.mkdir(parents=True, exist_ok=True, mode=0o700)
54
+ stamp = time.strftime("%Y%m%d-%H%M%S") + f"-{time.time_ns() % 1_000_000:06d}"
55
+ (folder / f"crontab-{stamp}.txt").write_text(text)
56
+ for old in sorted(p for p in folder.glob("crontab-*.txt"))[:-BACKUPS_KEPT]:
57
+ old.unlink(missing_ok=True)
58
+
59
+
60
+ async def write(text, snapshot):
61
+ """Installs text as the crontab, but only if it still matches what the caller last read.
62
+
63
+ The previous crontab is backed up first.
64
+ ponytail: the re-read and the install aren't atomic; flock(1) on a lock file would close that gap.
65
+ """
66
+ current = await read()
67
+ if current != snapshot:
68
+ raise Conflict()
69
+ if text == current:
70
+ return
71
+ if current:
72
+ _backup(current)
73
+ data = text if text == "" or text.endswith("\n") else text + "\n" # cron needs a final newline
74
+ status, _, err = await _run(["crontab", "-"], stdin=data)
75
+ if status != 0:
76
+ raise RuntimeError(err.strip() or f"crontab exited with status {status}")
77
+
78
+
79
+ async def run_job(argv, env):
80
+ """Runs a job now in a transient systemd user unit, so it isn't a child of this service.
81
+
82
+ Waits for it to end and returns (the job's exit status, stderr); a non-empty stderr
83
+ means systemd-run itself failed. The job's own output goes to the user journal.
84
+ """
85
+ runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
86
+ bus = Path(runtime) / "bus"
87
+ # Shells started with dbus-run-session (nested or test sessions) have a private bus without systemd on it.
88
+ bus_env = {"DBUS_SESSION_BUS_ADDRESS": f"unix:path={bus}"} if bus.exists() else {}
89
+ unit = f"cronboard-run-{time.time_ns() // 1000}"
90
+ status, _, err = await _run(
91
+ ["systemd-run", "--user", "--wait", "--collect", "--quiet", f"--unit={unit}",
92
+ f"--working-directory={env.get('HOME', str(Path.home()))}", "--",
93
+ "/usr/bin/env", "-i", *(f"{k}={v}" for k, v in env.items()), *argv],
94
+ env=bus_env,
95
+ )
96
+ return status, err
97
+
98
+
99
+ async def cron_active():
100
+ """False when a known cron service exists but isn't running."""
101
+ try:
102
+ _, out, _ = await _run(["systemctl", "is-active", "cron.service", "crond.service", "cronie.service"])
103
+ except NotInstalled:
104
+ return True # no systemctl: we can't tell, so don't warn
105
+ return re.search(r"^active$", out, re.M) is not None
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: cronboard-service
3
+ Version: 1.0.4
4
+ Summary: Session D-Bus service that lets the Cronboard GNOME Shell extension read and change your crontab
5
+ License-Expression: GPL-2.0-or-later
6
+ Project-URL: Homepage, https://github.com/seniru-dilmith/cronboard
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: dbus-fast>=2
10
+
11
+ # Cronboard service
12
+
13
+ The companion service for the [Cronboard](https://github.com/seniru-dilmith/cronboard) GNOME Shell extension. It runs in your user session and does the work the extension is not allowed to do itself: reading and writing your crontab (with a backup of the previous one), running a job on demand, and checking that the cron service is running. The extension talks to it over the session D-Bus.
14
+
15
+ ```bash
16
+ pipx install cronboard-service
17
+ cronboard-service install
18
+ ```
19
+
20
+ `install` registers the service with the session bus so it starts on demand. Without it you can run `cronboard-service` in a terminal instead.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ cronboard_service/__init__.py
4
+ cronboard_service/__main__.py
5
+ cronboard_service/core.py
6
+ cronboard_service.egg-info/PKG-INFO
7
+ cronboard_service.egg-info/SOURCES.txt
8
+ cronboard_service.egg-info/dependency_links.txt
9
+ cronboard_service.egg-info/entry_points.txt
10
+ cronboard_service.egg-info/requires.txt
11
+ cronboard_service.egg-info/top_level.txt
12
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cronboard-service = cronboard_service.__main__:main
@@ -0,0 +1 @@
1
+ cronboard_service
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cronboard-service"
7
+ version = "1.0.4"
8
+ description = "Session D-Bus service that lets the Cronboard GNOME Shell extension read and change your crontab"
9
+ readme = "README.md"
10
+ license = "GPL-2.0-or-later"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["dbus-fast>=2"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/seniru-dilmith/cronboard"
16
+
17
+ [project.scripts]
18
+ cronboard-service = "cronboard_service.__main__:main"
19
+
20
+ [tool.setuptools]
21
+ packages = ["cronboard_service"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,64 @@
1
+ import asyncio
2
+ import os
3
+ import stat
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from cronboard_service import core
9
+
10
+ # A stand-in crontab that keeps its "crontab" in a file, so the tests never touch the real one.
11
+ FAKE = """#!/bin/sh
12
+ f="$FAKE_CRONTAB"
13
+ case "$1" in
14
+ -l) [ -f "$f" ] && cat "$f" || { echo "no crontab for tester" >&2; exit 1; } ;;
15
+ -) cat >"$f" ;;
16
+ esac
17
+ """
18
+
19
+
20
+ class CoreTest(unittest.TestCase):
21
+ def setUp(self):
22
+ self.tmp = Path(tempfile.mkdtemp())
23
+ fake = self.tmp / "crontab"
24
+ fake.write_text(FAKE)
25
+ fake.chmod(fake.stat().st_mode | stat.S_IXUSR)
26
+ self.env = {
27
+ "PATH": f"{self.tmp}:{os.environ['PATH']}",
28
+ "FAKE_CRONTAB": str(self.tmp / "stored"),
29
+ "XDG_DATA_HOME": str(self.tmp / "data"),
30
+ }
31
+ self.saved = {k: os.environ.get(k) for k in self.env}
32
+ os.environ.update(self.env)
33
+
34
+ def tearDown(self):
35
+ for k, v in self.saved.items():
36
+ if v is None:
37
+ os.environ.pop(k, None)
38
+ else:
39
+ os.environ[k] = v
40
+
41
+ def test_no_crontab_reads_as_empty(self):
42
+ self.assertEqual(asyncio.run(core.read()), "")
43
+
44
+ def test_write_then_read_and_backup(self):
45
+ asyncio.run(core.write("0 5 * * * a", ""))
46
+ self.assertEqual(asyncio.run(core.read()), "0 5 * * * a\n") # final newline added
47
+ asyncio.run(core.write("0 6 * * * b\n", "0 5 * * * a\n"))
48
+ backups = list((self.tmp / "data" / "cronboard" / "backups").glob("crontab-*.txt"))
49
+ self.assertEqual([b.read_text() for b in backups], ["0 5 * * * a\n"])
50
+
51
+ def test_write_refuses_when_crontab_changed(self):
52
+ asyncio.run(core.write("x\n", ""))
53
+ with self.assertRaises(core.Conflict):
54
+ asyncio.run(core.write("y\n", "something else\n"))
55
+ self.assertEqual(asyncio.run(core.read()), "x\n")
56
+
57
+ def test_missing_crontab_command(self):
58
+ os.environ["PATH"] = "/nonexistent"
59
+ with self.assertRaises(core.NotInstalled):
60
+ asyncio.run(core.read())
61
+
62
+
63
+ if __name__ == "__main__":
64
+ unittest.main()