pkgwise 0.1.1__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.
Files changed (47) hide show
  1. {pkgwise-0.1.1 → pkgwise-0.2.0}/PKG-INFO +11 -14
  2. {pkgwise-0.1.1 → pkgwise-0.2.0}/README.md +9 -13
  3. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/__init__.py +1 -1
  4. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/backends/arch.py +90 -1
  5. pkgwise-0.2.0/pkgwise/backends/aur.py +117 -0
  6. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/backends/base.py +17 -1
  7. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/backends/debian.py +81 -1
  8. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/backends/factory.py +45 -1
  9. pkgwise-0.2.0/pkgwise/backends/flatpak.py +178 -0
  10. pkgwise-0.2.0/pkgwise/backends/snap.py +182 -0
  11. pkgwise-0.2.0/pkgwise/cli.py +448 -0
  12. pkgwise-0.2.0/pkgwise/core/analyzer.py +285 -0
  13. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/core/models.py +60 -0
  14. pkgwise-0.2.0/pkgwise/core/state.py +174 -0
  15. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise.egg-info/PKG-INFO +11 -14
  16. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise.egg-info/SOURCES.txt +5 -0
  17. {pkgwise-0.1.1 → pkgwise-0.2.0}/pyproject.toml +2 -1
  18. {pkgwise-0.1.1 → pkgwise-0.2.0}/tests/test_analyzer.py +119 -1
  19. {pkgwise-0.1.1 → pkgwise-0.2.0}/tests/test_backends.py +189 -1
  20. pkgwise-0.2.0/tests/test_state.py +103 -0
  21. pkgwise-0.1.1/pkgwise/cli.py +0 -222
  22. pkgwise-0.1.1/pkgwise/core/analyzer.py +0 -169
  23. {pkgwise-0.1.1 → pkgwise-0.2.0}/LICENSE +0 -0
  24. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/__main__.py +0 -0
  25. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/backends/__init__.py +0 -0
  26. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/core/__init__.py +0 -0
  27. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/core/priority.py +0 -0
  28. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/core/storage.py +0 -0
  29. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/mockdata.py +0 -0
  30. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/py.typed +0 -0
  31. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/ui/__init__.py +0 -0
  32. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/ui/app.py +0 -0
  33. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/ui/screens.py +0 -0
  34. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/ui/widgets.py +0 -0
  35. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/utils/__init__.py +0 -0
  36. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/utils/commands.py +0 -0
  37. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/utils/config.py +0 -0
  38. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/utils/distro.py +0 -0
  39. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise/utils/formatting.py +0 -0
  40. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise.egg-info/dependency_links.txt +0 -0
  41. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise.egg-info/entry_points.txt +0 -0
  42. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise.egg-info/requires.txt +0 -0
  43. {pkgwise-0.1.1 → pkgwise-0.2.0}/pkgwise.egg-info/top_level.txt +0 -0
  44. {pkgwise-0.1.1 → pkgwise-0.2.0}/setup.cfg +0 -0
  45. {pkgwise-0.1.1 → pkgwise-0.2.0}/tests/test_priority.py +0 -0
  46. {pkgwise-0.1.1 → pkgwise-0.2.0}/tests/test_storage.py +0 -0
  47. {pkgwise-0.1.1 → 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.1.1
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,20 +122,10 @@ 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.prepare_update()` |
119
- | Privilege check before install | `analyzer.prepare_update()` |
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
- ## Development
123
-
124
- ```bash
125
- python3 -m venv .venv && source .venv/bin/activate
126
- pip install -e ".[test]"
127
- pytest
128
- ```
129
-
130
- See [CONTRIBUTING](https://github.com/Senpi-Station/PkgWise/blob/main/docs/CONTRIBUTING.md) and [DEVELOPMENT](https://github.com/Senpi-Station/PkgWise/blob/main/docs/DEVELOPMENT.md).
131
-
132
129
  ## License
133
130
 
134
131
  Released under the [MIT License](https://github.com/Senpi-Station/PkgWise/blob/main/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,20 +86,10 @@ 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.prepare_update()` |
84
- | Privilege check before install | `analyzer.prepare_update()` |
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
- ## Development
88
-
89
- ```bash
90
- python3 -m venv .venv && source .venv/bin/activate
91
- pip install -e ".[test]"
92
- pytest
93
- ```
94
-
95
- See [CONTRIBUTING](https://github.com/Senpi-Station/PkgWise/blob/main/docs/CONTRIBUTING.md) and [DEVELOPMENT](https://github.com/Senpi-Station/PkgWise/blob/main/docs/DEVELOPMENT.md).
96
-
97
93
  ## License
98
94
 
99
95
  Released under the [MIT License](https://github.com/Senpi-Station/PkgWise/blob/main/LICENSE).
@@ -7,6 +7,6 @@ package manager (currently ``pacman`` and ``apt``) as its backend and provides
7
7
  a better interface for understanding and selecting package updates.
8
8
  """
9
9
 
10
- __version__ = "0.1.1"
10
+ __version__ = "0.2.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -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 {