dnfseek 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v9.0.0
16
+ - name: Build package
17
+ run: uv build
18
+ - name: Publish to PyPI
19
+ run: uv publish
@@ -0,0 +1,121 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ .hypothesis/
50
+ .pytest_cache/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django stuff:
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+
61
+ # Flask stuff:
62
+ instance/
63
+ .webassets-cache
64
+
65
+ # Scrapy stuff:
66
+ .scrapy
67
+
68
+ # Sphinx documentation
69
+ docs/_build/
70
+
71
+ # PyBuilder
72
+ target/
73
+
74
+ # Jupyter Notebook
75
+ .ipynb_checkpoints
76
+
77
+ # IPython
78
+ profile_default/
79
+ ipython_config.py
80
+
81
+ # pyenv
82
+ .python-version
83
+
84
+ # celery beat schedule file
85
+ celerybeat-schedule
86
+
87
+ # SageMath parsed files
88
+ *.sage.py
89
+
90
+ # Environments
91
+ .env
92
+ .venv
93
+ env/
94
+ venv/
95
+ ENV/
96
+ env.bak/
97
+ venv.bak/
98
+
99
+ # Spyder project settings
100
+ .spyderproject
101
+ .spyproject
102
+
103
+ # Rope project settings
104
+ .ropeproject
105
+
106
+ # mkdocs documentation
107
+ /site
108
+
109
+ # mypy
110
+ .mypy_cache/
111
+ .dmypy.json
112
+ dmypy.json
113
+
114
+ # Pyre type checker
115
+ .pyre/
116
+
117
+ # VSCODE IDE
118
+ .vscode
119
+
120
+ #Pycharm IDE
121
+ .idea
dnfseek-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: dnfseek
3
+ Version: 0.1.0
4
+ Summary: A TUI package browser for Fedora, built with Textual
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: textual>=8.2.8
7
+ Description-Content-Type: text/markdown
8
+
9
+ # dnfseek
10
+
11
+ A TUI package browser for Fedora, built with [Textual](https://github.com/Textualize/textual).
12
+ A Python port of my forked bash + fzf [`dnfseek`](https://github.com/Sam-Horry/dnfseek) script.
13
+
14
+ ![dnfseek](https://img.shields.io/badge/python-3.12%2B-blue)
15
+
16
+ ## Prerequisites
17
+
18
+ - Fedora (or another dnf-based distro)
19
+ - [`uv`](https://docs.astral.sh/uv/) — `curl -LsSf https://astral.sh/uv/install.sh | sh` (recommended)
20
+
21
+ ## Test it out
22
+
23
+ You can test out dnfseek without having to install it, using uvx:
24
+
25
+ ```bash
26
+ uvx --from git+https://github.com/Sam-Horry/dnfseek-tui dnfseek
27
+ ```
28
+
29
+ ## Installation
30
+
31
+ ### From git (recommended)
32
+
33
+ ```bash
34
+ uv tool install git+https://github.com/Sam-Horry/dnfseek-tui
35
+ ```
36
+
37
+ ### From PyPI
38
+
39
+ ```bash
40
+ uv tool install dnfseek
41
+ ```
42
+
43
+ Either way you get a `dnfseek` command on your PATH. On first run the app
44
+ asks for your sudo password (via `sudo -v`) before the interface starts.
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ dnfseek
50
+ ```
51
+
52
+ | Key | Action |
53
+ | ----- | -------- |
54
+ | `tab` | Switch between search and package list |
55
+ | `u` | Upgrade all packages |
56
+ | `r` | Refresh package cache |
57
+ | `i` / `x` / `e` / `g` | Install / remove / reinstall / update selected package |
58
+ | `d` | Show dependencies |
59
+ | `space` | Show package info |
60
+
61
+ Package lists are cached in `~/.cache/dnfseek` and refreshed if older than
62
+ 24 hours.
63
+
64
+ Using the built-in command palette (ctrl+p), you can change dnfseek's behaviour by searching all
65
+ available packages, or only search packages currently installed on your system.
66
+ All the above actions can be performed from the command palette, as well as:
67
+
68
+ | Command | Action |
69
+ | --------- | -------- |
70
+ | Search all | Search all available packages |
71
+ | Search installed | Search installed packages |
72
+ | Theme | Change the app theme |
73
+ | Keys | Shows a help widget with a summary of available keys |
74
+
75
+ ## Updating
76
+
77
+ ```bash
78
+ uv tool upgrade dnfseek
79
+ ```
80
+
81
+ ## Notes
82
+
83
+ - This tool is basically a wrapper for dnf, with only a few commands at the moment.
84
+ - This tool is provided as-is, use at your own risk. I am not liable if something goes
85
+ wrong - though in practice, dnf makes it pretty hard to brick your system.
86
+ Most mistakes are recoverable with a rollback or `dnf history undo`
87
+ - Installed as a tool, the binary lives in `~/.local/bin/dnfseek`, which
88
+ takes precedence over a `dnfseek` script elsewhere on your PATH.
89
+ - Sudo authentication happens once, before the TUI starts. If your sudo
90
+ session expires mid-session, restart the app to re-authenticate.
91
+ - Requires Python 3.12+; `uv` downloads a compatible interpreter if your
92
+ system Python is older.
@@ -0,0 +1,84 @@
1
+ # dnfseek
2
+
3
+ A TUI package browser for Fedora, built with [Textual](https://github.com/Textualize/textual).
4
+ A Python port of my forked bash + fzf [`dnfseek`](https://github.com/Sam-Horry/dnfseek) script.
5
+
6
+ ![dnfseek](https://img.shields.io/badge/python-3.12%2B-blue)
7
+
8
+ ## Prerequisites
9
+
10
+ - Fedora (or another dnf-based distro)
11
+ - [`uv`](https://docs.astral.sh/uv/) — `curl -LsSf https://astral.sh/uv/install.sh | sh` (recommended)
12
+
13
+ ## Test it out
14
+
15
+ You can test out dnfseek without having to install it, using uvx:
16
+
17
+ ```bash
18
+ uvx --from git+https://github.com/Sam-Horry/dnfseek-tui dnfseek
19
+ ```
20
+
21
+ ## Installation
22
+
23
+ ### From git (recommended)
24
+
25
+ ```bash
26
+ uv tool install git+https://github.com/Sam-Horry/dnfseek-tui
27
+ ```
28
+
29
+ ### From PyPI
30
+
31
+ ```bash
32
+ uv tool install dnfseek
33
+ ```
34
+
35
+ Either way you get a `dnfseek` command on your PATH. On first run the app
36
+ asks for your sudo password (via `sudo -v`) before the interface starts.
37
+
38
+ ## Usage
39
+
40
+ ```bash
41
+ dnfseek
42
+ ```
43
+
44
+ | Key | Action |
45
+ | ----- | -------- |
46
+ | `tab` | Switch between search and package list |
47
+ | `u` | Upgrade all packages |
48
+ | `r` | Refresh package cache |
49
+ | `i` / `x` / `e` / `g` | Install / remove / reinstall / update selected package |
50
+ | `d` | Show dependencies |
51
+ | `space` | Show package info |
52
+
53
+ Package lists are cached in `~/.cache/dnfseek` and refreshed if older than
54
+ 24 hours.
55
+
56
+ Using the built-in command palette (ctrl+p), you can change dnfseek's behaviour by searching all
57
+ available packages, or only search packages currently installed on your system.
58
+ All the above actions can be performed from the command palette, as well as:
59
+
60
+ | Command | Action |
61
+ | --------- | -------- |
62
+ | Search all | Search all available packages |
63
+ | Search installed | Search installed packages |
64
+ | Theme | Change the app theme |
65
+ | Keys | Shows a help widget with a summary of available keys |
66
+
67
+ ## Updating
68
+
69
+ ```bash
70
+ uv tool upgrade dnfseek
71
+ ```
72
+
73
+ ## Notes
74
+
75
+ - This tool is basically a wrapper for dnf, with only a few commands at the moment.
76
+ - This tool is provided as-is, use at your own risk. I am not liable if something goes
77
+ wrong - though in practice, dnf makes it pretty hard to brick your system.
78
+ Most mistakes are recoverable with a rollback or `dnf history undo`
79
+ - Installed as a tool, the binary lives in `~/.local/bin/dnfseek`, which
80
+ takes precedence over a `dnfseek` script elsewhere on your PATH.
81
+ - Sudo authentication happens once, before the TUI starts. If your sudo
82
+ session expires mid-session, restart the app to re-authenticate.
83
+ - Requires Python 3.12+; `uv` downloads a compatible interpreter if your
84
+ system Python is older.
dnfseek-0.1.0/main.py ADDED
@@ -0,0 +1,648 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import tempfile
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ from textual.app import App, ComposeResult, SystemCommand
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.screen import Screen
13
+ from textual.theme import Theme
14
+ from textual.widgets import (
15
+ Input,
16
+ Static,
17
+ Header,
18
+ Footer,
19
+ OptionList,
20
+ LoadingIndicator,
21
+ )
22
+ from textual.widgets._option_list import Option
23
+
24
+ CACHE_DIR = Path.home() / ".cache" / "dnfseek"
25
+ INSTALLED_CACHE = CACHE_DIR / "installed"
26
+ AVAILABLE_CACHE = CACHE_DIR / "available"
27
+ CACHE_MAX_AGE = 24 * 60 * 60
28
+ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "dnfseek"
29
+ THEME_FILE = CONFIG_DIR / "theme"
30
+ DEFAULT_HINT = "Search for a package, and press TAB to switch focus"
31
+ HIDDEN_SYSTEM_COMMANDS = {"Screenshot", "Maximize", "Minimize"}
32
+
33
+
34
+ class PackageList(OptionList):
35
+ BINDINGS = [
36
+ Binding("space", "select", "Select", show=False),
37
+ ]
38
+
39
+
40
+ class DnfseekApp(App):
41
+ CSS_PATH = "main.tcss"
42
+ TITLE = "dnfseek"
43
+ SUB_TITLE = "a TUI dnf wrapper"
44
+
45
+ BINDINGS = [
46
+ ("u", "upgrade_all", "Upgrade all packages"),
47
+ ("r", "refresh_cache", "Refresh cache"),
48
+ ("i", "install", "Install package"),
49
+ ("x", "remove", "Remove package"),
50
+ ("e", "reinstall", "Reinstall package"),
51
+ ("g", "update_package", "Update package"),
52
+ ("d", "deps", "Show dependencies"),
53
+ ]
54
+
55
+ def __init__(self) -> None:
56
+ super().__init__()
57
+ if (saved_theme := self._load_saved_theme()) is not None:
58
+ self.theme = saved_theme
59
+ self._installed: set[str] = set()
60
+ self._available: set[str] = set()
61
+ self._info_cache: dict[str, str] = {}
62
+ self._deps_cache: dict[str, str] = {}
63
+ self._pending_fetches: set[str] = set()
64
+ self._active_package: str | None = None
65
+ self._view_names: list[str] = []
66
+ self._filter = ""
67
+
68
+ def compose(self) -> ComposeResult:
69
+ yield Header()
70
+ with Horizontal(id="options"):
71
+ yield LoadingIndicator(id="spinner", classes="hidden")
72
+ yield Static(DEFAULT_HINT, id="options_text")
73
+
74
+ with Horizontal():
75
+ with Vertical():
76
+ yield Input(placeholder="Type Package Name", id="input")
77
+ yield PackageList(id="left_panel")
78
+ yield Static("Select a package (enter/space) to view its information", id="right_panel", markup=False)
79
+ yield Footer()
80
+
81
+ def on_mount(self) -> None:
82
+ self.theme_changed_signal.subscribe(self, self._save_theme)
83
+ self.styles.scrollbar_visibility = "hidden"
84
+ self.run_worker(
85
+ self._show_packages(installed_only=False),
86
+ name="search-all",
87
+ group="search",
88
+ exclusive=True,
89
+ exit_on_error=False,
90
+ )
91
+
92
+ def _load_saved_theme(self) -> str | None:
93
+ if "TEXTUAL_THEME" in os.environ:
94
+ return None
95
+ try:
96
+ saved = THEME_FILE.read_text().strip()
97
+ except OSError:
98
+ return None
99
+ if not saved or self.get_theme(saved) is None:
100
+ return None
101
+ return saved
102
+
103
+ def _save_theme(self, theme: Theme) -> None:
104
+ try:
105
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
106
+ THEME_FILE.write_text(theme.name)
107
+ except OSError:
108
+ pass
109
+
110
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
111
+ # keep built-in commands, minus the ones we don't need
112
+ for command in super().get_system_commands(screen):
113
+ if command.title not in HIDDEN_SYSTEM_COMMANDS:
114
+ yield command
115
+ # custom commands
116
+ yield SystemCommand(
117
+ "Upgrade all", "Upgrade all packages with dnf", self.upgrade_all
118
+ )
119
+ yield SystemCommand(
120
+ "Refresh cache", "Refresh the dnf package cache", self.refresh_cache
121
+ )
122
+ yield SystemCommand(
123
+ "Search all", "Search all available packages", self.search_all
124
+ )
125
+ yield SystemCommand(
126
+ "Search installed", "Search installed packages", self.search_installed
127
+ )
128
+ yield SystemCommand(
129
+ "Install package", "Install the highlighted package with dnf",
130
+ self.action_install,
131
+ )
132
+ yield SystemCommand(
133
+ "Remove package", "Remove the highlighted package with dnf",
134
+ self.action_remove,
135
+ )
136
+ yield SystemCommand(
137
+ "Reinstall package", "Reinstall the highlighted package with dnf",
138
+ self.action_reinstall,
139
+ )
140
+ yield SystemCommand(
141
+ "Update package", "Update the highlighted package with dnf",
142
+ self.action_update_package,
143
+ )
144
+ yield SystemCommand(
145
+ "Show dependencies", "Show dependencies of the highlighted package",
146
+ self.action_deps,
147
+ )
148
+
149
+ async def _dnf_list(self, args: list[str]) -> str | None:
150
+ process = await asyncio.create_subprocess_exec(
151
+ "dnf", "-q", *args,
152
+ stdout=asyncio.subprocess.PIPE,
153
+ stderr=asyncio.subprocess.PIPE,
154
+ )
155
+ stdout, _ = await process.communicate()
156
+ if process.returncode != 0:
157
+ return None
158
+ return stdout.decode()
159
+
160
+ @staticmethod
161
+ def _parse_dnf_list(output: str) -> list[str]:
162
+ names = []
163
+ for line in output.splitlines():
164
+ parts = line.split()
165
+ if len(parts) >= 3 and re.search(r"[0-9.-]", parts[1]):
166
+ names.append(parts[0])
167
+ return names
168
+
169
+ async def _write_cache(self, path: Path, names: list[str]) -> None:
170
+ fd, tmp = tempfile.mkstemp(dir=CACHE_DIR)
171
+ try:
172
+ with os.fdopen(fd, "w") as f:
173
+ f.write("\n".join(names))
174
+ f.write("\n")
175
+ os.replace(tmp, path)
176
+ except OSError:
177
+ os.unlink(tmp)
178
+
179
+ def _load_cache(self) -> None:
180
+ if INSTALLED_CACHE.exists():
181
+ self._installed = set(INSTALLED_CACHE.read_text().split())
182
+ if AVAILABLE_CACHE.exists():
183
+ self._available = set(AVAILABLE_CACHE.read_text().split())
184
+
185
+ def _cache_is_stale(self) -> bool:
186
+ for path in (INSTALLED_CACHE, AVAILABLE_CACHE):
187
+ if not path.exists():
188
+ return True
189
+ if time.time() - path.stat().st_mtime > CACHE_MAX_AGE:
190
+ return True
191
+ return False
192
+
193
+ async def update_cache(self) -> None:
194
+ self.notify("Refreshing package lists...", timeout=2)
195
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
196
+ installed, available = await asyncio.gather(
197
+ self._dnf_list(["list", "--installed"]),
198
+ self._dnf_list(
199
+ ["-y", "--setopt=gpgcheck=1", "--setopt=repo_gpgcheck=1",
200
+ "list", "--available"]
201
+ ),
202
+ )
203
+ if installed is None or available is None:
204
+ self.notify("Failed to refresh package lists", severity="error")
205
+ return
206
+ installed_names = self._parse_dnf_list(installed)
207
+ available_names = self._parse_dnf_list(available)
208
+ await asyncio.gather(
209
+ self._write_cache(INSTALLED_CACHE, installed_names),
210
+ self._write_cache(AVAILABLE_CACHE, available_names),
211
+ )
212
+ self._installed = set(installed_names)
213
+ self._available = set(available_names)
214
+ self.notify("Package lists refreshed", severity="information")
215
+
216
+ async def _ensure_cache(self) -> None:
217
+ if not self._installed and not self._available:
218
+ self._load_cache()
219
+ if self._cache_is_stale():
220
+ await self.update_cache()
221
+
222
+ async def refresh_cache(self) -> None:
223
+ self.run_worker(
224
+ self.update_cache(),
225
+ name="refresh-cache",
226
+ group="cache",
227
+ exclusive=True,
228
+ exit_on_error=False,
229
+ )
230
+
231
+ def action_refresh_cache(self) -> None:
232
+ self.run_worker(
233
+ self.update_cache(),
234
+ name="refresh-cache",
235
+ group="cache",
236
+ exclusive=True,
237
+ exit_on_error=False,
238
+ )
239
+
240
+ def _populate_options(self) -> None:
241
+ names = self._view_names
242
+ if self._filter:
243
+ needle = self._filter.casefold()
244
+ names = [name for name in names if needle in name.casefold()]
245
+ options = [
246
+ Option(f"✅ {name}" if name in self._installed else name, id=name)
247
+ for name in names
248
+ ]
249
+ left = self.query_one("#left_panel", OptionList)
250
+ left.clear_options()
251
+ left.add_options(options)
252
+ if options:
253
+ left.highlighted = 0
254
+
255
+ async def search_all(self) -> None:
256
+ self.run_worker(
257
+ self._show_packages(installed_only=False),
258
+ name="search-all",
259
+ group="search",
260
+ exclusive=True,
261
+ exit_on_error=False,
262
+ )
263
+
264
+ async def search_installed(self) -> None:
265
+ self.run_worker(
266
+ self._show_packages(installed_only=True),
267
+ name="show-installed",
268
+ group="search",
269
+ exclusive=True,
270
+ exit_on_error=False,
271
+ )
272
+
273
+ async def _show_packages(self, installed_only: bool) -> None:
274
+ await self._ensure_cache()
275
+ if installed_only:
276
+ names = sorted(self._installed)
277
+ else:
278
+ names = sorted(self._installed | self._available)
279
+ self._view_names = names
280
+ self._filter = ""
281
+ self.query_one("#input", Input).value = ""
282
+ self._populate_options()
283
+
284
+ def on_input_changed(self, event: Input.Changed) -> None:
285
+ if event.input.id != "input":
286
+ return
287
+ if event.value == self._filter:
288
+ return
289
+ self._filter = event.value
290
+ self._populate_options()
291
+
292
+ def _selected_package(self) -> str | None:
293
+ option = self.query_one("#left_panel", OptionList).highlighted_option
294
+ return option.id if option is not None else None
295
+
296
+ def _format_info(self, name: str, content: str) -> str:
297
+ if name in self._installed:
298
+ actions = "x Remove | e Reinstall | g Update | d Dependencies"
299
+ else:
300
+ actions = "i Install | d Dependencies"
301
+ return f"{content}\n\n── Actions ──\n{actions}"
302
+
303
+ def on_option_list_option_selected(
304
+ self, event: OptionList.OptionSelected
305
+ ) -> None:
306
+ name = event.option_id
307
+ if name is None:
308
+ return
309
+ self._active_package = name
310
+ right_panel = self.query_one("#right_panel", Static)
311
+ if name in self._info_cache:
312
+ right_panel.update(self._format_info(name, self._info_cache[name]))
313
+ return
314
+ if name in self._pending_fetches:
315
+ return
316
+ self._pending_fetches.add(name)
317
+ right_panel.update(f"Fetching info for {name}...")
318
+ self.run_worker(
319
+ self._fetch_package_info(name),
320
+ name=f"info-{name}",
321
+ group="info",
322
+ exit_on_error=False,
323
+ )
324
+
325
+ async def _fetch_package_info(self, name: str) -> None:
326
+ process = await asyncio.create_subprocess_exec(
327
+ "dnf", "info", name,
328
+ stdout=asyncio.subprocess.PIPE,
329
+ stderr=asyncio.subprocess.PIPE,
330
+ )
331
+ stdout, _ = await process.communicate()
332
+ self._pending_fetches.discard(name)
333
+ if process.returncode != 0:
334
+ return
335
+ lines = [
336
+ line.strip()
337
+ for line in stdout.decode().splitlines()
338
+ if re.match(r"^(Name|Summary|Version|Release|Size|URL|Description)", line.strip())
339
+ ]
340
+ info = "\n".join(lines)
341
+ self._info_cache[name] = info
342
+ if name == self._active_package:
343
+ self.query_one("#right_panel", Static).update(
344
+ self._format_info(name, info)
345
+ )
346
+
347
+ def action_install(self) -> None:
348
+ name = self._selected_package()
349
+ if name is None:
350
+ self.notify("No package selected", severity="warning")
351
+ return
352
+ if name in self._installed:
353
+ self.notify(f"{name} is already installed", severity="warning")
354
+ return
355
+ self.notify(f"Installing {name}...", timeout=2)
356
+ self.run_worker(
357
+ self._run_dnf(
358
+ ["dnf", "install", "-y", name],
359
+ success_msg=f"Installed: {name}",
360
+ name=name,
361
+ installed="add",
362
+ ),
363
+ name=f"install-{name}",
364
+ group="dnf",
365
+ exclusive=True,
366
+ exit_on_error=False,
367
+ )
368
+
369
+ def action_remove(self) -> None:
370
+ name = self._selected_package()
371
+ if name is None:
372
+ self.notify("No package selected", severity="warning")
373
+ return
374
+ if name not in self._installed:
375
+ self.notify(f"{name} is not installed", severity="warning")
376
+ return
377
+ self.notify(f"Removing {name}...", timeout=2)
378
+ self.run_worker(
379
+ self._run_dnf(
380
+ ["dnf", "remove", "-y", name],
381
+ success_msg=f"Removed: {name}",
382
+ name=name,
383
+ installed="remove",
384
+ ),
385
+ name=f"remove-{name}",
386
+ group="dnf",
387
+ exclusive=True,
388
+ exit_on_error=False,
389
+ )
390
+
391
+ def action_reinstall(self) -> None:
392
+ name = self._selected_package()
393
+ if name is None:
394
+ self.notify("No package selected", severity="warning")
395
+ return
396
+ if name not in self._installed:
397
+ self.notify(f"{name} is not installed", severity="warning")
398
+ return
399
+ self.notify(f"Reinstalling {name}...", timeout=2)
400
+ self.run_worker(
401
+ self._run_dnf(
402
+ ["dnf", "reinstall", "-y", name],
403
+ success_msg=f"Reinstalled: {name}",
404
+ name=name,
405
+ ),
406
+ name=f"reinstall-{name}",
407
+ group="dnf",
408
+ exclusive=True,
409
+ exit_on_error=False,
410
+ )
411
+
412
+ def action_update_package(self) -> None:
413
+ name = self._selected_package()
414
+ if name is None:
415
+ self.notify("No package selected", severity="warning")
416
+ return
417
+ if name not in self._installed:
418
+ self.notify(f"{name} is not installed", severity="warning")
419
+ return
420
+ self.notify(f"Updating {name}...", timeout=2)
421
+ self.run_worker(
422
+ self._run_dnf(
423
+ ["dnf", "upgrade", "-y", name],
424
+ success_msg=f"Updated: {name}",
425
+ name=name,
426
+ ),
427
+ name=f"update-{name}",
428
+ group="dnf",
429
+ exclusive=True,
430
+ exit_on_error=False,
431
+ )
432
+
433
+ def action_deps(self) -> None:
434
+ name = self._selected_package()
435
+ if name is None:
436
+ self.notify("No package selected", severity="warning")
437
+ return
438
+ right_panel = self.query_one("#right_panel", Static)
439
+ if name in self._deps_cache:
440
+ right_panel.update(self._format_info(name, self._deps_cache[name]))
441
+ return
442
+ right_panel.update(f"Fetching dependencies for {name}...")
443
+ self.run_worker(
444
+ self._fetch_deps(name),
445
+ name=f"deps-{name}",
446
+ group="info",
447
+ exit_on_error=False,
448
+ )
449
+
450
+ async def _fetch_deps(self, name: str) -> None:
451
+ process = await asyncio.create_subprocess_exec(
452
+ "dnf", "repoquery", "--requires", name,
453
+ stdout=asyncio.subprocess.PIPE,
454
+ stderr=asyncio.subprocess.PIPE,
455
+ )
456
+ stdout, _ = await process.communicate()
457
+ right_panel = self.query_one("#right_panel", Static)
458
+ if process.returncode != 0:
459
+ right_panel.update(f"Could not fetch dependencies for {name}")
460
+ self.notify(f"Could not fetch dependencies for {name}", severity="error")
461
+ return
462
+ deps = [line.strip() for line in stdout.decode().splitlines() if line.strip()]
463
+ text = "\n".join(deps) if deps else "No dependencies"
464
+ self._deps_cache[name] = text
465
+ right_panel.update(self._format_info(name, text))
466
+
467
+ def _restore_package_info(self, name: str) -> None:
468
+ self._active_package = name
469
+ if name in self._info_cache:
470
+ self.query_one("#right_panel", Static).update(
471
+ self._format_info(name, self._info_cache[name])
472
+ )
473
+ else:
474
+ self.run_worker(
475
+ self._fetch_package_info(name),
476
+ name=f"info-{name}",
477
+ group="info",
478
+ exit_on_error=False,
479
+ )
480
+
481
+ def _mark_installed(self, name: str) -> None:
482
+ self._installed.add(name)
483
+ self._available.discard(name)
484
+ self._info_cache.pop(name, None)
485
+ self._deps_cache.pop(name, None)
486
+ self._populate_options()
487
+ self.run_worker(
488
+ self._sync_cache_files(),
489
+ name="cache-sync",
490
+ group="cache",
491
+ exclusive=True,
492
+ exit_on_error=False,
493
+ )
494
+
495
+ def _mark_removed(self, name: str) -> None:
496
+ self._installed.discard(name)
497
+ self._available.add(name)
498
+ self._info_cache.pop(name, None)
499
+ self._deps_cache.pop(name, None)
500
+ self._populate_options()
501
+ self.run_worker(
502
+ self._sync_cache_files(),
503
+ name="cache-sync",
504
+ group="cache",
505
+ exclusive=True,
506
+ exit_on_error=False,
507
+ )
508
+
509
+ async def _sync_cache_files(self) -> None:
510
+ await asyncio.gather(
511
+ self._write_cache(INSTALLED_CACHE, sorted(self._installed)),
512
+ self._write_cache(AVAILABLE_CACHE, sorted(self._available)),
513
+ )
514
+
515
+ def _start_upgrade(self) -> None:
516
+ self.notify("Upgrading all packages...", timeout=2)
517
+ self.run_worker(
518
+ self._run_dnf(
519
+ ["dnf", "upgrade", "-y"],
520
+ success_msg="All packages upgraded!",
521
+ ),
522
+ name="upgrade_all",
523
+ group="dnf",
524
+ exclusive=True,
525
+ exit_on_error=False,
526
+ )
527
+
528
+ async def upgrade_all(self) -> None:
529
+ self._start_upgrade()
530
+
531
+ def action_upgrade_all(self) -> None:
532
+ self._start_upgrade()
533
+
534
+ async def _run_dnf(
535
+ self,
536
+ args: list[str],
537
+ success_msg: str = "Command completed successfully",
538
+ name: str | None = None,
539
+ installed: str | None = None,
540
+ ) -> None:
541
+ right_panel = self.query_one("#right_panel", Static)
542
+ self._show_status(self._action_status(args, name))
543
+ try:
544
+ right_panel.update(f"Running: {' '.join(args)}")
545
+ returncode, stderr = await self._sudo_once(args)
546
+ if returncode != 0 and (
547
+ b"a password is required" in stderr.lower()
548
+ or b"no password was provided" in stderr.lower()
549
+ ):
550
+ self.notify(
551
+ "Sudo session expired — please restart dnfseek",
552
+ severity="error",
553
+ )
554
+ self.exit(
555
+ result="dnfseek: sudo session expired. "
556
+ "Re-run the app to reauthenticate."
557
+ )
558
+ return
559
+ if returncode == 0:
560
+ self.notify(success_msg, timeout=2, severity="information")
561
+ if installed == "add" and name is not None:
562
+ self._mark_installed(name)
563
+ self._restore_package_info(name)
564
+ elif installed == "remove" and name is not None:
565
+ self._mark_removed(name)
566
+ self._restore_package_info(name)
567
+ elif name is not None:
568
+ self._restore_package_info(name)
569
+ else:
570
+ stderr_text = stderr.decode(errors="replace")
571
+ error_text = stderr_text.lower()
572
+ if stderr:
573
+ error_lines = stderr_text.splitlines()[-10:]
574
+ right_panel.update("\n".join(error_lines))
575
+ if "already installed" in error_text:
576
+ self.notify(
577
+ f"{name or 'Package'} is already installed", severity="warning"
578
+ )
579
+ elif "already the latest" in error_text or "nothing to do" in error_text:
580
+ self.notify(
581
+ f"{name or 'Packages'} are already up to date",
582
+ severity="warning",
583
+ )
584
+ else:
585
+ self.notify(
586
+ f"Command failed (error code {returncode})",
587
+ severity="error",
588
+ )
589
+ finally:
590
+ self._hide_status()
591
+
592
+ async def _sudo_once(self, args: list[str]) -> tuple[int, bytes]:
593
+ """Run `sudo -n <cmd> ...` once, streaming stdout to the right panel."""
594
+ right_panel = self.query_one("#right_panel", Static)
595
+ process = await asyncio.create_subprocess_exec(
596
+ "sudo", "-n", *args,
597
+ stdout=asyncio.subprocess.PIPE,
598
+ stderr=asyncio.subprocess.PIPE,
599
+ )
600
+ stderr_task = asyncio.create_task(process.stderr.read())
601
+ lines: list[str] = []
602
+ async for chunk in process.stdout:
603
+ for line in chunk.decode(errors="replace").replace("\r", "\n").splitlines():
604
+ if line.strip():
605
+ lines.append(line.strip())
606
+ right_panel.update("\n".join(lines[-15:]))
607
+ stderr = await stderr_task
608
+ await process.wait()
609
+ return process.returncode or 0, stderr
610
+
611
+ @staticmethod
612
+ def _action_status(args: list[str], name: str | None) -> str:
613
+ action = args[1]
614
+ if action == "install":
615
+ return f"Installing {name}..."
616
+ if action == "remove":
617
+ return f"Removing {name}..."
618
+ if action == "reinstall":
619
+ return f"Reinstalling {name}..."
620
+ if action == "upgrade":
621
+ if name is not None:
622
+ return f"Upgrading {name}..."
623
+ return "Upgrading all packages..."
624
+ return f"Running: {' '.join(args)}"
625
+
626
+ def _show_status(self, message: str) -> None:
627
+ self.query_one("#spinner", LoadingIndicator).remove_class("hidden")
628
+ self.query_one("#options_text", Static).update(message)
629
+
630
+ def _hide_status(self) -> None:
631
+ self.query_one("#spinner", LoadingIndicator).add_class("hidden")
632
+ self.query_one("#options_text", Static).update(DEFAULT_HINT)
633
+
634
+
635
+ def main() -> None:
636
+ import subprocess
637
+ import sys
638
+
639
+ if subprocess.run(["sudo", "-v"]).returncode != 0:
640
+ raise SystemExit("Authentication failed - dnfseek requires sudo access")
641
+ app = DnfseekApp()
642
+ result = app.run()
643
+ if result is not None:
644
+ print(result, file=sys.stderr)
645
+
646
+
647
+ if __name__ == "__main__":
648
+ main()
@@ -0,0 +1,78 @@
1
+ Screen {
2
+ background: $background;
3
+ color: $foreground;
4
+ }
5
+
6
+ Horizontal {
7
+ width: 100%;
8
+ }
9
+
10
+ #options {
11
+ background: $panel;
12
+ padding: 0 1;
13
+ border: solid $border-blurred;
14
+ height: 3;
15
+ align: left middle;
16
+ }
17
+
18
+ #options_text {
19
+ width: 1fr;
20
+ }
21
+
22
+ #spinner {
23
+ width: 10;
24
+ height: 1;
25
+ }
26
+
27
+ .hidden {
28
+ display: none;
29
+ }
30
+
31
+ #input {
32
+ background: $panel;
33
+ border: solid $border-blurred;
34
+ padding: 0 1;
35
+ /*margin: 0 0 1 0;*/
36
+ }
37
+ #input:focus {
38
+ border: solid $border;
39
+ }
40
+
41
+ #left_panel {
42
+ width: 1fr;
43
+ height: 1fr;
44
+ background: $panel;
45
+ border: solid $border-blurred;
46
+ padding: 0 1;
47
+ /*margin: 0 0 1 0;*/
48
+ }
49
+ #left_panel:focus-within {
50
+ border: solid $border;
51
+ }
52
+
53
+ #right_panel {
54
+ width: 66%;
55
+ height: 1fr;
56
+ background: $panel;
57
+ border: solid $border-blurred;
58
+ padding: 1 2;
59
+ /*margin: 0 0 1 0;*/
60
+ }
61
+ #right_panel:focus-within {
62
+ border: solid $border;
63
+ }
64
+
65
+ PasswordScreen {
66
+ align: center middle;
67
+ }
68
+
69
+ PasswordScreen #password {
70
+ width: 40;
71
+ background: $panel;
72
+ border: solid $border-blurred;
73
+ padding: 0 1;
74
+ }
75
+
76
+ PasswordScreen #password:focus {
77
+ border: solid $border;
78
+ }
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "dnfseek"
3
+ version = "0.1.0"
4
+ description = "A TUI package browser for Fedora, built with Textual"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "textual>=8.2.8",
9
+ ]
10
+
11
+ [project.scripts]
12
+ dnfseek = "main:main"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ include = [
20
+ "main.py",
21
+ "main.tcss",
22
+ ]
dnfseek-0.1.0/uv.lock ADDED
@@ -0,0 +1,130 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "dnfseek"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
9
+ dependencies = [
10
+ { name = "textual" },
11
+ ]
12
+
13
+ [package.metadata]
14
+ requires-dist = [{ name = "textual", specifier = ">=8.2.8" }]
15
+
16
+ [[package]]
17
+ name = "linkify-it-py"
18
+ version = "2.1.0"
19
+ source = { registry = "https://pypi.org/simple" }
20
+ dependencies = [
21
+ { name = "uc-micro-py" },
22
+ ]
23
+ sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
24
+ wheels = [
25
+ { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "markdown-it-py"
30
+ version = "4.2.0"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ dependencies = [
33
+ { name = "mdurl" },
34
+ ]
35
+ sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
36
+ wheels = [
37
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
38
+ ]
39
+
40
+ [package.optional-dependencies]
41
+ linkify = [
42
+ { name = "linkify-it-py" },
43
+ ]
44
+
45
+ [[package]]
46
+ name = "mdit-py-plugins"
47
+ version = "0.6.1"
48
+ source = { registry = "https://pypi.org/simple" }
49
+ dependencies = [
50
+ { name = "markdown-it-py" },
51
+ ]
52
+ sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
53
+ wheels = [
54
+ { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
55
+ ]
56
+
57
+ [[package]]
58
+ name = "mdurl"
59
+ version = "0.1.2"
60
+ source = { registry = "https://pypi.org/simple" }
61
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
62
+ wheels = [
63
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
64
+ ]
65
+
66
+ [[package]]
67
+ name = "platformdirs"
68
+ version = "4.11.0"
69
+ source = { registry = "https://pypi.org/simple" }
70
+ sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" }
71
+ wheels = [
72
+ { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
73
+ ]
74
+
75
+ [[package]]
76
+ name = "pygments"
77
+ version = "2.20.0"
78
+ source = { registry = "https://pypi.org/simple" }
79
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
80
+ wheels = [
81
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
82
+ ]
83
+
84
+ [[package]]
85
+ name = "rich"
86
+ version = "15.0.0"
87
+ source = { registry = "https://pypi.org/simple" }
88
+ dependencies = [
89
+ { name = "markdown-it-py" },
90
+ { name = "pygments" },
91
+ ]
92
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
93
+ wheels = [
94
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
95
+ ]
96
+
97
+ [[package]]
98
+ name = "textual"
99
+ version = "8.2.8"
100
+ source = { registry = "https://pypi.org/simple" }
101
+ dependencies = [
102
+ { name = "markdown-it-py", extra = ["linkify"] },
103
+ { name = "mdit-py-plugins" },
104
+ { name = "platformdirs" },
105
+ { name = "pygments" },
106
+ { name = "rich" },
107
+ { name = "typing-extensions" },
108
+ ]
109
+ sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
110
+ wheels = [
111
+ { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
112
+ ]
113
+
114
+ [[package]]
115
+ name = "typing-extensions"
116
+ version = "4.16.0"
117
+ source = { registry = "https://pypi.org/simple" }
118
+ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
119
+ wheels = [
120
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
121
+ ]
122
+
123
+ [[package]]
124
+ name = "uc-micro-py"
125
+ version = "2.0.0"
126
+ source = { registry = "https://pypi.org/simple" }
127
+ sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
128
+ wheels = [
129
+ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
130
+ ]