linux-kernel-manager 0.1.2__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.
Files changed (45) hide show
  1. linux_kernel_manager-0.1.2.dist-info/METADATA +271 -0
  2. linux_kernel_manager-0.1.2.dist-info/RECORD +45 -0
  3. linux_kernel_manager-0.1.2.dist-info/WHEEL +4 -0
  4. linux_kernel_manager-0.1.2.dist-info/entry_points.txt +3 -0
  5. linux_kernel_manager-0.1.2.dist-info/licenses/LICENSE +675 -0
  6. lkm/__init__.py +2 -0
  7. lkm/cli/__init__.py +0 -0
  8. lkm/cli/main.py +332 -0
  9. lkm/cli/output.py +57 -0
  10. lkm/core/__init__.py +0 -0
  11. lkm/core/backends/__init__.py +54 -0
  12. lkm/core/backends/apk.py +46 -0
  13. lkm/core/backends/apt.py +77 -0
  14. lkm/core/backends/base.py +81 -0
  15. lkm/core/backends/dnf.py +44 -0
  16. lkm/core/backends/nix.py +195 -0
  17. lkm/core/backends/pacman.py +78 -0
  18. lkm/core/backends/portage.py +107 -0
  19. lkm/core/backends/xbps.py +139 -0
  20. lkm/core/backends/zypper.py +44 -0
  21. lkm/core/kernel.py +95 -0
  22. lkm/core/manager.py +243 -0
  23. lkm/core/providers/__init__.py +62 -0
  24. lkm/core/providers/base.py +88 -0
  25. lkm/core/providers/distro.py +139 -0
  26. lkm/core/providers/gentoo.py +68 -0
  27. lkm/core/providers/liquorix.py +80 -0
  28. lkm/core/providers/lkf_build.py +355 -0
  29. lkm/core/providers/local_file.py +104 -0
  30. lkm/core/providers/mainline.py +106 -0
  31. lkm/core/providers/nixos.py +76 -0
  32. lkm/core/providers/void.py +69 -0
  33. lkm/core/providers/xanmod.py +81 -0
  34. lkm/core/system.py +235 -0
  35. lkm/gui/__init__.py +0 -0
  36. lkm/gui/app.py +94 -0
  37. lkm/gui/kernel_model.py +98 -0
  38. lkm/gui/main_window.py +385 -0
  39. lkm/gui/widgets/__init__.py +0 -0
  40. lkm/gui/widgets/gentoo_compile_dialog.py +132 -0
  41. lkm/gui/widgets/kernel_view.py +121 -0
  42. lkm/gui/widgets/lkf_build_dialog.py +370 -0
  43. lkm/gui/widgets/log_panel.py +78 -0
  44. lkm/gui/widgets/note_dialog.py +38 -0
  45. lkm/qt.py +122 -0
@@ -0,0 +1,195 @@
1
+ """
2
+ nix backend — NixOS.
3
+
4
+ NixOS manages kernels declaratively via /etc/nixos/configuration.nix (channels)
5
+ or a flake.nix (flakes). This backend:
6
+
7
+ - Detects whether the system uses channels or flakes.
8
+ - Patches boot.kernelPackages in the appropriate config file.
9
+ - Runs nixos-rebuild switch to apply the change.
10
+ - Hold/unhold explains flake input pinning rather than silently failing.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import shutil
17
+ import tempfile
18
+ from collections.abc import Iterator
19
+ from pathlib import Path
20
+
21
+ from lkm.core.backends.base import PackageBackend
22
+ from lkm.core.system import privilege_escalation_cmd
23
+
24
+ _NIXPKGS_KERNEL_ATTRS = {
25
+ "linuxPackages", "linuxPackages_latest", "linuxPackages_6_6",
26
+ "linuxPackages_6_1", "linuxPackages_5_15", "linuxPackages_rt",
27
+ "linuxPackages_hardened", "linuxPackages_zen",
28
+ "linuxPackages_xanmod", "linuxPackages_xanmod_latest",
29
+ }
30
+
31
+ _NIXOS_CONFIG = Path("/etc/nixos/configuration.nix")
32
+ _NIXOS_FLAKE = Path("/etc/nixos/flake.nix")
33
+
34
+ _KERNEL_PKG_RE = re.compile(
35
+ r"(boot\.kernelPackages\s*=\s*)([^;]+)(;)",
36
+ re.MULTILINE,
37
+ )
38
+
39
+
40
+ def _is_flake_system() -> bool:
41
+ return _NIXOS_FLAKE.exists()
42
+
43
+
44
+ def _config_path() -> Path:
45
+ return _NIXOS_FLAKE if _is_flake_system() else _NIXOS_CONFIG
46
+
47
+
48
+ def _read_config(path: Path) -> str:
49
+ try:
50
+ return path.read_text()
51
+ except OSError:
52
+ return ""
53
+
54
+
55
+ def _write_config(path: Path, text: str) -> tuple[int, str, str]:
56
+ priv = privilege_escalation_cmd()
57
+ with tempfile.NamedTemporaryFile("w", suffix=".nix", delete=False) as f:
58
+ f.write(text)
59
+ tmp = f.name
60
+ import subprocess
61
+ result = subprocess.run(priv + ["cp", tmp, str(path)], capture_output=True, text=True)
62
+ os.unlink(tmp)
63
+ return result.returncode, result.stdout, result.stderr
64
+
65
+
66
+ def _patch_kernel_attr(text: str, new_attr: str) -> tuple[str, bool]:
67
+ """Replace boot.kernelPackages = <old>; or insert it if absent."""
68
+ if _KERNEL_PKG_RE.search(text):
69
+ patched = _KERNEL_PKG_RE.sub(lambda m: m.group(1) + new_attr + m.group(3), text)
70
+ return patched, patched != text
71
+ insert = f"\n boot.kernelPackages = {new_attr};\n"
72
+ idx = text.rfind("}")
73
+ if idx == -1:
74
+ return text + insert, True
75
+ return text[:idx] + insert + text[idx:], True
76
+
77
+
78
+ def _nixpkgs_attr_for(package_name: str) -> str:
79
+ """Convert a package name / version string to a pkgs.linuxPackages_* expression."""
80
+ if package_name.startswith("pkgs."):
81
+ return package_name
82
+ if package_name in _NIXPKGS_KERNEL_ATTRS:
83
+ return f"pkgs.{package_name}"
84
+ m = re.match(r"^(\d+)\.(\d+)", package_name)
85
+ if m:
86
+ return f"pkgs.linuxPackages_{m.group(1)}_{m.group(2)}"
87
+ return f"pkgs.{package_name}"
88
+
89
+
90
+ class NixBackend(PackageBackend):
91
+
92
+ @property
93
+ def name(self) -> str:
94
+ return "nix"
95
+
96
+ @staticmethod
97
+ def available() -> bool:
98
+ return bool(shutil.which("nixos-rebuild") or shutil.which("nix-env"))
99
+
100
+ @staticmethod
101
+ def is_nixos() -> bool:
102
+ try:
103
+ return "nixos" in open("/etc/os-release").read().lower()
104
+ except OSError:
105
+ return False
106
+
107
+ @staticmethod
108
+ def is_flake() -> bool:
109
+ return _is_flake_system()
110
+
111
+ def install_packages(self, packages: list[str]) -> Iterator[str]:
112
+ if not packages:
113
+ return
114
+ attr = _nixpkgs_attr_for(packages[0])
115
+ cfg = _config_path()
116
+ text = _read_config(cfg)
117
+ if not text:
118
+ yield f"Cannot read {cfg} — is this NixOS?\n"
119
+ yield f" boot.kernelPackages = {attr};\n"
120
+ return
121
+ patched, changed = _patch_kernel_attr(text, attr)
122
+ if not changed:
123
+ yield f"boot.kernelPackages already set to {attr} in {cfg}\n"
124
+ else:
125
+ yield f"Patching {cfg}: boot.kernelPackages = {attr}\n"
126
+ rc, _, err = _write_config(cfg, patched)
127
+ if rc != 0:
128
+ yield f"Failed to write {cfg}: {err}\n"
129
+ yield f"Apply manually: boot.kernelPackages = {attr};\n"
130
+ return
131
+ yield f"Written {cfg}\n"
132
+ yield from self._rebuild()
133
+
134
+ def install_local(self, path: str) -> Iterator[str]:
135
+ yield f"Adding {path} to the Nix store...\n"
136
+ if shutil.which("nix-store"):
137
+ priv = privilege_escalation_cmd()
138
+ yield from self._run_streaming(priv + ["nix-store", "--add-fixed", "sha256", path])
139
+ yield f"Add the store path to boot.kernelPackages in {_config_path()}\n"
140
+ yield "Then run: sudo nixos-rebuild switch\n"
141
+
142
+ def remove_packages(self, packages: list[str], purge: bool = False) -> Iterator[str]:
143
+ yield f"Reverting boot.kernelPackages to pkgs.linuxPackages in {_config_path()}\n"
144
+ yield from self.install_packages(["linuxPackages"])
145
+
146
+ def hold(self, packages: list[str]) -> tuple[int, str, str]:
147
+ if _is_flake_system():
148
+ msg = (
149
+ "NixOS (flakes): pin nixpkgs to a specific revision in flake.lock:\n"
150
+ " nix flake lock --update-input nixpkgs "
151
+ "--override-input nixpkgs github:NixOS/nixpkgs/<commit>\n"
152
+ )
153
+ else:
154
+ msg = (
155
+ "NixOS (channels): pin to a fixed channel URL:\n"
156
+ " sudo nix-channel --add "
157
+ "https://releases.nixos.org/nixos/<version>/nixos-<version> nixos\n"
158
+ " sudo nix-channel --update\n"
159
+ )
160
+ return 0, msg, ""
161
+
162
+ def unhold(self, packages: list[str]) -> tuple[int, str, str]:
163
+ if _is_flake_system():
164
+ msg = "NixOS (flakes): unpin by running: nix flake update\n"
165
+ else:
166
+ msg = (
167
+ "NixOS (channels): unpin by switching back to the rolling channel:\n"
168
+ " sudo nix-channel --add https://nixos.org/channels/nixos-unstable nixos\n"
169
+ " sudo nix-channel --update\n"
170
+ )
171
+ return 0, msg, ""
172
+
173
+ def is_installed(self, package: str) -> bool:
174
+ attr = _nixpkgs_attr_for(package).replace("pkgs.", "")
175
+ current = self.current_kernel_attr()
176
+ return bool(current) and attr in current
177
+
178
+ def current_kernel_attr(self) -> str:
179
+ text = _read_config(_config_path())
180
+ m = _KERNEL_PKG_RE.search(text)
181
+ return m.group(2).strip() if m else ""
182
+
183
+ def list_available_kernels(self) -> list[str]:
184
+ rc, out, _ = self._run(["nix-env", "-qaP", "--no-name", "linuxPackages"])
185
+ if rc != 0:
186
+ return list(_NIXPKGS_KERNEL_ATTRS)
187
+ return [line.strip() for line in out.splitlines() if "linuxPackages" in line]
188
+
189
+ def _rebuild(self) -> Iterator[str]:
190
+ if not shutil.which("nixos-rebuild"):
191
+ yield "nixos-rebuild not found — run: sudo nixos-rebuild switch\n"
192
+ return
193
+ priv = privilege_escalation_cmd()
194
+ yield "Running nixos-rebuild switch...\n"
195
+ yield from self._run_streaming(priv + ["nixos-rebuild", "switch"])
@@ -0,0 +1,78 @@
1
+ """pacman backend — Arch, Manjaro, EndeavourOS, CachyOS, and derivatives."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Iterator
5
+
6
+ from lkm.core.backends.base import PackageBackend
7
+ from lkm.core.system import privilege_escalation_cmd
8
+
9
+
10
+ class PacmanBackend(PackageBackend):
11
+
12
+ @property
13
+ def name(self) -> str:
14
+ return "pacman"
15
+
16
+ def install_packages(self, packages: list[str]) -> Iterator[str]:
17
+ priv = privilege_escalation_cmd()
18
+ yield from self._run_streaming(
19
+ priv + ["pacman", "-S", "--noconfirm", "--needed"] + packages
20
+ )
21
+
22
+ def install_local(self, path: str) -> Iterator[str]:
23
+ priv = privilege_escalation_cmd()
24
+ yield from self._run_streaming(
25
+ priv + ["pacman", "-U", "--noconfirm", path]
26
+ )
27
+
28
+ def remove_packages(self, packages: list[str], purge: bool = False) -> Iterator[str]:
29
+ priv = privilege_escalation_cmd()
30
+ flags = ["-Rns"] if purge else ["-R"]
31
+ yield from self._run_streaming(
32
+ priv + ["pacman"] + flags + ["--noconfirm"] + packages
33
+ )
34
+
35
+ def hold(self, packages: list[str]) -> tuple[int, str, str]:
36
+ # pacman uses IgnorePkg in /etc/pacman.conf; we append to it
37
+ import re
38
+ priv = privilege_escalation_cmd()
39
+ conf = "/etc/pacman.conf"
40
+ try:
41
+ text = open(conf).read()
42
+ except OSError as e:
43
+ return 1, "", str(e)
44
+ pkg_str = " ".join(packages)
45
+ if "IgnorePkg" in text:
46
+ text = re.sub(
47
+ r"(IgnorePkg\s*=\s*)(.*)",
48
+ lambda m: m.group(1) + (m.group(2) + " " + pkg_str).strip(),
49
+ text,
50
+ )
51
+ else:
52
+ text = text.replace("[options]", f"[options]\nIgnorePkg = {pkg_str}", 1)
53
+ import tempfile
54
+ with tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False) as f:
55
+ f.write(text)
56
+ tmp = f.name
57
+ rc, out, err = self._run(priv + ["cp", tmp, conf])
58
+ return rc, out, err
59
+
60
+ def unhold(self, packages: list[str]) -> tuple[int, str, str]:
61
+ import re
62
+ priv = privilege_escalation_cmd()
63
+ conf = "/etc/pacman.conf"
64
+ try:
65
+ text = open(conf).read()
66
+ except OSError as e:
67
+ return 1, "", str(e)
68
+ for pkg in packages:
69
+ text = re.sub(rf"\b{re.escape(pkg)}\b\s*", "", text)
70
+ import tempfile
71
+ with tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False) as f:
72
+ f.write(text)
73
+ tmp = f.name
74
+ return self._run(priv + ["cp", tmp, conf])
75
+
76
+ def is_installed(self, package: str) -> bool:
77
+ rc, _, _ = self._run(["pacman", "-Q", package])
78
+ return rc == 0
@@ -0,0 +1,107 @@
1
+ """portage backend — Gentoo."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
8
+
9
+ from lkm.core.backends.base import PackageBackend
10
+ from lkm.core.system import privilege_escalation_cmd
11
+
12
+ _KERNEL_SOURCES_DIR = Path("/usr/src")
13
+
14
+
15
+ class PortageBackend(PackageBackend):
16
+
17
+ @property
18
+ def name(self) -> str:
19
+ return "portage"
20
+
21
+ def install_packages(self, packages: list[str]) -> Iterator[str]:
22
+ priv = privilege_escalation_cmd()
23
+ yield from self._run_streaming(
24
+ priv + ["emerge", "--ask=n", "--quiet-build"] + packages
25
+ )
26
+
27
+ def install_local(self, path: str) -> Iterator[str]:
28
+ # Portage doesn't install arbitrary binary packages; delegate to
29
+ # a manual unpack with a clear message.
30
+ yield f"Portage does not support binary package install from {path}.\n"
31
+ yield "Use 'lkm build' to compile from source on Gentoo.\n"
32
+
33
+ def remove_packages(self, packages: list[str], purge: bool = False) -> Iterator[str]:
34
+ priv = privilege_escalation_cmd()
35
+ flags = ["--depclean"] if purge else ["--unmerge"]
36
+ yield from self._run_streaming(
37
+ priv + ["emerge"] + flags + packages
38
+ )
39
+
40
+ def hold(self, packages: list[str]) -> tuple[int, str, str]:
41
+ # Write package.mask entries
42
+ priv = privilege_escalation_cmd()
43
+ mask_dir = Path("/etc/portage/package.mask")
44
+ mask_dir.mkdir(parents=True, exist_ok=True)
45
+ mask_file = mask_dir / "lkm-held"
46
+ existing = mask_file.read_text() if mask_file.exists() else ""
47
+ new_entries = "\n".join(f"={p}" for p in packages if f"={p}" not in existing)
48
+ if new_entries:
49
+ return self._run(
50
+ priv + ["tee", "-a", str(mask_file)],
51
+ input=new_entries + "\n",
52
+ )
53
+ return 0, "", ""
54
+
55
+ def unhold(self, packages: list[str]) -> tuple[int, str, str]:
56
+ import re
57
+ mask_file = Path("/etc/portage/package.mask/lkm-held")
58
+ if not mask_file.exists():
59
+ return 0, "", ""
60
+ text = mask_file.read_text()
61
+ for pkg in packages:
62
+ text = re.sub(rf"^={re.escape(pkg)}\n?", "", text, flags=re.MULTILINE)
63
+ priv = privilege_escalation_cmd()
64
+ return self._run(priv + ["tee", str(mask_file)], input=text)
65
+
66
+ def is_installed(self, package: str) -> bool:
67
+ rc, _, _ = self._run(["qlist", "-I", package])
68
+ return rc == 0
69
+
70
+ # ------------------------------------------------------------------
71
+ # Gentoo-specific helpers used by the Gentoo provider
72
+ # ------------------------------------------------------------------
73
+
74
+ def list_kernel_sources(self) -> list[str]:
75
+ """Return paths to installed kernel source trees under /usr/src."""
76
+ if not _KERNEL_SOURCES_DIR.exists():
77
+ return []
78
+ return sorted(
79
+ str(p) for p in _KERNEL_SOURCES_DIR.iterdir()
80
+ if p.is_dir() and p.name.startswith("linux-")
81
+ )
82
+
83
+ def has_genkernel(self) -> bool:
84
+ return bool(shutil.which("genkernel"))
85
+
86
+ def compile(self, src: str, use_genkernel: bool, jobs: int) -> Iterator[str]:
87
+ """Stream kernel compilation output."""
88
+ priv = privilege_escalation_cmd()
89
+ if use_genkernel:
90
+ yield from self._run_streaming(
91
+ priv + ["genkernel", "--kernel-config=/proc/config.gz", "all"],
92
+ cwd=src,
93
+ )
94
+ else:
95
+ j = jobs if jobs > 0 else os.cpu_count() or 1
96
+ yield from self._run_streaming(
97
+ priv + ["make", f"-j{j}"],
98
+ cwd=src,
99
+ )
100
+ yield from self._run_streaming(
101
+ priv + ["make", "modules_install"],
102
+ cwd=src,
103
+ )
104
+ yield from self._run_streaming(
105
+ priv + ["make", "install"],
106
+ cwd=src,
107
+ )
@@ -0,0 +1,139 @@
1
+ """
2
+ xbps backend — Void Linux.
3
+
4
+ Void ships its own kernel packages (linux, linux-lts, linux-mainline) via the
5
+ official xbps repository. This backend handles install/remove/hold for those
6
+ packages and for locally built .xbps archives produced by lkf.
7
+
8
+ Hold/unhold is implemented via xbps-pkgdb -m hold/unhold, which is the
9
+ official Void mechanism for pinning packages.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import shutil
15
+ from collections.abc import Iterator
16
+
17
+ from lkm.core.backends.base import PackageBackend
18
+ from lkm.core.system import privilege_escalation_cmd
19
+
20
+
21
+ class XbpsBackend(PackageBackend):
22
+
23
+ @property
24
+ def name(self) -> str:
25
+ return "xbps"
26
+
27
+ # ------------------------------------------------------------------
28
+ # Availability guard
29
+ # ------------------------------------------------------------------
30
+
31
+ @staticmethod
32
+ def available() -> bool:
33
+ return bool(shutil.which("xbps-install"))
34
+
35
+ # ------------------------------------------------------------------
36
+ # Core operations
37
+ # ------------------------------------------------------------------
38
+
39
+ def install_packages(self, packages: list[str]) -> Iterator[str]:
40
+ priv = privilege_escalation_cmd()
41
+ # -y: assume yes; -S: sync repos first
42
+ yield from self._run_streaming(
43
+ priv + ["xbps-install", "-Sy"] + packages
44
+ )
45
+
46
+ def install_local(self, path: str) -> Iterator[str]:
47
+ """
48
+ Install a locally built .xbps package.
49
+
50
+ xbps-install can install from a local repository directory. We point
51
+ it at the directory containing the package file and install by name.
52
+ """
53
+ import os
54
+ priv = privilege_escalation_cmd()
55
+ pkg_dir = os.path.dirname(os.path.abspath(path))
56
+ # xbps-install -R <repodir> -y <pkgname>
57
+ # The package name is the filename without the arch+suffix.
58
+ pkg_name = os.path.basename(path).split("-")[0]
59
+ yield from self._run_streaming(
60
+ priv + ["xbps-install", "-R", pkg_dir, "-y", pkg_name]
61
+ )
62
+
63
+ def remove_packages(self, packages: list[str], purge: bool = False) -> Iterator[str]:
64
+ priv = privilege_escalation_cmd()
65
+ # -R: remove recursively (orphaned deps); -y: assume yes
66
+ flags = ["-Ry"] if purge else ["-y"]
67
+ yield from self._run_streaming(
68
+ priv + ["xbps-remove"] + flags + packages
69
+ )
70
+
71
+ def hold(self, packages: list[str]) -> tuple[int, str, str]:
72
+ """
73
+ Pin packages with xbps-pkgdb -m hold.
74
+
75
+ A held package is excluded from xbps-install -u (system upgrades)
76
+ but can still be explicitly upgraded.
77
+ """
78
+ priv = privilege_escalation_cmd()
79
+ rc, out, err = 0, "", ""
80
+ for pkg in packages:
81
+ r, o, e = self._run(priv + ["xbps-pkgdb", "-m", "hold", pkg])
82
+ rc = rc or r
83
+ out += o
84
+ err += e
85
+ return rc, out, err
86
+
87
+ def unhold(self, packages: list[str]) -> tuple[int, str, str]:
88
+ priv = privilege_escalation_cmd()
89
+ rc, out, err = 0, "", ""
90
+ for pkg in packages:
91
+ r, o, e = self._run(priv + ["xbps-pkgdb", "-m", "unhold", pkg])
92
+ rc = rc or r
93
+ out += o
94
+ err += e
95
+ return rc, out, err
96
+
97
+ def is_installed(self, package: str) -> bool:
98
+ rc, out, _ = self._run(["xbps-query", package])
99
+ return rc == 0 and "state: installed" in out.lower()
100
+
101
+ # ------------------------------------------------------------------
102
+ # Void-specific helpers
103
+ # ------------------------------------------------------------------
104
+
105
+ def list_available_kernels(self) -> list[str]:
106
+ """
107
+ Return kernel package names available in the xbps repos.
108
+
109
+ xbps-query -Rs output format (varies by version):
110
+ [-] linux-6.6.30_1 The Linux kernel and modules
111
+ [*] linux-6.6.30_1 The Linux kernel and modules (installed)
112
+
113
+ We accept both the flag-prefixed form and bare pkgver strings.
114
+ Only packages whose name starts with "linux" and whose name does
115
+ NOT contain "-headers" or "-dbg" are returned.
116
+ """
117
+ rc, out, _ = self._run(["xbps-query", "-Rs", "linux"])
118
+ if rc != 0:
119
+ return []
120
+
121
+ names: list[str] = []
122
+ # Match: optional [x] prefix, then pkgver like linux-6.6.30_1
123
+ _LINE_RE = re.compile(r"(?:\[.\]\s+)?(linux[a-zA-Z0-9._+-]+?)(?:_\d+)?\s")
124
+ for line in out.splitlines():
125
+ m = _LINE_RE.search(line)
126
+ if not m:
127
+ continue
128
+ name = m.group(1)
129
+ # Skip sub-packages
130
+ if any(x in name for x in ("-headers", "-dbg", "-doc", "-devel")):
131
+ continue
132
+ if name not in names:
133
+ names.append(name)
134
+ return names
135
+
136
+ def sync(self) -> Iterator[str]:
137
+ """Sync xbps repository index."""
138
+ priv = privilege_escalation_cmd()
139
+ yield from self._run_streaming(priv + ["xbps-install", "-S"])
@@ -0,0 +1,44 @@
1
+ """zypper backend — openSUSE Leap, Tumbleweed, SLES, Regata."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Iterator
5
+
6
+ from lkm.core.backends.base import PackageBackend
7
+ from lkm.core.system import privilege_escalation_cmd
8
+
9
+
10
+ class ZypperBackend(PackageBackend):
11
+
12
+ @property
13
+ def name(self) -> str:
14
+ return "zypper"
15
+
16
+ def install_packages(self, packages: list[str]) -> Iterator[str]:
17
+ priv = privilege_escalation_cmd()
18
+ yield from self._run_streaming(
19
+ priv + ["zypper", "--non-interactive", "install"] + packages
20
+ )
21
+
22
+ def install_local(self, path: str) -> Iterator[str]:
23
+ priv = privilege_escalation_cmd()
24
+ yield from self._run_streaming(
25
+ priv + ["zypper", "--non-interactive", "install", path]
26
+ )
27
+
28
+ def remove_packages(self, packages: list[str], purge: bool = False) -> Iterator[str]:
29
+ priv = privilege_escalation_cmd()
30
+ yield from self._run_streaming(
31
+ priv + ["zypper", "--non-interactive", "remove"] + packages
32
+ )
33
+
34
+ def hold(self, packages: list[str]) -> tuple[int, str, str]:
35
+ priv = privilege_escalation_cmd()
36
+ return self._run(priv + ["zypper", "addlock"] + packages)
37
+
38
+ def unhold(self, packages: list[str]) -> tuple[int, str, str]:
39
+ priv = privilege_escalation_cmd()
40
+ return self._run(priv + ["zypper", "removelock"] + packages)
41
+
42
+ def is_installed(self, package: str) -> bool:
43
+ rc, _, _ = self._run(["rpm", "-q", package])
44
+ return rc == 0
lkm/core/kernel.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ Kernel data model: KernelEntry, KernelVersion, KernelFamily, KernelStatus.
3
+
4
+ Unchanged from ukm except KernelFamily gains LKF_BUILD for locally compiled
5
+ kernels produced by the lkf build pipeline.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from functools import total_ordering
13
+
14
+
15
+ class KernelFamily(Enum):
16
+ MAINLINE = "mainline" # Ubuntu Mainline PPA
17
+ XANMOD = "xanmod"
18
+ LIQUORIX = "liquorix"
19
+ DISTRO = "distro" # whatever the distro ships
20
+ GENTOO = "gentoo"
21
+ LOCAL_FILE = "local_file" # pre-built .deb/.rpm/.pkg.tar.* dropped in
22
+ LKF_BUILD = "lkf_build" # compiled by the lkf build pipeline
23
+
24
+
25
+ class KernelStatus(Enum):
26
+ AVAILABLE = "available"
27
+ INSTALLED = "installed"
28
+ RUNNING = "running"
29
+ HELD = "held"
30
+
31
+
32
+ @total_ordering
33
+ @dataclass
34
+ class KernelVersion:
35
+ """Comparable semantic kernel version, e.g. 6.12.3."""
36
+ major: int
37
+ minor: int
38
+ patch: int
39
+ extra: str = "" # e.g. "-xanmod1", "-rt4", "-lkf"
40
+
41
+ _VERSION_RE = re.compile(
42
+ r"^(\d+)\.(\d+)(?:\.(\d+))?(.*)$"
43
+ )
44
+
45
+ @classmethod
46
+ def parse(cls, s: str) -> KernelVersion:
47
+ m = cls._VERSION_RE.match(s.strip())
48
+ if not m:
49
+ raise ValueError(f"Cannot parse kernel version: {s!r}")
50
+ major = int(m.group(1))
51
+ minor = int(m.group(2))
52
+ patch = int(m.group(3) or 0)
53
+ extra = m.group(4) or ""
54
+ return cls(major=major, minor=minor, patch=patch, extra=extra)
55
+
56
+ def __str__(self) -> str:
57
+ return f"{self.major}.{self.minor}.{self.patch}{self.extra}"
58
+
59
+ def __eq__(self, other: object) -> bool:
60
+ if not isinstance(other, KernelVersion):
61
+ return NotImplemented
62
+ return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
63
+
64
+ def __lt__(self, other: KernelVersion) -> bool:
65
+ return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
66
+
67
+ def __hash__(self) -> int:
68
+ return hash((self.major, self.minor, self.patch, self.extra))
69
+
70
+
71
+ @dataclass
72
+ class KernelEntry:
73
+ """A single kernel known to lkm — may be available, installed, or running."""
74
+ version: KernelVersion
75
+ family: KernelFamily
76
+ flavor: str # e.g. "generic", "rt", "xanmod-edge", "lkf-tkg"
77
+ arch: str # normalised arch string
78
+ provider_id: str # matches KernelProvider.id
79
+ status: KernelStatus = KernelStatus.AVAILABLE
80
+ held: bool = False
81
+ notes: str = ""
82
+ # For LOCAL_FILE / LKF_BUILD: path to the package file
83
+ local_path: str | None = field(default=None)
84
+
85
+ @property
86
+ def display_name(self) -> str:
87
+ return f"{self.version} ({self.family.value}, {self.flavor})"
88
+
89
+ @property
90
+ def is_installed(self) -> bool:
91
+ return self.status in (KernelStatus.INSTALLED, KernelStatus.RUNNING, KernelStatus.HELD)
92
+
93
+ @property
94
+ def is_running(self) -> bool:
95
+ return self.status == KernelStatus.RUNNING