Cpython interoperability via uv
Hi there!
There were many discussion on the forum on how to leverage the cpython ecosystem in pyRevit.
pyRevit CPython support is still lacking (also because of me giving up on that), so the best solution nowadays is to use subprocess to spawn an external python distribution to run the script.
While this solution works, it stills has a drawback: how to deploy the script to others? one should instruct people to:
- install a python distribution
- (preferably) create an environment
- install the dependencies in the environment
- change the path of the python executable in the script that calls cpython
Luckily for us, the wonderful uv project can greatly simplify all of this! It can:
- manage python installation
- manage environments
- manage dependencies
- run python scripts in the environment
But the most important feature that will simplify the life for us is the ability to declare the dependencies of a script directly inside it using inline metadata, and to create an ephemeral environment with that dependencies when using uv run
So we only need to:
- ensure that uv is installed, and if not, install it
- use
subprocesspassing the command["uv", "run", script_path, arguments...]
Meet uv_interop.py:
# -*- coding: utf-8 -*-
import os
import subprocess
from pyrevit.script import get_logger
logger = get_logger()
EXPECTED_UV_INSTALL_DIR = os.path.expandvars(r"%USERPROFILE%\.local\bin")
EXPECTED_UV_PATH = os.path.join(EXPECTED_UV_INSTALL_DIR, "uv.exe")
def run_uv_script(script_path, *args, **kwargs):
"""
Runs a python script using uv.
Args:
script_path (str): path of the python script to run.
args (str): arguments to pass to the script.
check_uv (bool): whether to ensure that uv is installed before running the script.
defaults to False.
Returns:
str: contents of the standard output
"""
if kwargs.pop("check_uv", False):
ensure_uv()
cmd = ["uv", "run", script_path] + list(args)
try:
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
return _decode_output(output)
except subprocess.CalledProcessError as ex:
logger.error(_decode_output(ex.output).strip())
except OSError:
logger.exception("Something wrong happened")
def ensure_uv():
"""
Installs `uv` if it is not already available on the system.
Notes:
Requires internet access.
"""
if os.path.isfile(EXPECTED_UV_PATH):
logger.info("`uv` is already installed.")
return
logger.info("`uv` not found. Installing via official PowerShell installer...")
try:
output = _install_uv()
except subprocess.CalledProcessError as ex:
logger.error("Installation command failed: %s", _decode_output(ex.output))
return
except OSError:
logger.exception("PowerShell executable was not found.")
return
logger.debug(_decode_output(output))
if not os.path.isfile(EXPECTED_UV_PATH):
logger.error(
"`uv` installer completed, but uv.exe not found at the expected location."
)
_refresh_paths()
logger.info("`uv` installed successfully.")
def _refresh_paths():
current_path = os.environ.get("PATH", "")
current_parts = {p.strip().lower() for p in current_path.split(";") if p.strip()}
if EXPECTED_UV_INSTALL_DIR not in current_parts:
if current_path and not current_path.endswith(";"):
current_path += ";"
current_path += EXPECTED_UV_INSTALL_DIR
os.environ["PATH"] = current_path
def _decode_output(value):
if value is None:
return ""
try:
return value.decode("utf-8")
except UnicodeDecodeError:
try:
return value.decode("mbcs")
except UnicodeDecodeError:
return repr(value)
except AttributeError:
return str(value)
def _install_uv():
ps_cmd = (
"[Net.ServicePointManager]::SecurityProtocol = "
"[Net.SecurityProtocolType]::Tls12; "
"& ([ScriptBlock]::Create((Invoke-WebRequest -UseBasicParsing https://astral.sh/uv/install.ps1).Content))"
)
return subprocess.check_output(
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd],
stderr=subprocess.STDOUT,
)
NOTE: I’ve kept the
ensure_uvfunction simple and only check for the typical regular user (non admin) installation path with the official installer script.
Now you can write a cpython script and add the dependencies using
uv add --script <script path> <dependency>
and run it by calling run_uv_script.
This is a silly example, but shows how to handle arguments exchange:
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "cowsay>=6.1",
# ]
# ///
import argparse
import cowsay
def build_parser():
parser = argparse.ArgumentParser()
parser.add_argument("sentence", help="What to say")
parser.add_argument("--animal", default="cow", help="Who says that")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
print(cowsay.get_output_string(args.animal, args.sentence))
if __name__ == "__main__":
main()
and the script for the pushbutton:
import os
from pyrevit.script import get_output
from uv_interop import run_uv_script
script_path = os.path.join(os.path.dirname(__file__), "cpython_example.py")
result = run_uv_script(script_path, "Howdy", "--animal", "turtle")
get_output().print_code(result)
Here’s the result
(it seems that the pyrevit output messes with leading whitespaces, but you get the idea)
As stated in the docstring, if you add check_uv=true to run_uv_script, it will check for uv presence and install it for you, but it may be better to create a separate button to set it up once and for all.
Also, while uv is really fast to resolve and install dependencies, it will need some time to create a new ephemeral environment each time the script is run.
For this silly example, my laptop takes almost 7 seconds from the button click to the turtle shown in the output window, so my suggestion is to limit your dependencies and use cpython for heavy calculations!
