pkgwise 0.1.2__tar.gz → 0.2.0__tar.gz
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-0.1.2 → pkgwise-0.2.0}/PKG-INFO +11 -4
- {pkgwise-0.1.2 → pkgwise-0.2.0}/README.md +9 -3
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/__init__.py +1 -1
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/backends/arch.py +90 -1
- pkgwise-0.2.0/pkgwise/backends/aur.py +117 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/backends/base.py +17 -1
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/backends/debian.py +81 -1
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/backends/factory.py +45 -1
- pkgwise-0.2.0/pkgwise/backends/flatpak.py +178 -0
- pkgwise-0.2.0/pkgwise/backends/snap.py +182 -0
- pkgwise-0.2.0/pkgwise/cli.py +448 -0
- pkgwise-0.2.0/pkgwise/core/analyzer.py +285 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/core/models.py +60 -0
- pkgwise-0.2.0/pkgwise/core/state.py +174 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise.egg-info/PKG-INFO +11 -4
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise.egg-info/SOURCES.txt +5 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pyproject.toml +2 -1
- {pkgwise-0.1.2 → pkgwise-0.2.0}/tests/test_analyzer.py +119 -1
- {pkgwise-0.1.2 → pkgwise-0.2.0}/tests/test_backends.py +189 -1
- pkgwise-0.2.0/tests/test_state.py +103 -0
- pkgwise-0.1.2/pkgwise/cli.py +0 -222
- pkgwise-0.1.2/pkgwise/core/analyzer.py +0 -169
- {pkgwise-0.1.2 → pkgwise-0.2.0}/LICENSE +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/__main__.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/backends/__init__.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/core/__init__.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/core/priority.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/core/storage.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/mockdata.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/py.typed +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/ui/__init__.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/ui/app.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/ui/screens.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/ui/widgets.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/utils/__init__.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/utils/commands.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/utils/config.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/utils/distro.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise/utils/formatting.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise.egg-info/dependency_links.txt +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise.egg-info/entry_points.txt +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise.egg-info/requires.txt +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/pkgwise.egg-info/top_level.txt +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/setup.cfg +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/tests/test_priority.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/tests/test_storage.py +0 -0
- {pkgwise-0.1.2 → pkgwise-0.2.0}/tests/test_tui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pkgwise
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Know what you're updating before you update — a smart Linux update analyzer.
|
|
5
5
|
Author: PkgWise Developers
|
|
6
6
|
License: MIT
|
|
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
23
|
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
25
|
Classifier: Topic :: System :: Systems Administration
|
|
25
26
|
Requires-Python: >=3.9
|
|
26
27
|
Description-Content-Type: text/markdown
|
|
@@ -53,7 +54,7 @@ explicitly confirm the exact command.
|
|
|
53
54
|
|
|
54
55
|
## Features
|
|
55
56
|
|
|
56
|
-
- **Dual package managers** — `pacman` (Arch) and `apt-get`/`apt-cache` (Debian, Kali, Ubuntu).
|
|
57
|
+
- **Dual package managers** — `pacman` (Arch) and `apt-get`/`apt-cache` (Debian, Kali, Ubuntu), plus **Flatpak**, **Snap**, and a read-only **AUR** overlay (yay/paru) when installed. `check`/`updates` merge everything.
|
|
57
58
|
- **Sizes you can plan with** — download and installed sizes are read from the package
|
|
58
59
|
manager's own metadata, never guessed.
|
|
59
60
|
- **Transparent priorities** — every CRITICAL/HIGH/MEDIUM/LOW label comes with the
|
|
@@ -63,6 +64,7 @@ explicitly confirm the exact command.
|
|
|
63
64
|
checks gate every real update.
|
|
64
65
|
- **Two interfaces** — a fast terminal CLI and a Textual TUI with selection, detail and
|
|
65
66
|
confirmation screens.
|
|
67
|
+
- **Upgrade history** — every check/update records a snapshot; compare runs with `pkgwise diff`.
|
|
66
68
|
- **Mock mode** — preview the whole tool on any machine without touching a real package
|
|
67
69
|
manager.
|
|
68
70
|
|
|
@@ -80,6 +82,11 @@ One command installs everything: the CLI, the TUI, and the storage analysis (psu
|
|
|
80
82
|
$ pkgwise check # storage summary + available updates, prioritized
|
|
81
83
|
$ pkgwise updates # just the pending updates
|
|
82
84
|
$ pkgwise storage # disk information only (no package manager needed)
|
|
85
|
+
$ pkgwise search <query> # find packages across all detected backends
|
|
86
|
+
$ pkgwise info <package> # version/size/details of a single package
|
|
87
|
+
$ pkgwise update <pkg…> --yes # apply specific updates (commands shown first)
|
|
88
|
+
$ pkgwise update --all --yes # apply everything available
|
|
89
|
+
$ pkgwise diff # what changed since the last check/update
|
|
83
90
|
$ pkgwise --refresh # refresh package indexes first (network)
|
|
84
91
|
$ pkgwise # launch the interactive TUI
|
|
85
92
|
$ pkgwise --version # version and system info
|
|
@@ -115,8 +122,8 @@ reasons, so nothing is ever marked important without an explanation.
|
|
|
115
122
|
| Nothing runs automatically | `cli.py`, TUI confirm screens |
|
|
116
123
|
| Exact command shown before running | backends' `install_command()` |
|
|
117
124
|
| Empty selection can't upgrade everything | backends return `[]` for empty selections |
|
|
118
|
-
| Disk-space check before install | `analyzer.
|
|
119
|
-
| Privilege check before install | `analyzer.
|
|
125
|
+
| Disk-space check before install | `analyzer.plan_update()` |
|
|
126
|
+
| Privilege check before install | `analyzer.plan_update()` |
|
|
120
127
|
| No packages removed or cleaned | analyzer never invokes remove/clean |
|
|
121
128
|
|
|
122
129
|
## License
|
|
@@ -18,7 +18,7 @@ explicitly confirm the exact command.
|
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
20
|
|
|
21
|
-
- **Dual package managers** — `pacman` (Arch) and `apt-get`/`apt-cache` (Debian, Kali, Ubuntu).
|
|
21
|
+
- **Dual package managers** — `pacman` (Arch) and `apt-get`/`apt-cache` (Debian, Kali, Ubuntu), plus **Flatpak**, **Snap**, and a read-only **AUR** overlay (yay/paru) when installed. `check`/`updates` merge everything.
|
|
22
22
|
- **Sizes you can plan with** — download and installed sizes are read from the package
|
|
23
23
|
manager's own metadata, never guessed.
|
|
24
24
|
- **Transparent priorities** — every CRITICAL/HIGH/MEDIUM/LOW label comes with the
|
|
@@ -28,6 +28,7 @@ explicitly confirm the exact command.
|
|
|
28
28
|
checks gate every real update.
|
|
29
29
|
- **Two interfaces** — a fast terminal CLI and a Textual TUI with selection, detail and
|
|
30
30
|
confirmation screens.
|
|
31
|
+
- **Upgrade history** — every check/update records a snapshot; compare runs with `pkgwise diff`.
|
|
31
32
|
- **Mock mode** — preview the whole tool on any machine without touching a real package
|
|
32
33
|
manager.
|
|
33
34
|
|
|
@@ -45,6 +46,11 @@ One command installs everything: the CLI, the TUI, and the storage analysis (psu
|
|
|
45
46
|
$ pkgwise check # storage summary + available updates, prioritized
|
|
46
47
|
$ pkgwise updates # just the pending updates
|
|
47
48
|
$ pkgwise storage # disk information only (no package manager needed)
|
|
49
|
+
$ pkgwise search <query> # find packages across all detected backends
|
|
50
|
+
$ pkgwise info <package> # version/size/details of a single package
|
|
51
|
+
$ pkgwise update <pkg…> --yes # apply specific updates (commands shown first)
|
|
52
|
+
$ pkgwise update --all --yes # apply everything available
|
|
53
|
+
$ pkgwise diff # what changed since the last check/update
|
|
48
54
|
$ pkgwise --refresh # refresh package indexes first (network)
|
|
49
55
|
$ pkgwise # launch the interactive TUI
|
|
50
56
|
$ pkgwise --version # version and system info
|
|
@@ -80,8 +86,8 @@ reasons, so nothing is ever marked important without an explanation.
|
|
|
80
86
|
| Nothing runs automatically | `cli.py`, TUI confirm screens |
|
|
81
87
|
| Exact command shown before running | backends' `install_command()` |
|
|
82
88
|
| Empty selection can't upgrade everything | backends return `[]` for empty selections |
|
|
83
|
-
| Disk-space check before install | `analyzer.
|
|
84
|
-
| Privilege check before install | `analyzer.
|
|
89
|
+
| Disk-space check before install | `analyzer.plan_update()` |
|
|
90
|
+
| Privilege check before install | `analyzer.plan_update()` |
|
|
85
91
|
| No packages removed or cleaned | analyzer never invokes remove/clean |
|
|
86
92
|
|
|
87
93
|
## License
|
|
@@ -14,11 +14,13 @@ import re
|
|
|
14
14
|
from typing import Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
from pkgwise.backends.base import InstallError, PackageManager
|
|
17
|
-
from pkgwise.core.models import PackageUpdate
|
|
17
|
+
from pkgwise.core.models import PackageInfo, PackageSearchResult, PackageUpdate
|
|
18
18
|
from pkgwise.utils.commands import CommandError, run_command
|
|
19
19
|
|
|
20
20
|
_QU_LINE = re.compile(r"^(?P<name>[^\s]+)\s+(?P<current>[^\s]+)\s*=>\s*(?P<new>[^\s]+)$")
|
|
21
21
|
_SI_FIELD = re.compile(r"^(?P<key>[^:]+):(?P<value>.*)$")
|
|
22
|
+
_SS_LINE = re.compile(r"^(?P<repo>[^/]+)/(?P<name>[^\s]+)\s+(?P<version>\S+)(?:\s+\[(?P<installed>[^\]]+)\])?$")
|
|
23
|
+
_SS_DESC = re.compile(r"^\s+(?P<description>.+)$")
|
|
22
24
|
|
|
23
25
|
_UPDATE_COMMAND = "-Su"
|
|
24
26
|
|
|
@@ -87,6 +89,19 @@ class ArchBackend(PackageManager):
|
|
|
87
89
|
return []
|
|
88
90
|
return ["pacman", "--noconfirm", _UPDATE_COMMAND, *selected]
|
|
89
91
|
|
|
92
|
+
def search_packages(self, query: str) -> List[PackageSearchResult]:
|
|
93
|
+
"""Search sync databases via ``pacman -Ss`` (read-only, offline)."""
|
|
94
|
+
lines = self._run_pacman(["-Ss", query]).stdout.splitlines()
|
|
95
|
+
return _parse_syncdb_search(lines, manager=self.name)
|
|
96
|
+
|
|
97
|
+
def package_info(self, name: str) -> Optional[PackageInfo]:
|
|
98
|
+
"""Read a package entry from the local sync databases (``pacman -Si``)."""
|
|
99
|
+
try:
|
|
100
|
+
output = self._run_pacman(["-Si", name]).stdout
|
|
101
|
+
except CommandError:
|
|
102
|
+
return None
|
|
103
|
+
return _parse_si_info(output, manager=self.name, fallback_name=name)
|
|
104
|
+
|
|
90
105
|
# -- internal helpers --------------------------------------------------
|
|
91
106
|
|
|
92
107
|
def _sync_databases(self) -> None:
|
|
@@ -140,6 +155,80 @@ def _parse_qu_line(line: str) -> Optional[tuple]:
|
|
|
140
155
|
return (match.group("name"), match.group("current"), match.group("new"))
|
|
141
156
|
|
|
142
157
|
|
|
158
|
+
def _parse_syncdb_search(lines: List[str], manager: str) -> List[PackageSearchResult]:
|
|
159
|
+
"""Parse ``pacman -Ss`` output into ``PackageSearchResult`` items.
|
|
160
|
+
|
|
161
|
+
The sync-database format is a ``repo/name version`` pair line, optionally
|
|
162
|
+
marked ``[installed]``, immediately followed by an indented description.
|
|
163
|
+
"""
|
|
164
|
+
results: List[PackageSearchResult] = []
|
|
165
|
+
pending: Optional[PackageSearchResult] = None
|
|
166
|
+
for raw in lines:
|
|
167
|
+
line = raw.rstrip()
|
|
168
|
+
pair = _SS_LINE.match(line)
|
|
169
|
+
if pair:
|
|
170
|
+
if pending is not None:
|
|
171
|
+
results.append(pending)
|
|
172
|
+
installed = pair.group("installed") is not None
|
|
173
|
+
repo = pair.group("repo")
|
|
174
|
+
pending = PackageSearchResult(
|
|
175
|
+
name=pair.group("name"),
|
|
176
|
+
version=pair.group("version"),
|
|
177
|
+
description="",
|
|
178
|
+
repository=repo,
|
|
179
|
+
installed=installed,
|
|
180
|
+
package_manager=manager,
|
|
181
|
+
)
|
|
182
|
+
continue
|
|
183
|
+
desc = _SS_DESC.match(line)
|
|
184
|
+
if desc and pending is not None and not pending.description:
|
|
185
|
+
pending = PackageSearchResult(
|
|
186
|
+
name=pending.name,
|
|
187
|
+
version=pending.version,
|
|
188
|
+
description=desc.group("description").strip(),
|
|
189
|
+
repository=pending.repository,
|
|
190
|
+
installed=pending.installed,
|
|
191
|
+
package_manager=manager,
|
|
192
|
+
)
|
|
193
|
+
if pending is not None:
|
|
194
|
+
results.append(pending)
|
|
195
|
+
return results
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parse_si_info(output: str, manager: str, fallback_name: str) -> Optional[PackageInfo]:
|
|
199
|
+
"""Parse one ``pacman -Si`` stanza into a ``PackageInfo``."""
|
|
200
|
+
block: Dict[str, str] = {}
|
|
201
|
+
for raw in output.splitlines():
|
|
202
|
+
match = _SI_FIELD.match(raw.strip())
|
|
203
|
+
if not match:
|
|
204
|
+
continue
|
|
205
|
+
key = match.group("key").strip().lower()
|
|
206
|
+
block[key] = match.group("value").strip()
|
|
207
|
+
|
|
208
|
+
name = block.get("name") or fallback_name
|
|
209
|
+
depends = _split_depends(block.get("depends on"))
|
|
210
|
+
return PackageInfo(
|
|
211
|
+
name=name,
|
|
212
|
+
version=block.get("version"),
|
|
213
|
+
description=block.get("description"),
|
|
214
|
+
repository=block.get("repository", "").split("/")[0] or "",
|
|
215
|
+
license=block.get("licenses"),
|
|
216
|
+
architecture=block.get("architecture"),
|
|
217
|
+
homepage=block.get("url"),
|
|
218
|
+
depends=depends,
|
|
219
|
+
download_size=_parse_size(block.get("download size")),
|
|
220
|
+
installed_size=_parse_size(block.get("installed size")),
|
|
221
|
+
package_manager=manager,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _split_depends(raw: Optional[str]) -> List[str]:
|
|
226
|
+
"""Parse ``Depends On`` into a list of package/symbol tokens."""
|
|
227
|
+
if not raw:
|
|
228
|
+
return []
|
|
229
|
+
return [part.strip() for part in raw.split() if part.strip()]
|
|
230
|
+
|
|
231
|
+
|
|
143
232
|
def _parse_si_blocks(output: str) -> Dict[str, Dict[str, Optional[int]]]:
|
|
144
233
|
"""Parse consolidated ``pacman -Si`` output into ``{name: sizes}``.
|
|
145
234
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""AUR backend — read-only overlay for Arch User Repository helpers.
|
|
2
|
+
|
|
3
|
+
The AUR has no single binary: packages are managed by a *helper* such as
|
|
4
|
+
``yay`` or ``paru``. This backend detects whichever helper is installed and
|
|
5
|
+
uses it only for **read-only** operations (``-Qua``, ``-Ss``, ``-Si``).
|
|
6
|
+
|
|
7
|
+
Deliberately, it does **not** install anything:
|
|
8
|
+
|
|
9
|
+
- AUR packages are built from source by the helper (``makepkg``), so an
|
|
10
|
+
unattended update is out of scope for PkgWise's "show first, never default"
|
|
11
|
+
safety model;
|
|
12
|
+
- :meth:`install_command` returns an empty list and
|
|
13
|
+
:meth:`install_updates` raises :class:`InstallError` explaining why.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from pkgwise.backends.arch import (
|
|
21
|
+
_parse_qu_line,
|
|
22
|
+
_parse_si_info,
|
|
23
|
+
_parse_syncdb_search,
|
|
24
|
+
)
|
|
25
|
+
from pkgwise.backends.base import InstallError, PackageManager
|
|
26
|
+
from pkgwise.core.models import PackageInfo, PackageSearchResult, PackageUpdate
|
|
27
|
+
from pkgwise.utils.commands import CommandError, run_command
|
|
28
|
+
|
|
29
|
+
_HELPERS = ("yay", "paru")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AurBackend(PackageManager):
|
|
33
|
+
"""AUR-helper-backed implementation (read-only overlay)."""
|
|
34
|
+
|
|
35
|
+
executable = ""
|
|
36
|
+
name = "aur"
|
|
37
|
+
needs_root = False
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def supported(self) -> bool:
|
|
41
|
+
return _detect_helper() is not None
|
|
42
|
+
|
|
43
|
+
def _helper(self) -> str:
|
|
44
|
+
helper = _detect_helper()
|
|
45
|
+
if helper is None:
|
|
46
|
+
raise InstallError(
|
|
47
|
+
"No AUR helper (yay or paru) is installed.",
|
|
48
|
+
detail="Install yay or paru to query AUR updates.",
|
|
49
|
+
)
|
|
50
|
+
self.executable = helper
|
|
51
|
+
return helper
|
|
52
|
+
|
|
53
|
+
# -- public API ---------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def check_updates(self) -> List[PackageUpdate]:
|
|
56
|
+
"""List pending AUR updates via ``<helper> -Qua`` (read-only)."""
|
|
57
|
+
helper = self._helper()
|
|
58
|
+
output = run_command([helper, "-Qua"], timeout=120).stdout
|
|
59
|
+
updates: List[PackageUpdate] = []
|
|
60
|
+
for line in output.splitlines():
|
|
61
|
+
row = _parse_qu_line(line)
|
|
62
|
+
if row is None:
|
|
63
|
+
continue
|
|
64
|
+
name, current, new = row
|
|
65
|
+
updates.append(
|
|
66
|
+
PackageUpdate(
|
|
67
|
+
name=name,
|
|
68
|
+
current_version=current,
|
|
69
|
+
new_version=new,
|
|
70
|
+
package_manager=self.name,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return updates
|
|
74
|
+
|
|
75
|
+
def list_updates(self) -> List[PackageUpdate]:
|
|
76
|
+
"""AUR helpers have no separate index refresh; check_updates == list."""
|
|
77
|
+
return self.check_updates()
|
|
78
|
+
|
|
79
|
+
def install_updates(self, names: List[str]) -> None:
|
|
80
|
+
"""AUR helper installs are never triggered automatically by PkgWise."""
|
|
81
|
+
raise InstallError(
|
|
82
|
+
"PkgWise does not run AUR helper installs automatically.",
|
|
83
|
+
detail=(
|
|
84
|
+
"AUR packages are built from source. Review them with "
|
|
85
|
+
"'pkgwise search'/'pkgwise info' and install through yay/paru "
|
|
86
|
+
"yourself."
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def install_command(self, names: List[str]) -> List[str]:
|
|
91
|
+
"""No deterministic install command is offered for AUR packages."""
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
def search_packages(self, query: str) -> List[PackageSearchResult]:
|
|
95
|
+
"""Search repos + AUR via ``<helper> -Ss``."""
|
|
96
|
+
helper = self._helper()
|
|
97
|
+
output = run_command([helper, "-Ss", query], timeout=120).stdout
|
|
98
|
+
return _parse_syncdb_search(output.splitlines(), manager=self.name)
|
|
99
|
+
|
|
100
|
+
def package_info(self, name: str) -> Optional[PackageInfo]:
|
|
101
|
+
"""Read AUR metadata via ``<helper> -Si``; ``None`` when not found."""
|
|
102
|
+
helper = self._helper()
|
|
103
|
+
try:
|
|
104
|
+
output = run_command([helper, "-Si", name], timeout=60).stdout
|
|
105
|
+
except CommandError:
|
|
106
|
+
return None
|
|
107
|
+
return _parse_si_info(output, manager=self.name, fallback_name=name)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _detect_helper() -> Optional[str]:
|
|
111
|
+
"""Return the installed AUR helper name, or ``None``."""
|
|
112
|
+
import shutil
|
|
113
|
+
|
|
114
|
+
for helper in _HELPERS:
|
|
115
|
+
if shutil.which(helper) is not None:
|
|
116
|
+
return helper
|
|
117
|
+
return None
|
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
from abc import ABC, abstractmethod
|
|
15
15
|
from typing import List, Optional
|
|
16
16
|
|
|
17
|
-
from pkgwise.core.models import PackageUpdate
|
|
17
|
+
from pkgwise.core.models import PackageInfo, PackageSearchResult, PackageUpdate
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class UpdateCheckError(Exception):
|
|
@@ -43,6 +43,10 @@ class PackageManager(ABC):
|
|
|
43
43
|
#: Human name for the backend, e.g. "pacman".
|
|
44
44
|
name: str = ""
|
|
45
45
|
|
|
46
|
+
#: Whether updates typically require root. Backends that manage per-user
|
|
47
|
+
#: state (e.g. flatpak) do not need the ``sudo`` elevation prefix.
|
|
48
|
+
needs_root: bool = True
|
|
49
|
+
|
|
46
50
|
@property
|
|
47
51
|
@abstractmethod
|
|
48
52
|
def supported(self) -> bool:
|
|
@@ -71,6 +75,18 @@ class PackageManager(ABC):
|
|
|
71
75
|
Exists so callers can display/confirm the exact command first.
|
|
72
76
|
"""
|
|
73
77
|
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def search_packages(self, query: str) -> List[PackageSearchResult]:
|
|
80
|
+
"""Search the package repositories for *query* (read-only)."""
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def package_info(self, name: str) -> Optional[PackageInfo]:
|
|
84
|
+
"""Return detailed metadata for a single package, or ``None``.
|
|
85
|
+
|
|
86
|
+
``None`` means the backend could not find or inspect *name* — the CLI
|
|
87
|
+
renders that as "not found" rather than failing.
|
|
88
|
+
"""
|
|
89
|
+
|
|
74
90
|
|
|
75
91
|
class InstallError(Exception):
|
|
76
92
|
"""Raised when an update operation fails."""
|
|
@@ -18,12 +18,13 @@ import re
|
|
|
18
18
|
from typing import Dict, List, Optional, Tuple
|
|
19
19
|
|
|
20
20
|
from pkgwise.backends.base import InstallError, PackageManager, UpdateCheckError
|
|
21
|
-
from pkgwise.core.models import PackageUpdate
|
|
21
|
+
from pkgwise.core.models import PackageInfo, PackageSearchResult, PackageUpdate
|
|
22
22
|
from pkgwise.utils.commands import run_command
|
|
23
23
|
|
|
24
24
|
_INST_LINE = re.compile(r"^Inst (?P<name>\S+)( \[(?P<current>[^\]]*)\])? \((?P<new>\S+)")
|
|
25
25
|
_URI_LINE = re.compile(r"^'(?P<url>.*)'\s+(?P<filename>\S+)\s+(?P<size>\d+)")
|
|
26
26
|
_STANZA_FIELD = re.compile(r"^(?P<key>[^:]+):\s*(?P<value>.*)$")
|
|
27
|
+
_CACHE_SEARCH = re.compile(r"^(?P<name>\S+)\s*-\s*(?P<description>.*)$")
|
|
27
28
|
|
|
28
29
|
_UPGRADE_TARGET = "upgrade"
|
|
29
30
|
|
|
@@ -89,6 +90,32 @@ class DebianBackend(PackageManager):
|
|
|
89
90
|
return []
|
|
90
91
|
return ["apt-get", "--yes", "install", *selected]
|
|
91
92
|
|
|
93
|
+
def search_packages(self, query: str) -> List[PackageSearchResult]:
|
|
94
|
+
"""Search the apt package lists via ``apt-cache search`` (offline)."""
|
|
95
|
+
if _apt_cache_available() is False:
|
|
96
|
+
return []
|
|
97
|
+
result = run_command(["apt-cache", "search", query], timeout=180, check=False)
|
|
98
|
+
results: List[PackageSearchResult] = []
|
|
99
|
+
for line in result.stdout.splitlines():
|
|
100
|
+
match = _CACHE_SEARCH.match(line.strip())
|
|
101
|
+
if not match:
|
|
102
|
+
continue
|
|
103
|
+
results.append(
|
|
104
|
+
PackageSearchResult(
|
|
105
|
+
name=match.group("name"),
|
|
106
|
+
description=match.group("description").strip(),
|
|
107
|
+
package_manager=self.name,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return results
|
|
111
|
+
|
|
112
|
+
def package_info(self, name: str) -> Optional[PackageInfo]:
|
|
113
|
+
"""Read candidate metadata via ``apt-cache show`` for *name*."""
|
|
114
|
+
if _apt_cache_available() is False:
|
|
115
|
+
return None
|
|
116
|
+
result = run_command(["apt-cache", "show", name], timeout=180, check=False)
|
|
117
|
+
return _parse_apt_show_first(result.stdout, manager=self.name, fallback_name=name)
|
|
118
|
+
|
|
92
119
|
# -- parsing helpers -----------------------------------------------------
|
|
93
120
|
|
|
94
121
|
def _simulate_upgrade(self) -> List[PackageUpdate]:
|
|
@@ -217,6 +244,59 @@ def _parse_apt_cache_stanzas(output: str) -> List[Tuple[str, Optional[str], Opti
|
|
|
217
244
|
return parsed
|
|
218
245
|
|
|
219
246
|
|
|
247
|
+
def _apt_cache_available() -> bool:
|
|
248
|
+
"""Return whether the ``apt-cache`` binary exists (import-local helper)."""
|
|
249
|
+
import shutil
|
|
250
|
+
|
|
251
|
+
return shutil.which("apt-cache") is not None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _parse_apt_show_first(output: str, manager: str, fallback_name: str) -> Optional[PackageInfo]:
|
|
255
|
+
"""Parse the first ``apt-cache show`` stanza into a ``PackageInfo``."""
|
|
256
|
+
block: Dict[str, str] = {}
|
|
257
|
+
for raw in output.splitlines():
|
|
258
|
+
if raw.strip() == "":
|
|
259
|
+
if block:
|
|
260
|
+
break # first stanza only
|
|
261
|
+
continue
|
|
262
|
+
match = _STANZA_FIELD.match(raw)
|
|
263
|
+
if not match:
|
|
264
|
+
continue
|
|
265
|
+
key = match.group("key").lower()
|
|
266
|
+
if key in {
|
|
267
|
+
"package", "version", "description", "architecture",
|
|
268
|
+
"homepage", "depends", "filename", "installed-size",
|
|
269
|
+
}:
|
|
270
|
+
block[key] = match.group("value").strip()
|
|
271
|
+
|
|
272
|
+
if "package" not in block and not output.strip():
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
installed: Optional[int] = None
|
|
276
|
+
if block.get("installed-size"):
|
|
277
|
+
try:
|
|
278
|
+
installed = int(block["installed-size"]) * 1024
|
|
279
|
+
except ValueError:
|
|
280
|
+
installed = None
|
|
281
|
+
|
|
282
|
+
repo = ""
|
|
283
|
+
if block.get("filename"):
|
|
284
|
+
repo = block["filename"].split("/")[0]
|
|
285
|
+
|
|
286
|
+
return PackageInfo(
|
|
287
|
+
name=block.get("package") or fallback_name,
|
|
288
|
+
version=block.get("version"),
|
|
289
|
+
description=block.get("description"),
|
|
290
|
+
repository=repo,
|
|
291
|
+
architecture=block.get("architecture"),
|
|
292
|
+
homepage=block.get("homepage"),
|
|
293
|
+
depends=[p.strip() for p in (block.get("depends") or "").split(",") if p.strip()],
|
|
294
|
+
download_size=None,
|
|
295
|
+
installed_size=installed,
|
|
296
|
+
package_manager=manager,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
220
300
|
def _with_download_size(update: PackageUpdate, size: int) -> PackageUpdate:
|
|
221
301
|
from dataclasses import replace
|
|
222
302
|
|
|
@@ -7,11 +7,14 @@ analyzer. Selection is driven by the distro ID reported by os-release.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from typing import Dict
|
|
10
|
+
from typing import Dict, List
|
|
11
11
|
|
|
12
12
|
from pkgwise.backends.arch import ArchBackend
|
|
13
|
+
from pkgwise.backends.aur import AurBackend
|
|
13
14
|
from pkgwise.backends.base import PackageManager, UnsupportedDistroError
|
|
14
15
|
from pkgwise.backends.debian import DebianBackend
|
|
16
|
+
from pkgwise.backends.flatpak import FlatpakBackend
|
|
17
|
+
from pkgwise.backends.snap import SnapBackend
|
|
15
18
|
from pkgwise.utils import distro as distro_utils
|
|
16
19
|
|
|
17
20
|
#: distro ID families -> backend class.
|
|
@@ -21,6 +24,11 @@ BACKENDS: Dict[str, type] = {
|
|
|
21
24
|
# Future backends register here, e.g. "fedora": DnfBackend
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
#: Auxiliary backends that overlay the primary manager (flapkak/snap/AUR are
|
|
28
|
+
#: independent of the OS package manager). Instantiated whenever their tool is
|
|
29
|
+
#: available and merged into the same analysis result.
|
|
30
|
+
AUX_BACKENDS: tuple = (FlatpakBackend, SnapBackend, AurBackend)
|
|
31
|
+
|
|
24
32
|
|
|
25
33
|
def get_backend(distro_name: str = "", package_manager: str = "") -> PackageManager:
|
|
26
34
|
"""Return the backend matching the current system.
|
|
@@ -55,6 +63,42 @@ def get_backend(distro_name: str = "", package_manager: str = "") -> PackageMana
|
|
|
55
63
|
)
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
def get_aux_backends() -> List[PackageManager]:
|
|
67
|
+
"""Return every auxiliary backend whose tool is installed (read-only check).
|
|
68
|
+
|
|
69
|
+
Errors are swallowed: a misbehaving overlay must never break the primary
|
|
70
|
+
analysis, and none of these run any side-effecting command during probing.
|
|
71
|
+
"""
|
|
72
|
+
available: List[PackageManager] = []
|
|
73
|
+
for backend_cls in AUX_BACKENDS:
|
|
74
|
+
try:
|
|
75
|
+
instance = backend_cls()
|
|
76
|
+
except Exception: # pragma: no cover - defensive
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
if instance.supported:
|
|
80
|
+
available.append(instance)
|
|
81
|
+
except Exception: # pragma: no cover - defensive
|
|
82
|
+
continue
|
|
83
|
+
return available
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_all_backends() -> List[PackageManager]:
|
|
87
|
+
"""Return the primary backend (if supported) plus all auxiliary backends.
|
|
88
|
+
|
|
89
|
+
Used by commands that operate across managers (``search``, ``update``).
|
|
90
|
+
The primary backend is attempted first so it stays first in the list, then
|
|
91
|
+
the auxiliaries in registration order.
|
|
92
|
+
"""
|
|
93
|
+
backends: List[PackageManager] = []
|
|
94
|
+
try:
|
|
95
|
+
backends.append(get_backend())
|
|
96
|
+
except UnsupportedDistroError: # pragma: no cover - exercised on exotic OSes
|
|
97
|
+
pass
|
|
98
|
+
backends.extend(get_aux_backends())
|
|
99
|
+
return backends
|
|
100
|
+
|
|
101
|
+
|
|
58
102
|
def _managers_for(distro_key: str) -> tuple:
|
|
59
103
|
"""Map a backend key to the package-manager names it satisfies."""
|
|
60
104
|
return {
|