pkgwise 0.1.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,199 @@
1
+ """Transparent, conservative update-priority scoring.
2
+
3
+ PkgWise never claims it can tell how "important" an update is. Instead it
4
+ collects *observable facts* about a package (security metadata, whether it is
5
+ a kernel, a core system component or a system library) and maps those facts to
6
+ a category through a small, documented scoring table.
7
+
8
+ The scoring is deliberately conservative: facts that are not available add no
9
+ points and surface as "Security status: Unknown" rather than guesses.
10
+
11
+ Points table
12
+ ------------
13
+ ================ ====
14
+ fact pts
15
+ ================ ====
16
+ security update 4
17
+ kernel/likely 3
18
+ reboot
19
+ core system 2
20
+ component
21
+ system library 2
22
+ small utility -1
23
+ ================ ====
24
+
25
+ Thresholds
26
+ ----------
27
+ ==== ======== ========
28
+ pts category reason shown
29
+ ==== ======== ========
30
+ >=4 CRITICAL security-related update
31
+ >=3 HIGH kernel/reboot, core component
32
+ >=1 MEDIUM system library / core component
33
+ 0 MEDIUM default (no facts)
34
+ <0 LOW small utility
35
+ ==== ======== ========
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import re
41
+ from typing import Iterable, List, Tuple
42
+
43
+ from pkgwise.core.models import PackageUpdate, Priority
44
+
45
+ #: Facts fixed by name pattern. These are *system properties* of well-known
46
+ #: base packages (a kernel update requires a reboot, glibc underpins the
47
+ #: system), not an opinion about whether the update matters to the user.
48
+ KERNEL_PREFIXES = (
49
+ "linux",
50
+ "kernel",
51
+ "linux-image",
52
+ "linux-headers",
53
+ "linux-modules",
54
+ )
55
+ CORE_COMPONENT_PREFIXES = (
56
+ "systemd",
57
+ "bash",
58
+ "coreutils",
59
+ "binutils",
60
+ "util-linux",
61
+ "udev",
62
+ "dbus",
63
+ "pam",
64
+ "iproute2",
65
+ "glibc",
66
+ "busybox",
67
+ "initramfs",
68
+ "shim",
69
+ "grub",
70
+ "grub2",
71
+ )
72
+ LIBRARY_PREFIXES = (
73
+ "lib",
74
+ "libc",
75
+ "libssl",
76
+ "openssl",
77
+ "gnutls",
78
+ "zlib",
79
+ "ssl",
80
+ "ncurses",
81
+ "readline",
82
+ "python3",
83
+ "openssl",
84
+ "perl",
85
+ )
86
+
87
+ #: Rough retry-too-low threshold for the "small utility" heuristic.
88
+ _SMALL_UTILITY_BYTES = 15 * 1024 * 1024
89
+
90
+
91
+ def analyze_priority(update: PackageUpdate) -> Tuple[Priority, List[str]]:
92
+ """Score one update and return ``(priority, human_reasons)``.
93
+
94
+ The reasons are the *only* justification for the category; the UI displays
95
+ them verbatim so nothing is labeled important without explanation.
96
+ """
97
+ facts = _collect_facts(update)
98
+ return _score(facts)
99
+
100
+
101
+ def _collect_facts(update: PackageUpdate) -> List[str]:
102
+ """Return the list of fact labels that apply to this update."""
103
+ facts: List[str] = []
104
+ name = update.name or ""
105
+
106
+ if update.security_update is True:
107
+ facts.append("security-update")
108
+ if _is_kernel(name):
109
+ facts.append("kernel")
110
+ if _is_core_component(name):
111
+ facts.append("core-component")
112
+ elif _is_system_library(name):
113
+ facts.append("system-library")
114
+ elif _is_small_utility(update):
115
+ facts.append("small-utility")
116
+
117
+ if update.installed_size is None and update.download_size is None:
118
+ facts.append("unknown-metadata")
119
+ return facts
120
+
121
+
122
+ def _score(facts: Iterable[str]) -> Tuple[Priority, List[str]]:
123
+ points = 0
124
+ reasons: List[str] = []
125
+ fact_set = set(facts)
126
+
127
+ if "security-update" in fact_set:
128
+ points += 4
129
+ reasons.append("Security-related update")
130
+ if "kernel" in fact_set:
131
+ points += 3
132
+ reasons.append("Kernel package — likely requires rebooting")
133
+ if "core-component" in fact_set:
134
+ points += 2
135
+ reasons.append("Core system component")
136
+ elif "system-library" in fact_set:
137
+ points += 2
138
+ reasons.append("System library")
139
+ if "small-utility" in fact_set:
140
+ points -= 1
141
+
142
+ if points >= 4:
143
+ priority = Priority.CRITICAL
144
+ elif points >= 3:
145
+ priority = Priority.HIGH
146
+ elif points >= 1:
147
+ priority = Priority.MEDIUM
148
+ elif points == 0:
149
+ priority = Priority.MEDIUM
150
+ else:
151
+ priority = Priority.LOW
152
+ reasons.append("Small utility — low overall impact")
153
+
154
+ if not reasons:
155
+ reasons.append("No security or system-critical metadata was detected.")
156
+ return priority, reasons
157
+
158
+
159
+ # -- fact helpers ------------------------------------------------------------
160
+
161
+
162
+ def _is_kernel(name: str) -> bool:
163
+ """True for kernel packages (linux, linux-lts, linux-zen, ...)."""
164
+ return name.startswith(KERNEL_PREFIXES)
165
+
166
+
167
+ def _is_core_component(name: str) -> bool:
168
+ """True for base system components such as systemd, bash or grub."""
169
+ return name.startswith(CORE_COMPONENT_PREFIXES)
170
+
171
+
172
+ def _is_system_library(name: str) -> bool:
173
+ """True for shared libraries and language runtimes (libc, libssl, ...)."""
174
+ if name.startswith(LIBRARY_PREFIXES):
175
+ return True
176
+ # Versioned runtime names like libc6, libssl3, python3.11
177
+ return bool(re.match(r"^(lib|c|python\d)(\d|[a-z])", name))
178
+
179
+
180
+ def _is_small_utility(update: PackageUpdate) -> bool:
181
+ """True when an update is small and not part of the base system.
182
+
183
+ Uses only the sizes we actually measured; unknown sizes never score here.
184
+ """
185
+ if _is_kernel(update.name or "") or _is_core_component(update.name or "") or _is_system_library(update.name or ""):
186
+ return False
187
+ size = update.download_size if update.download_size is not None else update.installed_size
188
+ return size is not None and size < _SMALL_UTILITY_BYTES
189
+
190
+
191
+ def priority_emoji(priority: Priority) -> str:
192
+ """Return the colored label used across the UI for a priority."""
193
+ icons = {
194
+ Priority.CRITICAL: "🔴 CRITICAL",
195
+ Priority.HIGH: "🟠 HIGH",
196
+ Priority.MEDIUM: "🟡 MEDIUM",
197
+ Priority.LOW: "🟢 LOW",
198
+ }
199
+ return icons[priority]
@@ -0,0 +1,82 @@
1
+ """Disk storage detection.
2
+
3
+ Uses :mod:`psutil` when available for reliable cross-platform statistics.
4
+ When psutil is not installed, degrades to the standard-library
5
+ ``shutil.disk_usage`` — 100% of the functionality with zero hard dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Optional
12
+
13
+ from pkgwise.core.models import StorageInfo
14
+
15
+ _ROOT_PATH = "/"
16
+
17
+
18
+ def _psutil_partitions() -> list:
19
+ """psutil partitions with filesystem types, or empty list if unavailable."""
20
+ try:
21
+ import psutil # type: ignore[import-not-found]
22
+
23
+ return psutil.disk_partitions(all=False)
24
+ except Exception:
25
+ return []
26
+
27
+
28
+ def _psutil_usage(path: str) -> Optional[tuple]:
29
+ """Return (total, used, free) from psutil or ``None`` on failure."""
30
+ try:
31
+ import psutil # type: ignore[import-not-found]
32
+
33
+ usage = psutil.disk_usage(path)
34
+ return (usage.total, usage.used, usage.free)
35
+ except Exception:
36
+ return None
37
+
38
+
39
+ def get_storage_info(path: str = _ROOT_PATH) -> StorageInfo:
40
+ """Return ``StorageInfo`` for the filesystem containing *path*.
41
+
42
+ Resolution order:
43
+ 1. psutil ``disk_partitions`` + ``disk_usage`` (excludes pseudo-filesystems).
44
+ 2. psutil ``disk_usage`` directly on the path.
45
+ 3. stdlib ``shutil.disk_usage`` on the path.
46
+ """
47
+ usage = _psutil_by_mount(path) or _psutil_usage(path) or _shutil_usage(path)
48
+ total, used, free = usage
49
+ return StorageInfo(path=path, total_bytes=total, used_bytes=used, free_bytes=free)
50
+
51
+
52
+ def _psutil_by_mount(path: str) -> Optional[tuple]:
53
+ """Find the mount point for *path* and read its usage via psutil."""
54
+ for partition in _psutil_partitions():
55
+ mountpoint = partition.mountpoint
56
+ if _is_contained(path, mountpoint):
57
+ usage = _psutil_usage(mountpoint)
58
+ if usage is not None:
59
+ return usage
60
+ return None
61
+
62
+
63
+ def _is_contained(path: str, mountpoint: str) -> bool:
64
+ if not mountpoint:
65
+ return False
66
+ try:
67
+ real_path = os.path.realpath(path)
68
+ real_mount = os.path.realpath(mountpoint)
69
+ except (AttributeError, ValueError): # invalid paths
70
+ return False
71
+ if real_path == real_mount:
72
+ return True
73
+ if real_mount == os.sep:
74
+ return real_path.startswith(os.sep)
75
+ return real_path.startswith(real_mount + os.sep)
76
+
77
+
78
+ def _shutil_usage(path: str) -> tuple:
79
+ import shutil
80
+
81
+ usage = shutil.disk_usage(path)
82
+ return (usage.total, usage.used, usage.free)
pkgwise/mockdata.py ADDED
@@ -0,0 +1,43 @@
1
+ """Mock package updates for development and testing.
2
+
3
+ The TUI can be run in ``--mock`` mode (or with ``PKGWISE_MOCK=1``) so it can be
4
+ developed — and previewed — on any machine, without a specific Linux
5
+ distribution or package manager. Production code never loads this module; it
6
+ exists only for local preview, tests and screenshots.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import List
12
+
13
+ from pkgwise.core.models import PackageUpdate
14
+ from pkgwise.core.priority import analyze_priority
15
+
16
+ _MB = 1024 * 1024
17
+
18
+
19
+ def mock_packages() -> List[PackageUpdate]:
20
+ """Return a realistic set of mock updates, pre-scored by the real analyzer."""
21
+ rows = [
22
+ PackageUpdate("linux", "6.16.8", "6.16.9", 142 * _MB, 720 * _MB),
23
+ PackageUpdate("openssl", "3.5.2", "3.5.3", 8 * _MB, 24 * _MB, security_update=True),
24
+ PackageUpdate("firefox", "142.0", "142.0.1", 87 * _MB, 380 * _MB),
25
+ PackageUpdate("htop", "3.4", "3.5", 1 * _MB, 3 * _MB),
26
+ PackageUpdate("systemd", "257.1", "257.2", 12 * _MB, 45 * _MB),
27
+ PackageUpdate("libssl3", "3.5.2", "3.5.3", 2 * _MB, 9 * _MB, security_update=True),
28
+ PackageUpdate("python3", "3.14.2", "3.14.3", 34 * _MB, 160 * _MB),
29
+ PackageUpdate("vim", "9.1", "9.2", 4 * _MB, 20 * _MB),
30
+ ]
31
+ return [_prioritized(u) for u in rows]
32
+
33
+
34
+ def _prioritized(update: PackageUpdate) -> PackageUpdate:
35
+ priority, reasons = analyze_priority(update)
36
+ return update.prioritized(priority, reasons)
37
+
38
+
39
+ def enable_mock_mode() -> bool:
40
+ """True when a mock-mode flag is present in the environment."""
41
+ import os
42
+
43
+ return os.environ.get("PKGWISE_MOCK", "").strip().lower() in {"1", "true", "yes", "on"}
pkgwise/py.typed ADDED
File without changes
pkgwise/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Textual user interface: the app, screens and reusable widgets."""
pkgwise/ui/app.py ADDED
@@ -0,0 +1,228 @@
1
+ """PkgWise Textual application.
2
+
3
+ Assembly point: loads analysis (optionally with mock data), mounts the main
4
+ screen, and owns the *safe* update flow (check disk space, check elevation,
5
+ then either run the command as root or clearly show the command to run).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import List, Optional
12
+
13
+ from rich.text import Text
14
+ from textual import work
15
+ from textual.app import App
16
+ from textual.screen import Screen
17
+
18
+ from pkgwise import __version__
19
+ from pkgwise.core import analyzer as analyzer_mod
20
+ from pkgwise.core.analyzer import InsufficientDiskSpace, RequiresElevation, UpdateGated
21
+ from pkgwise.core.models import AnalysisResult, PackageUpdate
22
+ from pkgwise.mockdata import enable_mock_mode, mock_packages
23
+ from pkgwise.utils.formatting import format_bytes
24
+ from pkgwise.ui.screens import (
25
+ BlockedScreen,
26
+ ErrorScreen,
27
+ MainScreen,
28
+ ResultScreen,
29
+ )
30
+
31
+ #: Mock data for local preview/tests; production never enables this.
32
+ MOCK_UPDATE_NAME = "__pkgwise_mock__"
33
+
34
+
35
+ class PkgWiseApp(App):
36
+ """Root application object for the PkgWise terminal UI."""
37
+
38
+ TITLE = "PkgWise — Smart Linux Update Analyzer"
39
+ SUB_TITLE = f"v{__version__}"
40
+
41
+ SCREENS = {
42
+ "main": MainScreen,
43
+ "details": "pkgwise.ui.screens.DetailsScreen",
44
+ "confirm": "pkgwise.ui.screens.ConfirmScreen",
45
+ "blocked": BlockedScreen,
46
+ "result": ResultScreen,
47
+ "error": ErrorScreen,
48
+ }
49
+
50
+ BINDINGS = [("q", "quit", "Quit")]
51
+
52
+ def __init__(
53
+ self,
54
+ analysis: Optional[AnalysisResult] = None,
55
+ mock: Optional[bool] = None,
56
+ **kwargs,
57
+ ) -> None:
58
+ super().__init__(**kwargs)
59
+ self._analysis = analysis
60
+ self._mock = enable_mock_mode() if mock is None else mock
61
+
62
+ # -- lifecycle -----------------------------------------------------------
63
+
64
+ def on_mount(self) -> None:
65
+ if self._analysis is not None:
66
+ self.push_screen(MainScreen(analysis=self._analysis))
67
+ return
68
+ if self._mock:
69
+ self.push_screen(
70
+ MainScreen(
71
+ updates=mock_packages(),
72
+ )
73
+ )
74
+ return
75
+ self.run_worker(self._load_analysis, thread=True, exclusive=True)
76
+
77
+ def _load_analysis(self) -> None:
78
+ try:
79
+ self._analysis = analyzer_mod.analyze()
80
+ self.call_from_thread(self._show_main)
81
+ except Exception as exc:
82
+ self.call_from_thread(self._show_load_error, exc)
83
+
84
+ def _show_main(self) -> None:
85
+ if self._analysis is None:
86
+ return
87
+ self.push_screen(MainScreen(analysis=self._analysis))
88
+
89
+ def _show_load_error(self, exc: Exception) -> None:
90
+ self.push_screen(
91
+ ErrorScreen(
92
+ title="Unable to check for updates",
93
+ message=_friendly_error(exc),
94
+ )
95
+ )
96
+
97
+ # -- actions shared with screens --------------------------------------
98
+
99
+ def refresh_analysis(self) -> AnalysisResult:
100
+ """Re-analyze after refreshing the package index."""
101
+ if self._mock:
102
+ return AnalysisResult(
103
+ storage=self._analysis.storage if self._analysis else None, # type: ignore[arg-type]
104
+ system=self._analysis.system if self._analysis else None, # type: ignore[arg-type]
105
+ updates=mock_packages(),
106
+ )
107
+ assert self._analysis is not None
108
+ return analyzer_mod.refresh_updates(self._analysis)
109
+
110
+ def proceed_with_updates(self, updates: List[PackageUpdate]) -> None:
111
+ """Safety-gated entry point for actually updating packages.
112
+
113
+ Guarantees (in order): never automatic, disk-space check, elevation
114
+ check, and a human-visible command. When running as root the command
115
+ executes directly; otherwise the exact command is shown so the user
116
+ can run it in a terminal.
117
+ """
118
+ if self._mock:
119
+ self.push_screen(
120
+ ResultScreen(
121
+ title="Mock update complete",
122
+ body=(
123
+ "This was mock data, so nothing was actually installed.\n"
124
+ "In real mode this command would run:\n\n"
125
+ " sudo pacman -Su " + " ".join(sorted(u.name for u in updates))
126
+ ),
127
+ )
128
+ )
129
+ return
130
+
131
+ storage = self._analysis.storage if self._analysis else None
132
+ try:
133
+ analyzer_mod.prepare_update(updates, storage=storage)
134
+ except InsufficientDiskSpace as exc:
135
+ short = exc.required - exc.available
136
+ self.push_screen(
137
+ BlockedScreen(
138
+ title="⚠ INSUFFICIENT DISK SPACE",
139
+ message=(
140
+ f"Available: {format_bytes(exc.available)}\n"
141
+ f"Required: {format_bytes(exc.required)}\n\n"
142
+ f"You need approximately {format_bytes(short)} more free space.\n\n"
143
+ "PkgWise has NOT started the update.\n"
144
+ "It does not delete caches or packages automatically."
145
+ ),
146
+ )
147
+ )
148
+ return
149
+ except RequiresElevation as exc:
150
+ self.push_screen(
151
+ BlockedScreen(
152
+ title="Elevation required",
153
+ message=exc.message,
154
+ detail="PkgWise does not perform privilege escalation.",
155
+ )
156
+ )
157
+ return
158
+ except UpdateGated as exc:
159
+ self.push_screen(ErrorScreen(title="Update blocked", message=str(exc)))
160
+ return
161
+
162
+ names = sorted(u.name for u in updates)
163
+ command = analyzer_mod.display_command(names)
164
+ self._execute_or_instruct(command, names)
165
+
166
+ def _execute_or_instruct(self, command: List[str], names: List[str]) -> None:
167
+ from pkgwise.utils.commands import is_elevated
168
+
169
+ if is_elevated():
170
+ self._root_update(command, names)
171
+ self.notify("Update started… you are running as root.")
172
+ return
173
+ body = Text()
174
+ body.append("PkgWise requires elevated privileges to update packages.\n\n")
175
+ body.append("Run this command in a terminal:\n\n", style="bold")
176
+ body.append(" " + " ".join(command) + "\n\n")
177
+ body.append("Or restart PkgWise as root.")
178
+ self.push_screen(ResultScreen(title="Update command ready", body=body))
179
+
180
+ @work(thread=True, exclusive=True)
181
+ def _root_update(self, command: List[str], names: List[str]) -> None:
182
+ """Stream an update to the terminal while running as root."""
183
+ import subprocess
184
+
185
+ from pkgwise.utils.commands import which
186
+
187
+ exe = which(command[0])
188
+ if exe is None:
189
+ self.call_from_thread(
190
+ self._root_update_done,
191
+ "The command could not be found on this system.",
192
+ failed=True,
193
+ )
194
+ return
195
+ proc = subprocess.run([exe, *command[1:]], text=True, check=False)
196
+ status = "succeeded" if proc.returncode == 0 else f"failed (exit {proc.returncode})"
197
+ summary = (
198
+ f"Update {status}.\n\n"
199
+ f"Command: {' '.join(command)}\n\n"
200
+ "PkgWise does not verify package-manager output beyond the exit code."
201
+ )
202
+ self.call_from_thread(self._root_update_done, summary, failed=proc.returncode != 0)
203
+
204
+ def _root_update_done(self, message: str, failed: bool) -> None:
205
+ title = "Update failed" if failed else "Update finished"
206
+ self.push_screen(ResultScreen(title=title, body=message))
207
+
208
+
209
+ def _friendly_error(exc: Exception) -> str:
210
+ """Translate low-level failures into readable guidance."""
211
+ text = str(exc) or exc.__class__.__name__
212
+ lowered = text.lower()
213
+ if "not found" in lowered or "unavailable" in lowered or "unsupported" in lowered:
214
+ return (
215
+ f"{text}\n\n"
216
+ "Check that your package manager is installed and that the "
217
+ "distribution is supported (Arch/pacman, Debian/apt)."
218
+ )
219
+ if "network" in lowered or "could not resolve" in lowered or "e:" in lowered:
220
+ return (
221
+ "The package manager could not reach its repositories.\n\n"
222
+ "Check your network connection and package-manager configuration."
223
+ )
224
+ return (
225
+ f"{text}\n\n"
226
+ "The package manager returned an error.\n"
227
+ "Check your network connection and package-manager configuration."
228
+ )