Source code for rics.jupyter._venv_helper

import logging
import os
import subprocess
import sys
import tomllib
from collections.abc import Callable
from pathlib import Path
from typing import Any

from rics.logs import get_logger

Resolver = Callable[[Path, logging.Logger], tuple[str, str] | None]


[docs] class VenvHelper: """Helper class for working with virtual environments.""" def __init__( self, logger: logging.Logger | str = __package__ + ".VenvHelper", ) -> None: self.logger = get_logger(logger) manager, exec_prefix, executable = self.resolve() self._manager = manager self.exec_prefix = exec_prefix self.executable = executable self.config_path = Path(self.exec_prefix) / "pyvenv.cfg" self._config = self._read_pyvenv_cfg() @property def manager(self) -> str: """Manager name.""" return self._manager @property def config(self) -> dict[str, Any]: """Parsed `pyvenv.cfg` dict.""" return {**self._config} @property def slug(self) -> str: """Virtual environment slug. Typically, the shell prompt.""" return self._config.get("prompt") or self.config_path.parent.parent.name
[docs] def resolve(self) -> tuple[str, str, str]: """Resolve venv paths. Returns: A tuple ``(name, exec_prefix, executable)``. """ logger = self.logger.getChild("resolve") resolvers: list[Resolver] = [Resolve.poetry, Resolve.uv, Resolve.sys] logger.debug(f"Resolver order ({len(resolvers)}): {' | '.join(r.__name__ for r in resolvers)}.") cwd = Path.cwd() for i, resolver in enumerate(resolvers, start=1): name = resolver.__name__ msg = f"Resolver {i}/{len(resolvers)}: {name}(): %s." logger.debug(msg, "Starting") result = resolver(cwd, logger) if result: logger.debug(msg, f"succeeded with {result=}") return name, *result logger.debug(msg, "Failed") raise RuntimeError("exec resolution failed")
def _read_pyvenv_cfg(self) -> dict[str, str]: self.logger.debug("Reading pyvenv.cfg for manager='%s': '%s'.", self.manager, self.config_path) if not self.config_path.is_file(): msg = f"Not a virtualenv: '{self.exec_prefix}'." raise RuntimeError(msg) lines = self.config_path.read_text().splitlines() rv = {key: value for key, value in map(self._convert_pyenvcfg_line, lines)} self.logger.debug("Found %i keys in pyvenv.cfg: %s.", len(rv), [*rv]) return rv @classmethod def _convert_pyenvcfg_line(cls, line: str) -> tuple[str, str]: key, _, value = line.partition(" = ") return key, value
class Resolve: """Get ``(exec_prefix, executable)``-tuples for various package managers.""" @classmethod def poetry(cls, cwd: Path, logger: logging.Logger) -> tuple[str, str] | None: """Implementation for https://github.com/python-poetry/poetry.""" if not _check_files(cwd, "poetry", logger): return None return _print_paths(logger, "poetry", "run", "python") @classmethod def uv(cls, cwd: Path, logger: logging.Logger) -> tuple[str, str] | None: """Implementation for https://github.com/astral-sh/uv.""" if not _check_files(cwd, "uv", logger): return None paths = _print_paths(logger, "uv", "run") if paths is None: return None logger.debug("Ensure pip in uv venv.") # Not installed unless --seed/UV_VENV_SEED=pip is used. _get_output(logger, "uv", "pip", "install", "--no-upgrade", "--quiet", "pip") return paths @classmethod def sys(cls, cwd: Path, logger: logging.Logger) -> tuple[str, str]: # noqa: ARG003 return sys.exec_prefix, sys.executable def _check_files(cwd: Path, name: str, logger: logging.Logger) -> bool: lock = cwd / f"{name}.lock" if lock.is_file(): logger.debug("Found lockfile: '%s'.", lock) return True pyproject = cwd / "pyproject.toml" if not pyproject.is_file(): return False logger.debug("Found project file: '%s'.", pyproject) with pyproject.open("rb") as f: toml = tomllib.load(f) rv = name in toml if rv: logger.debug("Found key=%r in project file: '%s'.", name, pyproject) return rv def _print_paths(logger: logging.Logger, *args: str) -> tuple[str, str] | None: file = Path(__file__).parent / "_print_paths.py" output = _get_output(logger, *args, str(file)) if output is None: return None exec_prefix = "" executable = "" for line in output.strip().splitlines(): # This is a bit redundant, but helps in cases where the manager writes junk to stdout. if line.startswith("exec_prefix="): exec_prefix = line.removeprefix("exec_prefix=").rstrip() if line.startswith("executable="): executable = line.removeprefix("executable=").rstrip() if not (exec_prefix and executable): raise ValueError(f"Could not find 'exec_prefix' and 'executable' in output:\n{output}") return exec_prefix, executable def _get_output(logger: logging.Logger, *args: str) -> str | None: logger.debug("Executing command: %s", args) env = { **os.environ, "VIRTUAL_ENV": "", # Break out of current virtualenv. } try: process = subprocess.run(args, capture_output=True, check=False, env=env) # noqa: S603 except OSError as e: _log_failed_command(logger, args, e) return None if process.returncode: _log_failed_command(logger, args, process) return None if logger.isEnabledFor(logging.DEBUG): msg = f"Command {args} succeeded:{_format_streams(process)}" logger.debug(msg) return process.stdout.decode().strip() def _log_failed_command( logger: logging.Logger, args: tuple[str, ...], result: subprocess.CompletedProcess[bytes] | Exception, ) -> None: if not logger.isEnabledFor(logging.DEBUG): return msg = f"Command {args} failed" if isinstance(result, Exception): msg += f". {type(result).__name__}: {result}" else: msg += f" (status={result.returncode}): {_format_streams(result)}" logger.debug(msg) def _format_streams(process: subprocess.CompletedProcess[bytes]) -> str: rows = [] divider = "-" * 36 if process.stderr: rows.append(f"\n{divider} stderr {divider}\n{process.stderr.decode().strip()}") if process.stdout: rows.append(f"\n{divider} stdout {divider}\n{process.stdout.decode().strip()}") return "".join(rows) if rows else " <no output>"