pipseek 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipseek/__init__.py +3 -0
- pipseek/__main__.py +4 -0
- pipseek/build_index.py +240 -0
- pipseek/cache.py +80 -0
- pipseek/cli.py +217 -0
- pipseek/data/popular.tsv.gz +0 -0
- pipseek/env.py +116 -0
- pipseek/index.py +440 -0
- pipseek/outdated.py +101 -0
- pipseek/pypi.py +279 -0
- pipseek/render.py +222 -0
- pipseek/search.py +91 -0
- pipseek/tui.py +273 -0
- pipseek/vulns.py +140 -0
- pipseek-2.1.0.dist-info/METADATA +324 -0
- pipseek-2.1.0.dist-info/RECORD +19 -0
- pipseek-2.1.0.dist-info/WHEEL +4 -0
- pipseek-2.1.0.dist-info/entry_points.txt +2 -0
- pipseek-2.1.0.dist-info/licenses/LICENSE +674 -0
pipseek/__init__.py
ADDED
pipseek/__main__.py
ADDED
pipseek/build_index.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Build the pipseek search index: every PyPI project with its summary and keywords.
|
|
2
|
+
|
|
3
|
+
PyPI has no public full-text search API, so pipseek ships its own index. This script is run
|
|
4
|
+
on a schedule by GitHub Actions (see .github/workflows/index.yml) and publishes the result as
|
|
5
|
+
a release asset that clients download.
|
|
6
|
+
|
|
7
|
+
It is incremental: the PyPI Simple API reports a "last serial" for every project, which
|
|
8
|
+
changes whenever the project changes. Only projects whose serial moved since the previous
|
|
9
|
+
build are re-fetched from the JSON API, so after the first build a refresh touches a few
|
|
10
|
+
thousand projects instead of ~1M. A time budget lets the first build span several runs.
|
|
11
|
+
|
|
12
|
+
Output format (gzipped TSV, one project per line):
|
|
13
|
+
|
|
14
|
+
#pipseek-index v1 <unix-time>
|
|
15
|
+
name <TAB> serial <TAB> summary <TAB> keywords
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python -m pipseek.build_index --out pypi-index.tsv.gz [--previous old.tsv.gz]
|
|
19
|
+
python -m pipseek.build_index --out popular.tsv.gz --popular-only
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import gzip
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Iterable, Iterator
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
import requests
|
|
35
|
+
|
|
36
|
+
from pipseek import __version__
|
|
37
|
+
|
|
38
|
+
SIMPLE_URL = "https://pypi.org/simple/"
|
|
39
|
+
SIMPLE_ACCEPT = "application/vnd.pypi.simple.v1+json"
|
|
40
|
+
TOP_URL = (
|
|
41
|
+
"https://raw.githubusercontent.com/hugovk/top-pypi-packages/main/top-pypi-packages.min.json"
|
|
42
|
+
)
|
|
43
|
+
HEADER = "#pipseek-index v1"
|
|
44
|
+
USER_AGENT = f"pipseek-index-builder/{__version__} (+https://github.com/ohmybahgosh/pipseek)"
|
|
45
|
+
|
|
46
|
+
Record = tuple[str, int, str, str] # name, serial, summary, keywords
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def clean(text: Optional[str], limit: int) -> str:
|
|
50
|
+
if not text:
|
|
51
|
+
return ""
|
|
52
|
+
text = " ".join(str(text).split()) # collapses tabs/newlines
|
|
53
|
+
if text.lower() in ("unknown", "none", "n/a", "null"):
|
|
54
|
+
return ""
|
|
55
|
+
return text[:limit]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_index(path: str) -> Iterator[Record]:
|
|
59
|
+
"""Read a pipseek index file (gzipped or plain TSV)."""
|
|
60
|
+
opener = gzip.open if path.endswith(".gz") else open
|
|
61
|
+
with opener(path, "rt", encoding="utf-8") as fh:
|
|
62
|
+
yield from parse_lines(fh)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_lines(lines: Iterable[str]) -> Iterator[Record]:
|
|
66
|
+
for line in lines:
|
|
67
|
+
if not line or line.startswith("#"):
|
|
68
|
+
continue
|
|
69
|
+
parts = line.rstrip("\n").split("\t")
|
|
70
|
+
if len(parts) < 2:
|
|
71
|
+
continue
|
|
72
|
+
name = parts[0]
|
|
73
|
+
try:
|
|
74
|
+
serial = int(parts[1])
|
|
75
|
+
except ValueError:
|
|
76
|
+
serial = 0
|
|
77
|
+
summary = parts[2] if len(parts) > 2 else ""
|
|
78
|
+
keywords = parts[3] if len(parts) > 3 else ""
|
|
79
|
+
yield name, serial, summary, keywords
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_index(path: str, records: Iterable[Record]) -> int:
|
|
83
|
+
count = 0
|
|
84
|
+
tmp = path + ".tmp"
|
|
85
|
+
with gzip.open(tmp, "wt", encoding="utf-8", compresslevel=9) as fh:
|
|
86
|
+
fh.write(f"{HEADER} {int(time.time())}\n")
|
|
87
|
+
for name, serial, summary, keywords in records:
|
|
88
|
+
fh.write(f"{name}\t{serial}\t{summary}\t{keywords}\n")
|
|
89
|
+
count += 1
|
|
90
|
+
os.replace(tmp, path)
|
|
91
|
+
return count
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def fetch_projects(session: requests.Session) -> dict[str, int]:
|
|
95
|
+
resp = session.get(SIMPLE_URL, headers={"Accept": SIMPLE_ACCEPT}, timeout=120)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
return {p["name"]: int(p.get("_last-serial") or 0) for p in resp.json()["projects"]}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fetch_popular(session: requests.Session) -> list[str]:
|
|
101
|
+
resp = session.get(TOP_URL, timeout=60)
|
|
102
|
+
resp.raise_for_status()
|
|
103
|
+
return [row["project"] for row in resp.json()["rows"]]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fetch_metadata(session: requests.Session, name: str) -> Optional[tuple[str, str]]:
|
|
107
|
+
"""Return (summary, keywords), ("", "") for projects without releases, None on error."""
|
|
108
|
+
for attempt in range(4):
|
|
109
|
+
try:
|
|
110
|
+
resp = session.get(f"https://pypi.org/pypi/{name}/json", timeout=30)
|
|
111
|
+
except requests.RequestException:
|
|
112
|
+
time.sleep(2**attempt)
|
|
113
|
+
continue
|
|
114
|
+
if resp.status_code == 404:
|
|
115
|
+
return "", ""
|
|
116
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
117
|
+
time.sleep(2**attempt * 2)
|
|
118
|
+
continue
|
|
119
|
+
if resp.status_code != 200:
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
info = resp.json().get("info") or {}
|
|
123
|
+
except ValueError:
|
|
124
|
+
return None
|
|
125
|
+
keywords = info.get("keywords") or ""
|
|
126
|
+
if isinstance(keywords, list):
|
|
127
|
+
keywords = ", ".join(keywords)
|
|
128
|
+
return clean(info.get("summary"), 300), clean(keywords, 200)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build(
|
|
133
|
+
out: str,
|
|
134
|
+
previous: Optional[str] = None,
|
|
135
|
+
popular_only: bool = False,
|
|
136
|
+
time_budget: float = 5 * 3600,
|
|
137
|
+
concurrency: int = 32,
|
|
138
|
+
limit: Optional[int] = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
started = time.time()
|
|
141
|
+
session = requests.Session()
|
|
142
|
+
session.headers["User-Agent"] = USER_AGENT
|
|
143
|
+
adapter = requests.adapters.HTTPAdapter(pool_connections=concurrency, pool_maxsize=concurrency)
|
|
144
|
+
session.mount("https://", adapter)
|
|
145
|
+
|
|
146
|
+
log("Fetching the PyPI project list...")
|
|
147
|
+
serials = fetch_projects(session)
|
|
148
|
+
log(f"{len(serials):,} projects on PyPI")
|
|
149
|
+
|
|
150
|
+
old: dict[str, Record] = {}
|
|
151
|
+
if previous:
|
|
152
|
+
try:
|
|
153
|
+
old = {r[0]: r for r in read_index(previous)}
|
|
154
|
+
log(f"Loaded {len(old):,} records from {previous}")
|
|
155
|
+
except OSError as exc:
|
|
156
|
+
log(f"Could not read previous index ({exc}); starting fresh")
|
|
157
|
+
|
|
158
|
+
if popular_only:
|
|
159
|
+
by_norm = {n.lower().replace("_", "-").replace(".", "-"): n for n in serials}
|
|
160
|
+
names = []
|
|
161
|
+
for p in fetch_popular(session):
|
|
162
|
+
real = by_norm.get(p.lower().replace("_", "-").replace(".", "-"))
|
|
163
|
+
if real:
|
|
164
|
+
names.append(real)
|
|
165
|
+
else:
|
|
166
|
+
names = list(serials)
|
|
167
|
+
|
|
168
|
+
todo = [n for n in names if n not in old or old[n][1] != serials[n] or old[n][1] == 0]
|
|
169
|
+
if limit:
|
|
170
|
+
todo = todo[:limit]
|
|
171
|
+
log(f"{len(todo):,} projects need fetching")
|
|
172
|
+
|
|
173
|
+
results: dict[str, Record] = {}
|
|
174
|
+
lock = threading.Lock()
|
|
175
|
+
done = 0
|
|
176
|
+
out_of_time = threading.Event()
|
|
177
|
+
|
|
178
|
+
def work(name: str) -> None:
|
|
179
|
+
nonlocal done
|
|
180
|
+
if out_of_time.is_set():
|
|
181
|
+
return
|
|
182
|
+
meta = fetch_metadata(session, name)
|
|
183
|
+
with lock:
|
|
184
|
+
if meta is not None:
|
|
185
|
+
results[name] = (name, serials[name], meta[0], meta[1])
|
|
186
|
+
done += 1
|
|
187
|
+
if done % 5000 == 0:
|
|
188
|
+
rate = done / max(time.time() - started, 1)
|
|
189
|
+
log(f" {done:,}/{len(todo):,} fetched ({rate:.0f}/s)")
|
|
190
|
+
if time.time() - started > time_budget:
|
|
191
|
+
out_of_time.set()
|
|
192
|
+
|
|
193
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
194
|
+
for fut in as_completed([pool.submit(work, n) for n in todo]):
|
|
195
|
+
fut.result()
|
|
196
|
+
|
|
197
|
+
if out_of_time.is_set():
|
|
198
|
+
log("Time budget reached; remaining projects will be fetched on the next run.")
|
|
199
|
+
|
|
200
|
+
def merged() -> Iterator[Record]:
|
|
201
|
+
for name in names:
|
|
202
|
+
if name in results:
|
|
203
|
+
yield results[name]
|
|
204
|
+
elif name in old:
|
|
205
|
+
yield old[name]
|
|
206
|
+
else:
|
|
207
|
+
yield name, 0, "", "" # serial 0 means "fetch me next time"
|
|
208
|
+
|
|
209
|
+
count = write_index(out, merged())
|
|
210
|
+
missing = sum(1 for n in names if n not in results and n not in old)
|
|
211
|
+
elapsed = time.time() - started
|
|
212
|
+
log(f"Wrote {count:,} records to {out} ({missing:,} pending) in {elapsed:.0f}s")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def log(msg: str) -> None:
|
|
216
|
+
print(msg, file=sys.stderr, flush=True)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
220
|
+
p = argparse.ArgumentParser(description="Build the pipseek search index.")
|
|
221
|
+
p.add_argument("--out", required=True, help="output .tsv.gz path")
|
|
222
|
+
p.add_argument("--previous", help="previous index to update incrementally")
|
|
223
|
+
p.add_argument("--popular-only", action="store_true", help="only the ~15k most popular")
|
|
224
|
+
p.add_argument("--time-budget", type=float, default=5 * 3600, help="seconds (default 5h)")
|
|
225
|
+
p.add_argument("--concurrency", type=int, default=32)
|
|
226
|
+
p.add_argument("--limit", type=int, help="fetch at most N projects (for testing)")
|
|
227
|
+
args = p.parse_args(argv)
|
|
228
|
+
build(
|
|
229
|
+
args.out,
|
|
230
|
+
previous=args.previous,
|
|
231
|
+
popular_only=args.popular_only,
|
|
232
|
+
time_budget=args.time_budget,
|
|
233
|
+
concurrency=args.concurrency,
|
|
234
|
+
limit=args.limit,
|
|
235
|
+
)
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
raise SystemExit(main())
|
pipseek/cache.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tiny on-disk cache for the package index, popularity data, and API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cache_dir() -> Path:
|
|
16
|
+
"""Return the per-user cache directory, honoring PIPSEEK_CACHE_DIR and platform norms."""
|
|
17
|
+
override = os.environ.get("PIPSEEK_CACHE_DIR")
|
|
18
|
+
if override:
|
|
19
|
+
base = Path(override)
|
|
20
|
+
elif sys.platform == "win32":
|
|
21
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "pipseek"
|
|
22
|
+
elif sys.platform == "darwin":
|
|
23
|
+
base = Path.home() / "Library" / "Caches" / "pipseek"
|
|
24
|
+
else:
|
|
25
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "pipseek"
|
|
26
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return base
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Cache:
|
|
31
|
+
"""Gzipped JSON blobs keyed by name, each stored with a timestamp and optional ETag."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, root: Optional[Path] = None) -> None:
|
|
34
|
+
self.root = root or cache_dir()
|
|
35
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
def _path(self, key: str) -> Path:
|
|
38
|
+
safe = hashlib.sha1(key.encode()).hexdigest()[:16] if len(key) > 64 or "/" in key else key
|
|
39
|
+
return self.root / f"{safe}.json.gz"
|
|
40
|
+
|
|
41
|
+
def load(self, key: str) -> Optional[dict[str, Any]]:
|
|
42
|
+
path = self._path(key)
|
|
43
|
+
try:
|
|
44
|
+
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
|
45
|
+
return json.load(fh)
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def get(self, key: str, max_age: float) -> Optional[Any]:
|
|
50
|
+
"""Return cached data if younger than max_age seconds, else None."""
|
|
51
|
+
entry = self.load(key)
|
|
52
|
+
if entry and time.time() - entry.get("ts", 0) < max_age:
|
|
53
|
+
return entry.get("data")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def set(self, key: str, data: Any, etag: Optional[str] = None) -> None:
|
|
57
|
+
path = self._path(key)
|
|
58
|
+
tmp = path.with_suffix(".tmp")
|
|
59
|
+
try:
|
|
60
|
+
with gzip.open(tmp, "wt", encoding="utf-8", compresslevel=5) as fh:
|
|
61
|
+
json.dump({"ts": time.time(), "etag": etag, "data": data}, fh)
|
|
62
|
+
os.replace(tmp, path)
|
|
63
|
+
except OSError:
|
|
64
|
+
tmp.unlink(missing_ok=True)
|
|
65
|
+
|
|
66
|
+
def touch(self, key: str) -> None:
|
|
67
|
+
"""Mark an existing entry fresh (used after a 304 Not Modified)."""
|
|
68
|
+
entry = self.load(key)
|
|
69
|
+
if entry is not None:
|
|
70
|
+
self.set(key, entry.get("data"), entry.get("etag"))
|
|
71
|
+
|
|
72
|
+
def clear(self) -> int:
|
|
73
|
+
removed = 0
|
|
74
|
+
for path in self.root.glob("*.json.gz"):
|
|
75
|
+
try:
|
|
76
|
+
path.unlink()
|
|
77
|
+
removed += 1
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
return removed
|
pipseek/cli.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import textwrap
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from pipseek import __version__
|
|
14
|
+
from pipseek.cache import Cache
|
|
15
|
+
from pipseek.env import PythonEnv
|
|
16
|
+
from pipseek.index import IndexUnavailable, PackageIndex, load_downloads, normalize
|
|
17
|
+
from pipseek.pypi import PyPIClient
|
|
18
|
+
from pipseek.render import package_panel, results_table
|
|
19
|
+
from pipseek.search import SORTS, Searcher
|
|
20
|
+
|
|
21
|
+
EPILOG = textwrap.dedent(
|
|
22
|
+
"""\
|
|
23
|
+
examples:
|
|
24
|
+
pipseek open the interactive search
|
|
25
|
+
pipseek fastapi search and browse results
|
|
26
|
+
pipseek "pdf parser" -P print a table instead of opening the TUI
|
|
27
|
+
pipseek http -s downloads -n 10 --json | jq '.[].name'
|
|
28
|
+
pipseek --info requests full details for one package
|
|
29
|
+
pipseek --outdated what's out of date or vulnerable in your environment
|
|
30
|
+
pipseek --outdated --strict same, but exit 1 if anything installed is vulnerable (CI)
|
|
31
|
+
|
|
32
|
+
interactive keys:
|
|
33
|
+
/ search enter run search up/down or j/k move
|
|
34
|
+
n/p next/previous page s cycle sort c copy install command
|
|
35
|
+
o open on PyPI h open homepage/repo i pip install (or upgrade) q quit
|
|
36
|
+
|
|
37
|
+
environment:
|
|
38
|
+
PIPSEEK_CACHE_DIR override the cache location
|
|
39
|
+
"""
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
44
|
+
p = argparse.ArgumentParser(
|
|
45
|
+
prog="pipseek",
|
|
46
|
+
description="Search PyPI from your terminal.",
|
|
47
|
+
epilog=EPILOG,
|
|
48
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
49
|
+
)
|
|
50
|
+
p.add_argument("query", nargs="*", help="search terms")
|
|
51
|
+
p.add_argument("-i", "--info", metavar="PACKAGE", help="show full details for one package")
|
|
52
|
+
p.add_argument(
|
|
53
|
+
"-o",
|
|
54
|
+
"--outdated",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="list installed packages that are out of date or have known vulnerabilities",
|
|
57
|
+
)
|
|
58
|
+
p.add_argument(
|
|
59
|
+
"--all", action="store_true", help="with --outdated: list every installed package"
|
|
60
|
+
)
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"--strict",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="with --outdated: exit 1 if an installed version has a known vulnerability",
|
|
65
|
+
)
|
|
66
|
+
p.add_argument("-P", "--plain", action="store_true", help="print results, no interactive UI")
|
|
67
|
+
p.add_argument("-j", "--json", action="store_true", help="print results as JSON")
|
|
68
|
+
p.add_argument("-n", "--limit", type=int, default=20, metavar="N", help="results per page")
|
|
69
|
+
p.add_argument("-p", "--page", type=int, default=1, metavar="N", help="page number")
|
|
70
|
+
p.add_argument("-s", "--sort", choices=SORTS, default="relevance", help="result ordering")
|
|
71
|
+
p.add_argument(
|
|
72
|
+
"--python", metavar="PATH", help="interpreter used for installed checks and installs"
|
|
73
|
+
)
|
|
74
|
+
p.add_argument(
|
|
75
|
+
"--no-stats",
|
|
76
|
+
"--no-github",
|
|
77
|
+
dest="no_stats",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="skip repository stats and vulnerability checks",
|
|
80
|
+
)
|
|
81
|
+
p.add_argument("--refresh", action="store_true", help="re-download the package index now")
|
|
82
|
+
p.add_argument("--clear-cache", action="store_true", help="delete cached data and exit")
|
|
83
|
+
p.add_argument("-V", "--version", action="version", version=f"pipseek {__version__}")
|
|
84
|
+
return p
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
88
|
+
args = build_parser().parse_args(argv)
|
|
89
|
+
err = Console(stderr=True)
|
|
90
|
+
out = Console()
|
|
91
|
+
cache = Cache()
|
|
92
|
+
|
|
93
|
+
if args.clear_cache:
|
|
94
|
+
removed = cache.clear()
|
|
95
|
+
err.print(f"Removed {removed} cached file(s) from {cache.root}")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
if args.limit < 1 or args.page < 1:
|
|
99
|
+
err.print("[red]--limit and --page must be positive.[/]")
|
|
100
|
+
return 2
|
|
101
|
+
|
|
102
|
+
env = PythonEnv(args.python)
|
|
103
|
+
client = PyPIClient(cache=cache, stats=not args.no_stats, installed=env.version_of)
|
|
104
|
+
query = " ".join(args.query).strip()
|
|
105
|
+
interactive = (
|
|
106
|
+
not (args.plain or args.json or args.info or args.outdated) and sys.stdout.isatty()
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def make_searcher(progress=None) -> Searcher:
|
|
110
|
+
index = PackageIndex.load(
|
|
111
|
+
session=client.session, cache=cache, refresh=args.refresh, progress=progress
|
|
112
|
+
)
|
|
113
|
+
return Searcher(index, client)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
if args.info:
|
|
117
|
+
return _info(args, client, cache, out, err)
|
|
118
|
+
|
|
119
|
+
if args.outdated:
|
|
120
|
+
return _outdated(args, env, cache, out, err)
|
|
121
|
+
|
|
122
|
+
if interactive:
|
|
123
|
+
from pipseek.tui import PipSeekApp
|
|
124
|
+
|
|
125
|
+
PipSeekApp(make_searcher, query, args.sort, args.limit, env).run()
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
if not query:
|
|
129
|
+
build_parser().print_help()
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
with err.status("Loading package index...") as status:
|
|
133
|
+
searcher = make_searcher(lambda msg: status.update(msg))
|
|
134
|
+
status.update(f"Searching {len(searcher.index):,} packages...")
|
|
135
|
+
page = searcher.search(query, page=args.page, per_page=args.limit, sort=args.sort)
|
|
136
|
+
except IndexUnavailable as exc:
|
|
137
|
+
err.print(f"[red]{exc}[/]")
|
|
138
|
+
return 1
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
return 130
|
|
141
|
+
|
|
142
|
+
if args.json:
|
|
143
|
+
print(json.dumps([p.to_dict() for p in page.packages], indent=2))
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
if not page.packages:
|
|
147
|
+
err.print(f"No packages match [bold]{query}[/].")
|
|
148
|
+
else:
|
|
149
|
+
out.print(results_table(page.packages, show_stars=not args.no_stats))
|
|
150
|
+
footer = (
|
|
151
|
+
f"{page.start}-{page.end} of {page.total:,} matches | page {page.page}/{page.pages}"
|
|
152
|
+
)
|
|
153
|
+
if page.has_next:
|
|
154
|
+
footer += f" | next: --page {page.page + 1}"
|
|
155
|
+
err.print(f"[dim]{footer}[/]")
|
|
156
|
+
if page.suggestions:
|
|
157
|
+
err.print(f"[yellow]Did you mean:[/] {', '.join(page.suggestions)}")
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _outdated(args, env: PythonEnv, cache: Cache, out: Console, err: Console) -> int:
|
|
162
|
+
from pipseek.outdated import check_environment, outdated_dict, outdated_table, upgrade_command
|
|
163
|
+
|
|
164
|
+
client = PyPIClient(cache=cache, stats=False, installed=env.version_of)
|
|
165
|
+
if not env.installed:
|
|
166
|
+
err.print(f"[red]Could not list the packages installed for {env.python}.[/]")
|
|
167
|
+
return 1
|
|
168
|
+
with err.status("Checking installed packages...") as status:
|
|
169
|
+
packages = check_environment(
|
|
170
|
+
env, client, include_all=args.all, progress=lambda m: status.update(m)
|
|
171
|
+
)
|
|
172
|
+
vulnerable = [p for p in packages if p.installed_vulns]
|
|
173
|
+
if args.json:
|
|
174
|
+
print(json.dumps([outdated_dict(p) for p in packages], indent=2))
|
|
175
|
+
else:
|
|
176
|
+
err.print(f"[dim]Environment: {env.python}[/]")
|
|
177
|
+
if not packages:
|
|
178
|
+
err.print(
|
|
179
|
+
f"[green]All {len(env.installed)} installed packages are up to date, "
|
|
180
|
+
"with no known vulnerabilities.[/]"
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
out.print(outdated_table(packages))
|
|
184
|
+
outdated = sum(1 for p in packages if p.update_available)
|
|
185
|
+
err.print(
|
|
186
|
+
f"[dim]{outdated} outdated, {len(vulnerable)} with known vulnerabilities, "
|
|
187
|
+
f"out of {len(env.installed)} installed. Vulnerability data: OSV.dev[/]"
|
|
188
|
+
)
|
|
189
|
+
if env.externally_managed:
|
|
190
|
+
err.print(
|
|
191
|
+
"[yellow]This is a system Python managed by your OS, so pip shouldn't "
|
|
192
|
+
"upgrade it directly.[/] Update these through your package manager, "
|
|
193
|
+
"or install what you need in a virtualenv or with pipx."
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
cmd = upgrade_command(env, packages)
|
|
197
|
+
if cmd:
|
|
198
|
+
label = "Upgrade" if outdated <= 12 else "Start with"
|
|
199
|
+
err.print(f"[bold]{label}:[/] {cmd}")
|
|
200
|
+
return 1 if args.strict and vulnerable else 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _info(args, client: PyPIClient, cache: Cache, out: Console, err: Console) -> int:
|
|
204
|
+
downloads = load_downloads(client.session, cache).get(normalize(args.info))
|
|
205
|
+
pkg = client.package(args.info, downloads)
|
|
206
|
+
if pkg is None:
|
|
207
|
+
err.print(f"[red]Package '{args.info}' was not found on PyPI.[/]")
|
|
208
|
+
return 1
|
|
209
|
+
if args.json:
|
|
210
|
+
print(json.dumps(pkg.to_dict(), indent=2))
|
|
211
|
+
else:
|
|
212
|
+
out.print(package_panel(pkg, full=True))
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
sys.exit(main())
|
|
Binary file
|
pipseek/env.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Find the Python environment the user actually works in, and what's installed there.
|
|
2
|
+
|
|
3
|
+
pipseek is usually installed with pipx or uv, so its own interpreter is an isolated venv.
|
|
4
|
+
To show "installed" badges and to install packages, we target the user's environment:
|
|
5
|
+
an explicit --python, else the active virtualenv/conda env, else the first python on PATH.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from pipseek.index import normalize
|
|
19
|
+
|
|
20
|
+
_LIST_SCRIPT = (
|
|
21
|
+
"import json, importlib.metadata as m\n"
|
|
22
|
+
"out = {}\n"
|
|
23
|
+
"for d in m.distributions():\n"
|
|
24
|
+
" n = d.metadata.get('Name')\n"
|
|
25
|
+
" if n: out[n] = d.version\n"
|
|
26
|
+
"print(json.dumps(out))\n"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _venv_python(root: str) -> Optional[str]:
|
|
31
|
+
base = Path(root)
|
|
32
|
+
for candidate in (base / "bin" / "python", base / "Scripts" / "python.exe"):
|
|
33
|
+
if candidate.exists():
|
|
34
|
+
return str(candidate)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_python(explicit: Optional[str] = None) -> str:
|
|
39
|
+
if explicit:
|
|
40
|
+
return shutil.which(explicit) or explicit
|
|
41
|
+
for var in ("VIRTUAL_ENV", "CONDA_PREFIX"):
|
|
42
|
+
root = os.environ.get(var)
|
|
43
|
+
if root:
|
|
44
|
+
found = _venv_python(root)
|
|
45
|
+
if found:
|
|
46
|
+
return found
|
|
47
|
+
for name in ("python3", "python"):
|
|
48
|
+
found = shutil.which(name)
|
|
49
|
+
if found:
|
|
50
|
+
return found
|
|
51
|
+
return sys.executable
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_MANAGED_SCRIPT = (
|
|
55
|
+
"import os, sys, sysconfig\n"
|
|
56
|
+
"venv = sys.prefix != getattr(sys, 'base_prefix', sys.prefix)\n"
|
|
57
|
+
"marker = os.path.join(sysconfig.get_paths()['stdlib'], 'EXTERNALLY-MANAGED')\n"
|
|
58
|
+
"print(int(not venv and os.path.exists(marker)))\n"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PythonEnv:
|
|
63
|
+
def __init__(self, python: Optional[str] = None) -> None:
|
|
64
|
+
self.python = find_python(python)
|
|
65
|
+
self._installed: Optional[dict[str, str]] = None
|
|
66
|
+
self._managed: Optional[bool] = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def externally_managed(self) -> bool:
|
|
70
|
+
"""True for an OS-managed Python (PEP 668), where pip shouldn't install packages."""
|
|
71
|
+
if self._managed is None:
|
|
72
|
+
try:
|
|
73
|
+
out = subprocess.run(
|
|
74
|
+
[self.python, "-c", _MANAGED_SCRIPT],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=10,
|
|
78
|
+
check=True,
|
|
79
|
+
).stdout.strip()
|
|
80
|
+
self._managed = out == "1"
|
|
81
|
+
except (OSError, subprocess.SubprocessError):
|
|
82
|
+
self._managed = False
|
|
83
|
+
return self._managed
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def installed(self) -> dict[str, str]:
|
|
87
|
+
"""Normalized name -> version for every distribution in the target environment."""
|
|
88
|
+
if self._installed is None:
|
|
89
|
+
self._installed = {}
|
|
90
|
+
try:
|
|
91
|
+
out = subprocess.run(
|
|
92
|
+
[self.python, "-c", _LIST_SCRIPT],
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
timeout=15,
|
|
96
|
+
check=True,
|
|
97
|
+
).stdout
|
|
98
|
+
self._installed = {normalize(k): v for k, v in json.loads(out).items()}
|
|
99
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
100
|
+
pass
|
|
101
|
+
return self._installed
|
|
102
|
+
|
|
103
|
+
def version_of(self, name: str) -> Optional[str]:
|
|
104
|
+
return self.installed.get(normalize(name))
|
|
105
|
+
|
|
106
|
+
def install_command(self, name: str, upgrade: bool = False) -> list[str]:
|
|
107
|
+
cmd = [self.python, "-m", "pip", "install"]
|
|
108
|
+
if upgrade:
|
|
109
|
+
cmd.append("--upgrade")
|
|
110
|
+
cmd.append(name)
|
|
111
|
+
return cmd
|
|
112
|
+
|
|
113
|
+
def install(self, name: str, upgrade: bool = False) -> int:
|
|
114
|
+
result = subprocess.run(self.install_command(name, upgrade))
|
|
115
|
+
self._installed = None
|
|
116
|
+
return result.returncode
|