add ipython magic importer
This commit is contained in:
parent
3b78341c40
commit
4d6e953a34
|
@ -0,0 +1,170 @@
|
||||||
|
# Sets up interactive sessions in ipython to automatically import missing modules using nix Depends
|
||||||
|
# on nix-index/nix-locate being installed and functional, see
|
||||||
|
# <https://github.com/nix-community/nix-index-database> for a prebuilt database.
|
||||||
|
#
|
||||||
|
# How it works:
|
||||||
|
# - During startup, the sysconfig is used to determine where to find site-packages and the probable
|
||||||
|
# nixpkgs variable name for the python scope.
|
||||||
|
# - To find a package, nix-locate is used to try to find a package which exports
|
||||||
|
# {sitePackages}/{name}/__init__.py. If that is found, it attempts to download nixpkgs#{result}.
|
||||||
|
# - If that fails, it instead attempts to build nixpkgs#{pythonScope}.{name}.
|
||||||
|
# - When either of those strategies result in nix store paths, the store paths are recursively
|
||||||
|
# processed (taking propagated-build-inputs into account) and any {sitePackages} found in any
|
||||||
|
# dependency is added to sys.path.
|
||||||
|
# - Finally, the import is tried again with importlib.machinery.PathFinder
|
||||||
|
# - A directory is created under $XDG_RUNTIME_DIR/ipython per process which contains GC roots for
|
||||||
|
# any packages loaded this way. When ipython exits, the per-process directory is deleted. This
|
||||||
|
# ensures that a GC during a running ipython session doesn't nuke any temporarily installed
|
||||||
|
# packages.
|
||||||
|
#
|
||||||
|
# Limitations:
|
||||||
|
# - The only way to be able to look up a package that is not named the same as its top-level module
|
||||||
|
# is using nix-locate. The index is only built for the default python scope, and not any other
|
||||||
|
# versions of python. This limitation could be worked around by scanning for pythonImportsCheck in
|
||||||
|
# nixpkgs but this is not performed currently. Therefore, eg `import Crypto` works on the default
|
||||||
|
# python version for a given NixOS installation, but not any other version. This script also
|
||||||
|
# provids install_with_nix(package), which can be called with eg install_with_nix("pycryptodome"),
|
||||||
|
# after which such imports should work.
|
||||||
|
# - Support for propagated-build-inputs is very rudimentary. For example, a python package which
|
||||||
|
# depends on a binary being present in PATH which is declared in propagatedBuildInputs would not
|
||||||
|
# currently work. However, pure-python dependencies work as expected.
|
||||||
|
# - This will probably explode and should not be used outside of **interactive** sessions, there is
|
||||||
|
# a check to make sure an ipython input is in the call stack when the MetaPathFinder is triggered.
|
||||||
|
|
||||||
|
|
||||||
|
def _nix_importer_init():
|
||||||
|
import atexit
|
||||||
|
import importlib.abc
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import sysconfig
|
||||||
|
import tempfile
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
runtime_dir = pathlib.Path(
|
||||||
|
os.environ.get("XDG_RUNTIME_DIR",
|
||||||
|
str(pathlib.Path("/run/user") / str(os.getuid()))))
|
||||||
|
ipython_dir = runtime_dir / "ipython"
|
||||||
|
if not ipython_dir.is_dir():
|
||||||
|
ipython_dir.mkdir()
|
||||||
|
session_dir = ipython_dir / f"session-{os.getpid()}"
|
||||||
|
if session_dir.exists():
|
||||||
|
shutil.rmtree(session_dir)
|
||||||
|
session_dir.mkdir()
|
||||||
|
atexit.register(lambda: shutil.rmtree(session_dir))
|
||||||
|
|
||||||
|
|
||||||
|
class NixImporter(importlib.abc.MetaPathFinder):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._result_counter = 0
|
||||||
|
self._subfinder = importlib.machinery.PathFinder()
|
||||||
|
|
||||||
|
config_vars = sysconfig.get_config_vars()
|
||||||
|
config_vars["base"] = ""
|
||||||
|
path = sysconfig.get_path("purelib", scheme="posix_prefix", vars=config_vars)
|
||||||
|
# remove leading / if it exists
|
||||||
|
self._base_path = ("/" / pathlib.Path(path)).relative_to("/")
|
||||||
|
self._base_packages = f"python{config_vars['py_version_nodot']}Packages"
|
||||||
|
|
||||||
|
|
||||||
|
def find_spec(self, fullname: str,
|
||||||
|
path: Sequence[str] | None,
|
||||||
|
target: None | ModuleType = None) -> importlib.machinery.ModuleSpec | None:
|
||||||
|
# Deny autoimport to the nix store, and only allow it if the traceback indicates ipython
|
||||||
|
f = inspect.currentframe()
|
||||||
|
while f is not None:
|
||||||
|
if f.f_code.co_filename.startswith("/nix/store"):
|
||||||
|
return None
|
||||||
|
elif f.f_code.co_filename.startswith("<ipython-input"):
|
||||||
|
break
|
||||||
|
f = f.f_back
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
find_path = self._base_path
|
||||||
|
for element in fullname.split("."):
|
||||||
|
find_path = find_path / element
|
||||||
|
find_path = find_path / "__init__.py"
|
||||||
|
|
||||||
|
print(f"Module {fullname} not found, searching NixOS...")
|
||||||
|
command = ["nix-locate", "-w", "--minimal", str(find_path)]
|
||||||
|
|
||||||
|
selected_target: str | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(command).strip().decode()
|
||||||
|
if len(output) > 0:
|
||||||
|
if len(matches := output.split("\n")) > 1:
|
||||||
|
print("Multiple matches found!", matches)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
selected_target = output
|
||||||
|
except Exception:
|
||||||
|
print("Failed to search")
|
||||||
|
|
||||||
|
if selected_target is None and "." not in fullname:
|
||||||
|
selected_target = f"{self._base_packages}.{fullname}"
|
||||||
|
|
||||||
|
if selected_target is None:
|
||||||
|
print("Could not find installation target!")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Installing", selected_target)
|
||||||
|
self._install_with_nix(selected_target)
|
||||||
|
return self._subfinder.find_spec(fullname, path, target)
|
||||||
|
except Exception:
|
||||||
|
print("Failed to install")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_caches(self) -> None:
|
||||||
|
self._subfinder.invalidate_caches()
|
||||||
|
|
||||||
|
def _process_propagated_build_inputs(self, path: pathlib.Path) -> None:
|
||||||
|
python_path = path / self._base_path
|
||||||
|
if python_path.exists() and str(python_path) not in sys.path:
|
||||||
|
print("Appending path", str(python_path))
|
||||||
|
sys.path.append(str(python_path))
|
||||||
|
|
||||||
|
nix_support = path / "nix-support"
|
||||||
|
if not nix_support.is_dir():
|
||||||
|
return
|
||||||
|
propagated_build_inputs = nix_support / "propagated-build-inputs"
|
||||||
|
if not propagated_build_inputs.is_file():
|
||||||
|
return
|
||||||
|
with propagated_build_inputs.open("r") as f:
|
||||||
|
paths = f.read().strip().split(" ")
|
||||||
|
for child_path in paths:
|
||||||
|
self._process_propagated_build_inputs(pathlib.Path(child_path))
|
||||||
|
|
||||||
|
def _install_with_nix(self, path: str) -> None:
|
||||||
|
if not path.startswith("/nix/store"):
|
||||||
|
path = "nixpkgs#" + path
|
||||||
|
|
||||||
|
out_paths = subprocess.check_output(
|
||||||
|
["nix", "build", "--out-link", f"link-{self._result_counter}", "--print-out-paths", path],
|
||||||
|
cwd=session_dir).strip().decode().split("\n")
|
||||||
|
self._result_counter += 1
|
||||||
|
|
||||||
|
for result in out_paths:
|
||||||
|
self._process_propagated_build_inputs(pathlib.Path(result))
|
||||||
|
|
||||||
|
|
||||||
|
importer = NixImporter()
|
||||||
|
sys.meta_path.append(importer)
|
||||||
|
|
||||||
|
return importer
|
||||||
|
|
||||||
|
|
||||||
|
install_with_nix = _nix_importer_init()._install_with_nix
|
||||||
|
del _nix_importer_init
|
Loading…
Reference in New Issue