run_my_script / run_script_venv.py
sylvain471's picture
Upload 3 files
b671043 verified
#!/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)