File size: 6,657 Bytes
b671043 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
#!/usr/bin/env python3
"""
run_script_venv.py
==================
A utility that
1. Accepts a path to a Python script.
2. Creates an isolated working directory.
3. Creates a virtual environment inside that directory using **uv**.
4. Statically analyses the target script to discover third‑party dependencies.
5. Installs the detected dependencies into the environment.
6. Executes the target script inside the environment.
Usage
-----
$ python run_script_venv.py path/to/script.py [--workdir ./my_dir] [--keep-workdir]
Requirements
------------
* Python ≥ 3.10 (for ``sys.stdlib_module_names``)
* ``uv`` must be available on the host Python used to run this helper.
Notes
-----
* The dependency detection is based on import statements only. Runtime/optional
dependencies that are conditionally imported or loaded by plugins are not
detected; you can pass extra packages manually via ``--extra``.
* A small mapping translates common import‑name↔package‑name mismatches
(e.g. ``cv2`` → ``opencv‑python``).
"""
from __future__ import annotations
import argparse
import ast
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Iterable, Set
# --------------------------- Helpers -----------------------------------------
# Map import names to PyPI package names where they differ.
NAME_MAP: dict[str, str] = {
"cv2": "opencv-python",
"sklearn": "scikit-learn",
"PIL": "pillow",
"yaml": "pyyaml",
"Crypto": "pycryptodome",
}
def find_imports(script_path: Path) -> Set[str]:
"""Return a set of *import names* found in *script_path*."""
root = ast.parse(script_path.read_text())
imports: set[str] = set()
for node in ast.walk(root):
if isinstance(node, ast.Import):
for alias in node.names:
imports.add(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
if node.level == 0 and node.module: # skip relative imports
imports.add(node.module.split(".")[0])
return imports
def third_party_modules(modules: Iterable[str]) -> Set[str]:
"""Filter *modules* to those not in the Python stdlib."""
stdlib = set(sys.stdlib_module_names) # Python ≥ 3.10
return {m for m in modules if m not in stdlib}
def translate_names(modules: Iterable[str]) -> Set[str]:
"""Translate *modules* to their PyPI names using NAME_MAP."""
pkgs: set[str] = set()
for m in modules:
pkgs.add(NAME_MAP.get(m, m))
return pkgs
def create_venv(venv_dir: Path) -> Path:
"""Create a venv at *venv_dir* using ``uv venv`` and return the Python exe."""
subprocess.check_call(["uv", "venv", str(venv_dir)])
python_exe = venv_dir / ("Scripts" if os.name == "nt" else "bin") / "python"
return python_exe
def install_packages(python_exe: Path, packages: Iterable[str]) -> None:
if not packages:
print("[+] No third‑party packages detected; skipping installation.")
return
print(f"[+] Installing: {' '.join(packages)}")
# subprocess.check_call([str(python_exe), "-m", "pip", "install", *packages])
subprocess.check_call(["uv", "pip", "install", "-p", str(python_exe), *packages])
# --------------------------- Main routine ------------------------------------
def main() -> None:
p = argparse.ArgumentParser(prog="run_script_venv")
p.add_argument("script", type=Path, help="Python script to execute")
p.add_argument(
"--workdir",
type=Path,
help="Directory to create the environment in (defaults to a temp dir)",
)
p.add_argument(
"--extra",
nargs="*",
default=[],
metavar="PKG",
help="Extra PyPI packages to install in addition to auto‑detected ones",
)
p.add_argument(
"--keep-workdir",
action="store_true",
help="Do not delete the temporary working directory on success",
)
args = p.parse_args()
script_path: Path = args.script.resolve()
if not script_path.is_file():
p.error(f"Script '{script_path}' not found.")
# ---------------------------------------------------------------------
# Prepare working directory and venv
# ---------------------------------------------------------------------
workdir: Path
temp_created = False
if args.workdir:
workdir = args.workdir.resolve()
workdir.mkdir(parents=True, exist_ok=True)
else:
workdir = Path(tempfile.mkdtemp(prefix=f"{script_path.stem}_work_"))
temp_created = True
venv_dir = workdir / ".venv"
print(f"[+] Working directory: {workdir}")
# ---------------------------------------------------------------------
# Detect and install dependencies
# ---------------------------------------------------------------------
mods = find_imports(script_path)
third_party = translate_names(third_party_modules(mods)) | set(args.extra)
print(f"[+] Third‑party imports: {sorted(third_party) if third_party else 'None'}")
py = create_venv(venv_dir)
install_packages(py, third_party)
# ---------------------------------------------------------------------
# Run the target script inside the environment
# ---------------------------------------------------------------------
env = os.environ.copy()
env["VIRTUAL_ENV"] = str(venv_dir)
env["PATH"] = str(py.parent) + os.pathsep + env["PATH"]
print("[+] Running script …\n----------------------------------------")
proc = subprocess.Popen(
[str(py), str(script_path)],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
# Stream the script output line by line
assert proc.stdout is not None
for line in proc.stdout:
print(line, end="")
retcode = proc.wait()
print("----------------------------------------")
if retcode == 0:
print("[+] Script finished successfully.")
else:
print(f"[!] Script exited with return code {retcode}.")
sys.exit(retcode)
# ---------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------
if temp_created and not args.keep_workdir:
shutil.rmtree(workdir)
print(f"[+] Removed temporary directory {workdir}")
if __name__ == "__main__":
try:
main()
except subprocess.CalledProcessError as e:
print("[!] A subprocess exited with a non‑zero status:", e, file=sys.stderr)
sys.exit(e.returncode)
|