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/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()
@@ -0,0 +1 @@
1
+ """Core domain logic: models, storage, priority analysis and the analyzer."""
@@ -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)