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/ui/screens.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""PkgWise TUI screens.
|
|
2
|
+
|
|
3
|
+
Each screen owns one focused task:
|
|
4
|
+
|
|
5
|
+
- :class:`MainScreen` — storage + update list + selection
|
|
6
|
+
- :class:`DetailsScreen` — why a single update is (or isn't) important
|
|
7
|
+
- :class:`ConfirmScreen` — the always-on "Continue? [y/N]" gate
|
|
8
|
+
- :class:`BlockedScreen` — insufficient disk space / elevation notice
|
|
9
|
+
- :class:`ResultScreen` — sit back and run the shown command
|
|
10
|
+
- :class:`ErrorScreen` — human-readable failure report
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.binding import Binding
|
|
20
|
+
from textual.containers import VerticalScroll
|
|
21
|
+
from textual.screen import ModalScreen, Screen
|
|
22
|
+
from textual.widgets import DataTable, Footer, Header, Label, Static
|
|
23
|
+
|
|
24
|
+
from pkgwise.core.models import AnalysisResult, PackageUpdate, StorageInfo
|
|
25
|
+
from pkgwise.utils.formatting import format_bytes
|
|
26
|
+
from pkgwise.ui.widgets import progress_text, storage_summary_text
|
|
27
|
+
|
|
28
|
+
_PRIORITY_LABEL = {
|
|
29
|
+
4: "🔴 CRITICAL",
|
|
30
|
+
3: "🟠 HIGH",
|
|
31
|
+
2: "🟡 MEDIUM",
|
|
32
|
+
1: "🟢 LOW",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MainScreen(Screen):
|
|
37
|
+
"""Primary screen: overview, priority breakdown and selectable update list."""
|
|
38
|
+
|
|
39
|
+
BINDINGS = [
|
|
40
|
+
Binding("space", "toggle_select", "Select"),
|
|
41
|
+
Binding("enter", "show_details", "Details"),
|
|
42
|
+
Binding("u", "update_selected", "Update"),
|
|
43
|
+
Binding("a", "select_all", "Select all"),
|
|
44
|
+
Binding("r", "refresh", "Refresh"),
|
|
45
|
+
Binding("q", "quit", "Quit"),
|
|
46
|
+
Binding("escape", "stop", "Back"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
analysis: Optional[AnalysisResult] = None,
|
|
52
|
+
updates: Optional[List[PackageUpdate]] = None,
|
|
53
|
+
**kwargs,
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(**kwargs)
|
|
56
|
+
self._analysis = analysis
|
|
57
|
+
if updates is not None:
|
|
58
|
+
self._updates = updates
|
|
59
|
+
elif analysis is not None:
|
|
60
|
+
self._updates = list(analysis.updates)
|
|
61
|
+
else:
|
|
62
|
+
self._updates = []
|
|
63
|
+
self._selected: set[str] = set()
|
|
64
|
+
|
|
65
|
+
def compose(self) -> ComposeResult:
|
|
66
|
+
yield Header(show_clock=True)
|
|
67
|
+
with VerticalScroll(id="overview"):
|
|
68
|
+
yield Static(id="storage-block")
|
|
69
|
+
yield Static(id="summary-block")
|
|
70
|
+
yield Static(id="counts-block")
|
|
71
|
+
yield Label("", id="selection-block")
|
|
72
|
+
yield DataTable(id="updates-table")
|
|
73
|
+
yield Footer()
|
|
74
|
+
|
|
75
|
+
def on_mount(self) -> None:
|
|
76
|
+
table = self.query_one("#updates-table", DataTable)
|
|
77
|
+
table.cursor_type = "row"
|
|
78
|
+
table.zebra_stripes = True
|
|
79
|
+
table.add_columns("", "PACKAGE", "CURRENT", "NEW", "SIZE", "PRIORITY")
|
|
80
|
+
self._rebuild_all()
|
|
81
|
+
|
|
82
|
+
def _rebuild_all(self) -> None:
|
|
83
|
+
self._render_storage()
|
|
84
|
+
self._render_summary()
|
|
85
|
+
self._render_counts()
|
|
86
|
+
self._render_selection()
|
|
87
|
+
self._render_table()
|
|
88
|
+
|
|
89
|
+
def _render_storage(self) -> None:
|
|
90
|
+
widget = self.query_one("#storage-block", Static)
|
|
91
|
+
storage: Optional[StorageInfo] = None
|
|
92
|
+
if self._analysis is not None:
|
|
93
|
+
storage = self._analysis.storage
|
|
94
|
+
if storage is None:
|
|
95
|
+
widget.update(Text("STORAGE\nUnknown", style="dim"))
|
|
96
|
+
return
|
|
97
|
+
bar_width = max(self.size.width - 6, 10)
|
|
98
|
+
widget.update(storage_summary_text(storage, bar_width))
|
|
99
|
+
|
|
100
|
+
def _render_summary(self) -> None:
|
|
101
|
+
widget = self.query_one("#summary-block", Static)
|
|
102
|
+
if not self._updates:
|
|
103
|
+
widget.update("")
|
|
104
|
+
return
|
|
105
|
+
download = sum(u.download_size for u in self._updates if u.download_size)
|
|
106
|
+
install = sum(u.installed_size for u in self._updates if u.installed_size)
|
|
107
|
+
line = (
|
|
108
|
+
f"{len(self._updates)} available updates"
|
|
109
|
+
)
|
|
110
|
+
if download:
|
|
111
|
+
line += f" {format_bytes(download)} download"
|
|
112
|
+
if install:
|
|
113
|
+
line += f" {format_bytes(install)} install"
|
|
114
|
+
widget.update(line)
|
|
115
|
+
|
|
116
|
+
def _render_counts(self) -> None:
|
|
117
|
+
widget = self.query_one("#counts-block", Static)
|
|
118
|
+
counts = {p: 0 for p in (4, 3, 2, 1)}
|
|
119
|
+
for update in self._updates:
|
|
120
|
+
counts[update.priority.value] += 1
|
|
121
|
+
text = Text()
|
|
122
|
+
for value in (4, 3, 2, 1):
|
|
123
|
+
if counts[value]:
|
|
124
|
+
text.append(f"{_PRIORITY_LABEL[value]:<10} {counts[value]}\n")
|
|
125
|
+
widget.update(text)
|
|
126
|
+
|
|
127
|
+
def _render_selection(self) -> None:
|
|
128
|
+
widget = self.query_one("#selection-block", Label)
|
|
129
|
+
if not self._selected:
|
|
130
|
+
widget.update("")
|
|
131
|
+
return
|
|
132
|
+
chosen = [u for u in self._updates if u.name in self._selected]
|
|
133
|
+
download = sum(u.download_size for u in chosen if u.download_size)
|
|
134
|
+
install = sum(u.installed_size for u in chosen if u.installed_size)
|
|
135
|
+
parts = [f"Selected: {len(chosen)} packages"]
|
|
136
|
+
if download:
|
|
137
|
+
parts.append(f"Download: {format_bytes(download)}")
|
|
138
|
+
if install:
|
|
139
|
+
parts.append(f"Estimated install: {format_bytes(install)}")
|
|
140
|
+
widget.update(" ".join(parts))
|
|
141
|
+
|
|
142
|
+
def _render_table(self) -> None:
|
|
143
|
+
table = self.query_one("#updates-table", DataTable)
|
|
144
|
+
table.clear()
|
|
145
|
+
order = {4: 0, 3: 1, 2: 2, 1: 3}
|
|
146
|
+
sorted_updates = sorted(
|
|
147
|
+
self._updates, key=lambda u: (order.get(u.priority.value, 2), u.name)
|
|
148
|
+
)
|
|
149
|
+
for update in sorted_updates:
|
|
150
|
+
mark = "[x]" if update.name in self._selected else "[ ]"
|
|
151
|
+
prio = _PRIORITY_LABEL.get(update.priority.value, "MEDIUM")
|
|
152
|
+
size = format_bytes(
|
|
153
|
+
update.download_size if update.download_size is not None else update.installed_size
|
|
154
|
+
)
|
|
155
|
+
table.add_row(
|
|
156
|
+
mark,
|
|
157
|
+
update.name,
|
|
158
|
+
update.current_version or "Unknown",
|
|
159
|
+
update.new_version or "Unknown",
|
|
160
|
+
size,
|
|
161
|
+
prio,
|
|
162
|
+
key=update.name,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# -- actions ----------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def action_toggle_select(self) -> None:
|
|
168
|
+
table = self.query_one("#updates-table", DataTable)
|
|
169
|
+
update = self._current_update()
|
|
170
|
+
if update is None:
|
|
171
|
+
return
|
|
172
|
+
name = update.name
|
|
173
|
+
if name in self._selected:
|
|
174
|
+
self._selected.discard(name)
|
|
175
|
+
else:
|
|
176
|
+
self._selected.add(name)
|
|
177
|
+
self._render_selection()
|
|
178
|
+
self._render_table()
|
|
179
|
+
|
|
180
|
+
def _current_update(self) -> Optional[PackageUpdate]:
|
|
181
|
+
table = self.query_one("#updates-table", DataTable)
|
|
182
|
+
if not table.row_count:
|
|
183
|
+
return None
|
|
184
|
+
row = table.coordinate_to_cell_key(table.cursor_coordinate)
|
|
185
|
+
if not row or not row.row_key.value:
|
|
186
|
+
return None
|
|
187
|
+
name = str(row.row_key.value)
|
|
188
|
+
return next((u for u in self._updates if u.name == name), None)
|
|
189
|
+
|
|
190
|
+
def action_show_details(self) -> None:
|
|
191
|
+
update = self._current_update()
|
|
192
|
+
if update is None:
|
|
193
|
+
return
|
|
194
|
+
self.app.push_screen(DetailsScreen(update))
|
|
195
|
+
|
|
196
|
+
def action_update_selected(self) -> None:
|
|
197
|
+
chosen = [u for u in self._updates if u.name in self._selected]
|
|
198
|
+
if not chosen:
|
|
199
|
+
self.app.push_screen(
|
|
200
|
+
ErrorScreen(
|
|
201
|
+
title="Nothing selected",
|
|
202
|
+
message="Select at least one package with SPACE before updating.",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
return
|
|
206
|
+
self.app.push_screen(ConfirmScreen(chosen))
|
|
207
|
+
|
|
208
|
+
def action_select_all(self) -> None:
|
|
209
|
+
all_names = {u.name for u in self._updates}
|
|
210
|
+
if self._selected == all_names:
|
|
211
|
+
self._selected = set()
|
|
212
|
+
else:
|
|
213
|
+
self._selected = all_names
|
|
214
|
+
self._render_selection()
|
|
215
|
+
self._render_table()
|
|
216
|
+
|
|
217
|
+
def action_refresh(self) -> None:
|
|
218
|
+
self.app.notify("Refreshing… this can take a moment")
|
|
219
|
+
try:
|
|
220
|
+
refreshed = self.app.refresh_analysis()
|
|
221
|
+
self._analysis = refreshed
|
|
222
|
+
self._updates = refreshed.updates
|
|
223
|
+
self._selected = {n for n in self._selected if n in {u.name for u in refreshed.updates}}
|
|
224
|
+
self._rebuild_all()
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
self.app.push_screen(
|
|
227
|
+
ErrorScreen(
|
|
228
|
+
title="Refresh failed",
|
|
229
|
+
message="The package manager could not refresh the update list.",
|
|
230
|
+
detail=str(exc),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def action_stop(self) -> None:
|
|
235
|
+
"""Pop the screen only when there is something to pop."""
|
|
236
|
+
if len(self.app.screen_stack) > 1:
|
|
237
|
+
self.app.pop_screen()
|
|
238
|
+
|
|
239
|
+
def on_resize(self) -> None:
|
|
240
|
+
self._render_storage()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class DetailsScreen(Screen):
|
|
244
|
+
"""Explain why a single update is (or is not) prioritized highly."""
|
|
245
|
+
|
|
246
|
+
BINDINGS = [
|
|
247
|
+
Binding("escape", "app.pop_screen", "Back"),
|
|
248
|
+
Binding("u", "update_package", "Update this package"),
|
|
249
|
+
Binding("q", "quit", "Quit"),
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
def __init__(self, update: PackageUpdate, **kwargs) -> None:
|
|
253
|
+
super().__init__(**kwargs)
|
|
254
|
+
self._update = update
|
|
255
|
+
|
|
256
|
+
def compose(self) -> ComposeResult:
|
|
257
|
+
yield Header(show_clock=True)
|
|
258
|
+
with VerticalScroll(id="details"):
|
|
259
|
+
yield Static(id="detail-body")
|
|
260
|
+
yield Footer()
|
|
261
|
+
|
|
262
|
+
def on_mount(self) -> None:
|
|
263
|
+
update = self._update
|
|
264
|
+
text = Text()
|
|
265
|
+
text.append("PACKAGE DETAILS\n", style="bold")
|
|
266
|
+
text.append("\n")
|
|
267
|
+
text.append(f"Name: {update.name}\n")
|
|
268
|
+
text.append(f"Current: {update.current_version or 'Unknown'}\n")
|
|
269
|
+
text.append(f"Available: {update.new_version or 'Unknown'}\n")
|
|
270
|
+
text.append("\n")
|
|
271
|
+
text.append(f"Download: {format_bytes(update.download_size)}\n")
|
|
272
|
+
text.append(f"Install: {format_bytes(update.installed_size)}\n")
|
|
273
|
+
text.append("\n")
|
|
274
|
+
label = _PRIORITY_LABEL.get(update.priority.value, "MEDIUM")
|
|
275
|
+
text.append(f"Priority: {label}\n", style="bold")
|
|
276
|
+
text.append("\n")
|
|
277
|
+
text.append("WHY?\n", style="underline")
|
|
278
|
+
for reason in update.priority_reasons or ["No metadata available."]:
|
|
279
|
+
text.append(f"✓ {reason}\n")
|
|
280
|
+
text.append("\n")
|
|
281
|
+
security = "Unknown"
|
|
282
|
+
if update.security_update is True:
|
|
283
|
+
security = "Yes"
|
|
284
|
+
elif update.security_update is False:
|
|
285
|
+
security = "No"
|
|
286
|
+
text.append(f"Security status: {security}\n")
|
|
287
|
+
reboot = "Unknown"
|
|
288
|
+
if update.reboot_required is True:
|
|
289
|
+
reboot = "Likely (kernel)"
|
|
290
|
+
elif update.reboot_required is False:
|
|
291
|
+
reboot = "No"
|
|
292
|
+
text.append(f"Likely reboot: {reboot}\n")
|
|
293
|
+
text.append(f"Package manager: {update.package_manager}\n")
|
|
294
|
+
self.query_one("#detail-body", Static).update(text)
|
|
295
|
+
|
|
296
|
+
def action_update_package(self) -> None:
|
|
297
|
+
self.app.push_screen(ConfirmScreen([self._update]))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ConfirmScreen(ModalScreen):
|
|
301
|
+
"""Explicit-update gate. Default answer is always No."""
|
|
302
|
+
|
|
303
|
+
BINDINGS = [
|
|
304
|
+
Binding("escape", "decline", "Back"),
|
|
305
|
+
Binding("y", "accept", "Yes"),
|
|
306
|
+
Binding("n", "decline", "No"),
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
def __init__(self, updates: List[PackageUpdate], **kwargs) -> None:
|
|
310
|
+
super().__init__(**kwargs)
|
|
311
|
+
self._updates = updates
|
|
312
|
+
|
|
313
|
+
def compose(self) -> ComposeResult:
|
|
314
|
+
download = sum(u.download_size for u in self._updates if u.download_size)
|
|
315
|
+
install = sum(u.installed_size for u in self._updates if u.installed_size)
|
|
316
|
+
names = ", ".join(sorted(u.name for u in self._updates))
|
|
317
|
+
body = Text()
|
|
318
|
+
body.append("\n")
|
|
319
|
+
body.append(f"You selected {len(self._updates)} packages.\n\n")
|
|
320
|
+
body.append(f"Packages: {names}\n\n")
|
|
321
|
+
body.append(f"Download: {format_bytes(download)}\n")
|
|
322
|
+
body.append(f"Estimated disk usage: {format_bytes(install)}\n\n")
|
|
323
|
+
body.append("Continue? [y/N] ", style="bold")
|
|
324
|
+
yield Static(body)
|
|
325
|
+
yield Footer()
|
|
326
|
+
|
|
327
|
+
def action_accept(self) -> None:
|
|
328
|
+
self.app.pop_screen()
|
|
329
|
+
self.app.proceed_with_updates(self._updates)
|
|
330
|
+
|
|
331
|
+
def action_decline(self) -> None:
|
|
332
|
+
self.app.pop_screen()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class BlockedScreen(ModalScreen):
|
|
336
|
+
"""Shown instead of an update when a safety check fails."""
|
|
337
|
+
|
|
338
|
+
BINDINGS = [Binding("escape", "app.pop_screen", "Back"), Binding("q", "quit", "Quit")]
|
|
339
|
+
|
|
340
|
+
def __init__(self, title: str, message: str, detail: str = "", **kwargs) -> None:
|
|
341
|
+
super().__init__(**kwargs)
|
|
342
|
+
self._title = title
|
|
343
|
+
self._message = message
|
|
344
|
+
self._detail = detail
|
|
345
|
+
|
|
346
|
+
def compose(self) -> ComposeResult:
|
|
347
|
+
body = Text()
|
|
348
|
+
body.append(f"{self._title}\n", style="bold red")
|
|
349
|
+
body.append("\n")
|
|
350
|
+
body.append(self._message)
|
|
351
|
+
if self._detail:
|
|
352
|
+
body.append("\n\n")
|
|
353
|
+
body.append(self._detail, style="dim")
|
|
354
|
+
body.append("\n\n[ESC] Back", style="dim")
|
|
355
|
+
yield Static(body)
|
|
356
|
+
yield Footer()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class ResultScreen(Screen):
|
|
360
|
+
"""Post-update report: what happened and (usually) what to run manually."""
|
|
361
|
+
|
|
362
|
+
BINDINGS = [Binding("escape", "app.pop_screen", "Back"), Binding("q", "quit", "Quit")]
|
|
363
|
+
|
|
364
|
+
def __init__(self, title: str, body: Text | str, **kwargs) -> None:
|
|
365
|
+
super().__init__(**kwargs)
|
|
366
|
+
self._title = title
|
|
367
|
+
self._body = body
|
|
368
|
+
|
|
369
|
+
def compose(self) -> ComposeResult:
|
|
370
|
+
yield Header(show_clock=True)
|
|
371
|
+
text = Text(f"{self._title}\n\n", style="bold")
|
|
372
|
+
if isinstance(self._body, Text):
|
|
373
|
+
text.append_text(self._body)
|
|
374
|
+
else:
|
|
375
|
+
text.append(str(self._body))
|
|
376
|
+
yield Static(text)
|
|
377
|
+
yield Footer()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class ErrorScreen(ModalScreen):
|
|
381
|
+
"""Human-readable error report — never a raw traceback by default."""
|
|
382
|
+
|
|
383
|
+
BINDINGS = [Binding("escape", "app.pop_screen", "Back"), Binding("q", "quit", "Quit")]
|
|
384
|
+
|
|
385
|
+
def __init__(self, title: str, message: str, detail: str = "", **kwargs) -> None:
|
|
386
|
+
super().__init__(**kwargs)
|
|
387
|
+
self._title = title
|
|
388
|
+
self._message = message
|
|
389
|
+
self._detail = detail
|
|
390
|
+
|
|
391
|
+
def compose(self) -> ComposeResult:
|
|
392
|
+
body = Text()
|
|
393
|
+
body.append(f"⚠ {self._title}\n", style="bold red")
|
|
394
|
+
body.append("\n")
|
|
395
|
+
body.append(self._message)
|
|
396
|
+
if self._detail:
|
|
397
|
+
body.append("\n\nDetail:\n", style="dim")
|
|
398
|
+
body.append(self._detail, style="dim")
|
|
399
|
+
body.append("\n\n[ESC] Back", style="dim")
|
|
400
|
+
yield Static(body)
|
|
401
|
+
yield Footer()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def render_storage_block(storage: StorageInfo, width: int) -> Text:
|
|
405
|
+
"""Compose the storage text block including the progress bar."""
|
|
406
|
+
return storage_summary_text(storage, width)
|
pkgwise/ui/widgets.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Reusable TUI widgets.
|
|
2
|
+
|
|
3
|
+
Kept deliberately small: a progress-bar renderer and a storage summary
|
|
4
|
+
builder that adapt to the current terminal width (no hard-coded dimensions).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual.widgets import Static
|
|
13
|
+
|
|
14
|
+
from pkgwise.core.models import StorageInfo
|
|
15
|
+
from pkgwise.utils.formatting import format_bytes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def progress_text(percentage: float, width: Optional[int] = None) -> Text:
|
|
19
|
+
"""Render a percentage as a filled █/░ progress bar at *width* columns."""
|
|
20
|
+
if width is None:
|
|
21
|
+
width = 24
|
|
22
|
+
width = max(width, 6)
|
|
23
|
+
inner = width - 2
|
|
24
|
+
filled = int(round((min(max(percentage, 0.0), 100.0) / 100.0) * inner))
|
|
25
|
+
bar = "█" * filled + "░" * (inner - filled)
|
|
26
|
+
label = f" {percentage:.0f}%"
|
|
27
|
+
return Text(bar + label)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def storage_summary_text(
|
|
31
|
+
storage: StorageInfo, width: Optional[int] = None
|
|
32
|
+
) -> Text:
|
|
33
|
+
"""Multi-line Rich Text describing an :class:`StorageInfo` snapshot."""
|
|
34
|
+
if width is None:
|
|
35
|
+
width = 24
|
|
36
|
+
bar = progress_text(storage.usage_percentage, width)
|
|
37
|
+
text = Text()
|
|
38
|
+
text.append("STORAGE\n", style="bold")
|
|
39
|
+
text.append(bar)
|
|
40
|
+
text.append("\n")
|
|
41
|
+
text.append(
|
|
42
|
+
f"{format_bytes(storage.used_bytes)} used / "
|
|
43
|
+
f"{format_bytes(storage.total_bytes)}"
|
|
44
|
+
)
|
|
45
|
+
text.append(" ", style="dim")
|
|
46
|
+
text.append(f"{format_bytes(storage.free_bytes)} free", style="dim")
|
|
47
|
+
return text
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def make_status_widget(inner: bool = False) -> type:
|
|
51
|
+
"""Return a lightweight Static subclass for status lines."""
|
|
52
|
+
return Static
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def short_size(download: Optional[int], installed: Optional[int]) -> str:
|
|
56
|
+
"""One compact size string, preferring download size for tables."""
|
|
57
|
+
return format_bytes(download if download is not None else installed)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Small helpers for command execution, distro detection, formatting and config."""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Safe subprocess helpers.
|
|
2
|
+
|
|
3
|
+
All package-manager interaction goes through :func:`run_command`. Commands are
|
|
4
|
+
always executed as argument lists with ``shell=False`` — never via a shell
|
|
5
|
+
string — so no package metadata or user input can ever be interpreted as a
|
|
6
|
+
shell instruction.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CommandResult:
|
|
19
|
+
"""Result of a successfully executed command."""
|
|
20
|
+
|
|
21
|
+
stdout: str
|
|
22
|
+
stderr: str
|
|
23
|
+
returncode: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CommandError(Exception):
|
|
27
|
+
"""Raised when a command cannot be run or exits with a failure code."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, command: List[str], message: str, returncode: Optional[int] = None):
|
|
30
|
+
self.command = list(command)
|
|
31
|
+
self.message = message
|
|
32
|
+
self.returncode = returncode
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CommandNotFoundError(CommandError):
|
|
37
|
+
"""Raised when the executable for a command does not exist."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def which(executable: str) -> Optional[str]:
|
|
41
|
+
"""Return the path to *executable* or ``None`` if it is not on ``PATH``."""
|
|
42
|
+
import shutil
|
|
43
|
+
|
|
44
|
+
return shutil.which(executable)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_command(
|
|
48
|
+
command: List[str],
|
|
49
|
+
timeout: Optional[int] = 120,
|
|
50
|
+
check: bool = True,
|
|
51
|
+
env: Optional[dict] = None,
|
|
52
|
+
) -> CommandResult:
|
|
53
|
+
"""Run *command* (an argument list) and return its captured output.
|
|
54
|
+
|
|
55
|
+
Prefer this over direct ``subprocess`` calls everywhere so error handling
|
|
56
|
+
stays consistent. ``timeout=None`` waits indefinitely (used for long,
|
|
57
|
+
interactive installs that the TUI runs with its own progress handling).
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
CommandNotFoundError: the executable is not available.
|
|
61
|
+
CommandError: the command failed (return code != 0) and ``check``.
|
|
62
|
+
TimeoutExpired: the command exceeded *timeout* seconds.
|
|
63
|
+
"""
|
|
64
|
+
if not command:
|
|
65
|
+
raise ValueError("run_command requires a non-empty command list")
|
|
66
|
+
|
|
67
|
+
executable = command[0]
|
|
68
|
+
path = which(executable)
|
|
69
|
+
if path is None:
|
|
70
|
+
raise CommandNotFoundError(command, f"Command not found: {executable}")
|
|
71
|
+
|
|
72
|
+
proc = subprocess.run(
|
|
73
|
+
[path, *command[1:]],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=timeout,
|
|
77
|
+
env=env,
|
|
78
|
+
check=False,
|
|
79
|
+
)
|
|
80
|
+
if check and proc.returncode != 0:
|
|
81
|
+
raise CommandError(
|
|
82
|
+
command,
|
|
83
|
+
f"Command exited with status {proc.returncode}: {' '.join(command)}",
|
|
84
|
+
returncode=proc.returncode,
|
|
85
|
+
)
|
|
86
|
+
return CommandResult(stdout=proc.stdout or "", stderr=proc.stderr or "", returncode=proc.returncode)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_elevated() -> bool:
|
|
90
|
+
"""Return whether the current process has elevated privileges (root).
|
|
91
|
+
|
|
92
|
+
``os.geteuid()`` is not available on non-POSIX systems; guard it so the
|
|
93
|
+
check degrades to ``False`` (requiring elevation) elsewhere.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
return os.geteuid() == 0
|
|
97
|
+
except AttributeError: # pragma: no cover - non-POSIX
|
|
98
|
+
return False
|
pkgwise/utils/config.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Configuration loading for PkgWise.
|
|
2
|
+
|
|
3
|
+
The MVP reads a single optional TOML file at ``~/.config/pkgwise/config.toml``.
|
|
4
|
+
Unknown keys are ignored, missing keys fall back to built-in defaults, and a
|
|
5
|
+
malformed config file never crashes the application — it produces a warning
|
|
6
|
+
string instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
try: # Python 3.11+
|
|
17
|
+
import tomllib
|
|
18
|
+
|
|
19
|
+
_TOMLLI_LOAD = tomllib.load
|
|
20
|
+
except ImportError: # Python 3.9/3.10 — optional backport
|
|
21
|
+
try:
|
|
22
|
+
import tomli # type: ignore[import-not-found]
|
|
23
|
+
|
|
24
|
+
_TOMLLI_LOAD = tomli.load
|
|
25
|
+
except ImportError:
|
|
26
|
+
_TOMLLI_LOAD = None # config will degrade to defaults with a warning
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PkgWiseConfig:
|
|
31
|
+
"""Effective configuration for the current run."""
|
|
32
|
+
|
|
33
|
+
show_low_priority: bool = True
|
|
34
|
+
show_download_size: bool = True
|
|
35
|
+
show_install_size: bool = True
|
|
36
|
+
confirm_before_update: bool = True
|
|
37
|
+
config_path: Optional[str] = None
|
|
38
|
+
warnings: List[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_DEFAULTS = {
|
|
42
|
+
"display": {
|
|
43
|
+
"show_low_priority": True,
|
|
44
|
+
"show_download_size": True,
|
|
45
|
+
"show_install_size": True,
|
|
46
|
+
},
|
|
47
|
+
"updates": {
|
|
48
|
+
"confirm_before_update": True,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_config_path() -> Path:
|
|
54
|
+
"""Path of the per-user config file (``$XDG_CONFIG_HOME`` aware)."""
|
|
55
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
56
|
+
if base:
|
|
57
|
+
return Path(base) / "pkgwise" / "config.toml"
|
|
58
|
+
return Path.home() / ".config" / "pkgwise" / "config.toml"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_config() -> PkgWiseConfig:
|
|
62
|
+
"""Load configuration from the default user location.
|
|
63
|
+
|
|
64
|
+
Never raises; unsupported/malformed files downgrade to defaults with an
|
|
65
|
+
explanatory warning.
|
|
66
|
+
"""
|
|
67
|
+
path = default_config_path()
|
|
68
|
+
if not path.is_file():
|
|
69
|
+
return PkgWiseConfig()
|
|
70
|
+
|
|
71
|
+
return load_config_file(path)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config_file(path: Path) -> PkgWiseConfig:
|
|
75
|
+
"""Load configuration from a specific TOML *path* (used by tests/CLI)."""
|
|
76
|
+
warnings: List[str] = []
|
|
77
|
+
try:
|
|
78
|
+
data = _parse_toml(path)
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
return PkgWiseConfig(
|
|
81
|
+
config_path=str(path), warnings=[f"Could not read {path}: {exc}"]
|
|
82
|
+
)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
return PkgWiseConfig(
|
|
85
|
+
config_path=str(path), warnings=[f"Config file {path} is invalid: {exc}"]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
merged = _merge_defaults(data)
|
|
89
|
+
return PkgWiseConfig(
|
|
90
|
+
show_low_priority=_as_bool(merged["display"]["show_low_priority"], True),
|
|
91
|
+
show_download_size=_as_bool(merged["display"]["show_download_size"], True),
|
|
92
|
+
show_install_size=_as_bool(merged["display"]["show_install_size"], True),
|
|
93
|
+
confirm_before_update=_as_bool(merged["updates"]["confirm_before_update"], True),
|
|
94
|
+
config_path=str(path),
|
|
95
|
+
warnings=warnings,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_toml(path: Path) -> dict:
|
|
100
|
+
if _TOMLLI_LOAD is None:
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
"A TOML parser is unavailable on this Python (need 3.11+ or 'tomli')."
|
|
103
|
+
)
|
|
104
|
+
with open(path, "rb") as fh:
|
|
105
|
+
return _TOMLLI_LOAD(fh)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _merge_defaults(raw: dict) -> dict:
|
|
109
|
+
merged = {
|
|
110
|
+
section: dict(defaults)
|
|
111
|
+
for section, defaults in _DEFAULTS.items()
|
|
112
|
+
}
|
|
113
|
+
for section, defaults in _DEFAULTS.items():
|
|
114
|
+
theirs = raw.get(section)
|
|
115
|
+
if not isinstance(theirs, dict):
|
|
116
|
+
continue
|
|
117
|
+
for key in defaults:
|
|
118
|
+
if key in theirs:
|
|
119
|
+
merged[section][key] = theirs[key]
|
|
120
|
+
return merged
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _as_bool(value: object, default: bool) -> bool:
|
|
124
|
+
if isinstance(value, bool):
|
|
125
|
+
return value
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
lowered = value.strip().lower()
|
|
128
|
+
if lowered in {"true", "yes", "on", "1"}:
|
|
129
|
+
return True
|
|
130
|
+
if lowered in {"false", "no", "off", "0"}:
|
|
131
|
+
return False
|
|
132
|
+
if isinstance(value, int) and value in (0, 1):
|
|
133
|
+
return bool(value)
|
|
134
|
+
return default
|