sysrepo-python-plugind 1.0.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.
@@ -0,0 +1,15 @@
1
+ # Copyright (c) 2026 EntryPoint Communications, LLC
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ """sysrepo-python-plugind — Python plugin daemon for sysrepo.
5
+
6
+ Public API:
7
+
8
+ - :class:`SysrepoPlugin`: Abstract base class for Python plugins.
9
+ - :func:`main`: Entry point for the ``sysrepo-python-plugind`` console script.
10
+ """
11
+
12
+ from .cli import main
13
+ from .plugin import SysrepoPlugin
14
+
15
+ __all__ = ["SysrepoPlugin", "main"]
@@ -0,0 +1,8 @@
1
+ # Copyright (c) 2026 EntryPoint Communications, LLC
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ sys.exit(main())
@@ -0,0 +1,158 @@
1
+ # Copyright (c) 2026 EntryPoint Communications, LLC
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ import argparse
5
+ import logging
6
+ import os
7
+ import sys
8
+ from typing import Optional
9
+
10
+ import sysrepo
11
+
12
+ from .daemon import PluginDaemon
13
+ from .pid import PidFile
14
+
15
+ # Map sysrepo-plugind -v levels to Python logging levels, matching the C daemon.
16
+ _LOG_LEVELS = {
17
+ 0: logging.ERROR,
18
+ 1: logging.WARNING,
19
+ 2: logging.INFO,
20
+ 3: logging.DEBUG,
21
+ 4: logging.DEBUG,
22
+ }
23
+
24
+
25
+ def main() -> int:
26
+ """Parse CLI arguments and run the sysrepo Python plugin daemon.
27
+
28
+ Parses ``-d``, ``-v``, ``-p``, and ``-f`` flags (mirroring the C
29
+ sysrepo-plugind), configures logging, creates a PluginDaemon, and
30
+ either daemonizes or runs in the foreground.
31
+
32
+ Returns:
33
+ int: Exit code; 0 on success, 1 on error.
34
+ """
35
+ parser = argparse.ArgumentParser(
36
+ prog="sysrepo-python-plugind",
37
+ description="Sysrepo Python plugin daemon",
38
+ )
39
+ parser.add_argument(
40
+ "-d",
41
+ dest="debug",
42
+ action="store_true",
43
+ help="debug mode: stay in foreground, log to stderr",
44
+ )
45
+ parser.add_argument(
46
+ "-v",
47
+ dest="verbosity",
48
+ type=int,
49
+ default=2,
50
+ metavar="LEVEL",
51
+ choices=range(5),
52
+ help="log verbosity 0-4 (default: 2 = INFO)",
53
+ )
54
+ parser.add_argument(
55
+ "-p",
56
+ dest="pid_file",
57
+ metavar="PATH",
58
+ help="write PID file at PATH",
59
+ )
60
+ parser.add_argument(
61
+ "-f",
62
+ dest="fatal_fail",
63
+ action="store_true",
64
+ help="exit if any plugin fails to initialise",
65
+ )
66
+ args = parser.parse_args()
67
+
68
+ log_level = _LOG_LEVELS.get(args.verbosity, logging.DEBUG)
69
+ _configure_logging(debug=args.debug, level=log_level)
70
+
71
+ daemon = PluginDaemon(fatal_fail=args.fatal_fail)
72
+
73
+ if args.pid_file:
74
+ return _run_with_pid(daemon, args.pid_file, debug=args.debug)
75
+
76
+ if not args.debug:
77
+ _daemonize()
78
+ return daemon.run()
79
+
80
+
81
+ # ------------------------------------------------------------------------------
82
+ def _run_with_pid(daemon: PluginDaemon, pid_path: str, debug: bool) -> int:
83
+ """Run the daemon with a PID file, handling lock failure gracefully.
84
+
85
+ Opens the PID file lock before daemonizing so that a second invocation
86
+ fails immediately with a clear error message.
87
+
88
+ Args:
89
+ daemon (PluginDaemon): Configured daemon instance to run.
90
+ pid_path (str): Filesystem path for the PID file.
91
+ debug (bool): If True, skip daemonization and stay in foreground.
92
+
93
+ Returns:
94
+ int: Exit code from daemon.run(), or 1 if the PID file is already
95
+ locked by another instance.
96
+ """
97
+ try:
98
+ with PidFile(pid_path) as pid_file:
99
+ if not debug:
100
+ _daemonize()
101
+ return daemon.run(pid_file)
102
+ except RuntimeError as exc:
103
+ # PID file lock failure — another instance is running.
104
+ logging.getLogger(__name__).error("%s", exc)
105
+ return 1
106
+
107
+
108
+ def _configure_logging(debug: bool, level: int) -> None:
109
+ """Configure Python and sysrepo logging.
110
+
111
+ In debug mode logs to stderr; otherwise routes to syslog under the
112
+ application name ``'sysrepo-python-plugind'``.
113
+
114
+ Args:
115
+ debug (bool): If True, log to stderr; otherwise log to syslog.
116
+ level (int): Python logging level (e.g. ``logging.INFO``).
117
+ """
118
+ handler = logging.StreamHandler()
119
+ handler.setLevel(level)
120
+ logging.getLogger().addHandler(handler)
121
+ logging.getLogger().setLevel(level)
122
+
123
+ if debug:
124
+ sysrepo.configure_logging(stderr_level=level, py_logging=True)
125
+ else:
126
+ sysrepo.configure_logging(
127
+ syslog_level=level,
128
+ syslog_app_name="sysrepo-python-plugind",
129
+ py_logging=True,
130
+ )
131
+
132
+
133
+ def _daemonize() -> None:
134
+ """Daemonize the current process using the standard double-fork technique.
135
+
136
+ After the second fork the process is in its own session, has no
137
+ controlling terminal, and has stdin/stdout/stderr redirected to
138
+ /dev/null. File descriptors beyond stderr are left open so that a
139
+ PidFile lock opened before this call survives in the child process.
140
+ """
141
+ # First fork: become session leader's child.
142
+ pid = os.fork()
143
+ if pid > 0:
144
+ sys.exit(0)
145
+
146
+ os.setsid()
147
+
148
+ # Second fork: ensure we can never re-acquire a controlling terminal.
149
+ pid = os.fork()
150
+ if pid > 0:
151
+ sys.exit(0)
152
+
153
+ os.chdir("/")
154
+
155
+ devnull = os.open(os.devnull, os.O_RDWR)
156
+ for fd in (sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno()):
157
+ os.dup2(devnull, fd)
158
+ os.close(devnull)
@@ -0,0 +1,306 @@
1
+ # Copyright (c) 2026 EntryPoint Communications, LLC
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ import asyncio
5
+ import contextlib
6
+ import logging
7
+ import os
8
+ import signal
9
+ import socket
10
+ import sys
11
+ import threading
12
+ from importlib.metadata import entry_points
13
+ from typing import Optional
14
+
15
+ import sysrepo
16
+
17
+ from .pid import PidFile
18
+ from .plugin import SysrepoPlugin
19
+ from .sort import PluginList, sort_plugins
20
+
21
+ LOG = logging.getLogger(__name__)
22
+
23
+ _LOADED_XPATH = (
24
+ "/sysrepo-plugind:sysrepo-plugind"
25
+ "/sysrepo-python-plugind:python-loaded-plugins/plugin"
26
+ )
27
+ _LOADED_CONTAINER = (
28
+ "/sysrepo-plugind:sysrepo-plugind"
29
+ "/sysrepo-python-plugind:python-loaded-plugins"
30
+ )
31
+
32
+
33
+ class PluginDaemon:
34
+ """Python equivalent of sysrepo-plugind.
35
+
36
+ Loads Python plugins via entry points, manages their lifecycle (init,
37
+ run, cleanup), and publishes loaded-plugin state to the sysrepo
38
+ operational datastore.
39
+
40
+ Args:
41
+ fatal_fail (bool): If True, a plugin init() failure aborts the
42
+ entire daemon. If False (default), the failing plugin is
43
+ skipped and the remaining plugins are attempted.
44
+
45
+ Example::
46
+
47
+ daemon = PluginDaemon(fatal_fail=False)
48
+ with PidFile("/run/srpy-plugind.pid") as pid_file:
49
+ _daemonize()
50
+ rc = daemon.run(pid_file)
51
+ sys.exit(rc)
52
+ """
53
+
54
+ def __init__(self, fatal_fail: bool = False) -> None:
55
+ self.fatal_fail = fatal_fail
56
+ self._stop = threading.Event()
57
+ self._plugins: PluginList = []
58
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
59
+ self._loop_thread: Optional[threading.Thread] = None
60
+
61
+ # ------------------------------------------------------------------
62
+ # Public entry point
63
+
64
+ def run(self, pid_file: Optional[PidFile] = None) -> int:
65
+ """Run the daemon; block until a termination signal is received.
66
+
67
+ Opens a sysrepo connection, initialises all discovered plugins,
68
+ publishes the loaded-plugins list to the operational datastore,
69
+ notifies systemd, and then waits for a stop signal before cleaning
70
+ up.
71
+
72
+ Args:
73
+ pid_file (PidFile, optional): If provided, write() is called
74
+ after all plugins are initialised so the file contains the
75
+ daemonized child's PID.
76
+
77
+ Returns:
78
+ int: 0 on clean shutdown, 1 if a fatal error occurred.
79
+ """
80
+ self._setup_signals()
81
+ self._start_event_loop()
82
+
83
+ rc = 0
84
+ try:
85
+ with sysrepo.SysrepoConnection() as conn:
86
+ with conn.start_session("running") as sess:
87
+ self._init_plugins(sess)
88
+ self._publish_loaded(sess)
89
+ if pid_file is not None:
90
+ pid_file.write()
91
+ _sd_notify("READY=1")
92
+ LOG.info(
93
+ "daemon ready, %d plugin(s) loaded", len(self._plugins)
94
+ )
95
+ self._stop.wait()
96
+ LOG.info("shutting down")
97
+ _sd_notify("STOPPING=1")
98
+ self._cleanup_plugins(sess)
99
+ except Exception:
100
+ LOG.exception("daemon error")
101
+ rc = 1
102
+ finally:
103
+ self._stop_event_loop()
104
+
105
+ return rc
106
+
107
+ # ------------------------------------------------------------------
108
+ # Signal handling
109
+
110
+ def _setup_signals(self) -> None:
111
+ """Register signal handlers matching the C sysrepo-plugind behaviour.
112
+
113
+ SIGINT, SIGTERM, SIGQUIT, SIGABRT, and SIGHUP trigger a graceful
114
+ shutdown. A second signal while shutdown is already in progress
115
+ calls sys.exit(1). SIGPIPE, SIGTSTP, SIGTTIN, and SIGTTOU are
116
+ ignored.
117
+ """
118
+ def _handler(sig: int, _frame) -> None:
119
+ if self._stop.is_set():
120
+ # Second signal while already shutting down → hard exit.
121
+ sys.exit(1)
122
+ LOG.info("received %s, initiating shutdown", signal.Signals(sig).name)
123
+ self._stop.set()
124
+
125
+ for sig in (
126
+ signal.SIGINT,
127
+ signal.SIGTERM,
128
+ signal.SIGQUIT,
129
+ signal.SIGABRT,
130
+ signal.SIGHUP,
131
+ ):
132
+ signal.signal(sig, _handler)
133
+
134
+ for sig in (signal.SIGPIPE, signal.SIGTSTP, signal.SIGTTIN, signal.SIGTTOU):
135
+ signal.signal(sig, signal.SIG_IGN)
136
+
137
+ # ------------------------------------------------------------------
138
+ # asyncio event loop
139
+
140
+ def _start_event_loop(self) -> None:
141
+ """Create and start an asyncio event loop in a background daemon thread.
142
+
143
+ The loop is set as the thread-local event loop via
144
+ asyncio.set_event_loop() before any plugin init() calls, so plugins
145
+ that use asyncio_register=True subscriptions can rely on
146
+ asyncio.get_event_loop() returning a running loop without managing
147
+ one themselves.
148
+ """
149
+ self._loop = asyncio.new_event_loop()
150
+ # Make the loop visible to asyncio.get_event_loop() in plugin init().
151
+ asyncio.set_event_loop(self._loop)
152
+ self._loop_thread = threading.Thread(
153
+ target=self._loop.run_forever,
154
+ name="sysrepo-asyncio",
155
+ daemon=True,
156
+ )
157
+ self._loop_thread.start()
158
+ LOG.debug("asyncio event loop started")
159
+
160
+ def _stop_event_loop(self) -> None:
161
+ """Signal the asyncio event loop to stop and join its thread.
162
+
163
+ Safe to call if the loop was never started. Waits up to 5 seconds
164
+ for the loop thread to exit before returning.
165
+ """
166
+ if self._loop and not self._loop.is_closed():
167
+ self._loop.call_soon_threadsafe(self._loop.stop)
168
+ if self._loop_thread:
169
+ self._loop_thread.join(timeout=5.0)
170
+ LOG.debug("asyncio event loop stopped")
171
+
172
+ # ------------------------------------------------------------------
173
+ # Plugin lifecycle
174
+
175
+ def _discover_plugins(self) -> PluginList:
176
+ """Discover all installed plugins via the sysrepo_python.plugins entry point group.
177
+
178
+ Loads each entry point, verifies it is a SysrepoPlugin subclass,
179
+ and instantiates it.
180
+
181
+ Returns:
182
+ PluginList: (entry-point-name, instance) pairs in discovery order.
183
+
184
+ Raises:
185
+ Exception: Re-raised from ep.load() or instantiation when
186
+ fatal_fail is True; otherwise logged and skipped.
187
+ """
188
+ discovered: PluginList = []
189
+ for ep in entry_points(group="sysrepo_python.plugins"):
190
+ try:
191
+ cls = ep.load()
192
+ if not (isinstance(cls, type) and issubclass(cls, SysrepoPlugin)):
193
+ raise TypeError(
194
+ f"{ep.value!r} must be a SysrepoPlugin subclass, got {cls!r}"
195
+ )
196
+ discovered.append((ep.name, cls()))
197
+ LOG.debug("discovered plugin %r (%s)", ep.name, ep.value)
198
+ except Exception:
199
+ LOG.exception("failed to load entry point %r", ep.name)
200
+ if self.fatal_fail:
201
+ raise
202
+ return discovered
203
+
204
+ def _init_plugins(self, sess: sysrepo.session.SysrepoSession) -> None:
205
+ """Discover, sort, and initialise all plugins.
206
+
207
+ Calls _discover_plugins(), reorders the result via sort_plugins(),
208
+ then calls init() on each plugin. Successfully initialised plugins
209
+ are appended to self._plugins.
210
+
211
+ Args:
212
+ sess (sysrepo.session.SysrepoSession): Active running-datastore session
213
+ passed to each plugin's init().
214
+
215
+ Raises:
216
+ Exception: Re-raised from plugin init() when fatal_fail is True;
217
+ otherwise logged and the plugin is skipped.
218
+ """
219
+ plugins = self._discover_plugins()
220
+ plugins = sort_plugins(sess, plugins)
221
+
222
+ for ep_name, inst in plugins:
223
+ try:
224
+ inst.init(sess)
225
+ self._plugins.append((ep_name, inst))
226
+ LOG.info("initialized plugin %r", ep_name)
227
+ except Exception:
228
+ LOG.exception("plugin %r init() failed", ep_name)
229
+ if self.fatal_fail:
230
+ raise
231
+
232
+ def _cleanup_plugins(self, sess: sysrepo.session.SysrepoSession) -> None:
233
+ """Call cleanup() on all initialised plugins in reverse init order.
234
+
235
+ Exceptions from individual cleanup() calls are logged but do not
236
+ prevent the remaining plugins from being cleaned up.
237
+
238
+ Args:
239
+ sess (sysrepo.session.SysrepoSession): Active running-datastore session
240
+ passed to each plugin's cleanup().
241
+ """
242
+ for ep_name, inst in reversed(self._plugins):
243
+ try:
244
+ inst.cleanup(sess)
245
+ LOG.info("cleaned up plugin %r", ep_name)
246
+ except Exception:
247
+ LOG.exception("plugin %r cleanup() raised", ep_name)
248
+
249
+ # ------------------------------------------------------------------
250
+ # Operational datastore
251
+
252
+ def _publish_loaded(self, sess: sysrepo.session.SysrepoSession) -> None:
253
+ """Publish initialised plugin names to the sysrepo operational datastore.
254
+
255
+ Switches to the operational datastore, clears any stale entries
256
+ under the python-loaded-plugins container, writes one leaf-list
257
+ entry per successfully initialised plugin, applies changes, then
258
+ switches back to the running datastore.
259
+
260
+ Args:
261
+ sess (sysrepo.session.SysrepoSession): Active sysrepo session;
262
+ temporarily switched to operational and back to running.
263
+
264
+ Raises:
265
+ sysrepo.SysrepoNotFoundError: Caught internally when clearing
266
+ stale entries on first run; not propagated.
267
+ """
268
+ sess.switch_datastore("operational")
269
+ try:
270
+ sess.discard_items(_LOADED_CONTAINER)
271
+ except sysrepo.SysrepoNotFoundError:
272
+ pass
273
+
274
+ for ep_name, _ in self._plugins:
275
+ sess.set_item(_LOADED_XPATH, ep_name)
276
+ LOG.info("add plugin %r to operational datastore", ep_name)
277
+
278
+ sess.apply_changes()
279
+ LOG.info("operational store update complete")
280
+ sess.switch_datastore("running")
281
+ LOG.debug(
282
+ "published %d plugin name(s) to operational datastore",
283
+ len(self._plugins),
284
+ )
285
+
286
+
287
+ # ------------------------------------------------------------------------------
288
+ def _sd_notify(state: str) -> None:
289
+ """Send a state notification to systemd via the NOTIFY_SOCKET.
290
+
291
+ Does nothing if the NOTIFY_SOCKET environment variable is not set.
292
+ Suppresses OSError (e.g. if the socket path is stale or invalid).
293
+
294
+ Args:
295
+ state (str): Notification string, e.g. ``'READY=1'`` or
296
+ ``'STOPPING=1'``.
297
+ """
298
+ sock_path = os.environ.get("NOTIFY_SOCKET", "")
299
+ if not sock_path:
300
+ return
301
+ # systemd may use an abstract socket (leading '@' means NUL byte).
302
+ addr = "\0" + sock_path[1:] if sock_path.startswith("@") else sock_path
303
+ with contextlib.suppress(OSError):
304
+ with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as s:
305
+ s.connect(addr)
306
+ s.sendall(state.encode())
@@ -0,0 +1,82 @@
1
+ # Copyright (c) 2026 EntryPoint Communications, LLC
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ """Install or upgrade the sysrepo-python-plugind YANG module into sysrepo."""
5
+
6
+ import argparse
7
+ import subprocess
8
+ import sys
9
+ from importlib.resources import as_file, files
10
+
11
+
12
+ _MODULE_NAME = "sysrepo-python-plugind"
13
+
14
+
15
+ def main() -> int:
16
+ """Install or upgrade the sysrepo-python-plugind YANG augmentation module.
17
+
18
+ Checks whether the module is already installed via ``sysrepoctl --list``
19
+ and either skips (already installed, no ``--force``), installs (first
20
+ run), or upgrades (``--force``) using ``sysrepoctl``.
21
+
22
+ Returns:
23
+ int: 0 on success or when already installed, non-zero if
24
+ ``sysrepoctl`` exits with an error.
25
+ """
26
+ parser = argparse.ArgumentParser(
27
+ prog="sysrepo-python-plugind-setup",
28
+ description="Install the sysrepo-python-plugind YANG augmentation module.",
29
+ )
30
+ parser.add_argument(
31
+ "--force",
32
+ action="store_true",
33
+ help="upgrade the module even if it is already installed",
34
+ )
35
+ args = parser.parse_args()
36
+
37
+ already_installed = _is_installed()
38
+
39
+ if not args.force and already_installed:
40
+ print(f"{_MODULE_NAME}: already installed, nothing to do")
41
+ print(" (use --force to upgrade)")
42
+ return 0
43
+
44
+ flag = "--update" if already_installed else "--install"
45
+ verb = "Upgrading" if already_installed else "Installing"
46
+
47
+ yang_ref = files("sysrepo_python_plugind").joinpath(
48
+ "yang/sysrepo-python-plugind.yang"
49
+ )
50
+ with as_file(yang_ref) as yang_path:
51
+ print(f"{verb} {yang_path} ...")
52
+ result = subprocess.run(
53
+ ["sysrepoctl", flag, str(yang_path)],
54
+ check=False,
55
+ )
56
+ if result.returncode != 0:
57
+ print(
58
+ f"sysrepoctl {flag} failed (exit {result.returncode})",
59
+ file=sys.stderr,
60
+ )
61
+ return result.returncode
62
+
63
+ print(f"{_MODULE_NAME}: module installed successfully")
64
+ return 0
65
+
66
+
67
+ def _is_installed() -> bool:
68
+ """Check whether the sysrepo-python-plugind YANG module is installed.
69
+
70
+ Runs ``sysrepoctl --list`` and searches the output for the module name.
71
+
72
+ Returns:
73
+ bool: True if the module appears in ``sysrepoctl`` output, False
74
+ otherwise or if ``sysrepoctl`` itself fails.
75
+ """
76
+ result = subprocess.run(
77
+ ["sysrepoctl", "--list"],
78
+ capture_output=True,
79
+ text=True,
80
+ check=False,
81
+ )
82
+ return _MODULE_NAME in result.stdout
@@ -0,0 +1,92 @@
1
+ # Copyright (c) 2026 EntryPoint Communications, LLC
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ import fcntl
5
+ import logging
6
+ import os
7
+
8
+ LOG = logging.getLogger(__name__)
9
+
10
+
11
+ class PidFile:
12
+ """POSIX PID file with exclusive advisory lock.
13
+
14
+ Matches the C sysrepo-plugind behaviour:
15
+
16
+ - Open and lock **before** daemonizing so a second invocation fails
17
+ immediately, even before the first instance writes its PID.
18
+ - :meth:`write` is called **after** plugins have initialised so the
19
+ file contains the daemonized child's PID, not the parent's.
20
+ - On exit the file is closed and unlinked.
21
+
22
+ Args:
23
+ path (str): Filesystem path for the PID file.
24
+
25
+ Example::
26
+
27
+ with PidFile("/run/sysrepo-python-plugind.pid") as pid_file:
28
+ _daemonize()
29
+ # ... init plugins ...
30
+ pid_file.write()
31
+ """
32
+
33
+ __slots__ = ("path", "_fd")
34
+
35
+ def __init__(self, path: str) -> None:
36
+ self.path = path
37
+ self._fd: int | None = None
38
+
39
+ def __enter__(self) -> "PidFile":
40
+ """Open and exclusively lock the PID file.
41
+
42
+ Returns:
43
+ PidFile: self, for use as a context manager.
44
+
45
+ Raises:
46
+ RuntimeError: If the exclusive lock cannot be acquired because
47
+ another instance of the daemon already holds it.
48
+ """
49
+ self._fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o640)
50
+ try:
51
+ fcntl.lockf(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
52
+ except OSError as exc:
53
+ os.close(self._fd)
54
+ self._fd = None
55
+ raise RuntimeError(
56
+ f"another sysrepo-python-plugind instance is running "
57
+ f"(cannot lock {self.path})"
58
+ ) from exc
59
+ return self
60
+
61
+ def write(self) -> None:
62
+ """Write the current process PID to the file.
63
+
64
+ Truncates any existing content before writing so the file always
65
+ contains exactly one line. Should be called after daemonizing so
66
+ the written PID belongs to the final child process, not the
67
+ pre-fork parent.
68
+ """
69
+ if self._fd is None:
70
+ return
71
+ pid_bytes = f"{os.getpid()}\n".encode()
72
+ os.ftruncate(self._fd, 0)
73
+ os.lseek(self._fd, 0, os.SEEK_SET)
74
+ os.write(self._fd, pid_bytes)
75
+ LOG.debug("wrote PID %d to %s", os.getpid(), self.path)
76
+
77
+ def __exit__(self, *_) -> None:
78
+ """Close and unlink the PID file.
79
+
80
+ Silently ignores FileNotFoundError in case the file was already
81
+ removed externally.
82
+
83
+ Args:
84
+ *_: Exception info from the ``with`` block; not used.
85
+ """
86
+ if self._fd is not None:
87
+ os.close(self._fd)
88
+ self._fd = None
89
+ try:
90
+ os.unlink(self.path)
91
+ except FileNotFoundError:
92
+ pass
@@ -0,0 +1,56 @@
1
+ # Copyright (c) 2026 EntryPoint Communications, LLC
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ from abc import ABC, abstractmethod
5
+
6
+ import sysrepo
7
+
8
+
9
+ class SysrepoPlugin(ABC):
10
+ """Abstract base class for sysrepo Python plugins.
11
+
12
+ Plugins are discovered by sysrepo-python-plugind via the
13
+ ``sysrepo_python.plugins`` entry point group. Declare a plugin in
14
+ your package's ``pyproject.toml``::
15
+
16
+ [project.entry-points."sysrepo_python.plugins"]
17
+ my-plugin = "my_package.plugin:MyPlugin"
18
+
19
+ The daemon instantiates the class with no arguments, calls
20
+ :meth:`init` at startup (after plugin ordering), and :meth:`cleanup`
21
+ at shutdown in reverse init order. State that the C daemon stored in
22
+ ``void *private_data`` should be stored as instance attributes.
23
+ """
24
+
25
+ @abstractmethod
26
+ def init(self, session: sysrepo.session.SysrepoSession) -> None:
27
+ """Initialise the plugin at daemon startup.
28
+
29
+ Called once after plugin ordering and before ``sd_notify("READY=1")``.
30
+ Register sysrepo subscriptions and allocate resources here. The
31
+ session is on ``SR_DS_RUNNING`` and is shared with all other plugins.
32
+
33
+ Args:
34
+ session (sysrepo.session.SysrepoSession): Active running-datastore
35
+ session shared across all plugins.
36
+
37
+ Raises:
38
+ Exception: Signals rejection of this plugin. With
39
+ ``--fatal-plugin-fail`` the daemon exits; otherwise this
40
+ plugin is skipped and the next one is attempted.
41
+ """
42
+
43
+ def cleanup(self, session: sysrepo.session.SysrepoSession) -> None:
44
+ """Clean up plugin resources at daemon shutdown.
45
+
46
+ Called once in reverse init order after a stop signal is received.
47
+ Sysrepo subscriptions created on the session are automatically
48
+ released when the session closes, so this method only needs to
49
+ release external resources (file handles, network connections, etc.).
50
+
51
+ The default implementation does nothing.
52
+
53
+ Args:
54
+ session (sysrepo.session.SysrepoSession): Active running-datastore
55
+ session shared across all plugins.
56
+ """
@@ -0,0 +1,91 @@
1
+ # Copyright (c) 2026 EntryPoint Communications, LLC
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+
4
+ import logging
5
+ from typing import TYPE_CHECKING
6
+
7
+ import sysrepo
8
+
9
+ if TYPE_CHECKING:
10
+ from .plugin import SysrepoPlugin
11
+
12
+ LOG = logging.getLogger(__name__)
13
+
14
+ _ORDER_XPATH = (
15
+ "/sysrepo-plugind:sysrepo-plugind"
16
+ "/sysrepo-python-plugind:python-plugin-order/plugin"
17
+ )
18
+
19
+ # Type alias for the (entry-point-name, instance) pairs the daemon works with.
20
+ PluginList = list[tuple[str, "SysrepoPlugin"]]
21
+
22
+
23
+ def sort_plugins(session: sysrepo.session.SysrepoSession, plugins: PluginList) -> PluginList:
24
+ """Reorder plugins according to the operator-configured plugin order.
25
+
26
+ Reads the ``python-plugin-order`` leaf-list from the sysrepo running
27
+ datastore and performs a stable partition: configured plugins are moved
28
+ to the front in YANG list order; unconfigured plugins retain their
29
+ original discovery order at the end.
30
+
31
+ Mirrors ``srpd_sort_plugins()`` from
32
+ ``sysrepo/src/executables/srpd_common.c``.
33
+
34
+ Args:
35
+ session (sysrepo.session.SysrepoSession): Active running-datastore session
36
+ used to read the python-plugin-order configuration.
37
+ plugins (PluginList): Discovered (entry-point-name, instance) pairs
38
+ in discovery order. The input list is not modified.
39
+
40
+ Returns:
41
+ PluginList: Reordered (entry-point-name, instance) pairs.
42
+ Configured plugins appear first in YANG list order; remaining
43
+ plugins follow in their original discovery order.
44
+
45
+ Raises:
46
+ sysrepo.SysrepoNotFoundError: Caught internally when no
47
+ plugin-order is configured; treated as an empty list and the
48
+ original order is returned unchanged.
49
+ """
50
+ try:
51
+ # String(Value, str) — each item IS a Python str already.
52
+ configured = [_strip_ext(v) for v in session.get_items(_ORDER_XPATH)]
53
+ except sysrepo.SysrepoNotFoundError:
54
+ LOG.debug("no plugin-order configured, using discovery order")
55
+ return plugins
56
+
57
+ if not configured:
58
+ return plugins
59
+
60
+ LOG.debug("plugin-order from sysrepo: %s", configured)
61
+
62
+ ordered: PluginList = []
63
+ remaining = list(plugins)
64
+
65
+ for cfg_name in configured:
66
+ for i, (ep_name, inst) in enumerate(remaining):
67
+ if _strip_ext(ep_name) == cfg_name:
68
+ ordered.append(remaining.pop(i))
69
+ LOG.debug("placed plugin %r at position %d", ep_name, len(ordered))
70
+ break
71
+ # cfg_name not found among loaded plugins — ignore, per C implementation.
72
+
73
+ return ordered + remaining
74
+
75
+
76
+ def _strip_ext(name: str) -> str:
77
+ """Strip the last file extension from a plugin name, if any.
78
+
79
+ Used to normalise operator-configured names (e.g. ``'my_plugin.so'``)
80
+ to match Python entry-point names (e.g. ``'my_plugin'``), preserving
81
+ compatibility with operator configs written for the C daemon.
82
+
83
+ Args:
84
+ name (str): Plugin name, optionally including a file extension.
85
+
86
+ Returns:
87
+ str: Name with the last dot-suffix removed, or the original name
88
+ if no dot is present.
89
+ """
90
+ dot = name.rfind(".")
91
+ return name[:dot] if dot >= 0 else name
@@ -0,0 +1,54 @@
1
+ module sysrepo-python-plugind {
2
+ yang-version 1.1;
3
+ namespace "urn:sysrepo:python-plugind";
4
+ prefix srpdpy;
5
+
6
+ import sysrepo-plugind {
7
+ prefix srpd;
8
+ revision-date 2022-08-26;
9
+ }
10
+
11
+ organization "sysrepo-python-plugind contributors";
12
+ description
13
+ "Augments sysrepo-plugind with Python-daemon-specific configuration
14
+ and state nodes. This allows the Python daemon and the C daemon to
15
+ coexist: each manages only its own subtree and neither overwrites the
16
+ other's operational data.";
17
+
18
+ revision 2026-05-13 {
19
+ description "Initial revision.";
20
+ reference "https://github.com/...";
21
+ }
22
+
23
+ augment "/srpd:sysrepo-plugind" {
24
+ description "Python-daemon plugin ordering and loaded-plugin state.";
25
+
26
+ container python-plugin-order {
27
+ description
28
+ "Ordered list of Python plugin entry-point names. Mirrors
29
+ plugin-order from the base module but applies only to the
30
+ Python daemon.";
31
+ leaf-list plugin {
32
+ type string;
33
+ ordered-by user;
34
+ description
35
+ "Entry-point name of a Python plugin. A trailing file
36
+ extension (e.g. '.so') is stripped before comparison,
37
+ preserving compatibility with operator configs written for
38
+ the C daemon.";
39
+ }
40
+ }
41
+
42
+ container python-loaded-plugins {
43
+ config false;
44
+ description
45
+ "Names of Python plugins successfully initialised by the running
46
+ Python daemon. Written to the operational datastore after all
47
+ plugins have been initialised.";
48
+ leaf-list plugin {
49
+ type string;
50
+ description "Entry-point name of a loaded Python plugin.";
51
+ }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: sysrepo-python-plugind
3
+ Version: 1.0.0
4
+ Summary: Python plugin daemon for sysrepo (replacement for sysrepo-plugind)
5
+ License-Expression: BSD-3-Clause
6
+ Project-URL: Repository, https://github.com/entrypoint-communications/sysrepo-python-plugind
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/x-rst
9
+ License-File: LICENSE
10
+ Requires-Dist: sysrepo~=2.0
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=7; extra == "test"
13
+ Dynamic: license-file
14
+
15
+ sysrepo-python-plugind
16
+ ======================
17
+
18
+ A Python reimplementation of ``sysrepo-plugind`` — the sysrepo plugin daemon.
19
+
20
+ The C daemon loads compiled ``.so`` plugins via ``dlopen``. This daemon loads
21
+ **Python plugins** discovered via entry points (``importlib.metadata``), giving
22
+ plugin authors a pure-Python development experience against the existing
23
+ `sysrepo-python`_ CFFI bindings.
24
+
25
+ The two daemons can run **simultaneously**: the Python daemon manages its own
26
+ YANG subtree (``python-plugin-order``, ``python-loaded-plugins``) and never
27
+ touches the C daemon's nodes.
28
+
29
+ .. _sysrepo-python: https://github.com/sysrepo/sysrepo-python
30
+
31
+
32
+ Requirements
33
+ ------------
34
+
35
+ - Python ≥ 3.10
36
+ - sysrepo-python_ (and the underlying ``libsysrepo.so``)
37
+ - sysrepo core 4.2.10 (ships ``sysrepo-plugind@2022-08-26.yang``)
38
+
39
+
40
+ Installation
41
+ ------------
42
+
43
+ .. code-block:: console
44
+
45
+ pip install sysrepo-python-plugind
46
+
47
+ Then register the YANG augmentation module with sysrepo (requires write
48
+ access to the sysrepo repository, typically run as root):
49
+
50
+ .. code-block:: console
51
+
52
+ sysrepo-python-plugind-setup
53
+
54
+ This is idempotent — safe to run on every deployment or system boot. Pass
55
+ ``--force`` to upgrade the module after a package update.
56
+
57
+
58
+ Writing a plugin
59
+ ----------------
60
+
61
+ Subclass ``SysrepoPlugin`` and implement ``init()``. Register the class via
62
+ an entry point in your package's ``pyproject.toml``:
63
+
64
+ .. code-block:: toml
65
+
66
+ [project.entry-points."sysrepo_python.plugins"]
67
+ my-plugin = "my_package.plugin:MyPlugin"
68
+
69
+ Installing your package makes the plugin visible to the daemon immediately —
70
+ no copying of files or environment variables required.
71
+
72
+ Minimal example:
73
+
74
+ .. code-block:: python
75
+
76
+ import logging
77
+ import sysrepo
78
+ from sysrepo_python_plugind import SysrepoPlugin
79
+
80
+ LOG = logging.getLogger(__name__)
81
+
82
+ class MyPlugin(SysrepoPlugin):
83
+
84
+ def init(self, session: sysrepo.SysrepoSession) -> None:
85
+ self._counter = 0
86
+ session.subscribe_module_change(
87
+ "my-module", None, self._on_change, done_only=True
88
+ )
89
+ session.subscribe_oper_data_request(
90
+ "my-module", "/my-module:stats", self._on_oper
91
+ )
92
+ LOG.info("MyPlugin ready")
93
+
94
+ def cleanup(self, session: sysrepo.SysrepoSession) -> None:
95
+ LOG.info("MyPlugin stopping (counter=%d)", self._counter)
96
+
97
+ def _on_change(self, event, req_id, changes, priv):
98
+ self._counter += 1
99
+
100
+ def _on_oper(self, xpath, priv):
101
+ return {"my-module": {"stats": {"change-count": self._counter}}}
102
+
103
+ State that the C daemon stored in ``void *private_data`` is stored as instance
104
+ attributes (``self._counter`` above).
105
+
106
+ Async callbacks
107
+ ~~~~~~~~~~~~~~~
108
+
109
+ The daemon starts an asyncio event loop in a background thread before calling
110
+ any ``init()``, so plugins can use ``asyncio_register=True`` subscriptions
111
+ without managing a loop themselves:
112
+
113
+ .. code-block:: python
114
+
115
+ class AsyncMyPlugin(SysrepoPlugin):
116
+
117
+ def init(self, session: sysrepo.SysrepoSession) -> None:
118
+ session.subscribe_module_change(
119
+ "my-module", None, self._on_change,
120
+ done_only=True, asyncio_register=True,
121
+ )
122
+
123
+ async def _on_change(self, event, req_id, changes, priv):
124
+ ...
125
+
126
+
127
+ Running the daemon
128
+ ------------------
129
+
130
+ .. code-block:: console
131
+
132
+ # Foreground, INFO-level logging to stderr:
133
+ sysrepo-python-plugind -d -v 2
134
+
135
+ # Background (daemonize), write PID file:
136
+ sysrepo-python-plugind -p /run/sysrepo-python-plugind.pid
137
+
138
+ CLI flags mirror those of the C ``sysrepo-plugind``:
139
+
140
+ ============ ===================================================================
141
+ ``-d`` Stay in foreground; log to stderr instead of syslog
142
+ ``-v LEVEL`` Verbosity: 0=ERROR 1=WARNING 2=INFO 3=DEBUG (default: 2)
143
+ ``-p PATH`` Write PID file at PATH
144
+ ``-f`` Exit if any plugin fails to initialise (default: skip and continue)
145
+ ============ ===================================================================
146
+
147
+
148
+ Plugin ordering
149
+ ---------------
150
+
151
+ By default plugins are initialised in entry-point discovery order. To
152
+ override this, set the ``python-plugin-order`` leaf-list in sysrepo's running
153
+ datastore:
154
+
155
+ .. code-block:: console
156
+
157
+ sysrepocfg -d running --edit
158
+
159
+ Add under ``/sysrepo-plugind:sysrepo-plugind``:
160
+
161
+ .. code-block:: xml
162
+
163
+ <python-plugin-order xmlns="urn:sysrepo:python-plugind">
164
+ <plugin>my-plugin</plugin>
165
+ <plugin>other-plugin</plugin>
166
+ </python-plugin-order>
167
+
168
+ Plugins absent from the list are appended after the ordered set, preserving
169
+ their discovery order. A trailing file extension in a configured name (e.g.
170
+ ``my-plugin.so``) is stripped before comparison, so operator configs written
171
+ for the C daemon can be reused unchanged.
172
+
173
+ The list of successfully initialised plugins is published to the operational
174
+ datastore at ``/sysrepo-plugind:sysrepo-plugind/sysrepo-python-plugind:python-loaded-plugins/plugin``.
175
+
176
+
177
+ Systemd deployment
178
+ ------------------
179
+
180
+ A service file is provided in the ``systemd/`` directory of the source
181
+ distribution:
182
+
183
+ .. code-block:: console
184
+
185
+ cp systemd/sysrepo-python-plugind.service /etc/systemd/system/
186
+ systemctl daemon-reload
187
+ systemctl enable --now sysrepo-python-plugind
188
+
189
+ The service runs ``sysrepo-python-plugind-setup`` as ``ExecStartPre=``,
190
+ ensuring the YANG module is present before the daemon starts.
191
+
192
+ If the daemon binary is not at ``/usr/bin/sysrepo-python-plugind`` (e.g. when
193
+ installed into a virtual environment), update the ``ExecStartPre=`` and
194
+ ``ExecStart=`` paths in the service file accordingly.
195
+
196
+
197
+ License
198
+ -------
199
+
200
+ BSD-3-Clause — same as sysrepo-python.
@@ -0,0 +1,15 @@
1
+ sysrepo_python_plugind/__init__.py,sha256=Lo3hxrcCQPfchf5mEglwlR9hrNdGsSDB68wkujX3nIo,414
2
+ sysrepo_python_plugind/__main__.py,sha256=nW9n-CT29lx3_sAo84sNy9AiAejptM5XvSvkLmZJisU,145
3
+ sysrepo_python_plugind/cli.py,sha256=_uqqNqscJH2jrsFE5EmBPFicU_Ea1OFv6JUxJSxu_M8,4625
4
+ sysrepo_python_plugind/daemon.py,sha256=akOafUkE2XugFQ1xkzRymDwAdaG0sTtnbfTJdBm5x8w,11217
5
+ sysrepo_python_plugind/install_yang.py,sha256=hT7i0qfBvL9DzeRrwElWWfPSL_66X7tW6NtxxZGUDaI,2543
6
+ sysrepo_python_plugind/pid.py,sha256=lpgatUUZUZcFm_ik8EJsZ0MqnPwvd_O4euPOrASGEa8,2826
7
+ sysrepo_python_plugind/plugin.py,sha256=SL8D5LX-MBObvARYTcAiO4L6GJsiAmKzvSNt-HwFVEc,2175
8
+ sysrepo_python_plugind/sort.py,sha256=W-uTeqHwHCnVjWYg-mm23QuX7ZLKffo3TOfm29stVd4,3198
9
+ sysrepo_python_plugind/yang/sysrepo-python-plugind.yang,sha256=R122VcgpoZ4jlcaS384ts06Pfr6M70ATGpadk8nrpjg,1673
10
+ sysrepo_python_plugind-1.0.0.dist-info/licenses/LICENSE,sha256=Ci_I22dLniZTPKK9Q_l2vbAzwhnKXjFltyD-AE_T9wI,1516
11
+ sysrepo_python_plugind-1.0.0.dist-info/METADATA,sha256=C2TLhu6KOyb9uyYfOViRvc3W4L2sTPKyTSsYfU2hQZs,6106
12
+ sysrepo_python_plugind-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ sysrepo_python_plugind-1.0.0.dist-info/entry_points.txt,sha256=VuJTp-tPelbpAFCeyasniST9I-jgn6zrdc89Vb43ONY,143
14
+ sysrepo_python_plugind-1.0.0.dist-info/top_level.txt,sha256=k8eY3OJSeYlfOuhzyjr2dSOSGBVp5GgnAHqec-IOtAE,23
15
+ sysrepo_python_plugind-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sysrepo-python-plugind = sysrepo_python_plugind:main
3
+ sysrepo-python-plugind-setup = sysrepo_python_plugind.install_yang:main
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026 EntryPoint Communications, LLC
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice,
9
+ this list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its contributors
16
+ may be used to endorse or promote products derived from this software
17
+ without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ sysrepo_python_plugind