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.
- pipscope-0.1.0/.github/workflows/python-publish.yml +70 -0
- pipscope-0.1.0/.gitignore +23 -0
- pipscope-0.1.0/PKG-INFO +111 -0
- pipscope-0.1.0/README.md +86 -0
- pipscope-0.1.0/pipscope.py +565 -0
- pipscope-0.1.0/pyproject.toml +39 -0
|
@@ -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
|
pipscope-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
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
|
pipscope-0.1.0/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# pipscope
|
|
2
|
+
|
|
3
|
+
A fast, interactive terminal UI for exploring installed Python packages. Think `htop` for `pip`.
|
|
4
|
+
|
|
5
|
+

|
|
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"
|