pipscope 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,70 @@
1
+ # This workflow will upload a Python Package to PyPI when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ # This workflow uses actions that are not certified by GitHub.
5
+ # They are provided by a third-party and are governed by
6
+ # separate terms of service, privacy policy, and support
7
+ # documentation.
8
+
9
+ name: Upload Python Package
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ release-build:
20
+ runs-on: ubuntu-latest
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.x"
28
+
29
+ - name: Build release distributions
30
+ run: |
31
+ # NOTE: put your own distribution build steps here.
32
+ python -m pip install build
33
+ python -m build
34
+
35
+ - name: Upload distributions
36
+ uses: actions/upload-artifact@v4
37
+ with:
38
+ name: release-dists
39
+ path: dist/
40
+
41
+ pypi-publish:
42
+ runs-on: ubuntu-latest
43
+ needs:
44
+ - release-build
45
+ permissions:
46
+ # IMPORTANT: this permission is mandatory for trusted publishing
47
+ id-token: write
48
+
49
+ # Dedicated environments with protections for publishing are strongly recommended.
50
+ # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
51
+ environment:
52
+ name: pypi
53
+ # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
54
+ url: https://pypi.org/p/pipscope
55
+ #
56
+ # ALTERNATIVE: if your GitHub Release name is the PyPI project version string
57
+ # ALTERNATIVE: exactly, uncomment the following line instead:
58
+ # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
59
+
60
+ steps:
61
+ - name: Retrieve release distributions
62
+ uses: actions/download-artifact@v4
63
+ with:
64
+ name: release-dists
65
+ path: dist/
66
+
67
+ - name: Publish release distributions to PyPI
68
+ uses: pypa/gh-action-pypi-publish@release/v1
69
+ with:
70
+ packages-dir: dist/
@@ -0,0 +1,23 @@
1
+ # Virtual environment
2
+ .venv/
3
+ venv/
4
+ env/
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+ build/
13
+ dist/
14
+ *.egg-info/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Export files
23
+ packages_*.json
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipscope
3
+ Version: 0.1.0
4
+ Summary: Interactive TUI for exploring installed Python packages
5
+ Project-URL: Homepage, https://github.com/yourusername/pipscope
6
+ Project-URL: Repository, https://github.com/yourusername/pipscope
7
+ Author-email: Your Name <you@example.com>
8
+ License-Expression: MIT
9
+ Keywords: packages,pip,terminal,textual,tui
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: System :: Systems Administration
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: textual>=0.47.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # pipscope
27
+
28
+ A fast, interactive terminal UI for exploring installed Python packages. Think `htop` for `pip`.
29
+
30
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)
31
+
32
+ ## Features
33
+
34
+ - Browse all installed Python packages in a split-pane interface
35
+ - Live search filtering as you type
36
+ - View package details: version, summary, location, dependencies
37
+ - See reverse dependencies (which packages depend on each one)
38
+ - Sort by name or version
39
+ - Export all package data to JSON
40
+ - Keyboard-driven, works over SSH
41
+ - No subprocess calls — uses native Python APIs
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ # Clone and install
47
+ git clone https://github.com/yourusername/pipscope.git
48
+ cd pipscope
49
+ pip install .
50
+
51
+ # Or install in development mode
52
+ pip install -e .
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ # If installed via pip
59
+ pipscope
60
+
61
+ # Or run directly
62
+ python pipscope.py
63
+ ```
64
+
65
+ ## Keyboard Controls
66
+
67
+ | Key | Action |
68
+ |-----|--------|
69
+ | `↑` / `↓` | Navigate package list |
70
+ | `j` / `k` | Navigate (vim-style) |
71
+ | `/` | Focus search bar |
72
+ | `Esc` | Clear search, return to list |
73
+ | `s` | Toggle sort: name / version |
74
+ | `e` | Export packages to JSON |
75
+ | `q` | Quit |
76
+
77
+ ## Interface
78
+
79
+ ```
80
+ ┌──────────────────────────────────────────────────────────────┐
81
+ │ Type to search packages... │
82
+ ├──────────────────────────────┬───────────────────────────────┤
83
+ │ numpy (1.26.4) │ numpy │
84
+ │ pandas (2.1.0) │ Version: 1.26.4 │
85
+ │ requests (2.31.0) │ │
86
+ │ torch (2.0.1) │ Summary: │
87
+ │ ... │ Fundamental package for │
88
+ │ │ scientific computing │
89
+ │ │ │
90
+ │ │ Location: │
91
+ │ │ /usr/lib/python3/site-pkg │
92
+ │ │ │
93
+ │ │ Dependencies: (3) │
94
+ │ │ setuptools │
95
+ │ │ ... │
96
+ │ │ │
97
+ │ │ Used by: (12) │
98
+ │ │ pandas │
99
+ │ │ scipy │
100
+ │ │ ... │
101
+ └──────────────────────────────┴───────────────────────────────┘
102
+ ```
103
+
104
+ ## Requirements
105
+
106
+ - Python 3.9+
107
+ - [Textual](https://github.com/Textualize/textual) (installed automatically)
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,86 @@
1
+ # pipscope
2
+
3
+ A fast, interactive terminal UI for exploring installed Python packages. Think `htop` for `pip`.
4
+
5
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)
6
+
7
+ ## Features
8
+
9
+ - Browse all installed Python packages in a split-pane interface
10
+ - Live search filtering as you type
11
+ - View package details: version, summary, location, dependencies
12
+ - See reverse dependencies (which packages depend on each one)
13
+ - Sort by name or version
14
+ - Export all package data to JSON
15
+ - Keyboard-driven, works over SSH
16
+ - No subprocess calls — uses native Python APIs
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ # Clone and install
22
+ git clone https://github.com/yourusername/pipscope.git
23
+ cd pipscope
24
+ pip install .
25
+
26
+ # Or install in development mode
27
+ pip install -e .
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ # If installed via pip
34
+ pipscope
35
+
36
+ # Or run directly
37
+ python pipscope.py
38
+ ```
39
+
40
+ ## Keyboard Controls
41
+
42
+ | Key | Action |
43
+ |-----|--------|
44
+ | `↑` / `↓` | Navigate package list |
45
+ | `j` / `k` | Navigate (vim-style) |
46
+ | `/` | Focus search bar |
47
+ | `Esc` | Clear search, return to list |
48
+ | `s` | Toggle sort: name / version |
49
+ | `e` | Export packages to JSON |
50
+ | `q` | Quit |
51
+
52
+ ## Interface
53
+
54
+ ```
55
+ ┌──────────────────────────────────────────────────────────────┐
56
+ │ Type to search packages... │
57
+ ├──────────────────────────────┬───────────────────────────────┤
58
+ │ numpy (1.26.4) │ numpy │
59
+ │ pandas (2.1.0) │ Version: 1.26.4 │
60
+ │ requests (2.31.0) │ │
61
+ │ torch (2.0.1) │ Summary: │
62
+ │ ... │ Fundamental package for │
63
+ │ │ scientific computing │
64
+ │ │ │
65
+ │ │ Location: │
66
+ │ │ /usr/lib/python3/site-pkg │
67
+ │ │ │
68
+ │ │ Dependencies: (3) │
69
+ │ │ setuptools │
70
+ │ │ ... │
71
+ │ │ │
72
+ │ │ Used by: (12) │
73
+ │ │ pandas │
74
+ │ │ scipy │
75
+ │ │ ... │
76
+ └──────────────────────────────┴───────────────────────────────┘
77
+ ```
78
+
79
+ ## Requirements
80
+
81
+ - Python 3.9+
82
+ - [Textual](https://github.com/Textualize/textual) (installed automatically)
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,565 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pipscope: Interactive TUI for exploring installed Python packages.
4
+
5
+ A fast, keyboard-driven terminal interface for browsing installed Python packages,
6
+ viewing their details, dependencies, and reverse dependencies.
7
+
8
+ Usage:
9
+ python pipscope.py
10
+ pipscope (if installed via pip)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+ from importlib.metadata import distributions
20
+
21
+ from textual import on
22
+ from textual.app import App, ComposeResult
23
+ from textual.binding import Binding
24
+ from textual.containers import Horizontal, Vertical, VerticalScroll
25
+ from textual.message import Message
26
+ from textual.reactive import reactive
27
+ from textual.widgets import Input, ListItem, ListView, Static
28
+
29
+
30
+ # -----------------------------------------------------------------------------
31
+ # Data Model
32
+ # -----------------------------------------------------------------------------
33
+
34
+
35
+ @dataclass
36
+ class PackageInfo:
37
+ """Represents metadata for an installed Python package."""
38
+
39
+ name: str
40
+ version: str
41
+ summary: str
42
+ requires: list[str] = field(default_factory=list)
43
+ location: str = ""
44
+
45
+ @property
46
+ def name_lower(self) -> str:
47
+ return self.name.lower()
48
+
49
+
50
+ def normalize_package_name(name: str) -> str:
51
+ """Normalize package name for comparison (PEP 503)."""
52
+ return re.sub(r"[-_.]+", "-", name).lower()
53
+
54
+
55
+ def extract_dependency_name(req: str) -> str:
56
+ """Extract the package name from a requirement string."""
57
+ match = re.match(r"^([a-zA-Z0-9][-a-zA-Z0-9._]*)", req)
58
+ return match.group(1) if match else req
59
+
60
+
61
+ def load_packages() -> list[PackageInfo]:
62
+ """Load all installed packages using importlib.metadata."""
63
+ packages = []
64
+
65
+ for dist in distributions():
66
+ try:
67
+ name = dist.metadata.get("Name", "Unknown")
68
+ version = dist.metadata.get("Version", "Unknown")
69
+ summary = dist.metadata.get("Summary", "") or ""
70
+
71
+ requires = []
72
+ if dist.requires:
73
+ requires = [str(r) for r in dist.requires]
74
+
75
+ location = ""
76
+ if dist._path:
77
+ location = str(dist._path.parent)
78
+
79
+ packages.append(PackageInfo(
80
+ name=name,
81
+ version=version,
82
+ summary=summary,
83
+ requires=requires,
84
+ location=location,
85
+ ))
86
+ except Exception:
87
+ continue
88
+
89
+ return packages
90
+
91
+
92
+ def build_reverse_deps(packages: list[PackageInfo]) -> dict[str, list[str]]:
93
+ """Build a mapping of package -> list of packages that depend on it."""
94
+ reverse_deps: dict[str, list[str]] = {}
95
+ installed_normalized = {normalize_package_name(p.name) for p in packages}
96
+
97
+ for pkg in packages:
98
+ for req in pkg.requires:
99
+ dep_name = extract_dependency_name(req)
100
+ dep_normalized = normalize_package_name(dep_name)
101
+
102
+ if dep_normalized in installed_normalized:
103
+ if dep_normalized not in reverse_deps:
104
+ reverse_deps[dep_normalized] = []
105
+ reverse_deps[dep_normalized].append(pkg.name)
106
+
107
+ return reverse_deps
108
+
109
+
110
+ # -----------------------------------------------------------------------------
111
+ # Widgets
112
+ # -----------------------------------------------------------------------------
113
+
114
+
115
+ class PackageListItem(ListItem):
116
+ """A list item representing a package."""
117
+
118
+ def __init__(self, package: PackageInfo) -> None:
119
+ super().__init__()
120
+ self.package = package
121
+
122
+ def compose(self) -> ComposeResult:
123
+ yield Static(f"[#ededf0]{self.package.name}[/#ededf0] [#5c5c6a]v{self.package.version}[/#5c5c6a]", markup=True)
124
+
125
+
126
+ class PackageListView(ListView):
127
+ """Custom ListView for packages."""
128
+
129
+ BINDINGS = [
130
+ Binding("up", "cursor_up", "Up", show=False),
131
+ Binding("down", "cursor_down", "Down", show=False),
132
+ Binding("k", "cursor_up", "Up", show=False),
133
+ Binding("j", "cursor_down", "Down", show=False),
134
+ ]
135
+
136
+
137
+ class DetailContent(Static):
138
+ """Static widget for displaying package details with rich markup."""
139
+
140
+ def __init__(self, *args, **kwargs) -> None:
141
+ super().__init__(*args, **kwargs)
142
+ self._reverse_deps: dict[str, list[str]] = {}
143
+
144
+ def set_reverse_deps(self, reverse_deps: dict[str, list[str]]) -> None:
145
+ self._reverse_deps = reverse_deps
146
+
147
+ def show_package(self, package: PackageInfo | None) -> None:
148
+ if package is None:
149
+ self.update("[#5c5c6a italic]Select a package to view details[/#5c5c6a italic]")
150
+ return
151
+
152
+ pkg = package
153
+ lines = []
154
+
155
+ # Package name - large and prominent (Linear violet)
156
+ lines.append(f"[bold #ededf0]{pkg.name}[/bold #ededf0]")
157
+ lines.append(f"[#5e6ad2]v{pkg.version}[/#5e6ad2]")
158
+ lines.append("")
159
+
160
+ # Summary
161
+ if pkg.summary:
162
+ lines.append("[bold #6e6e80]DESCRIPTION[/bold #6e6e80]")
163
+ lines.append(f"[#a2a2b0]{pkg.summary}[/#a2a2b0]")
164
+ lines.append("")
165
+
166
+ # Location
167
+ if pkg.location:
168
+ lines.append("[bold #6e6e80]LOCATION[/bold #6e6e80]")
169
+ lines.append(f"[#5c5c6a]{pkg.location}[/#5c5c6a]")
170
+ lines.append("")
171
+
172
+ # Dependencies (green accent)
173
+ dep_count = len(pkg.requires)
174
+ lines.append(f"[bold #6e6e80]DEPENDENCIES[/bold #6e6e80] [#5c5c6a]({dep_count})[/#5c5c6a]")
175
+
176
+ if pkg.requires:
177
+ for req in sorted(pkg.requires)[:30]:
178
+ lines.append(f" [#4cc38a]{req}[/#4cc38a]")
179
+ if len(pkg.requires) > 30:
180
+ lines.append(f" [#5c5c6a]... and {len(pkg.requires) - 30} more[/#5c5c6a]")
181
+ else:
182
+ lines.append(" [#5c5c6a]No dependencies[/#5c5c6a]")
183
+ lines.append("")
184
+
185
+ # Reverse dependencies (amber/orange accent)
186
+ pkg_normalized = normalize_package_name(pkg.name)
187
+ dependents = self._reverse_deps.get(pkg_normalized, [])
188
+
189
+ lines.append(f"[bold #6e6e80]USED BY[/bold #6e6e80] [#5c5c6a]({len(dependents)})[/#5c5c6a]")
190
+
191
+ if dependents:
192
+ for dep in sorted(dependents)[:30]:
193
+ lines.append(f" [#e5a50a]{dep}[/#e5a50a]")
194
+ if len(dependents) > 30:
195
+ lines.append(f" [#5c5c6a]... and {len(dependents) - 30} more[/#5c5c6a]")
196
+ else:
197
+ lines.append(" [#5c5c6a]Not used by any installed package[/#5c5c6a]")
198
+
199
+ self.update("\n".join(lines))
200
+
201
+
202
+ class SearchInput(Input):
203
+ """Search input with custom styling."""
204
+
205
+ class SearchChanged(Message):
206
+ def __init__(self, value: str) -> None:
207
+ super().__init__()
208
+ self.value = value
209
+
210
+ class SearchSubmitted(Message):
211
+ """Emitted when Enter is pressed."""
212
+ pass
213
+
214
+ def __init__(self, *args, **kwargs) -> None:
215
+ kwargs.setdefault("placeholder", "Search packages...")
216
+ super().__init__(*args, **kwargs)
217
+
218
+ @on(Input.Changed)
219
+ def on_input_changed(self, event: Input.Changed) -> None:
220
+ self.post_message(self.SearchChanged(event.value))
221
+
222
+ @on(Input.Submitted)
223
+ def on_input_submitted(self, event: Input.Submitted) -> None:
224
+ self.post_message(self.SearchSubmitted())
225
+
226
+
227
+ # -----------------------------------------------------------------------------
228
+ # Main Application
229
+ # -----------------------------------------------------------------------------
230
+
231
+
232
+ class PipScope(App):
233
+ """Interactive TUI for exploring installed Python packages."""
234
+
235
+ CSS = """
236
+ /* Linear-inspired dark theme */
237
+ Screen {
238
+ background: #0d0d12;
239
+ }
240
+
241
+ /* Header with search */
242
+ #header {
243
+ height: 3;
244
+ background: #161620;
245
+ padding: 0 2;
246
+ border-bottom: solid #26263d;
247
+ }
248
+
249
+ #header-title {
250
+ dock: left;
251
+ width: auto;
252
+ padding: 1 2 0 0;
253
+ color: #5e6ad2;
254
+ text-style: bold;
255
+ }
256
+
257
+ #search-box {
258
+ margin-top: 0;
259
+ background: #0d0d12;
260
+ border: tall #2e2e3f;
261
+ padding: 0 1;
262
+ }
263
+
264
+ #search-box:focus {
265
+ border: tall #5e6ad2;
266
+ }
267
+
268
+ #search-box > .input--placeholder {
269
+ color: #4e4e5c;
270
+ }
271
+
272
+ /* Main layout */
273
+ #main-container {
274
+ height: 1fr;
275
+ }
276
+
277
+ /* Package list pane */
278
+ #list-pane {
279
+ width: 38%;
280
+ background: #161620;
281
+ border-right: solid #26263d;
282
+ }
283
+
284
+ #list-header {
285
+ height: 2;
286
+ background: #161620;
287
+ padding: 0 1;
288
+ border-bottom: solid #26263d;
289
+ color: #6e6e80;
290
+ text-style: bold;
291
+ }
292
+
293
+ #package-list {
294
+ background: #161620;
295
+ scrollbar-background: #161620;
296
+ scrollbar-color: #2e2e3f;
297
+ scrollbar-color-hover: #5e6ad2;
298
+ scrollbar-color-active: #8b92e8;
299
+ }
300
+
301
+ #package-list > ListItem {
302
+ padding: 0 1;
303
+ height: 2;
304
+ background: #161620;
305
+ color: #b4b4c0;
306
+ }
307
+
308
+ #package-list > ListItem:hover {
309
+ background: #1e1e2a;
310
+ }
311
+
312
+ #package-list > ListItem.-highlight {
313
+ background: #252538;
314
+ }
315
+
316
+ #package-list:focus > ListItem.-highlight {
317
+ background: #5e6ad2;
318
+ }
319
+
320
+ #package-list > ListItem.-highlight > Static {
321
+ color: #ededf0;
322
+ }
323
+
324
+ /* Detail pane */
325
+ #detail-pane {
326
+ width: 62%;
327
+ background: #0d0d12;
328
+ }
329
+
330
+ #detail-header {
331
+ height: 2;
332
+ background: #161620;
333
+ padding: 0 2;
334
+ border-bottom: solid #26263d;
335
+ color: #6e6e80;
336
+ text-style: bold;
337
+ }
338
+
339
+ #detail-scroll {
340
+ background: #0d0d12;
341
+ padding: 1 2;
342
+ scrollbar-background: #0d0d12;
343
+ scrollbar-color: #2e2e3f;
344
+ scrollbar-color-hover: #5e6ad2;
345
+ scrollbar-color-active: #8b92e8;
346
+ }
347
+
348
+ #detail-content {
349
+ background: #0d0d12;
350
+ padding: 0;
351
+ color: #b4b4c0;
352
+ }
353
+
354
+ /* Footer */
355
+ #footer {
356
+ height: 1;
357
+ background: #161620;
358
+ border-top: solid #26263d;
359
+ padding: 0 1;
360
+ color: #5c5c6a;
361
+ }
362
+ """
363
+
364
+ BINDINGS = [
365
+ Binding("q", "quit", "Quit", show=False),
366
+ Binding("ctrl+c", "quit", "Quit", show=False),
367
+ Binding("/", "focus_search", "Search", show=False),
368
+ Binding("escape", "escape_action", "Back", show=False),
369
+ Binding("s", "toggle_sort", "Sort", show=False),
370
+ Binding("e", "export_json", "Export", show=False),
371
+ ]
372
+
373
+ TITLE = "pipscope"
374
+
375
+ sort_mode: reactive[str] = reactive("name")
376
+
377
+ def __init__(self) -> None:
378
+ super().__init__()
379
+ self._all_packages: list[PackageInfo] = []
380
+ self._filtered_packages: list[PackageInfo] = []
381
+ self._reverse_deps: dict[str, list[str]] = {}
382
+ self._search_query: str = ""
383
+
384
+ def compose(self) -> ComposeResult:
385
+ # Header with title and search
386
+ with Horizontal(id="header"):
387
+ yield Static("pipscope", id="header-title")
388
+ yield SearchInput(id="search-box")
389
+
390
+ # Main content
391
+ with Horizontal(id="main-container"):
392
+ # Left pane - package list
393
+ with Vertical(id="list-pane"):
394
+ yield Static("PACKAGES", id="list-header")
395
+ yield PackageListView(id="package-list")
396
+
397
+ # Right pane - details
398
+ with Vertical(id="detail-pane"):
399
+ yield Static("DETAILS", id="detail-header")
400
+ with VerticalScroll(id="detail-scroll"):
401
+ yield DetailContent(id="detail-content")
402
+
403
+ # Footer with keybindings (Linear style)
404
+ yield Static(
405
+ "[#5e6ad2]/[/] [#6e6e80]Search[/] "
406
+ "[#5e6ad2]Enter[/] [#6e6e80]Select[/] "
407
+ "[#5e6ad2]j/k[/] [#6e6e80]Navigate[/] "
408
+ "[#5e6ad2]s[/] [#6e6e80]Sort[/] "
409
+ "[#5e6ad2]e[/] [#6e6e80]Export[/] "
410
+ "[#5e6ad2]q[/] [#6e6e80]Quit[/]",
411
+ id="footer"
412
+ )
413
+
414
+ def on_mount(self) -> None:
415
+ """Load packages when the app starts."""
416
+ self._load_packages()
417
+ self._apply_filter()
418
+
419
+ detail = self.query_one("#detail-content", DetailContent)
420
+ detail.set_reverse_deps(self._reverse_deps)
421
+
422
+ # Focus search input on start
423
+ self.query_one("#search-box", SearchInput).focus()
424
+
425
+ def _load_packages(self) -> None:
426
+ """Load all installed packages."""
427
+ self._all_packages = load_packages()
428
+ self._reverse_deps = build_reverse_deps(self._all_packages)
429
+ self._sort_packages()
430
+
431
+ def _sort_packages(self) -> None:
432
+ """Sort packages based on current sort mode."""
433
+ if self.sort_mode == "name":
434
+ self._all_packages.sort(key=lambda p: p.name_lower)
435
+ elif self.sort_mode == "version":
436
+ self._all_packages.sort(key=lambda p: (p.version, p.name_lower), reverse=True)
437
+
438
+ def _apply_filter(self) -> None:
439
+ """Filter packages based on search query."""
440
+ query = self._search_query.lower().strip()
441
+
442
+ if not query:
443
+ self._filtered_packages = self._all_packages[:]
444
+ else:
445
+ self._filtered_packages = [
446
+ p for p in self._all_packages
447
+ if query in p.name_lower or query in p.summary.lower()
448
+ ]
449
+
450
+ self._update_list()
451
+ self._update_header()
452
+
453
+ def _update_header(self) -> None:
454
+ """Update the list header with count."""
455
+ total = len(self._all_packages)
456
+ shown = len(self._filtered_packages)
457
+ header = self.query_one("#list-header", Static)
458
+ if shown == total:
459
+ header.update(f"PACKAGES ({total})")
460
+ else:
461
+ header.update(f"PACKAGES ({shown}/{total})")
462
+
463
+ def _update_list(self) -> None:
464
+ """Update the package list view."""
465
+ list_view = self.query_one("#package-list", PackageListView)
466
+ list_view.clear()
467
+
468
+ for pkg in self._filtered_packages:
469
+ list_view.append(PackageListItem(pkg))
470
+
471
+ if self._filtered_packages:
472
+ list_view.index = 0
473
+ self._show_package_detail(self._filtered_packages[0])
474
+ else:
475
+ self._show_package_detail(None)
476
+
477
+ def _show_package_detail(self, package: PackageInfo | None) -> None:
478
+ """Show package details in the detail panel."""
479
+ detail = self.query_one("#detail-content", DetailContent)
480
+ detail.show_package(package)
481
+
482
+ @on(SearchInput.SearchChanged)
483
+ def on_search_changed(self, event: SearchInput.SearchChanged) -> None:
484
+ """Handle search input changes."""
485
+ self._search_query = event.value
486
+ self._apply_filter()
487
+
488
+ @on(SearchInput.SearchSubmitted)
489
+ def on_search_submitted(self, event: SearchInput.SearchSubmitted) -> None:
490
+ """Handle Enter in search - move focus to package list."""
491
+ list_view = self.query_one("#package-list", PackageListView)
492
+ list_view.focus()
493
+
494
+ @on(ListView.Highlighted)
495
+ def on_list_highlighted(self, event: ListView.Highlighted) -> None:
496
+ """Handle package selection changes."""
497
+ if event.item is not None and isinstance(event.item, PackageListItem):
498
+ self._show_package_detail(event.item.package)
499
+
500
+ def action_focus_search(self) -> None:
501
+ """Focus the search input."""
502
+ self.query_one("#search-box", SearchInput).focus()
503
+
504
+ def action_escape_action(self) -> None:
505
+ """Handle escape - clear search or move to list."""
506
+ search = self.query_one("#search-box", SearchInput)
507
+ list_view = self.query_one("#package-list", PackageListView)
508
+
509
+ if search.has_focus:
510
+ if search.value:
511
+ search.value = ""
512
+ self._search_query = ""
513
+ self._apply_filter()
514
+ else:
515
+ list_view.focus()
516
+ else:
517
+ search.focus()
518
+
519
+ def action_toggle_sort(self) -> None:
520
+ """Toggle between sort modes."""
521
+ if self.sort_mode == "name":
522
+ self.sort_mode = "version"
523
+ else:
524
+ self.sort_mode = "name"
525
+
526
+ self._sort_packages()
527
+ self._apply_filter()
528
+
529
+ self.notify(f"Sorted by {self.sort_mode}", timeout=1.5)
530
+
531
+ def action_export_json(self) -> None:
532
+ """Export all packages to JSON file."""
533
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
534
+ filename = f"packages_{timestamp}.json"
535
+
536
+ export_data = []
537
+ for pkg in self._all_packages:
538
+ pkg_normalized = normalize_package_name(pkg.name)
539
+ dependents = self._reverse_deps.get(pkg_normalized, [])
540
+
541
+ export_data.append({
542
+ "name": pkg.name,
543
+ "version": pkg.version,
544
+ "summary": pkg.summary,
545
+ "requires": pkg.requires,
546
+ "location": pkg.location,
547
+ "used_by": dependents,
548
+ })
549
+
550
+ try:
551
+ with open(filename, "w") as f:
552
+ json.dump(export_data, f, indent=2)
553
+ self.notify(f"Exported to {filename}", timeout=2)
554
+ except Exception as e:
555
+ self.notify(f"Export failed: {e}", severity="error", timeout=3)
556
+
557
+
558
+ def main() -> None:
559
+ """Entry point for the application."""
560
+ app = PipScope()
561
+ app.run()
562
+
563
+
564
+ if __name__ == "__main__":
565
+ main()
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pipscope"
7
+ version = "0.1.0"
8
+ description = "Interactive TUI for exploring installed Python packages"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Your Name", email = "you@example.com" }
14
+ ]
15
+ keywords = ["pip", "tui", "packages", "terminal", "textual"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Topic :: System :: Systems Administration",
29
+ ]
30
+ dependencies = [
31
+ "textual>=0.47.0",
32
+ ]
33
+
34
+ [project.scripts]
35
+ pipscope = "pipscope:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/yourusername/pipscope"
39
+ Repository = "https://github.com/yourusername/pipscope"