"""
---------------------------------------------------------------------
paraview.envs - virtual environments manager for ParaView's Python
---------------------------------------------------------------------
ParaView ships its own bundled Python interpreter, which makes it hard to
pip-install extra packages (e.g. trame, custom apps) without touching the
ParaView install itself. This module uses `uv` to create separate virtual
environments and layers their site-packages on top of pvpython's own
`sys.path`, so scripts can `import paraview` (and its C++ bindings) *and*
whatever was pip-installed in the venv.
Environments are stored under
~/.config/ParaView/uv-venvs/<python-version>/<name>/
Commands
---------------------------------------------------------------------
list: List every environment that has been created or installed:
$ pvpython -m paraview.envs list
create: Create a named venv from a requirements.txt (does not run anything):
$ pvpython -m paraview.envs create trame ./requirements.txt
use: Enter a venv created with `create` and run a script provided after `--`:
$ pvpython -m paraview.envs use trame -- ./example.py [args...]
install: Install a script as a named, reusable application. Its dependencies are
declared as PEP 723 inline metadata (lines starting with `#` at the top of
the file, e.g. `# dependencies = [...]`) and installed via `uv run`.
--name defaults to the file's stem;
--replace overwrites an existing environment of the same name;
$ pvpython -m paraview.envs install ./cone.py [--name cone] [--replace]
run: Run a previously installed application by name. Add `--enable-ssl` if ssl is
required. `--enable-ssl` starts a new Python interpreter directly from the
virtual environment and add ParaView environment on top of it:
$ pvpython -m paraview.envs run cone [--enable-ssl]
remove: Delete an environment (install or create) and its venv:
$ pvpython -m paraview.envs remove cone
Main commands are: list / create / use / install / run / remove
Run `pvpython -m paraview.envs <command> --help` for a command's full options.
"""
import argparse
import platform
import sys
import os
import site
from pathlib import Path
import shutil
import subprocess
import importlib.util
PY_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
CURRENT_OS = platform.system()
IS_WINDOWS = CURRENT_OS == "Windows"
if IS_WINDOWS:
BASE_PATH = Path(os.environ.get("APPDATA")) / "ParaView"
else:
BASE_PATH = Path("~/.config/ParaView").expanduser()
UV_EXEC_CACHE = BASE_PATH / "uv-path"
UV_VENV_PATH = BASE_PATH / "uv-venvs" / PY_VERSION
UV_VENV_PATH.mkdir(parents=True, exist_ok=True)
# Use System uv if available
sys_uv = shutil.which('uv')
if sys_uv:
UV_EXEC_CACHE.write_text(sys_uv)
EXEC_EXTENSION = ".exe" if IS_WINDOWS else ""
EXEC_UV = f"uv{EXEC_EXTENSION}"
EXEC_PYTHON = f"python{EXEC_EXTENSION}"
[docs]
def find_uv_exec():
"""Locate the `uv` executable, caching the result on disk.
Looks up UV_EXEC_CACHE first; if missing or stale, walks up from
sys.executable (pvpython) up to 3 parent directories and searches each
subtree for a `uv` binary, since `uv` is typically bundled alongside or
near the ParaView Python install.
"""
if UV_EXEC_CACHE.exists():
uv_path = Path(UV_EXEC_CACHE.read_text())
if uv_path.exists():
return uv_path
CURRENT_DIRECTORY = Path(sys.executable).parent
for _ in range(3):
for file_path in CURRENT_DIRECTORY.rglob(EXEC_UV):
UV_EXEC_CACHE.write_text(str(file_path.resolve()))
return file_path.resolve()
CURRENT_DIRECTORY = CURRENT_DIRECTORY.parent
UV_EXEC = find_uv_exec()
[docs]
def paraview_env():
"""Build an environment dict that exposes ParaView's Python modules
(PYTHONPATH) to a *separate* Python process, e.g. the standalone
interpreter spawned by `run(..., enable_ssl=True)`. This is only needed
for code paths that run outside of the current pvpython process, since
in-process execution already has ParaView on sys.path.
"""
envs = {**os.environ.copy()}
if CURRENT_OS == "Windows":
PV_BIN = UV_EXEC.parent.resolve()
# envs["PATH"] = str(PV_BIN)
envs["PYTHONPATH"] = str(PV_BIN / "Lib" / "site-packages")
if CURRENT_OS == "Darwin":
PV_HOME = UV_EXEC.parent.parent.resolve()
# envs["DYLD_LIBRARY_PATH"] = str(PV_HOME / "Libraries")
envs["PYTHONPATH"] = str(PV_HOME / "Python")
if CURRENT_OS == "Linux":
PV_HOME = UV_EXEC.parent.parent.resolve()
# envs["LD_LIBRARY_PATH"] = str(PV_HOME / "lib")
envs["PYTHONPATH"] = str(
PV_HOME / "lib" / f"python{PY_VERSION}" / "site-packages"
)
return envs
def _create_venv(venv_path):
"""Create a venv at `venv_path`, printing uv's own progress output."""
result = subprocess.run(
[str(UV_EXEC), "venv", str(venv_path.resolve()), "-p", PY_VERSION],
capture_output=True,
text=True,
)
print(result.stderr)
def _venv_env(venv_path):
"""Environment for running `uv` against `venv_path` without needing to
`source`/activate it through a shell (which uv only offers a hint for
when it recognizes $SHELL, e.g. it is silent in most CI containers)."""
return {**os.environ, "VIRTUAL_ENV": str(venv_path.resolve())}
def _exec_script(script_file, script_args):
"""Graft `script_args` onto sys.argv (as a normal interpreter invocation
would: script path as argv[0], its own args following) and exec
`script_file` in-process, then call its main() if it defines one."""
sys.argv[:] = [str(script_file)] + script_args
spec = importlib.util.spec_from_file_location(script_file.stem, script_file)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
main = getattr(module, "main", None)
if main:
main()
# -----------------------------------------------------------------------------
[docs]
def remove(name):
"""Delete the environment (venv + app/dependency files) named `name`."""
path_to_remove = UV_VENV_PATH / name
if path_to_remove.exists():
shutil.rmtree(path_to_remove)
[docs]
def install(app, name, replace):
"""Install `app` (a .py file) as a persistent, named application.
Copies `app` into a new workspace as app.py, extracts its leading
`#`-comment lines (PEP 723 inline script metadata, e.g. `# dependencies
= [...]`) into dependencies.py, creates a fresh venv, and runs
`uv run` on dependencies.py so uv resolves and installs those
dependencies into the venv. The app can later be launched with
`run <name>`. Fails without side effects if `app` doesn't exist or an
environment named `name` already exists (unless replace=True).
"""
if replace:
remove(name)
workspace = UV_VENV_PATH / name
app = Path(app)
if not app.exists():
print(f"\nCan not install application with invalid path '{app}'.")
return
if workspace.exists():
print(f"\nCan not install application '{name}' as it already exist.")
return
# Create directory and capture app
workspace.mkdir(parents=True, exist_ok=True)
venv_path = workspace / "venv"
dst_app = workspace / "app.py"
dst_dep = workspace / "dependencies.py"
dst_app.write_text(app.read_text())
comments = []
for line in app.read_text().splitlines(True):
if line.startswith("#"):
comments.append(line)
dst_dep.write_text("".join(comments))
# Create venv
_create_venv(venv_path)
print("Install dependencies:")
install_dep = subprocess.run(
[str(UV_EXEC), "run", "--active", str(dst_dep.resolve())],
env=_venv_env(venv_path),
capture_output=True,
text=True,
)
print(install_dep.stderr)
[docs]
def load_venv(venv_base):
"""Graft a venv onto the *current*, already-running pvpython process.
Unlike a normal venv activation, this doesn't spawn a new interpreter:
it prepends the venv's `bin` to PATH and adds its site-packages to
sys.path, so packages pip-installed in the venv become importable
alongside ParaView's own bundled modules in this same process.
site.addsitedir() appends new entries to the end of sys.path, so they
are moved back to the front afterwards to make the venv take priority
over pvpython's built-ins when both provide the same package.
"""
bin_dir = str(venv_base / "bin")
os.environ["PATH"] = os.pathsep.join(
[bin_dir] + os.environ.get("PATH", "").split(os.pathsep)
)
os.environ["VIRTUAL_ENV"] = str(venv_base)
prev_length = len(sys.path)
if sys.platform == "win32":
python_libs = venv_base / "Lib/site-packages"
else:
python_libs = venv_base / f"lib/python{PY_VERSION}/site-packages"
site.addsitedir(python_libs)
sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length]
sys.real_prefix = sys.prefix
sys.prefix = venv_base
[docs]
def run(name, enable_ssl, extra_args):
"""Launch a previously `install`-ed application by name.
By default the app's app.py is loaded and its `main()` executed
in-process, after grafting the app's venv onto pvpython via
load_venv(). With enable_ssl=True, the app is instead launched as a
standalone subprocess using the venv's own `python`, with ParaView's
modules injected via PYTHONPATH (see paraview_env()); this is needed
because some apps (e.g. ones starting their own SSL/HTTPS server) must
own their process rather than share pvpython's. `extra_args` (anything
following `run <name>` on the command line) is passed through to the app.
"""
app_file = UV_VENV_PATH / name / "app.py"
venv_base = (UV_VENV_PATH / name / "venv").resolve()
if not venv_base.exists() or not app_file.exists():
print(f"\nNo application found with name `{name}`.")
return
if enable_ssl:
# Run venv as main Python interpreter
python_exec = venv_base / "bin" / EXEC_PYTHON
subprocess.run([str(python_exec), str(app_file), *extra_args], env=paraview_env())
else:
# Extend the current pvpython process with the venv's packages
load_venv(venv_base)
_exec_script(app_file, extra_args)
[docs]
def list_apps():
"""Print the names of all environments (install or create) on disk."""
print(f"Available environments in ({UV_VENV_PATH})")
for dir in UV_VENV_PATH.iterdir():
if dir.is_dir():
print(f" - {dir.name}")
[docs]
def create(name, requirement):
"""Create a named venv named `name` and `uv pip install -r requirement`.
Unlike install(), this doesn't copy or run any script - it just
prepares an environment meant to be entered later with `use <name>`
to run arbitrary scripts. Fails without side effects if `requirement`
doesn't exist or an environment named `name` already exists.
"""
workspace = UV_VENV_PATH / name
requirement = Path(requirement)
if not requirement.exists():
print(f"Can not install requirements with invalid path {requirement}.")
return
if workspace.exists():
print(f"Can not install environment {name} as it already exist.")
return
# Create directory and capture app
workspace.mkdir(parents=True, exist_ok=True)
venv_path = workspace / "venv"
dst_req = workspace / "requirement.txt"
dst_req.write_text(requirement.read_text())
# Create venv
_create_venv(venv_path)
print("Install dependencies:")
install_dep = subprocess.run(
[str(UV_EXEC), "pip", "install", "-r", str(dst_req.resolve())],
env=_venv_env(venv_path),
capture_output=True,
text=True,
)
print(install_dep.stderr)
[docs]
def use_venv(name, script_args):
"""Graft the venv `name` (built with `create`) onto pvpython, then run
the script in `script_args[0]` in-process, with script_args[1:] left in
sys.argv for the script to read.
"""
venv_base = (UV_VENV_PATH / name / "venv").resolve()
if not venv_base.exists():
print(f"\nNo application found for name: {name}.")
return
if not script_args:
print("\nNo file to execute. Pass a script after `--`.")
return
load_venv(venv_base)
_exec_script(Path(script_args[0]).resolve(), script_args[1:])
[docs]
def build_parser():
"""Build the `pvpython -m paraview.envs` argument parser: one subcommand
per top-level command (list/create/use/install/run/remove), each
documented via argparse's own --help rather than hand-written usage
strings. See the module docstring for the full command reference.
"""
parser = argparse.ArgumentParser(
prog="pvpython -m paraview.envs",
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command")
sub.add_parser(
"list", help="list every environment that has been created or installed")
p_create = sub.add_parser(
"create", help="create a named venv from a requirements.txt (does not run anything)")
p_create.add_argument("name", help="name of the environment to create")
p_create.add_argument("requirement", help="path to a requirements.txt file to use for initializing")
p_use = sub.add_parser(
"use", help="enter a venv created with `create` and run a script provided after `--`")
p_use.add_argument("name", help="name of the environment to run a script in")
p_install = sub.add_parser(
"install", help="install a script as a named, reusable application")
p_install.add_argument(
"app", help="path to a Python file containing PEP 723 inline script metadata")
p_install.add_argument("--name", help="defaults to the file's stem")
p_install.add_argument(
"--replace", action="store_true", help="override any existing install/environment")
p_run = sub.add_parser(
"run", help="run a previously installed application by name")
p_run.add_argument("name", help="name of the environment/application to run")
p_run.add_argument(
"--enable-ssl", action="store_true",
help="start a new Python interpreter directly from the virtual "
"environment and add ParaView's environment on top of it")
p_remove = sub.add_parser(
"remove", help="delete an environment (install or create) and its venv")
p_remove.add_argument("name", help="name of the environment/application to delete")
return parser
[docs]
def main():
"""Parse sys.argv for one of the top-level commands (list, create, use,
install, run, remove) and dispatch to it. Anything left over after a
known command and its own options are parsed (e.g. the script and args
following `use <name> --`, or extra args after `run <name>`) is passed
through untouched, so it never has to survive being re-parsed by
whatever argument parsing the invoked script/app does on its own.
"""
parser = build_parser()
args, extra_args = parser.parse_known_args(sys.argv[1:])
if args.command == "list":
list_apps()
elif args.command == "create":
create(args.name, args.requirement)
elif args.command == "use":
use_venv(args.name, extra_args)
elif args.command == "install":
install(args.app, args.name or Path(args.app).stem, args.replace)
elif args.command == "run":
run(args.name, args.enable_ssl, extra_args)
elif args.command == "remove":
remove(args.name)
else:
parser.print_help()
if __name__ == "__main__":
main()