"""pacman's alpm hooks: the work that happens around a transaction. A hook is an INI-ish file with one or more `[Trigger]` sections describing when it fires and an `[Action]` section describing what it runs. They are how a kernel upgrade rebuilds its initramfs, how systemd notices a unit changed, and how snapper takes its snapshots -- the things a plan is supposed to tell you about and currently does not. Parsed, never executed. """ from __future__ import annotations import fnmatch from dataclasses import dataclass, field from pathlib import Path #: system hooks ship here; local ones override by filename in /etc HOOK_DIRS = (Path("/usr/share/libalpm/hooks"), Path("/etc/pacman.d/hooks")) #: `snap-pac` names its snapshots, so a snapshot stage can be recognised SNAPSHOT_MARKER = "snap-pac" @dataclass(frozen=True) class Trigger: kind: str # "Package" or "Path" operations: tuple[str, ...] # Upgrade, Install, Remove targets: tuple[str, ...] # glob patterns def matches_name(self, name: str, operation: str = "Upgrade") -> bool: if self.kind != "Package": return False if self.operations and operation not in self.operations: return False return any(fnmatch.fnmatchcase(name, pat) for pat in self.targets) def matches_path(self, path: str, operation: str = "Upgrade") -> bool: if self.kind != "Path": return False if self.operations and operation not in self.operations: return False # hook paths are relative to the filesystem root, without a leading / cleaned = str(path).lstrip("/") return any(fnmatch.fnmatchcase(cleaned, pat) for pat in self.targets) @dataclass class Hook: path: Path description: str = "" when: str = "PostTransaction" exec_: str = "" depends: tuple[str, ...] = () triggers: list[Trigger] = field(default_factory=list) needs_targets: bool = False abort_on_fail: bool = False @property def is_snapshot(self) -> bool: return SNAPSHOT_MARKER in self.exec_.lower() or "snapper" in self.exec_.lower() @property def label(self) -> str: """What to show a person. pacman's own words where it gave any.""" if self.description: return self.description.rstrip(".").strip() return self.exec_.split()[0].rsplit("/", 1)[-1] if self.exec_ else self.path.name def fires_for( self, names: set[str], paths_by_package: dict[str, list[str]], operation: str = "Upgrade" ) -> bool: """Whether this hook will run for any of the given packages. Package triggers are exact. Path triggers are matched against the file list of the *installed* version, which is an approximation: a package that ships a systemd unit today almost certainly still will, but it is the old file list and not the new one. """ for trigger in self.triggers: if trigger.kind == "Package": if any(trigger.matches_name(n, operation) for n in names): return True elif trigger.kind == "Path": for name in names: for path in paths_by_package.get(name, ()): if trigger.matches_path(path, operation): return True return False def _sections(path: Path) -> list[tuple[str, dict[str, list[str]]]]: """Every section in a hook file, in order, with repeated keys kept. Hand-written rather than ConfigParser, for three reasons: a hook may declare several `[Trigger]` sections and ConfigParser keeps only one; the flags `NeedsTargets` and `AbortOnFail` are bare words with no `=` at all, which ConfigParser treats as a parse error and drops the whole file; and keys repeat -- a trigger lists `Operation = Upgrade`, `Install` and `Remove` on three lines, and keeping only the last one silently changed which transactions a hook fires for. """ try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return [] out: list[tuple[str, dict[str, list[str]]]] = [] current: dict[str, list[str]] | None = None for raw in text.splitlines(): line = raw.split("#", 1)[0].strip() if not line: continue if line.startswith("[") and line.endswith("]"): current = {} out.append((line[1:-1].strip().lower(), current)) continue if current is None: continue if "=" in line: key, _, value = line.partition("=") current.setdefault(key.strip().lower(), []).append(value.strip()) else: current.setdefault(line.lower(), []).append("") return out def _parse(path: Path) -> Hook | None: """One hook file, or None if it declares no usable trigger.""" sections = _sections(path) action: dict[str, list[str]] = {} for name, body in sections: if name == "action": action.update(body) if not action: return None hook = Hook( path=path, description=_first(action, "description") or "", when=_first(action, "when") or "PostTransaction", exec_=_first(action, "exec") or "", depends=tuple(_split(_first(action, "depends"))), needs_targets="needstargets" in action, abort_on_fail="abortonfail" in action, ) for name, block in sections: if name != "trigger": continue kind = _first(block, "type") targets = tuple(_split(_first(block, "target"))) operations = tuple(_split_all(block.get("operation"))) or ("Upgrade",) if kind and targets: hook.triggers.append(Trigger(kind=kind, operations=operations, targets=targets)) if not hook.triggers: return None return hook def _first(section: dict[str, list[str]], key: str) -> str | None: """The first value for a key. Keys are already lowercased by the reader.""" values = section.get(key.lower()) return values[0] if values else None def _split_all(values: list[str] | None) -> list[str]: out: list[str] = [] for value in values or (): out.extend(_split(value)) return out def _split(value: str | None) -> list[str]: if not value: return [] return [part for part in value.replace(",", " ").split() if part] def load(dirs: tuple[Path, ...] = HOOK_DIRS) -> list[Hook]: """Every readable hook, later directories overriding earlier ones by name. That is what pacman does: a hook in /etc/pacman.d/hooks with the same filename replaces the one the package shipped. """ seen: dict[str, Hook] = {} order: list[str] = [] for directory in dirs: try: entries = sorted(directory.glob("*.hook")) except OSError: continue for path in entries: hook = _parse(path) if hook is None: continue if path.name not in seen: order.append(path.name) seen[path.name] = hook return [seen[name] for name in order] def scheduled( hooks: list[Hook], names: set[str], paths_by_package: dict[str, list[str]] | None = None, *, operation: str = "Upgrade", ) -> tuple[list[Hook], list[Hook]]: """Split the hooks that will run into (before the transaction, after it).""" paths = paths_by_package or {} firing = [h for h in hooks if h.fires_for(names, paths, operation)] before = [h for h in firing if h.when.lower().startswith("pre")] after = [h for h in firing if not h.when.lower().startswith("pre")] return before, after