Cpython interoperability via uv

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 subprocess passing 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_uv function 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 :wink: (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!

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.

Or else, you can store uv path in pyRevit_config.ini file. Generate file path once in initial launch of script, store the path in .ini file, then access the path from .ini file.

In my extension, I have stored the pyRevit’s CPython path in pyRevit_config.ini file. So, there is less overhead required for getting the CPython path.

Here is my implementation of code.

Well, the check is just a os.path.exists of a known path, no need to store it in the configuration ini.
In fact, I started with the check enabled by default, but it just felt wrong to check it every time :sweat_smile:

But it may be that I was annoyed by the “uv is already installed” message, so we can just change the first lines of run_uv_script to

if kwargs.pop("check_uv", True):
    ensure_uv(quiet=True)

and suppress the log message like this

def ensure_uv(quiet=False):
# ...
    if os.path.isfile(EXPECTED_UV_PATH):
        if not quiet:
            logger.info("`uv` is already installed.")
        return

That being said, the config thing would be useful if you’re searching for uv in various folders (those stored in $PATH, for example) to perform the search once and remembering the found path.

wait, this class always calculaters the cpython path (_get_cpy_location_from_ipy is called in __init__), and reading the locations always writes them first in the config file,so there’s no real gain on using the config in the first place…

Thanks for the feedback. I’ll improve upon the code :sweat_smile:.