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/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""PkgWise — Smart Linux Update Analyzer.
|
|
2
|
+
|
|
3
|
+
Know what you're updating before you update.
|
|
4
|
+
|
|
5
|
+
PkgWise is an analysis-first package update assistant. It uses the native
|
|
6
|
+
package manager (currently ``pacman`` and ``apt``) as its backend and provides
|
|
7
|
+
a better interface for understanding and selecting package updates.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
pkgwise/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package-manager backends: pacman (Arch), apt (Debian) and the shared interface."""
|
pkgwise/backends/arch.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Arch Linux backend — wraps the ``pacman`` package manager.
|
|
2
|
+
|
|
3
|
+
Parser details
|
|
4
|
+
--------------
|
|
5
|
+
``pacman -Qu`` lists outdated packages as ``name current => new``.
|
|
6
|
+
``pacman -Si <name>...`` reads the local sync databases (no network) and
|
|
7
|
+
reports per-package ``Download Size`` and ``Installed Size`` for the *new*
|
|
8
|
+
version, so sizes are realistic for planning an update.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from pkgwise.backends.base import InstallError, PackageManager
|
|
17
|
+
from pkgwise.core.models import PackageUpdate
|
|
18
|
+
from pkgwise.utils.commands import CommandError, run_command
|
|
19
|
+
|
|
20
|
+
_QU_LINE = re.compile(r"^(?P<name>[^\s]+)\s+(?P<current>[^\s]+)\s*=>\s*(?P<new>[^\s]+)$")
|
|
21
|
+
_SI_FIELD = re.compile(r"^(?P<key>[^:]+):(?P<value>.*)$")
|
|
22
|
+
|
|
23
|
+
_UPDATE_COMMAND = "-Su"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArchBackend(PackageManager):
|
|
27
|
+
"""pacman-backed implementation for Arch Linux and derivatives."""
|
|
28
|
+
|
|
29
|
+
executable = "pacman"
|
|
30
|
+
name = "pacman"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def supported(self) -> bool:
|
|
34
|
+
import shutil
|
|
35
|
+
|
|
36
|
+
return shutil.which("pacman") is not None
|
|
37
|
+
|
|
38
|
+
# -- parsing -----------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def check_updates(self) -> List[PackageUpdate]:
|
|
41
|
+
"""Read available updates from the local sync databases (no refresh)."""
|
|
42
|
+
lines = self._run_pacman(["-Qu"]).stdout.splitlines()
|
|
43
|
+
parsed = [_parse_qu_line(line) for line in lines]
|
|
44
|
+
updates: List[PackageUpdate] = []
|
|
45
|
+
for row in parsed:
|
|
46
|
+
if row is None:
|
|
47
|
+
continue
|
|
48
|
+
name, current, new = row
|
|
49
|
+
updates.append(
|
|
50
|
+
PackageUpdate(
|
|
51
|
+
name=name,
|
|
52
|
+
current_version=current,
|
|
53
|
+
new_version=new,
|
|
54
|
+
package_manager=self.name,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
if not updates:
|
|
58
|
+
return updates
|
|
59
|
+
self._attach_sizes(updates)
|
|
60
|
+
return updates
|
|
61
|
+
|
|
62
|
+
def list_updates(self) -> List[PackageUpdate]:
|
|
63
|
+
"""Refresh sync databases, then return available updates."""
|
|
64
|
+
self._sync_databases()
|
|
65
|
+
return self.check_updates()
|
|
66
|
+
|
|
67
|
+
def install_updates(self, names: List[str]) -> None:
|
|
68
|
+
"""Build an update transaction for *names* and run it.
|
|
69
|
+
|
|
70
|
+
Authorization (root/sudo) is handled by the caller before this runs;
|
|
71
|
+
the produced command is deterministic and uses ``--noconfirm`` because
|
|
72
|
+
PkgWise already collected an explicit confirmation.
|
|
73
|
+
"""
|
|
74
|
+
if not names:
|
|
75
|
+
return
|
|
76
|
+
_run_real_install("pacman", self.install_command(names), names)
|
|
77
|
+
|
|
78
|
+
def install_command(self, names: List[str]) -> List[str]:
|
|
79
|
+
"""Return the ``pacman`` invocation used to update *names*.
|
|
80
|
+
|
|
81
|
+
An empty selection yields an empty command: with no explicit targets
|
|
82
|
+
``pacman -Su`` would upgrade the entire system, which PkgWise must
|
|
83
|
+
never trigger by accident.
|
|
84
|
+
"""
|
|
85
|
+
selected = sorted(set(names))
|
|
86
|
+
if not selected:
|
|
87
|
+
return []
|
|
88
|
+
return ["pacman", "--noconfirm", _UPDATE_COMMAND, *selected]
|
|
89
|
+
|
|
90
|
+
# -- internal helpers --------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _sync_databases(self) -> None:
|
|
93
|
+
"""Refresh the sync databases (``pacman -Sy``); read-only regarding packages."""
|
|
94
|
+
run_command(["pacman", "-Sy", "--noconfirm"], timeout=300, check=False)
|
|
95
|
+
|
|
96
|
+
def _run_pacman(self, args: List[str]):
|
|
97
|
+
return run_command(["pacman", *args], timeout=180)
|
|
98
|
+
|
|
99
|
+
def _attach_sizes(self, updates: List[PackageUpdate]) -> None:
|
|
100
|
+
"""Populate download/installed sizes using ``pacman -Si`` (offline)."""
|
|
101
|
+
if not updates:
|
|
102
|
+
return
|
|
103
|
+
names = [u.name for u in updates]
|
|
104
|
+
try:
|
|
105
|
+
output = self._run_pacman(["-Si", *names]).stdout
|
|
106
|
+
except CommandError:
|
|
107
|
+
return
|
|
108
|
+
sizes = _parse_si_blocks(output)
|
|
109
|
+
for index, update in enumerate(updates):
|
|
110
|
+
info = sizes.get(update.name)
|
|
111
|
+
if info is None:
|
|
112
|
+
continue
|
|
113
|
+
updated = replaceable(update, info)
|
|
114
|
+
if updated is not update:
|
|
115
|
+
updates[index] = updated
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def replaceable(update: PackageUpdate, info: Dict[str, Optional[int]]) -> PackageUpdate:
|
|
119
|
+
"""Return an updated copy only when *info* provides reliable values."""
|
|
120
|
+
from dataclasses import replace
|
|
121
|
+
|
|
122
|
+
overrides = {}
|
|
123
|
+
if info.get("download") is not None:
|
|
124
|
+
overrides["download_size"] = info["download"]
|
|
125
|
+
if info.get("installed") is not None:
|
|
126
|
+
overrides["installed_size"] = info["installed"]
|
|
127
|
+
if not overrides:
|
|
128
|
+
return update
|
|
129
|
+
return replace(update, **overrides)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_qu_line(line: str) -> Optional[tuple]:
|
|
133
|
+
"""Parse one ``pacman -Qu`` output line into (name, current, new)."""
|
|
134
|
+
stripped = line.strip()
|
|
135
|
+
if not stripped:
|
|
136
|
+
return None
|
|
137
|
+
match = _QU_LINE.match(stripped)
|
|
138
|
+
if not match:
|
|
139
|
+
return None
|
|
140
|
+
return (match.group("name"), match.group("current"), match.group("new"))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_si_blocks(output: str) -> Dict[str, Dict[str, Optional[int]]]:
|
|
144
|
+
"""Parse consolidated ``pacman -Si`` output into ``{name: sizes}``.
|
|
145
|
+
|
|
146
|
+
Sizes are converted from strings such as ``8.25 MiB`` to integer bytes.
|
|
147
|
+
Returns an empty dict when nothing parsable is found.
|
|
148
|
+
"""
|
|
149
|
+
result: Dict[str, Dict[str, Optional[int]]] = {}
|
|
150
|
+
current: Dict[str, str] = {}
|
|
151
|
+
for raw in output.splitlines():
|
|
152
|
+
line = raw.strip()
|
|
153
|
+
if not line and current:
|
|
154
|
+
_commit_si_block(result, current)
|
|
155
|
+
current = {}
|
|
156
|
+
continue
|
|
157
|
+
match = _SI_FIELD.match(line)
|
|
158
|
+
if not match:
|
|
159
|
+
continue
|
|
160
|
+
key = match.group("key").strip().lower()
|
|
161
|
+
value = match.group("value").strip()
|
|
162
|
+
if key in {"name", "version", "download size", "installed size"}:
|
|
163
|
+
current[key] = value
|
|
164
|
+
if current:
|
|
165
|
+
_commit_si_block(result, current)
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _commit_si_block(result: Dict[str, Dict[str, Optional[int]]], block: Dict[str, str]) -> None:
|
|
170
|
+
name = block.get("name")
|
|
171
|
+
if not name:
|
|
172
|
+
return
|
|
173
|
+
entry = {
|
|
174
|
+
"download": _parse_size(block.get("download size")),
|
|
175
|
+
"installed": _parse_size(block.get("installed size")),
|
|
176
|
+
}
|
|
177
|
+
result[name] = entry
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_size(raw: Optional[str]) -> Optional[int]:
|
|
181
|
+
"""Parse pacman sizes like ``408.88 KiB`` / ``4.02 MiB`` into bytes."""
|
|
182
|
+
if not raw:
|
|
183
|
+
return None
|
|
184
|
+
match = re.match(r"^([0-9.]+)\s*([A-Za-z]+)?$", raw.strip())
|
|
185
|
+
if not match:
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
value = float(match.group(1))
|
|
189
|
+
except ValueError:
|
|
190
|
+
return None
|
|
191
|
+
unit = (match.group(2) or "B").lower()
|
|
192
|
+
multiplier = {
|
|
193
|
+
"b": 1,
|
|
194
|
+
"kib": 1024,
|
|
195
|
+
"mib": 1024**2,
|
|
196
|
+
"gib": 1024**3,
|
|
197
|
+
"tib": 1024**4,
|
|
198
|
+
}.get(unit, 1)
|
|
199
|
+
return int(value * multiplier)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _run_real_install(manager: str, command: List[str], names: List[str]) -> None:
|
|
203
|
+
"""Execute a real install/update command, converting failures to InstallError.
|
|
204
|
+
|
|
205
|
+
Never constructs a shell string — always an argument list.
|
|
206
|
+
"""
|
|
207
|
+
from pkgwise.utils import commands as cmd_utils
|
|
208
|
+
|
|
209
|
+
path = cmd_utils.which(manager)
|
|
210
|
+
if path is None:
|
|
211
|
+
raise InstallError(
|
|
212
|
+
f"The package manager '{manager}' is not installed.",
|
|
213
|
+
detail="Install it before retrying an update.",
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
cmd_utils.run_command(command, timeout=_INSTALL_TIMEOUT, check=False)
|
|
217
|
+
except cmd_utils.CommandError as exc:
|
|
218
|
+
raise InstallError(
|
|
219
|
+
exc.message, returncode=exc.returncode, detail=exc.detail
|
|
220
|
+
) from exc
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
_INSTALL_TIMEOUT = 86_400 # 24h: installs can legitimately take a long time
|
pkgwise/backends/base.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Abstract package-manager interface.
|
|
2
|
+
|
|
3
|
+
The TUI and analyzer talk only to this interface. Concrete backends
|
|
4
|
+
(:mod:`pkgwise.backends.arch` and :mod:`pkgwise.backends.debian`)
|
|
5
|
+
implement the same contract, so adding Fedora/dnf later means adding one new
|
|
6
|
+
backend class — no changes to the UI or analyzer.
|
|
7
|
+
|
|
8
|
+
Parsers return raw parsed values (``None`` when unknown); assigning priority
|
|
9
|
+
is exclusively the job of :mod:`pkgwise.core.priority`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from pkgwise.core.models import PackageUpdate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UpdateCheckError(Exception):
|
|
21
|
+
"""Base error for any backend query failure. Raised with a human message."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, detail: Optional[str] = None):
|
|
24
|
+
self.message = message
|
|
25
|
+
self.detail = detail
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BackendUnavailableError(UpdateCheckError):
|
|
30
|
+
"""The package-manager binary is not installed on this system."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnsupportedDistroError(UpdateCheckError):
|
|
34
|
+
"""No backend maps to the detected distribution/package manager."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PackageManager(ABC):
|
|
38
|
+
"""Common contract every backend implements."""
|
|
39
|
+
|
|
40
|
+
#: Binary invoked to perform update operations.
|
|
41
|
+
executable: str = ""
|
|
42
|
+
|
|
43
|
+
#: Human name for the backend, e.g. "pacman".
|
|
44
|
+
name: str = ""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def supported(self) -> bool:
|
|
49
|
+
"""True when this backend's package manager is installed."""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def check_updates(self) -> List[PackageUpdate]:
|
|
53
|
+
"""Query and return available updates (read-only, no refresh)."""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def list_updates(self) -> List[PackageUpdate]:
|
|
57
|
+
"""Update the package index, then return available updates."""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def install_updates(self, names: List[str]) -> None:
|
|
61
|
+
"""Perform a real update for the given package names.
|
|
62
|
+
|
|
63
|
+
May raise :class:`InstallError` on failure. Intended to be run from
|
|
64
|
+
middleware that has already checked permissions and disk space.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def install_command(self, names: List[str]) -> List[str]:
|
|
69
|
+
"""Return the argument list that would update *names* (no side effects).
|
|
70
|
+
|
|
71
|
+
Exists so callers can display/confirm the exact command first.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InstallError(Exception):
|
|
76
|
+
"""Raised when an update operation fails."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str, returncode: Optional[int] = None, detail: Optional[str] = None):
|
|
79
|
+
self.message = message
|
|
80
|
+
self.returncode = returncode
|
|
81
|
+
self.detail = detail
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _as_int(raw: Optional[str]) -> Optional[int]:
|
|
86
|
+
"""Parse a size/count string (often with commas or units) to int."""
|
|
87
|
+
if not raw:
|
|
88
|
+
return None
|
|
89
|
+
cleaned = raw.replace(",", "").replace(" ", "").strip()
|
|
90
|
+
if not cleaned:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
return int(cleaned)
|
|
94
|
+
except (ValueError, TypeError):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _yes_no(raw: Optional[str]) -> Optional[bool]:
|
|
99
|
+
"""Parse a y/n style value into a bool, or ``None`` if unparseable."""
|
|
100
|
+
if raw is None:
|
|
101
|
+
return None
|
|
102
|
+
lowered = raw.strip().lower()
|
|
103
|
+
if lowered in {"y", "yes", "true", "1"}:
|
|
104
|
+
return True
|
|
105
|
+
if lowered in {"n", "no", "false", "0"}:
|
|
106
|
+
return False
|
|
107
|
+
return None
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Debian/Kali backend — wraps the ``apt`` toolchain (``apt-get``/``apt-cache``).
|
|
2
|
+
|
|
3
|
+
Parser details
|
|
4
|
+
--------------
|
|
5
|
+
``apt-get -s upgrade`` (simulate) lists upgrade actions as ``Inst`` lines:
|
|
6
|
+
|
|
7
|
+
Inst libssl3 [3.0.1-1] (3.0.2-1 ...) []
|
|
8
|
+
|
|
9
|
+
Parsing these gives package name, installed version and candidate version.
|
|
10
|
+
Download sizes come from ``apt-get --print-uris`` (a dry run that emits the
|
|
11
|
+
``.deb`` URL, filename and byte size per package). Installed sizes for the
|
|
12
|
+
*candidate* versions come from ``apt-cache show``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from pkgwise.backends.base import InstallError, PackageManager, UpdateCheckError
|
|
21
|
+
from pkgwise.core.models import PackageUpdate
|
|
22
|
+
from pkgwise.utils.commands import run_command
|
|
23
|
+
|
|
24
|
+
_INST_LINE = re.compile(r"^Inst (?P<name>\S+)( \[(?P<current>[^\]]*)\])? \((?P<new>\S+)")
|
|
25
|
+
_URI_LINE = re.compile(r"^'(?P<url>.*)'\s+(?P<filename>\S+)\s+(?P<size>\d+)")
|
|
26
|
+
_STANZA_FIELD = re.compile(r"^(?P<key>[^:]+):\s*(?P<value>.*)$")
|
|
27
|
+
|
|
28
|
+
_UPGRADE_TARGET = "upgrade"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DebianBackend(PackageManager):
|
|
32
|
+
"""apt-backed implementation for Debian, Kali, Ubuntu and derivatives."""
|
|
33
|
+
|
|
34
|
+
executable = "apt-get"
|
|
35
|
+
name = "apt"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def supported(self) -> bool:
|
|
39
|
+
import shutil
|
|
40
|
+
|
|
41
|
+
return shutil.which("apt-get") is not None
|
|
42
|
+
|
|
43
|
+
# -- public API ---------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def check_updates(self) -> List[PackageUpdate]:
|
|
46
|
+
"""Read available upgrades using the current package lists (no refresh)."""
|
|
47
|
+
updates = self._simulate_upgrade()
|
|
48
|
+
if not updates:
|
|
49
|
+
return updates
|
|
50
|
+
sizes = self._download_sizes()
|
|
51
|
+
for index, update in enumerate(updates):
|
|
52
|
+
if update.name in sizes:
|
|
53
|
+
updates[index] = _with_download_size(update, sizes[update.name])
|
|
54
|
+
self._attach_installed_sizes(updates)
|
|
55
|
+
return updates
|
|
56
|
+
|
|
57
|
+
def list_updates(self) -> List[PackageUpdate]:
|
|
58
|
+
"""Refresh package lists (``apt-get update``), then list upgrades."""
|
|
59
|
+
try:
|
|
60
|
+
result = run_command(["apt-get", "update"], timeout=300, check=False)
|
|
61
|
+
if result.returncode != 0:
|
|
62
|
+
raise UpdateCheckError(
|
|
63
|
+
"apt-get update could not refresh the package lists.",
|
|
64
|
+
detail=(result.stderr or result.stdout or "").strip()[:500],
|
|
65
|
+
)
|
|
66
|
+
except UpdateCheckError:
|
|
67
|
+
raise
|
|
68
|
+
except Exception as exc: # TimeoutExpired etc.
|
|
69
|
+
raise UpdateCheckError(
|
|
70
|
+
"apt-get update failed unexpectedly.", detail=str(exc)
|
|
71
|
+
) from exc
|
|
72
|
+
return self.check_updates()
|
|
73
|
+
|
|
74
|
+
def install_updates(self, names: List[str]) -> None:
|
|
75
|
+
"""Upgrade the given packages to their candidate versions."""
|
|
76
|
+
if not names:
|
|
77
|
+
return
|
|
78
|
+
_run_real_install("apt-get", self.install_command(names), names)
|
|
79
|
+
|
|
80
|
+
def install_command(self, names: List[str]) -> List[str]:
|
|
81
|
+
"""Return the ``apt-get`` invocation used to upgrade *names*.
|
|
82
|
+
|
|
83
|
+
An empty selection yields an empty command: with no explicit targets
|
|
84
|
+
``apt-get install`` would upgrade the entire system, which PkgWise
|
|
85
|
+
must never trigger by accident.
|
|
86
|
+
"""
|
|
87
|
+
selected = sorted(set(names))
|
|
88
|
+
if not selected:
|
|
89
|
+
return []
|
|
90
|
+
return ["apt-get", "--yes", "install", *selected]
|
|
91
|
+
|
|
92
|
+
# -- parsing helpers -----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _simulate_upgrade(self) -> List[PackageUpdate]:
|
|
95
|
+
"""Compute pending upgrades via ``apt-get -s upgrade``."""
|
|
96
|
+
output = self._run(["apt-get", "-s", _UPGRADE_TARGET]).stdout
|
|
97
|
+
return [row for row in (_parse_inst_line(line) for line in output.splitlines()) if row]
|
|
98
|
+
|
|
99
|
+
def _download_sizes(self) -> Dict[str, int]:
|
|
100
|
+
"""Return ``{name: download_bytes}`` from ``apt-get --print-uris``."""
|
|
101
|
+
try:
|
|
102
|
+
output = self._run(
|
|
103
|
+
["apt-get", "--print-uris", "--yes", _UPGRADE_TARGET],
|
|
104
|
+
timeout=300,
|
|
105
|
+
).stdout
|
|
106
|
+
except UpdateCheckError:
|
|
107
|
+
return {}
|
|
108
|
+
sizes: Dict[str, int] = {}
|
|
109
|
+
for line in output.splitlines():
|
|
110
|
+
match = _URI_LINE.match(line.strip())
|
|
111
|
+
if not match:
|
|
112
|
+
continue
|
|
113
|
+
filename = match.group("filename")
|
|
114
|
+
size = int(match.group("size"))
|
|
115
|
+
sizes[_package_from_filename(filename)] = size
|
|
116
|
+
return sizes
|
|
117
|
+
|
|
118
|
+
def _attach_installed_sizes(self, updates: List[PackageUpdate]) -> None:
|
|
119
|
+
"""Fill installed sizes from ``apt-cache show`` (candidate versions)."""
|
|
120
|
+
sizes = _apt_cache_installed_sizes([u.name for u in updates])
|
|
121
|
+
for index, update in enumerate(updates):
|
|
122
|
+
installed = sizes.get(update.name)
|
|
123
|
+
if installed is not None:
|
|
124
|
+
updates[index] = _with_installed_size(update, installed)
|
|
125
|
+
|
|
126
|
+
def _run(self, args: List[str], timeout: int = 180):
|
|
127
|
+
return run_command(args, timeout=timeout)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -- module-level parsers (pure functions, easily unit-tested) ----------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_inst_line(line: str) -> Optional[PackageUpdate]:
|
|
134
|
+
"""Parse one simulated ``Inst`` line into a ``PackageUpdate``.
|
|
135
|
+
|
|
136
|
+
Returns ``None`` for lines that do not describe an upgrade.
|
|
137
|
+
"""
|
|
138
|
+
match = _INST_LINE.match(line.strip())
|
|
139
|
+
if not match:
|
|
140
|
+
return None
|
|
141
|
+
return PackageUpdate(
|
|
142
|
+
name=match.group("name"),
|
|
143
|
+
current_version=match.group("current"),
|
|
144
|
+
new_version=match.group("new"),
|
|
145
|
+
package_manager="apt",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _package_from_filename(filename: str) -> str:
|
|
150
|
+
"""Derive a package name from a ``.deb`` filename like ``libssl3_3.0.2_amd64.deb``.
|
|
151
|
+
|
|
152
|
+
Names contain no underscores, so splitting on the first underscore is safe.
|
|
153
|
+
"""
|
|
154
|
+
return filename.split("_", 1)[0]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _apt_cache_installed_sizes(names: List[str]) -> Dict[str, int]:
|
|
158
|
+
"""Query ``apt-cache show`` for installed sizes of candidate versions.
|
|
159
|
+
|
|
160
|
+
``Installed-Size`` in apt metadata is expressed in KiB.
|
|
161
|
+
"""
|
|
162
|
+
if not names:
|
|
163
|
+
return {}
|
|
164
|
+
try:
|
|
165
|
+
import shutil
|
|
166
|
+
|
|
167
|
+
if shutil.which("apt-cache") is None:
|
|
168
|
+
return {}
|
|
169
|
+
result = run_command(["apt-cache", "show", *names], timeout=180, check=False)
|
|
170
|
+
except Exception:
|
|
171
|
+
return {}
|
|
172
|
+
stanzas = _parse_apt_cache_stanzas(result.stdout)
|
|
173
|
+
sizes: Dict[str, int] = {}
|
|
174
|
+
for name, version, kbytes in stanzas:
|
|
175
|
+
if name not in sizes and kbytes is not None:
|
|
176
|
+
sizes[name] = kbytes * 1024
|
|
177
|
+
return sizes
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_apt_cache_stanzas(output: str) -> List[Tuple[str, Optional[str], Optional[int]]]:
|
|
181
|
+
"""Parse ``apt-cache show`` blocks into ``(name, version, installed_kb)``.
|
|
182
|
+
|
|
183
|
+
Returns the newest stanza per package (apt lists candidates newest-first).
|
|
184
|
+
"""
|
|
185
|
+
stanzas: List[Dict[str, str]] = []
|
|
186
|
+
current: Dict[str, str] = {}
|
|
187
|
+
for raw in output.splitlines():
|
|
188
|
+
if raw.strip() == "":
|
|
189
|
+
if current:
|
|
190
|
+
stanzas.append(current)
|
|
191
|
+
current = {}
|
|
192
|
+
continue
|
|
193
|
+
match = _STANZA_FIELD.match(raw)
|
|
194
|
+
if not match:
|
|
195
|
+
continue
|
|
196
|
+
key = match.group("key").lower()
|
|
197
|
+
if key in {"package", "version", "installed-size"}:
|
|
198
|
+
current[key] = match.group("value")
|
|
199
|
+
if current:
|
|
200
|
+
stanzas.append(current)
|
|
201
|
+
|
|
202
|
+
parsed = []
|
|
203
|
+
seen_names: set = set()
|
|
204
|
+
for stanza in stanzas:
|
|
205
|
+
name = stanza.get("package")
|
|
206
|
+
if not name or name in seen_names:
|
|
207
|
+
continue
|
|
208
|
+
seen_names.add(name)
|
|
209
|
+
version = stanza.get("version")
|
|
210
|
+
size: Optional[int] = None
|
|
211
|
+
if stanza.get("installed-size"):
|
|
212
|
+
try:
|
|
213
|
+
size = int(stanza["installed-size"])
|
|
214
|
+
except ValueError:
|
|
215
|
+
size = None
|
|
216
|
+
parsed.append((name, version, size))
|
|
217
|
+
return parsed
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _with_download_size(update: PackageUpdate, size: int) -> PackageUpdate:
|
|
221
|
+
from dataclasses import replace
|
|
222
|
+
|
|
223
|
+
return replace(update, download_size=size)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _with_installed_size(update: PackageUpdate, size: int) -> PackageUpdate:
|
|
227
|
+
from dataclasses import replace
|
|
228
|
+
|
|
229
|
+
return replace(update, installed_size=size)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _run_real_install(manager: str, command: List[str], names: List[str]) -> None:
|
|
233
|
+
"""Execute a destructive command argument list, mapping failures to InstallError."""
|
|
234
|
+
from pkgwise.utils import commands as cmd_utils
|
|
235
|
+
|
|
236
|
+
path = cmd_utils.which(manager)
|
|
237
|
+
if path is None:
|
|
238
|
+
raise InstallError(f"The package manager '{manager}' is not installed.")
|
|
239
|
+
try:
|
|
240
|
+
cmd_utils.run_command(command, timeout=_INSTALL_TIMEOUT, check=False)
|
|
241
|
+
except cmd_utils.CommandError as exc:
|
|
242
|
+
raise InstallError(
|
|
243
|
+
exc.message, returncode=exc.returncode, detail=exc.detail
|
|
244
|
+
) from exc
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_INSTALL_TIMEOUT = 86_400 # 24h: installs can legitimately take a long time
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Backend selection and registration.
|
|
2
|
+
|
|
3
|
+
New distributions are added by subclassing :class:`PackageManager` and
|
|
4
|
+
registering it here via :data:`BACKENDS` — no changes required in the UI or
|
|
5
|
+
analyzer. Selection is driven by the distro ID reported by os-release.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Dict
|
|
11
|
+
|
|
12
|
+
from pkgwise.backends.arch import ArchBackend
|
|
13
|
+
from pkgwise.backends.base import PackageManager, UnsupportedDistroError
|
|
14
|
+
from pkgwise.backends.debian import DebianBackend
|
|
15
|
+
from pkgwise.utils import distro as distro_utils
|
|
16
|
+
|
|
17
|
+
#: distro ID families -> backend class.
|
|
18
|
+
BACKENDS: Dict[str, type] = {
|
|
19
|
+
"arch": ArchBackend,
|
|
20
|
+
"debian": DebianBackend,
|
|
21
|
+
# Future backends register here, e.g. "fedora": DnfBackend
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_backend(distro_name: str = "", package_manager: str = "") -> PackageManager:
|
|
26
|
+
"""Return the backend matching the current system.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
distro_name: rendered distro name (used for error messages only).
|
|
30
|
+
package_manager: ``pacman``/``apt``/``unknown``; recomputed when empty.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
UnsupportedDistroError: no backend matches the detected system.
|
|
34
|
+
"""
|
|
35
|
+
if not package_manager:
|
|
36
|
+
package_manager = distro_utils.get_system_info().package_manager
|
|
37
|
+
|
|
38
|
+
for key, backend_cls in BACKENDS.items():
|
|
39
|
+
if package_manager in _managers_for(key):
|
|
40
|
+
instance = backend_cls()
|
|
41
|
+
if not instance.supported:
|
|
42
|
+
raise UnsupportedDistroError(
|
|
43
|
+
f"{package_manager} is required but was not found on this system.",
|
|
44
|
+
detail=f"Install the '{package_manager}' package manager to use PkgWise here.",
|
|
45
|
+
)
|
|
46
|
+
return instance
|
|
47
|
+
|
|
48
|
+
raise UnsupportedDistroError(
|
|
49
|
+
"PkgWise does not recognize your Linux distribution.",
|
|
50
|
+
detail=(
|
|
51
|
+
f"Detected package manager: {package_manager or 'unknown'} "
|
|
52
|
+
f"(distro: {distro_name or 'unknown'}). "
|
|
53
|
+
"Supported: Arch Linux (pacman) and Debian-based systems (apt)."
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _managers_for(distro_key: str) -> tuple:
|
|
59
|
+
"""Map a backend key to the package-manager names it satisfies."""
|
|
60
|
+
return {
|
|
61
|
+
"arch": ("pacman",),
|
|
62
|
+
"debian": ("apt", "apt-get"),
|
|
63
|
+
}.get(distro_key, ())
|