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.
- pkgwise/__init__.py +12 -0
- pkgwise/__main__.py +6 -0
- pkgwise/backends/__init__.py +1 -0
- pkgwise/backends/arch.py +223 -0
- pkgwise/backends/base.py +107 -0
- pkgwise/backends/debian.py +247 -0
- pkgwise/backends/factory.py +63 -0
- pkgwise/cli.py +222 -0
- pkgwise/core/__init__.py +1 -0
- pkgwise/core/analyzer.py +169 -0
- pkgwise/core/models.py +126 -0
- pkgwise/core/priority.py +199 -0
- pkgwise/core/storage.py +82 -0
- pkgwise/mockdata.py +43 -0
- pkgwise/py.typed +0 -0
- pkgwise/ui/__init__.py +1 -0
- pkgwise/ui/app.py +228 -0
- pkgwise/ui/screens.py +406 -0
- pkgwise/ui/widgets.py +57 -0
- pkgwise/utils/__init__.py +1 -0
- pkgwise/utils/commands.py +98 -0
- pkgwise/utils/config.py +134 -0
- pkgwise/utils/distro.py +88 -0
- pkgwise/utils/formatting.py +58 -0
- pkgwise-0.1.0.dist-info/METADATA +135 -0
- pkgwise-0.1.0.dist-info/RECORD +30 -0
- pkgwise-0.1.0.dist-info/WHEEL +5 -0
- pkgwise-0.1.0.dist-info/entry_points.txt +2 -0
- pkgwise-0.1.0.dist-info/licenses/LICENSE +21 -0
- pkgwise-0.1.0.dist-info/top_level.txt +1 -0
pkgwise/core/priority.py
ADDED
|
@@ -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]
|
pkgwise/core/storage.py
ADDED
|
@@ -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
|
+
)
|