|
| 1 | +"""Over-the-air update utilities.""" |
| 2 | +import os |
| 3 | +import subprocess |
| 4 | +import sys |
| 5 | +from importlib import metadata |
| 6 | + |
| 7 | + |
| 8 | +def in_virtual_env() -> bool: |
| 9 | + """Detect venvs (venv/virtualenv), pipx venvs, and conda envs.""" |
| 10 | + # venv/virtualenv |
| 11 | + if getattr(sys, "real_prefix", None): # virtualenv sets this |
| 12 | + return True |
| 13 | + if sys.prefix != getattr(sys, "base_prefix", sys.prefix): # python -m venv |
| 14 | + return True |
| 15 | + # conda |
| 16 | + if os.environ.get("CONDA_PREFIX"): |
| 17 | + return True |
| 18 | + # pipx typically sets these and runs inside a venv too |
| 19 | + if os.environ.get("PIPX_BIN_DIR") or os.environ.get("PIPX_HOME"): |
| 20 | + return True |
| 21 | + # fallback: VIRTUAL_ENV env var |
| 22 | + return bool(os.environ.get("VIRTUAL_ENV")) |
| 23 | + |
| 24 | + |
| 25 | +def _dist_for_import_name(import_name: str) -> str: |
| 26 | + """Map a top-level import name (module) to its distribution name for pip. |
| 27 | +
|
| 28 | + Falls back to the import name if we can't find a better match. |
| 29 | + """ |
| 30 | + try: |
| 31 | + # e.g. {"requests": ["requests"]} or {"Pillow": ["PIL"]} |
| 32 | + mapping = metadata.packages_distributions() |
| 33 | + top = import_name.split(".")[0] |
| 34 | + dists = mapping.get(top, []) |
| 35 | + return dists[0] if dists else top |
| 36 | + except Exception: |
| 37 | + return import_name.split(".")[0] |
| 38 | + |
| 39 | + |
| 40 | +def self_update( |
| 41 | + import_name: str, |
| 42 | + version_spec: str | None = None, |
| 43 | + allow_system: bool = False, |
| 44 | + pre: bool = False, |
| 45 | + index_url: str | None = None, |
| 46 | + extra_index_url: str | None = None, |
| 47 | +) -> int: |
| 48 | + """Update the installed package that provides `import_name` using pip. |
| 49 | +
|
| 50 | + Returns the pip exit code. Re-raises CalledProcessError on failure. |
| 51 | +
|
| 52 | + - If not in a venv/conda and `allow_system` is False, install with --user. |
| 53 | + - Use `version_spec` (e.g., '==2.1.0' or '>=2.1,<3') to pin. |
| 54 | + - Set `pre=True` to allow pre-releases. |
| 55 | + """ |
| 56 | + dist = _dist_for_import_name(import_name) |
| 57 | + requirement = dist + (version_spec or "") |
| 58 | + |
| 59 | + cmd = [sys.executable, "-m", "pip", "install", "--upgrade", requirement, "--upgrade-strategy", "only-if-needed"] |
| 60 | + if pre: |
| 61 | + cmd.append("--pre") |
| 62 | + if index_url: |
| 63 | + cmd += ["--index-url", index_url] |
| 64 | + if extra_index_url: |
| 65 | + cmd += ["--extra-index-url", extra_index_url] |
| 66 | + |
| 67 | + if not in_virtual_env() and not allow_system: |
| 68 | + # Avoid modifying a global Python; prefer per-user install. |
| 69 | + cmd.append("--user") |
| 70 | + |
| 71 | + # On some locked-down images pip might be missing; ensurepip can help. |
| 72 | + try: |
| 73 | + return subprocess.call(cmd) |
| 74 | + except FileNotFoundError: |
| 75 | + # Try bootstrapping pip, then retry once. |
| 76 | + import ensurepip |
| 77 | + ensurepip.bootstrap() |
| 78 | + return subprocess.call(cmd) |
0 commit comments