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/cli.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Kept strictly separate from the TUI so ``pkgwise check`` / ``updates`` /
|
|
4
|
+
``storage`` work without a terminal UI or Textual installed.
|
|
5
|
+
|
|
6
|
+
Subcommands
|
|
7
|
+
-----------
|
|
8
|
+
``pkgwise`` launch the interactive TUI
|
|
9
|
+
``pkgwise check`` one-shot analysis: storage + update counts + sizes
|
|
10
|
+
``pkgwise updates`` list updates as plain text with priorities
|
|
11
|
+
``pkgwise storage`` print filesystem usage only
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from pkgwise import __version__
|
|
21
|
+
from pkgwise.core import analyzer as analyzer_mod
|
|
22
|
+
from pkgwise.core.models import AnalysisResult, Priority
|
|
23
|
+
from pkgwise.utils import distro as distro_utils
|
|
24
|
+
from pkgwise.utils.formatting import format_bytes
|
|
25
|
+
|
|
26
|
+
_TUI_IMPORT_ERROR = (
|
|
27
|
+
"The interactive interface needs the optional 'tui' extra.\n"
|
|
28
|
+
"Install it with: python -m pip install 'pkgwise[tui]'"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: Optional[List[str]] = None) -> None:
|
|
33
|
+
"""Entry point installed as the ``pkgwise`` console script."""
|
|
34
|
+
parser = _build_parser()
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
_dispatch(parser, args)
|
|
39
|
+
except KeyboardInterrupt:
|
|
40
|
+
print("\nInterrupted.")
|
|
41
|
+
raise SystemExit(130)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
parser = argparse.ArgumentParser(
|
|
46
|
+
prog="pkgwise",
|
|
47
|
+
description="Know what you're updating before you update. "
|
|
48
|
+
"Analyzes available Linux package updates.",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--version", action="version", version=f"pkgwise {__version__}"
|
|
52
|
+
)
|
|
53
|
+
sub = parser.add_subparsers(dest="command")
|
|
54
|
+
|
|
55
|
+
analyze_parser(sub, "check", "One-shot analysis of storage and available updates")
|
|
56
|
+
analyze_parser(sub, "updates", "List available updates with priorities")
|
|
57
|
+
analyze_parser(sub, "storage", "Show filesystem usage only")
|
|
58
|
+
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def analyze_parser(subparsers, name: str, help_text: str) -> None:
|
|
63
|
+
parser = subparsers.add_parser(name, help=help_text)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--refresh",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Refresh the package index before listing updates",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--plain", action="store_true", help="Disable emoji and ANSI color"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _dispatch(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
75
|
+
command = args.command
|
|
76
|
+
if command is None:
|
|
77
|
+
_launch_tui()
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
if command == "storage":
|
|
82
|
+
_print_storage_only(plain=args.plain)
|
|
83
|
+
return
|
|
84
|
+
analysis = analyzer_mod.analyze()
|
|
85
|
+
if args.refresh:
|
|
86
|
+
analysis = analyzer_mod.refresh_updates(analysis)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
print(f"⚠ Unable to check for updates.\n\n{_friendly_cli_error(exc)}", file=sys.stderr)
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
|
|
91
|
+
if command == "check":
|
|
92
|
+
_print_check(analysis, plain=args.plain)
|
|
93
|
+
elif command == "updates":
|
|
94
|
+
_print_updates(analysis, plain=args.plain)
|
|
95
|
+
else: # pragma: no cover - argparse rejects unknown commands
|
|
96
|
+
parser.print_help()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _print_storage_only(plain: bool = False) -> None:
|
|
100
|
+
"""Storage needs no package manager — print it directly."""
|
|
101
|
+
from pkgwise.core import storage as storage_mod
|
|
102
|
+
|
|
103
|
+
info = storage_mod.get_storage_info()
|
|
104
|
+
from pkgwise.core.models import SystemInfo
|
|
105
|
+
|
|
106
|
+
system = SystemInfo(
|
|
107
|
+
distro_name=distro_utils.get_system_info().distro_name,
|
|
108
|
+
package_manager=distro_utils.get_system_info().package_manager,
|
|
109
|
+
)
|
|
110
|
+
_print_storage(AnalysisResult(storage=info, system=system, updates=[]), plain=plain)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _friendly_cli_error(exc: Exception) -> str:
|
|
114
|
+
from pkgwise.utils import distro as distro_utils
|
|
115
|
+
|
|
116
|
+
base = str(exc) or exc.__class__.__name__
|
|
117
|
+
if "not recognized" in base or "unsupported" in base or "not found" in base:
|
|
118
|
+
return (
|
|
119
|
+
f"{base}\n\n"
|
|
120
|
+
"Check that your package manager is installed and your distribution "
|
|
121
|
+
"is supported: Arch Linux (pacman) or Debian/Kali/Ubuntu (apt)."
|
|
122
|
+
)
|
|
123
|
+
return (
|
|
124
|
+
f"{base}\n\n"
|
|
125
|
+
"The package manager returned an error.\n"
|
|
126
|
+
"Check your network connection and package-manager configuration."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _launch_tui() -> None:
|
|
131
|
+
"""Start the Textual app, with a helpful message if it is missing."""
|
|
132
|
+
try:
|
|
133
|
+
from pkgwise.ui.app import PkgWiseApp
|
|
134
|
+
except ImportError: # Textual not installed
|
|
135
|
+
print(_TUI_IMPORT_ERROR, file=sys.stderr)
|
|
136
|
+
raise SystemExit(1)
|
|
137
|
+
|
|
138
|
+
PkgWiseApp().run()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _print_storage(analysis: AnalysisResult, plain: bool = False) -> None:
|
|
142
|
+
info = analysis.storage
|
|
143
|
+
print("STORAGE")
|
|
144
|
+
print(f" Total : {format_bytes(info.total_bytes)}")
|
|
145
|
+
print(f" Used : {format_bytes(info.used_bytes)}")
|
|
146
|
+
print(f" Free : {format_bytes(info.free_bytes)}")
|
|
147
|
+
print(f" Usage : {info.usage_percentage:.1f}%")
|
|
148
|
+
if not plain:
|
|
149
|
+
print(f" System: {analysis.system.distro_name} "
|
|
150
|
+
f"({analysis.system.package_manager})")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _print_check(analysis: AnalysisResult, plain: bool = False) -> None:
|
|
154
|
+
_print_storage(analysis, plain=plain)
|
|
155
|
+
print()
|
|
156
|
+
_print_update_summary(analysis, plain=plain)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _print_update_summary(analysis: AnalysisResult, plain: bool = False) -> None:
|
|
160
|
+
updates = analysis.updates
|
|
161
|
+
print("UPDATES")
|
|
162
|
+
if not updates:
|
|
163
|
+
print(" System is up to date.")
|
|
164
|
+
return
|
|
165
|
+
download = _total_download(updates)
|
|
166
|
+
install = _total_install(updates)
|
|
167
|
+
print(f" Available : {len(updates)}")
|
|
168
|
+
print(f" Download : {download}")
|
|
169
|
+
print(f" Install : {install}")
|
|
170
|
+
cr = sum(1 for u in updates if u.priority == Priority.CRITICAL)
|
|
171
|
+
hi = sum(1 for u in updates if u.priority == Priority.HIGH)
|
|
172
|
+
me = sum(1 for u in updates if u.priority == Priority.MEDIUM)
|
|
173
|
+
lo = sum(1 for u in updates if u.priority == Priority.LOW)
|
|
174
|
+
if not plain:
|
|
175
|
+
print(f" CRITICAL : {cr}")
|
|
176
|
+
print(f" HIGH : {hi}")
|
|
177
|
+
print(f" MEDIUM : {me}")
|
|
178
|
+
print(f" LOW : {lo}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _print_updates(analysis: AnalysisResult, plain: bool = False) -> None:
|
|
182
|
+
_print_storage(analysis, plain=plain)
|
|
183
|
+
print()
|
|
184
|
+
_print_update_summary(analysis, plain=plain)
|
|
185
|
+
updates = analysis.updates
|
|
186
|
+
if not updates:
|
|
187
|
+
return
|
|
188
|
+
print()
|
|
189
|
+
order = {
|
|
190
|
+
Priority.CRITICAL: 0,
|
|
191
|
+
Priority.HIGH: 1,
|
|
192
|
+
Priority.MEDIUM: 2,
|
|
193
|
+
Priority.LOW: 3,
|
|
194
|
+
}
|
|
195
|
+
for update in sorted(updates, key=lambda u: (order[u.priority], u.name)):
|
|
196
|
+
tag = "" if plain else f"{_EMOJI[update.priority]} "
|
|
197
|
+
size = format_bytes(update.download_size)
|
|
198
|
+
current = update.current_version or "?"
|
|
199
|
+
new = update.new_version or "?"
|
|
200
|
+
print(f"{tag}{update.name:<24} {current:<16} -> {new:<16} {size:>10}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_EMOJI = {
|
|
204
|
+
Priority.CRITICAL: "CRIT",
|
|
205
|
+
Priority.HIGH: "HIGH",
|
|
206
|
+
Priority.MEDIUM: "MED ",
|
|
207
|
+
Priority.LOW: "LOW ",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _total_download(updates) -> str:
|
|
212
|
+
known = [u.download_size for u in updates if u.download_size is not None]
|
|
213
|
+
return format_bytes(sum(known)) if known else "Unknown"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _total_install(updates) -> str:
|
|
217
|
+
known = [u.installed_size for u in updates if u.installed_size is not None]
|
|
218
|
+
return format_bytes(sum(known)) if known else "Unknown"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
main()
|
pkgwise/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core domain logic: models, storage, priority analysis and the analyzer."""
|
pkgwise/core/analyzer.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""High-level analysis and update orchestration.
|
|
2
|
+
|
|
3
|
+
This is the single entry point the CLI and TUI use. It hides the backend
|
|
4
|
+
abstraction, storage detection and priority scoring behind a few focused
|
|
5
|
+
functions, and it is where all *safety* checks for real updates live:
|
|
6
|
+
|
|
7
|
+
- updates are never run automatically;
|
|
8
|
+
- the exact command is always shown before running it;
|
|
9
|
+
- disk-space and privilege checks gate execution;
|
|
10
|
+
- nothing is ever removed or cleaned automatically.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from pkgwise.backends.factory import get_backend
|
|
19
|
+
from pkgwise.core import storage as storage_mod
|
|
20
|
+
from pkgwise.core.models import AnalysisResult, PackageUpdate, StorageInfo, SystemInfo
|
|
21
|
+
from pkgwise.core.priority import analyze_priority
|
|
22
|
+
from pkgwise.utils import distro as distro_utils
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def analyze(distro_name: str = "", package_manager: str = "") -> AnalysisResult:
|
|
26
|
+
"""Produce a full analysis snapshot: storage + system + prioritized updates."""
|
|
27
|
+
system = SystemInfo(
|
|
28
|
+
distro_name=distro_name or distro_utils.get_system_info().distro_name,
|
|
29
|
+
distro_version=distro_utils.get_system_info().distro_version,
|
|
30
|
+
package_manager=package_manager or distro_utils.get_system_info().package_manager,
|
|
31
|
+
)
|
|
32
|
+
storage = storage_mod.get_storage_info()
|
|
33
|
+
updates = _load_updates(system.package_manager, distro_name=system.distro_name)
|
|
34
|
+
updates = _prioritize(updates)
|
|
35
|
+
return AnalysisResult(storage=storage, system=system, updates=updates)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_updates(package_manager: str, distro_name: str) -> List[PackageUpdate]:
|
|
39
|
+
backend = get_backend(distro_name=distro_name, package_manager=package_manager)
|
|
40
|
+
return backend.check_updates()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _prioritize(updates: List[PackageUpdate]) -> List[PackageUpdate]:
|
|
44
|
+
result: List[PackageUpdate] = []
|
|
45
|
+
for update in updates:
|
|
46
|
+
priority, reasons = analyze_priority(update)
|
|
47
|
+
result.append(update.prioritized(priority, reasons))
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def refresh_updates(analysis: AnalysisResult) -> AnalysisResult:
|
|
52
|
+
"""Re-run the analysis after refreshing the package index (``list_updates``)."""
|
|
53
|
+
backend = get_backend(
|
|
54
|
+
distro_name=analysis.system.distro_name,
|
|
55
|
+
package_manager=analysis.system.package_manager,
|
|
56
|
+
)
|
|
57
|
+
updates = _prioritize(backend.list_updates())
|
|
58
|
+
return AnalysisResult(
|
|
59
|
+
storage=analysis.storage,
|
|
60
|
+
system=analysis.system,
|
|
61
|
+
updates=updates,
|
|
62
|
+
warnings=analysis.warnings,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class UpdateGated(Exception):
|
|
67
|
+
"""Raised when a safety check blocks an update before execution."""
|
|
68
|
+
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InsufficientDiskSpace(UpdateGated):
|
|
73
|
+
"""Raised when the system does not have enough free space for an update."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, available: int, required: int):
|
|
76
|
+
self.available = available
|
|
77
|
+
self.required = required
|
|
78
|
+
super().__init__("Insufficient disk space for the planned update.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RequiresElevation(UpdateGated):
|
|
82
|
+
"""Raised when updating requires root and none is available."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, message: str, command: List[str]):
|
|
85
|
+
self.message = message
|
|
86
|
+
self.command = command
|
|
87
|
+
super().__init__(message)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def prepare_update(
|
|
91
|
+
selected: List[PackageUpdate],
|
|
92
|
+
storage: Optional[StorageInfo] = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Validate that *selected* can be safely updated. Raises on problems.
|
|
95
|
+
|
|
96
|
+
Checks, in order: an empty selection, sufficient free disk space, and that
|
|
97
|
+
the deep command (``pacman``/``apt``) can reach elevation. PkgWise never
|
|
98
|
+
runs anything itself — the TUI runs the resulting command only after the
|
|
99
|
+
user confirms it.
|
|
100
|
+
"""
|
|
101
|
+
if not selected:
|
|
102
|
+
raise UpdateGated("No packages selected.")
|
|
103
|
+
|
|
104
|
+
storage = storage or storage_mod.get_storage_info()
|
|
105
|
+
|
|
106
|
+
required = _estimated_required_bytes(selected)
|
|
107
|
+
if required is not None and required > storage.free_bytes:
|
|
108
|
+
raise InsufficientDiskSpace(available=storage.free_bytes, required=required)
|
|
109
|
+
|
|
110
|
+
names = [u.name for u in selected]
|
|
111
|
+
backend = get_backend()
|
|
112
|
+
command = _elevated_command(backend.install_command(names))
|
|
113
|
+
_ensure_elevation(command)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _elevated_command(command: List[str]) -> List[str]:
|
|
117
|
+
"""Prefix ``sudo`` when the command requires root and sudo is available."""
|
|
118
|
+
from pkgwise.utils.commands import is_elevated, which
|
|
119
|
+
|
|
120
|
+
if not is_elevated() and which("sudo"):
|
|
121
|
+
return ["sudo", *command]
|
|
122
|
+
return command
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _estimated_required_bytes(selected: List[PackageUpdate]) -> Optional[int]:
|
|
126
|
+
"""Best-effort estimate of disk space the update will use.
|
|
127
|
+
|
|
128
|
+
Uses installed sizes when present; falls back to download sizes. ``None``
|
|
129
|
+
when no sizes are available (the safety check is skipped in that case).
|
|
130
|
+
"""
|
|
131
|
+
known = [
|
|
132
|
+
u.installed_size
|
|
133
|
+
for u in selected
|
|
134
|
+
if u.installed_size is not None
|
|
135
|
+
]
|
|
136
|
+
if known:
|
|
137
|
+
return sum(known)
|
|
138
|
+
downloads = [
|
|
139
|
+
u.download_size
|
|
140
|
+
for u in selected
|
|
141
|
+
if u.download_size is not None
|
|
142
|
+
]
|
|
143
|
+
return sum(downloads) if downloads else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ensure_elevation(command: List[str]) -> None:
|
|
147
|
+
"""Fail with an explanation when the command cannot gain root."""
|
|
148
|
+
from pkgwise.utils.commands import is_elevated
|
|
149
|
+
|
|
150
|
+
if is_elevated():
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
from pkgwise.utils.commands import which
|
|
154
|
+
|
|
155
|
+
sudo = which("sudo")
|
|
156
|
+
if sudo is None:
|
|
157
|
+
raise RequiresElevation(
|
|
158
|
+
"Elevated privileges are required to update packages, but no "
|
|
159
|
+
"password-elevation tool (such as sudo) is available.",
|
|
160
|
+
command=command,
|
|
161
|
+
)
|
|
162
|
+
# sudo exists; the TUI will invoke it for the user to authenticate.
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def display_command(names: List[str]) -> List[str]:
|
|
167
|
+
"""Human-visible command PkgWise would run for *names* (used for confirmation)."""
|
|
168
|
+
backend = get_backend()
|
|
169
|
+
return _elevated_command(backend.install_command(names))
|
pkgwise/core/models.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Typed data models for PkgWise.
|
|
2
|
+
|
|
3
|
+
These are plain dataclasses (no runtime dependency); Pydantic is deliberately
|
|
4
|
+
avoided for the MVP because nothing here needs coercion or schema generation.
|
|
5
|
+
Unknown data is represented as ``None`` and rendered as "Unknown" by the UI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Priority(Enum):
|
|
16
|
+
"""Priority levels assigned by :mod:`pkgwise.core.priority`."""
|
|
17
|
+
|
|
18
|
+
CRITICAL = 4
|
|
19
|
+
HIGH = 3
|
|
20
|
+
MEDIUM = 2
|
|
21
|
+
LOW = 1
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def label(self) -> str:
|
|
25
|
+
return self.name.title()
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
28
|
+
return self.name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class StorageInfo:
|
|
33
|
+
"""Snapshot of disk usage for a filesystem."""
|
|
34
|
+
|
|
35
|
+
path: str
|
|
36
|
+
total_bytes: int
|
|
37
|
+
used_bytes: int
|
|
38
|
+
free_bytes: int
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def usage_percentage(self) -> float:
|
|
42
|
+
"""Usage percentage (0.0-100.0); 0 when total is unknown/zero."""
|
|
43
|
+
if self.total_bytes <= 0:
|
|
44
|
+
return 0.0
|
|
45
|
+
return (self.used_bytes / self.total_bytes) * 100.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class PackageUpdate:
|
|
50
|
+
"""A single available package update."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
current_version: Optional[str] = None
|
|
54
|
+
new_version: Optional[str] = None
|
|
55
|
+
download_size: Optional[int] = None
|
|
56
|
+
installed_size: Optional[int] = None
|
|
57
|
+
security_update: Optional[bool] = None
|
|
58
|
+
reboot_required: Optional[bool] = None
|
|
59
|
+
core: Optional[bool] = None
|
|
60
|
+
package_manager: str = "unknown"
|
|
61
|
+
|
|
62
|
+
# Assigned by the priority analyzer, not by the backend parser.
|
|
63
|
+
priority: Priority = Priority.MEDIUM
|
|
64
|
+
priority_reasons: List[str] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
def prioritized(self, priority: Priority, reasons: List[str]) -> "PackageUpdate":
|
|
67
|
+
"""Return a copy of this update with priority metadata attached."""
|
|
68
|
+
return replace(self, priority=priority, priority_reasons=list(reasons))
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def display_current(self) -> str:
|
|
72
|
+
return self.current_version or "Unknown"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def display_new(self) -> str:
|
|
76
|
+
return self.new_version or "Unknown"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def has_versions(self) -> bool:
|
|
80
|
+
return bool(self.current_version and self.new_version)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class UpdateSummary:
|
|
85
|
+
"""Aggregate information about a set of package updates."""
|
|
86
|
+
|
|
87
|
+
updates: List[PackageUpdate] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def count(self) -> int:
|
|
91
|
+
return len(self.updates)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def total_download_bytes(self) -> Optional[int]:
|
|
95
|
+
"""Sum of known download sizes; ``None`` when none are known."""
|
|
96
|
+
known = [u.download_size for u in self.updates if u.download_size is not None]
|
|
97
|
+
return sum(known) if known else None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def total_install_bytes(self) -> Optional[int]:
|
|
101
|
+
"""Sum of known installed sizes; ``None`` when none are known."""
|
|
102
|
+
known = [u.installed_size for u in self.updates if u.installed_size is not None]
|
|
103
|
+
return sum(known) if known else None
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def security_count(self) -> int:
|
|
107
|
+
return sum(1 for u in self.updates if u.security_update)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class SystemInfo:
|
|
112
|
+
"""Detected system context passed down to analysis."""
|
|
113
|
+
|
|
114
|
+
distro_name: str
|
|
115
|
+
distro_version: str = ""
|
|
116
|
+
package_manager: str = "unknown"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class AnalysisResult:
|
|
121
|
+
"""Everything the analysis stage produces in one call."""
|
|
122
|
+
|
|
123
|
+
storage: StorageInfo
|
|
124
|
+
system: SystemInfo
|
|
125
|
+
updates: List[PackageUpdate]
|
|
126
|
+
warnings: List[str] = field(default_factory=list)
|