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 ADDED
@@ -0,0 +1,3 @@
1
+ """pipseek: search PyPI from your terminal."""
2
+
3
+ __version__ = "2.1.0"
pipseek/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pipseek.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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