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/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
@@ -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