udevmon 0.8.1__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.
udevmon/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Juan Domingo Martín
3
+ """udevmon: user-level device and mount event automation."""
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def __getattr__(name):
9
+ """``__version__``: written at build time, else asked from git (a source checkout)."""
10
+ if name == "__version__":
11
+ try:
12
+ from ._version import version
13
+ except ImportError:
14
+ from ._scm import get_version
15
+ version = get_version(Path(__file__).resolve().parents[2])
16
+ globals()["__version__"] = version
17
+ return version
18
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
udevmon/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Juan Domingo Martín
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
udevmon/_scm.py ADDED
@@ -0,0 +1,108 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Juan Domingo Martín
3
+ """Derive the package version from git, formatted exactly like setuptools-scm.
4
+
5
+ Uses setuptools-scm's default schemes, without depending on it:
6
+
7
+ * ``guess-next-dev``: on a tag the version is the tag (``0.2.0``); after it, the last
8
+ number is bumped and ``.devN`` (N = commits since the tag) is added (``0.2.1.dev5``);
9
+ with no tag at all it is ``0.1.devN`` (N = number of commits).
10
+ * ``node-and-date``: anything that is not exactly a clean tag gets a local part
11
+ ``+g<commit>`` (``g`` plus 9 hex digits, as in setuptools-scm 10), plus ``.d<YYYYMMDD>`` (the build date) when tracked files have
12
+ uncommitted changes. Untracked files do not count.
13
+
14
+ Standard library only: setup.py imports this module before anything is installed.
15
+ Without git (or outside a git checkout) the version comes from ``PKG-INFO`` (an sdist),
16
+ else ``0.0.0+unknown``; this never raises.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import re
23
+ import subprocess
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ FALLBACK = "0.0.0+unknown"
28
+ _DESCRIBE = re.compile(r"^(?P<tag>.+)-(?P<distance>\d+)-g(?P<node>[0-9a-f]+)(?P<dirty>-dirty)?$")
29
+ NODE_LENGTH = 10 # "g" + 9 hex digits: what setuptools-scm keeps of the hash
30
+ _TAG = re.compile(r"^(?:[\w-]+-)?[vV]?(?P<version>\d+(?:\.\d+)*[^+]*)")
31
+
32
+
33
+ def _git(root: Path, *args: str) -> str | None:
34
+ """Run git in ``root``; stdout, or None if git is missing or the command fails."""
35
+ try:
36
+ done = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True,
37
+ env={**os.environ, "LC_ALL": "C"}, timeout=30)
38
+ except (OSError, subprocess.SubprocessError):
39
+ return None
40
+ return done.stdout.strip() if done.returncode == 0 else None
41
+
42
+
43
+ def _bump(version: str) -> str:
44
+ """Increment the trailing number: 0.1.0 -> 0.1.1, 0.2 -> 0.3, 1.0rc1 -> 1.0rc2."""
45
+ m = re.match(r"^(.*?)(\d+)$", version)
46
+ return f"{m.group(1)}{int(m.group(2)) + 1}" if m else version + ".1"
47
+
48
+
49
+ def _normalize_tag(tag: str) -> str | None:
50
+ m = _TAG.match(tag)
51
+ return m.group("version").lower() if m else None
52
+
53
+
54
+ def git_version(root: Path, now: datetime | None = None) -> str | None:
55
+ """The setuptools-scm style version of the git checkout at ``root``, or None."""
56
+ root = Path(root)
57
+ top = _git(root, "rev-parse", "--show-toplevel")
58
+ if top is None or os.path.realpath(top) != os.path.realpath(root):
59
+ return None # not (the top of) a git checkout
60
+ if _git(root, "rev-parse", "--verify", "--quiet", "HEAD") is None:
61
+ return None # no commits yet
62
+ described = _git(root, "describe", "--dirty", "--tags", "--long", "--abbrev=40",
63
+ "--match", "*[0-9]*")
64
+ m = _DESCRIBE.match(described) if described else None
65
+ tag = _normalize_tag(m.group("tag")) if m else None
66
+ if m and tag:
67
+ distance, dirty = int(m.group("distance")), bool(m.group("dirty"))
68
+ node = ("g" + m.group("node"))[:NODE_LENGTH]
69
+ else: # no usable tag
70
+ count = _git(root, "rev-list", "HEAD", "--count")
71
+ head = _git(root, "rev-parse", "HEAD")
72
+ if count is None or head is None:
73
+ return None
74
+ tag, distance, node = "0.0", int(count), ("g" + head)[:NODE_LENGTH]
75
+ dirty = bool(_git(root, "status", "--porcelain", "--untracked-files=no"))
76
+ if distance == 0 and not dirty:
77
+ return tag # exactly a clean tag
78
+ date = (now or datetime.now(timezone.utc)).strftime("%Y%m%d")
79
+ return f"{_bump(tag)}.dev{distance}+{node}" + (f".d{date}" if dirty else "")
80
+
81
+
82
+ def _pkg_info_version(root: Path) -> str | None:
83
+ try:
84
+ text = (Path(root) / "PKG-INFO").read_text()
85
+ except OSError:
86
+ return None
87
+ m = re.search(r"^Version:\s*(\S+)", text, re.M)
88
+ return m.group(1) if m else None
89
+
90
+
91
+ def get_version(root: Path) -> str:
92
+ """The version: from git, else from ``PKG-INFO``, else ``0.0.0+unknown``. Never raises."""
93
+ return git_version(root) or _pkg_info_version(root) or FALLBACK
94
+
95
+
96
+ def version_tuple(version: str) -> tuple:
97
+ """(0, 1, 'dev4', 'gabc1234.d20260920'): numbers first, then the other parts."""
98
+ main, _, local = version.partition("+")
99
+ parts = [int(p) if p.isdigit() else p for p in main.split(".")]
100
+ return tuple(parts + ([local] if local else []))
101
+
102
+
103
+ def write_version_file(path: Path, version: str) -> None:
104
+ """Write the ``_version.py`` that a built package reads its version from."""
105
+ Path(path).write_text(
106
+ "# file generated by setup.py from git; do not edit\n"
107
+ f"__version__ = version = {version!r}\n"
108
+ f"__version_tuple__ = version_tuple = {version_tuple(version)!r}\n")
udevmon/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ # file generated by setup.py from git; do not edit
2
+ __version__ = version = '0.8.1'
3
+ __version_tuple__ = version_tuple = (0, 8, 1)
udevmon/actions.py ADDED
@@ -0,0 +1,306 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Juan Domingo Martín
3
+ """Run actions as subprocesses (never via a shell)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ import signal
10
+ import subprocess
11
+ import threading
12
+ from concurrent.futures import ThreadPoolExecutor
13
+
14
+ from . import notify
15
+ from . import pause as pause_flag
16
+ from . import session, state
17
+ from .config import Action, expand
18
+ from .events import Event
19
+
20
+ log = logging.getLogger("udevmon.actions")
21
+
22
+
23
+ def build_env(event: Event, device: str | None, tag: str | None = None,
24
+ variables=None, state_dir: str | None = None) -> dict[str, str]:
25
+ """The environment for an action: the daemon's own plus ``UDEVMON_EVENT``,
26
+ ``UDEVMON_DEVICE``, ``UDEVMON_TAG``, ``UDEVMON_<PROPERTY>``, ``UDEVMON_VAR_<NAME>`` and
27
+ ``UDEVMON_STATE_DIR``, each when it applies.
28
+ """
29
+ env = dict(os.environ)
30
+ env["UDEVMON_EVENT"] = event.kind
31
+ if device:
32
+ env["UDEVMON_DEVICE"] = device
33
+ if tag:
34
+ env["UDEVMON_TAG"] = tag
35
+ for k, v in event.endpoint.props.items():
36
+ env[f"UDEVMON_{k.upper()}"] = v
37
+ for k, v in (variables or {}).items():
38
+ env[f"UDEVMON_VAR_{k.upper()}"] = v
39
+ if state_dir:
40
+ env["UDEVMON_STATE_DIR"] = state_dir
41
+ return env
42
+
43
+
44
+ def run_action(action: Action, event: Event, device: str | None, tag: str | None = None, *,
45
+ dry_run: bool = False, variables=None, state_dir: str | None = None,
46
+ outcome: dict | None = None) -> int | None:
47
+ """Run one action; returns exit status (None if skipped/timeout-killed).
48
+
49
+ ``outcome``, if given, gets ``failure``: how a None result failed (a timeout, a
50
+ command that could not start), for the notification; it is left alone otherwise.
51
+ """
52
+ outcome = {} if outcome is None else outcome
53
+ props = dict(event.props)
54
+ if state_dir:
55
+ props["state_dir"] = state_dir
56
+ props.update({f"var_{k}": v for k, v in (variables or {}).items()})
57
+ if device:
58
+ props["device"] = device
59
+ if tag:
60
+ props["tag"] = tag
61
+ try:
62
+ argv = expand(action.run, props)
63
+ except KeyError as e:
64
+ log.error("action %r skipped: placeholder(s) %s unavailable for event %r",
65
+ action.name, e.args[0], event.kind)
66
+ return None
67
+ if dry_run:
68
+ log.info("[dry-run] action %r would run: %s", action.name, argv)
69
+ return None
70
+ log.info("action %r: %s %s", action.name, "starting (detached)" if action.detach
71
+ else "running", argv)
72
+ env = build_env(event, device, tag, variables, state_dir)
73
+ session.update(env) # DISPLAY etc., which a service may not have had at start
74
+ try:
75
+ proc = subprocess.Popen(
76
+ argv, cwd=os.path.expanduser("~"), env=env,
77
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
78
+ text=True, errors="replace", start_new_session=True)
79
+ except OSError as e:
80
+ log.error("action %r failed to start: %s", action.name, e)
81
+ outcome["failure"] = f"could not start: {e}"
82
+ return None
83
+ # The output is logged as it arrives, by a reader that outlives the command if it has to:
84
+ # a program the command leaves running in the background keeps the pipe open, and
85
+ # waiting for the pipe (instead of the command) made the action look stuck.
86
+ reader = threading.Thread(target=_pump, args=(action, proc), daemon=True,
87
+ name=f"output-{action.name}")
88
+ reader.start()
89
+ if action.detach:
90
+ threading.Thread(target=_reap, args=(action, proc), daemon=True,
91
+ name=f"reaper-{action.name}").start()
92
+ return None
93
+ try:
94
+ proc.wait(timeout=action.timeout)
95
+ except subprocess.TimeoutExpired:
96
+ log.error("action %r timed out after %gs; terminating", action.name, action.timeout)
97
+ outcome["failure"] = f"timed out after {action.timeout:g} s"
98
+ _kill_group(proc)
99
+ try:
100
+ proc.wait(timeout=5)
101
+ except subprocess.TimeoutExpired:
102
+ _kill_group(proc, signal.SIGKILL)
103
+ proc.wait()
104
+ reader.join(timeout=1)
105
+ return None
106
+ reader.join(timeout=_DRAIN_WAIT) # normally the pipe is already closed; not for long
107
+ _log_exit(action, proc.returncode)
108
+ return proc.returncode
109
+
110
+
111
+ _DRAIN_WAIT = 0.2 # seconds to let the reader catch up after the command has exited
112
+
113
+
114
+ def _pump(action, proc) -> None:
115
+ """Log the command's output line by line until its last writer closes the pipe."""
116
+ try:
117
+ for line in proc.stdout:
118
+ log.info("[%s] %s", action.name, line.rstrip("\r\n"))
119
+ except (OSError, ValueError): # the pipe went away under us
120
+ pass
121
+ finally:
122
+ try:
123
+ proc.stdout.close()
124
+ except OSError:
125
+ pass
126
+
127
+
128
+ def _reap(action, proc) -> None:
129
+ """Log how a detached command ended."""
130
+ _log_exit(action, proc.wait(), "detached ")
131
+
132
+
133
+ def _log_exit(action, status: int, kind: str = "") -> None:
134
+ log.log(logging.INFO if status == 0 else logging.WARNING,
135
+ "%saction %r exited with status %d", kind, action.name, status)
136
+
137
+
138
+ def _kill_group(proc, sig=signal.SIGTERM):
139
+ try:
140
+ os.killpg(proc.pid, sig)
141
+ except ProcessLookupError:
142
+ pass
143
+
144
+
145
+ class FifoLock:
146
+ """A mutex that hands the turn out in the order the tickets were taken.
147
+
148
+ A ticket is taken when the event is submitted (on the daemon's single main thread, so in
149
+ the order the events arrived) and used later, by whichever worker runs the job. That
150
+ keeps the actions of a plug ahead of those of the following unplug even when the unplug
151
+ reaches a free worker first, which a plain lock would not.
152
+ """
153
+
154
+ def __init__(self, name: str):
155
+ self.name = name
156
+ self._cond = threading.Condition()
157
+ self._next = 0 # the next ticket to hand out
158
+ self._serving = 0 # the ticket whose turn it is
159
+
160
+ def take(self) -> int:
161
+ """Take the next ticket: a place in the queue, for :meth:`wait`."""
162
+ with self._cond:
163
+ ticket = self._next
164
+ self._next += 1
165
+ return ticket
166
+
167
+ def wait(self, ticket: int) -> bool:
168
+ """Block until it is ``ticket``'s turn; True if it had to wait."""
169
+ with self._cond:
170
+ waited = self._serving != ticket
171
+ while self._serving != ticket:
172
+ self._cond.wait()
173
+ return waited
174
+
175
+ def release(self) -> None:
176
+ """End the current turn and let the next ticket go."""
177
+ with self._cond:
178
+ self._serving += 1
179
+ self._cond.notify_all()
180
+
181
+
182
+ class ActionRunner:
183
+ """Runs the jobs of each event on a pool of ``max_workers`` threads.
184
+
185
+ The jobs of one event run one after another in one worker; those of different events
186
+ can overlap, unless their actions share a ``lock``. With ``dry_run`` the commands are
187
+ logged, not run.
188
+ """
189
+ def __init__(self, max_workers: int = 4, dry_run: bool = False):
190
+ self.dry_run = dry_run
191
+ self._pool = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="action")
192
+ self._locks: dict[str, FifoLock] = {}
193
+ self._stopping = threading.Event() # set by shutdown(): no more retries
194
+
195
+ def _lock(self, name: str) -> FifoLock:
196
+ return self._locks.setdefault(name, FifoLock(name))
197
+
198
+ def submit(self, event: Event, jobs, ended: str | None = None):
199
+ """Run the jobs (engine.Job) sequentially in one worker.
200
+
201
+ Called in the order the events arrived: a job whose action has a ``lock`` takes its
202
+ ticket now, so locked jobs run in that order and never at the same time. ``ended``
203
+ is the instance whose presence this event ends: its state folder is removed once
204
+ the jobs are done.
205
+ """
206
+ ticketed = [(job, self._lock(job.action.lock).take() if job.action.lock else None)
207
+ for job in jobs]
208
+ return self._pool.submit(self._run_all, event, ticketed, ended)
209
+
210
+ def _run_all(self, event, ticketed, ended=None):
211
+ """Run the jobs in order; returns [(action name, exit status or None)]."""
212
+ done = 0
213
+ results = []
214
+ try:
215
+ for job, ticket in ticketed:
216
+ done += 1
217
+ results.append((job.action.name, self._run_one(event, job, ticket)))
218
+ except BaseException:
219
+ for job, ticket in ticketed[done:]: # never leave a ticket unserved
220
+ if ticket is not None:
221
+ lock = self._lock(job.action.lock)
222
+ lock.wait(ticket)
223
+ lock.release()
224
+ raise
225
+ finally:
226
+ if ended:
227
+ state.remove(ended)
228
+ return results
229
+
230
+ def _run_one(self, event, job, ticket):
231
+ lock = self._lock(job.action.lock) if ticket is not None else None
232
+ if lock is not None:
233
+ if lock.wait(ticket):
234
+ log.info("action %r waited for lock %r", job.action.name, lock.name)
235
+ try:
236
+ return self._run_with_retries(event, job)
237
+ except Exception:
238
+ log.exception("action %r crashed", job.action.name)
239
+ return None
240
+ finally:
241
+ if lock is not None:
242
+ lock.release()
243
+
244
+ def _state_dir(self, event) -> str | None:
245
+ """The state folder of the event's instance, created on first use (not for a dry run)."""
246
+ instance = event.props.get("instance")
247
+ if not instance or self.dry_run:
248
+ return None
249
+ try:
250
+ return str(state.ensure(instance))
251
+ except OSError as e:
252
+ log.warning("cannot create the state folder for %s: %s", instance, e)
253
+ return None
254
+
255
+ def _run_with_retries(self, event, job):
256
+ """Run the action (with its retries); with ``notify_on_failure``, tell the user
257
+ when the last attempt failed (a non-zero exit, a timeout, a failure to start)."""
258
+ outcome: dict = {}
259
+ status = self._attempts(event, job, outcome)
260
+ if job.action.notify_on_failure and not self.dry_run:
261
+ what = outcome.get("failure") or (f"exit status {status}" if status else None)
262
+ if what:
263
+ if outcome.get("attempts", 1) > 1:
264
+ what += f" (after {outcome['attempts']} attempts)"
265
+ notify.action_failed(job.action.name, job.device, what)
266
+ return status
267
+
268
+ def _attempts(self, event, job, outcome):
269
+ """Run the action; after a non-zero exit run it again, up to ``retries`` more times.
270
+
271
+ Not retried: a timeout, a command that cannot start, a skipped or dry run (they
272
+ return None), and nothing once automation is paused or the daemon is stopping.
273
+ The lock, if any, is held across the attempts.
274
+ """
275
+ action = job.action
276
+ attempts = action.retries + 1
277
+ for attempt in range(1, attempts + 1):
278
+ outcome.clear()
279
+ outcome["attempts"] = attempt
280
+ status = run_action(action, event, job.device, job.tag, dry_run=self.dry_run,
281
+ variables=job.vars, state_dir=self._state_dir(event),
282
+ outcome=outcome)
283
+ if status is None or status == 0:
284
+ return status
285
+ if attempt == attempts:
286
+ if action.retries:
287
+ log.warning("action %r gave up after %d attempts (last status %d)",
288
+ action.name, attempts, status)
289
+ return status
290
+ if pause_flag.is_paused():
291
+ log.warning("action %r failed with status %d; automation is paused, so not "
292
+ "retrying", action.name, status)
293
+ return status
294
+ log.warning("action %r attempt %d of %d failed with status %d; retrying in %g s",
295
+ action.name, attempt, attempts, status, action.retry_delay)
296
+ if self._stopping.wait(action.retry_delay):
297
+ log.warning("action %r: the daemon is stopping, so not retrying", action.name)
298
+ return status
299
+ return None
300
+
301
+ def shutdown(self, wait=False):
302
+ """Stop retrying and shut the pool down: with ``wait``, after the running and queued jobs;
303
+ else queued jobs are cancelled.
304
+ """
305
+ self._stopping.set()
306
+ self._pool.shutdown(wait=wait, cancel_futures=not wait)