Browse Source

ref: add tools.fast_editable as a faster pip install -e (#60939)

this also allows us to upgrade `setuptools` which we're currently
holding back because its new approach to editable installs breaks our
builds

lmao

```console
$ time python3 -m tools.fast_editable
writing .venv/lib/python3.8/site-packages/getsentry.egg-link...
adding . to .venv/lib/python3.8/site-packages/easy-install.pth...
writing .venv/bin/getsentry...
creating getsentry.egg-info...
writing getsentry.egg-info/entry_points.txt...
writing getsentry.egg-info/PKG-INFO...

real	0m0.069s
user	0m0.054s
sys	0m0.012s
```

```console
$ time pip install -e . --no-deps
Obtaining file:///home/asottile/workspace/getsentry
  Installing build dependencies ... done
  Checking if build backend supports build_editable ... done
  Getting requirements to build editable ... done
  Preparing editable metadata (pyproject.toml) ... done
Building wheels for collected packages: getsentry
  Building editable for getsentry (pyproject.toml) ... done
  Created wheel for getsentry: filename=getsentry-0.0.0-0.editable-py3-none-any.whl size=2991 sha256=5434604b54a21d1dd35e6181673dcadf4d947a3262a274e01728a84f6bd9b27b
  Stored in directory: /tmp/pip-ephem-wheel-cache-42b0lics/wheels/f7/44/bb/3ffa6a599ffc47f7670a31ea60ce88bf3896868bf7e369dfc1
Successfully built getsentry
Installing collected packages: getsentry
  Attempting uninstall: getsentry
    Found existing installation: getsentry 0.0.0
    Uninstalling getsentry-0.0.0:
      Successfully uninstalled getsentry-0.0.0
Successfully installed getsentry-0.0.0

real	0m7.627s
user	0m6.645s
sys	0m0.780s
```
anthony sottile 1 year ago
parent
commit
cef27b49ea
4 changed files with 115 additions and 3 deletions
  1. 1 1
      .github/actions/setup-sentry/action.yml
  2. 1 1
      .github/workflows/backend.yml
  3. 1 1
      scripts/lib.sh
  4. 112 0
      tools/fast_editable.py

+ 1 - 1
.github/actions/setup-sentry/action.yml

@@ -130,7 +130,7 @@ runs:
       run: |
         cd "$WORKDIR"
         # We need to install editable otherwise things like check migration will fail.
-        SENTRY_LIGHT_BUILD=1 pip install --no-deps -e .
+        python3 -m tools.fast_editable --path .
 
     - name: Start devservices
       shell: bash --noprofile --norc -eo pipefail -ux {0}

+ 1 - 1
.github/workflows/backend.yml

@@ -284,7 +284,7 @@ jobs:
 
       - name: setup sentry (lite)
         run: |
-          SENTRY_LIGHT_BUILD=1 pip install --no-deps -e .
+          python3 -m tools.fast_editable --path .
           sentry init
 
       - run: mypy

+ 1 - 1
scripts/lib.sh

@@ -114,7 +114,7 @@ install-py-dev() {
     # SENTRY_LIGHT_BUILD=1 disables webpacking during setup.py.
     # Webpacked assets are only necessary for devserver (which does it lazily anyways)
     # and acceptance tests, which webpack automatically if run.
-    SENTRY_LIGHT_BUILD=1 pip-install -e . --no-deps
+    python3 -m tools.fast_editable --path .
 }
 
 setup-git-config() {

+ 112 - 0
tools/fast_editable.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import configparser
+import os.path
+import stat
+import sys
+import sysconfig
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--path", default=".")
+    args = parser.parse_args()
+
+    # simulate `pip install -e .` -- but bypass setuptools
+
+    def r(p: str) -> str:
+        return os.path.relpath(p, ".")
+
+    # must be in a virtualenv
+    assert not sys.flags.no_site, sys.flags.no_site
+    site_dir = sysconfig.get_path("purelib")
+    bin_dir = sysconfig.get_path("scripts")
+    assert "/.venv/" in site_dir, site_dir
+
+    cfg = configparser.RawConfigParser()
+    cfg.read(os.path.join(args.path, "setup.cfg"))
+
+    package_name = cfg["metadata"]["name"]
+
+    package_dir = cfg["options"].get("package_dir", "").strip()
+    if package_dir not in {"", "=src"}:
+        raise SystemExit(f"unsupported package_dir={package_dir!r}")
+
+    project_root = os.path.abspath(args.path)
+    if package_dir == "=src":
+        source_root = os.path.join(project_root, "src")
+        project_root_relative = "../"
+    else:
+        source_root = project_root
+        project_root_relative = "."
+
+    # egg-link indicates that the software is installed
+    egg_link = os.path.join(site_dir, f"{package_name}.egg-link")
+    print(f"writing {r(egg_link)}...")
+    with open(egg_link, "w") as f:
+        f.write(f"{source_root}\n{project_root_relative}")
+
+    # easy-install.pth is how code gets imported
+    easy_install_pth = os.path.join(site_dir, "easy-install.pth")
+    print(f"adding {r(source_root)} to {r(easy_install_pth)}...")
+    try:
+        with open(easy_install_pth) as f:
+            easy_install_paths = f.read().splitlines()
+    except OSError:
+        easy_install_paths = []
+    if source_root not in easy_install_paths:
+        easy_install_paths.append(source_root)
+        with open(easy_install_pth, "w") as f:
+            f.write("\n".join(easy_install_paths) + "\n")
+
+    # 0. create bin scripts for anything in `console_scripts`
+    console_scripts = cfg["options.entry_points"]["console_scripts"].strip()
+    for line in console_scripts.splitlines():
+        entry, rest = line.split(" = ")
+        mod, attr = rest.split(":")
+
+        binary = os.path.join(bin_dir, entry)
+        print(f"writing {r(binary)}...")
+        with open(binary, "w") as f:
+            f.write(
+                f"#!{sys.executable}\n"
+                f"from {mod} import {attr}\n"
+                f'if __name__ == "__main__":\n'
+                f"    raise SystemExit({attr}())\n"
+            )
+        mode = os.stat(binary).st_mode
+        mode |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
+        os.chmod(binary, mode)
+
+    # 0. write out the `sentry.egg-info` directory in `src/`
+    egg_info_dir = os.path.join(source_root, f"{package_name}.egg-info")
+    print(f"creating {r(egg_info_dir)}...")
+    os.makedirs(egg_info_dir, exist_ok=True)
+
+    entry_points_txt = os.path.join(egg_info_dir, "entry_points.txt")
+    print(f"writing {r(entry_points_txt)}...")
+    ep_cfg = configparser.RawConfigParser()
+    for section, eps in cfg["options.entry_points"].items():
+        ep_cfg.add_section(section)
+        for ep in eps.strip().splitlines():
+            k, v = ep.split(" = ")
+            ep_cfg[section][k] = v
+    with open(entry_points_txt, "w") as f:
+        ep_cfg.write(f)
+
+    pkg_info = os.path.join(egg_info_dir, "PKG-INFO")
+    print(f"writing {r(pkg_info)}...")
+    with open(pkg_info, "w") as f:
+        f.write(
+            f"Metadata-Version: 2.1\n"
+            f"Name: {package_name}\n"
+            f"Version: {cfg['metadata']['version']}\n"
+        )
+
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())