├── img.png ├── Makefile ├── tests ├── some_script.py ├── rich-fail.py ├── conftest.py ├── rich-script.py ├── test_basics.py └── test_examples.py ├── examples ├── pca-demo.py ├── kmeans.py └── linear-demo.py ├── demo.py ├── pyproject.toml ├── .github └── workflows │ └── tests.yaml ├── LICENSE ├── README.md ├── .gitignore ├── uvtrick └── __init__.py └── uv.lock /img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koaning/uvtrick/HEAD/img.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs 2 | 3 | install: 4 | uv pip install -e ".[dev]" 5 | 6 | pypi: 7 | uv build 8 | uv publish --token -------------------------------------------------------------------------------- /tests/some_script.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # ] 4 | # /// 5 | 6 | 7 | def add(a: int, b: int): 8 | return a + b 9 | -------------------------------------------------------------------------------- /tests/rich-fail.py: -------------------------------------------------------------------------------- 1 | import rich 2 | 3 | 4 | def hello(): 5 | rich.print("Hello, World!") 6 | return 1 7 | 8 | 9 | def add(a, b): 10 | return a + b 11 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | # Put root dir on the path so `pytest.importorskip` can run them 5 | root_dir = Path(__file__).parents[1] 6 | sys.path.append(str(root_dir)) 7 | -------------------------------------------------------------------------------- /tests/rich-script.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "rich", 4 | # ] 5 | # /// 6 | import rich 7 | 8 | 9 | def hello(): 10 | rich.print("Hello, World!") 11 | return 1 12 | 13 | 14 | def add(a, b): 15 | return a + b 16 | -------------------------------------------------------------------------------- /examples/pca-demo.py: -------------------------------------------------------------------------------- 1 | from uvtrick import Env 2 | from sklearn.datasets import make_regression 3 | 4 | X, y = make_regression(n_samples=10_000, n_features=10, random_state=42) 5 | 6 | def bench(X, y): 7 | from time import time 8 | from sklearn.decomposition import PCA 9 | 10 | tic = time() 11 | pca = PCA(n_components=2).fit(X) 12 | toc = time() 13 | return toc - tic 14 | 15 | for version in ["1.4", "1.5"]: 16 | for i in range(4): 17 | timed = Env(f"scikit-learn=={version}").run(bench, X, y) 18 | print(version, timed) 19 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | from uvtrick import Env, load 2 | 3 | hello = load("tests/rich-script.py", "hello") 4 | 5 | print(hello()) 6 | 7 | add = load("tests/rich-script.py", "add") 8 | 9 | print(add(1, 2)) 10 | print(add(a=1, b=2)) 11 | 12 | def uses_rich(a, b): 13 | from rich import print 14 | from importlib import metadata 15 | 16 | version = metadata.version("rich") 17 | print(f"hello from rich=={version}") 18 | return a + b 19 | 20 | # This runs the function `uses_rich` in a new environment with the `rich` package installed. 21 | # Just like the `load` function, the result is returned via pickle. 22 | for version in (10, 11, 12, 13): 23 | Env(f"rich=={version}", python="3.12").run(uses_rich, a=1, b=2) 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = [ 3 | {name = "Vincent D Warmerdam"} 4 | ] 5 | classifiers = [ 6 | "Intended Audience :: Developers", 7 | "License :: OSI Approved :: MIT License" 8 | ] 9 | dependencies = [ 10 | "cloudpickle>=2.0.0", 11 | "uv", 12 | ] 13 | description = "A fun party trick to run Python code from another venv into this one." 14 | license = {text = "MIT"} 15 | name = "uvtrick" 16 | version = "0.4.2" 17 | readme = "README.md" 18 | requires-python = ">=3.8" 19 | 20 | [project.urls] 21 | Homepage = "https://github.com/koaning/uvtrick" 22 | Repository = "https://github.com/koaning/uvtrick.git" 23 | 24 | [tool.pdm.dev-dependencies] 25 | test = [ 26 | "pytest>=8.3.2" 27 | ] 28 | 29 | [tool.uv] 30 | dev-dependencies = [ 31 | "pytest>=8.3.2" 32 | ] 33 | 34 | [build-system] 35 | requires = ["hatchling"] 36 | build-backend = "hatchling.build" 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Code Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | cache: 'pip' 25 | - name: Install Base Dependencies 26 | run: pip install -e . scikit-learn altair polars "numpy<2" 27 | - name: Demo 28 | run: python demo.py 29 | - name: Install Pytest 30 | run: python -m pip install pytest 31 | - name: Unittest 32 | run: pytest 33 | - name: Example runs 34 | run: | 35 | python examples/kmeans.py 36 | python examples/linear-demo.py 37 | python examples/pca-demo.py 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 vincent d warmerdam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/kmeans.py: -------------------------------------------------------------------------------- 1 | import random 2 | from uvtrick import Env 3 | import pickle 4 | import cloudpickle 5 | 6 | cloudpickle.PICKLE_PROTOCOL = pickle.DEFAULT_PROTOCOL 7 | 8 | def bench(): 9 | from sklearn.datasets import make_regression 10 | from time import time 11 | from sklearn.cluster import KMeans 12 | 13 | X, y = make_regression(n_samples=10_000, n_features=10, random_state=42) 14 | tic = time() 15 | pca = KMeans().fit(X, y) 16 | toc = time() 17 | return toc - tic 18 | 19 | def combiner(pairs, times=4): 20 | combos = [] 21 | for pkg, py in pairs: 22 | for i in range(times): 23 | combos.append([pkg, py, i]) 24 | random.shuffle(combos) 25 | return combos 26 | 27 | combos = combiner( 28 | pairs=[("1.4", "3.9"), ("1.5", "3.9")], 29 | times=10 30 | ) 31 | 32 | results = [] 33 | for version, py, i in combos: 34 | timed = Env(f"scikit-learn=={version}", python=py).run(bench) 35 | results.append({ 36 | "i": i, 37 | "version": version, 38 | "python": py, 39 | "time": timed 40 | }) 41 | print(version, timed) 42 | 43 | import polars as pl 44 | 45 | pl.DataFrame(results).sort("version").write_csv("results.csv") -------------------------------------------------------------------------------- /examples/linear-demo.py: -------------------------------------------------------------------------------- 1 | import random 2 | from uvtrick import Env 3 | 4 | 5 | def bench(): 6 | from sklearn.datasets import make_regression 7 | from time import time 8 | from sklearn.linear_model import Ridge 9 | 10 | X, y = make_regression(n_samples=100_000, n_features=20, random_state=42) 11 | tic = time() 12 | pca = Ridge().fit(X, y) 13 | toc = time() 14 | return toc - tic 15 | 16 | # for version in ["0.22", "0.23", "0.24"]: 17 | # for i in range(4): 18 | # timed = Env(f"scikit-learn=={version}", python="3.7").run(bench) 19 | # print(version, timed) 20 | 21 | # for version in ["1.4", "1.5"]: 22 | # for i in range(4): 23 | # timed = Env(f"scikit-learn=={version}", python="3.9").run(bench) 24 | # print(version, timed) 25 | 26 | def combiner(pairs, times=4): 27 | combos = [] 28 | for pkg, py in pairs: 29 | for i in range(times): 30 | combos.append([pkg, py, i]) 31 | random.shuffle(combos) 32 | return combos 33 | 34 | combos = combiner( 35 | pairs=[("1.4", "3.9"), ("1.5", "3.9")], 36 | times=10 37 | ) 38 | 39 | results = [] 40 | for version, py, i in combos: 41 | timed = Env(f"scikit-learn=={version}", python=py).run(bench) 42 | results.append({ 43 | "i": i, 44 | "version": version, 45 | "python": py, 46 | "time": timed 47 | }) 48 | print(version, timed) 49 | 50 | import polars as pl 51 | 52 | pl.DataFrame(results).sort("version").write_csv("results.csv") -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | """When I hacks this bad, I write tests.""" 2 | 3 | import pytest 4 | 5 | from uvtrick import Env, load 6 | 7 | hello = load("tests/rich-script.py", "hello") 8 | add = load("tests/rich-script.py", "add") 9 | 10 | 11 | def test_smoke(): 12 | assert hello() == 1 13 | 14 | 15 | def test_args(): 16 | assert add(1, 2) == 3 17 | assert add(a=1, b=4) == 5 18 | 19 | 20 | def test_no_exist(): 21 | with pytest.raises(ValueError): 22 | func = load("tests/rich-script.py", "no_exist") 23 | func() 24 | 25 | 26 | def test_no_metadata(): 27 | with pytest.raises(ValueError): 28 | func = load("tests/rich-fail.py", "add") 29 | func() 30 | 31 | 32 | def test_env_works1(): 33 | def uses_rich(a, b): 34 | from rich import print 35 | 36 | print("hello") 37 | return a + b 38 | 39 | for version in ["13", "12"]: 40 | assert Env(f"rich=={version}").run(uses_rich, a=1, b=2) == 3 41 | 42 | 43 | def test_env_works2(): 44 | def handles_all_types(arr, dictionary, string): 45 | return {"arr": arr, "dictionary": dictionary, "string": string} 46 | 47 | for version in ["13", "12"]: 48 | out = Env(f"rich=={version}").run( 49 | handles_all_types, 50 | arr=[1, 2, 3], 51 | dictionary={"a": 1, "b": 2}, 52 | string="hello", 53 | ) 54 | assert out == { 55 | "arr": [1, 2, 3], 56 | "dictionary": {"a": 1, "b": 2}, 57 | "string": "hello", 58 | } 59 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | test_assets_dir = Path(__file__).parent 4 | 5 | def test_demo(): 6 | import demo 7 | 8 | def test_add(): 9 | from uvtrick import load 10 | 11 | script = test_assets_dir / "rich-script.py" 12 | add = load(script, "add") 13 | 14 | print(f"{add(1, 2)=}") 15 | print(f"{add(a=1, b=2)=}") 16 | 17 | 18 | def test_hello(): 19 | from uvtrick import load 20 | 21 | script = test_assets_dir / "rich-script.py" 22 | hello = load(script, "hello") 23 | print(f"{hello()=}") 24 | 25 | 26 | def test_rich_versions(): 27 | from uvtrick import Env 28 | 29 | rich_versions = (10, 11, 12, 13) 30 | print(f"Now iterating through {rich_versions=}:") 31 | 32 | 33 | def uses_rich(a, b): 34 | from importlib import metadata 35 | 36 | from rich import print 37 | 38 | version = metadata.version("rich") 39 | 40 | print(f"hello from rich=={version}") 41 | return a + b 42 | 43 | 44 | # This runs the function `uses_rich` in a new environment with the `rich` package installed. 45 | # Just like the `load` function, the result is returned via pickle. 46 | for version in rich_versions: 47 | result = Env(f"rich=={version}", python="3.12").run(uses_rich, a=1, b=2) 48 | print(f" --> add(1, 2) = {result}") 49 | 50 | 51 | def test_simple(): 52 | from uvtrick import load 53 | 54 | # Load the function `add` from the file `some_script.py` 55 | # It runs in another virtualenv, but you get back the response via pickle. 56 | # Be aware of the limitations, please only consider base Python objects. 57 | script = test_assets_dir / "some_script.py" 58 | add = load(script, "add") 59 | 60 | # This result is from the `some_script.py` file, running in another virtualenv 61 | # with `uv`. A pickle in a temporary file is used to communicate the result. 62 | result = add(1, 2) # 3 63 | 64 | assert result == 3 65 | print(result) 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### uvtrick 4 | 5 | > A fun party trick, via `uv` and pickle, to run Python code from another venv ... into this one. 6 | 7 | ## Quickstart 8 | 9 | You can install this tool via: 10 | 11 | ``` 12 | uv pip install uvtrick 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### External scripts 18 | 19 | There are a few ways to use this library. The first one is to use the `load` function to point 20 | to a Python script that contains the function you want to use. This function assumes that the 21 | script carries [inline script metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/). 22 | 23 | ```python 24 | from uvtrick import load 25 | 26 | # Load the function `add` from the file `some_script.py` 27 | # It runs in another virtualenv, but you get back the response via pickle. 28 | # Be aware of the limitations, please only consider base Python objects. 29 | add = load("some_script.py", "add") 30 | 31 | # This result is from the `some_script.py` file, running in another virtualenv 32 | # with `uv`. A pickle in a temporary file is used to communicate the result. 33 | add(1, 2) # 3 34 | ``` 35 | 36 | ### From within Python 37 | 38 | But you can also take it a step further and use the `Env` class to run a function in a specific environment. 39 | 40 | ```python 41 | from uvtrick import Env 42 | 43 | # For illustration purposes, let's assume that rich is not part of the current environment. 44 | # Also note that all the imports happen inside of this function. 45 | def uses_rich(): 46 | from rich import print 47 | from importlib import metadata 48 | 49 | version = metadata.version("rich") 50 | print(f"hello from rich=={version}") 51 | 52 | # This runs the function `uses_rich` in a new environment with the `rich` package installed. 53 | # Just like the `load` function before, the result is returned via pickle. 54 | Env("rich", python="3.12").run(uses_rich) 55 | ``` 56 | 57 | This approach is pretty useful if you are interested in running the same function in different versions of 58 | a dependency to spot a performance regression. You might be able to do that via something like: 59 | 60 | ```python 61 | from uvtrick import Env 62 | 63 | def uses_rich(a, b): 64 | from rich import print 65 | from importlib import metadata 66 | 67 | version = metadata.version("rich") 68 | print(f"hello from rich=={version}") 69 | return a + b 70 | 71 | for version in (10, 11, 12, 13): 72 | Env(f"rich=={version}", python="3.12").run(uses_rich, a=1, b=2) 73 | ``` 74 | 75 | Be aware that a lot of pickling is happening under the hood here. This can be a problem if you are trying to pickle large objects 76 | or if your function is returning an object that needs a dependency that is not installed in the environment that is calling `Env`. 77 | 78 | Also note that thusfar this entire project is merely the result of a very entertaining recreational programming session. 79 | We might want to gather some community feedback before suggesting production usage. 80 | 81 | To quote [Simon Willison](https://simonwillison.net/search/?q=uv+vincent): 82 | 83 | > "This "fun party trick" by Vincent D. Warmerdam is absolutely brilliant and a little horrifying." 84 | 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | *.ipynb 163 | *.skops -------------------------------------------------------------------------------- /uvtrick/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import cloudpickle 5 | import subprocess 6 | import tempfile 7 | import textwrap 8 | from collections.abc import Callable 9 | from pathlib import Path 10 | 11 | 12 | def argskwargs_to_callstring(func, *args, **kwargs) -> str: 13 | string_kwargs = ", ".join([f"{k}={v}" for k, v in kwargs.items()]) 14 | string_args = ", ".join([f"{a}" for a in args]) + ", " if args else "" 15 | return f"{func.__name__}({string_args} {string_kwargs})" 16 | 17 | 18 | def uvtrick_(path: str | Path, func: Callable, *args, **kwargs): 19 | """This is a *very* hacky way to run functions from Python files from another virtual environment.""" 20 | string_kwargs = ", ".join([f"{k}={v}" for k, v in kwargs.items()]) 21 | string_args = ", ".join([f"{a}" for a in args]) + ", " if args else "" 22 | 23 | 24 | with tempfile.TemporaryDirectory() as temp_dir: 25 | temp_dir = Path(temp_dir) 26 | script = temp_dir / "pytemp.py" 27 | output = temp_dir / "tmp.pickle" 28 | 29 | code = Path(path).read_text() 30 | idx = code.find("if __name__") 31 | code = code[:idx] + "\n\n" 32 | 33 | if func + "(" not in code: 34 | raise ValueError(f"Function {func} not found in the file {path}") 35 | if "# /// script" not in code: 36 | raise ValueError("Script metadata/dependencies not found in the file") 37 | 38 | code += f"""if __name__ == "__main__": 39 | import cloudpickle 40 | with open('tmp.pickle', 'wb') as f: 41 | cloudpickle.dump({func}({string_args} {string_kwargs}), f)\n""" 42 | 43 | script.write_text(code) 44 | # print(code) 45 | cmd = ["uv", "run", "--with=cloudpickle", "--quiet", str(script)] 46 | subprocess.run(cmd, cwd=temp_dir, check=True) 47 | 48 | return cloudpickle.loads(output.read_bytes()) 49 | 50 | 51 | def load(path: str | Path, func: Callable) -> Callable: 52 | """ 53 | Load a function from a Python file, this function will be executed in a separate virtual environment using uv. 54 | 55 | Note that this approach is more of a demo, it is very hacky and it assumes that the Python script in question 56 | uses inline script metadata. More information on this feature can be found here: 57 | 58 | - https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies 59 | - https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata 60 | 61 | Usage: 62 | 63 | ```python 64 | from uvtrick import load 65 | 66 | # Load the function `hello` from the file `some_script.py` 67 | # It runs in another virtualenv, but you get back the response via cloudpickle. 68 | # Be aware of the limitations, please only consider base Python objects. 69 | hello = load("some_script.py", "hello") 70 | ``` 71 | """ 72 | def load_func(*args, **kwargs): 73 | return uvtrick_(path, func, *args, **kwargs) 74 | return load_func 75 | 76 | 77 | class Env: 78 | """Represents a virtual environment with a specific Python version and set of dependencies.""" 79 | def __init__(self, *requirements: str, python: str = None, debug: bool = False): 80 | self.requirements = requirements 81 | self.python = python 82 | self.debug = debug 83 | self.temp_dir: Path = None 84 | 85 | @property 86 | def inputs(self) -> Path: 87 | return self.temp_dir / "pickled_inputs.pickle" 88 | 89 | @property 90 | def script(self) -> Path: 91 | return self.temp_dir / "pytemp.py" 92 | 93 | @property 94 | def output(self) -> Path: 95 | return self.temp_dir / "tmp.pickle" 96 | 97 | @property 98 | def cmd(self) -> list[str]: 99 | quiet = [] if self.debug else ["--quiet"] 100 | deps = [f"--with={dep}" for dep in self.requirements] 101 | pyversion = [f"--python={self.python}"] if self.python else [] 102 | return ["uv", "run", "--with=cloudpickle", *quiet, *deps, *pyversion, str(self.script)] 103 | 104 | def report(self, contents: str) -> None: 105 | """Log the temporary dir, input kw/args and intermediate script to STDOUT.""" 106 | print(f"Running files in {self.temp_dir}\n{self.cmd}") 107 | args, kwargs = cloudpickle.loads(self.inputs) 108 | print(f"Pickled args: {args}") 109 | print(f"Pickled kwargs: {kwargs}") 110 | print(f"Contents of the script:\n\n{contents}") 111 | return 112 | 113 | def maincall(self, func: Callable) -> str: 114 | """A main block to deserialise a function signature then serialise a result. 115 | 116 | Load the args/kwargs from an 'inputs' cloudpickle, call a Python function 117 | with them, and store the result in an 'output' cloudpickle. 118 | """ 119 | func_name = func.__name__ 120 | inputs_path, output_path = self.inputs, self.output 121 | return textwrap.dedent(f""" 122 | if __name__ == "__main__": 123 | import cloudpickle 124 | from pathlib import Path 125 | 126 | args, kwargs = cloudpickle.loads(Path('{inputs_path!s}').read_bytes()) 127 | result = {func_name}(*args, **kwargs) 128 | Path('{output_path!s}').write_bytes(cloudpickle.dumps(result)) 129 | """) 130 | 131 | def run(self, func: Callable, *args, **kwargs): 132 | """Run a function in the virtual environment using uv.""" 133 | with tempfile.TemporaryDirectory() as temp_dir: 134 | self.temp_dir = Path(temp_dir) 135 | # First pickle the inputs 136 | self.inputs.write_bytes(cloudpickle.dumps((args, kwargs))) 137 | # Now write the contents of the script 138 | func_source = textwrap.dedent(inspect.getsource(func)) 139 | contents = func_source + "\n\n" + self.maincall(func) 140 | self.script.write_text(contents) 141 | 142 | if self.debug: 143 | self.report(contents) 144 | subprocess.run(self.cmd, cwd=temp_dir, check=True) 145 | # Lastly load the stored result of running the script 146 | return cloudpickle.loads(self.output.read_bytes()) 147 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.8" 3 | 4 | [[package]] 5 | name = "cloudpickle" 6 | version = "3.1.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/97/c7/f746cadd08c4c08129215cf1b984b632f9e579fc781301e63da9e85c76c1/cloudpickle-3.1.0.tar.gz", hash = "sha256:81a929b6e3c7335c863c771d673d105f02efdb89dfaba0c90495d1c64796601b", size = 66155 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/48/41/e1d85ca3cab0b674e277c8c4f678cf66a91cd2cecf93df94353a606fe0db/cloudpickle-3.1.0-py3-none-any.whl", hash = "sha256:fe11acda67f61aaaec473e3afe030feb131d78a43461b718185363384f1ba12e", size = 22021 }, 11 | ] 12 | 13 | [[package]] 14 | name = "colorama" 15 | version = "0.4.6" 16 | source = { registry = "https://pypi.org/simple" } 17 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 18 | wheels = [ 19 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 20 | ] 21 | 22 | [[package]] 23 | name = "exceptiongroup" 24 | version = "1.2.2" 25 | source = { registry = "https://pypi.org/simple" } 26 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 27 | wheels = [ 28 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 29 | ] 30 | 31 | [[package]] 32 | name = "iniconfig" 33 | version = "2.0.0" 34 | source = { registry = "https://pypi.org/simple" } 35 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 36 | wheels = [ 37 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 38 | ] 39 | 40 | [[package]] 41 | name = "packaging" 42 | version = "24.2" 43 | source = { registry = "https://pypi.org/simple" } 44 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 45 | wheels = [ 46 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 47 | ] 48 | 49 | [[package]] 50 | name = "pluggy" 51 | version = "1.5.0" 52 | source = { registry = "https://pypi.org/simple" } 53 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 54 | wheels = [ 55 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 56 | ] 57 | 58 | [[package]] 59 | name = "pytest" 60 | version = "8.3.3" 61 | source = { registry = "https://pypi.org/simple" } 62 | dependencies = [ 63 | { name = "colorama", marker = "sys_platform == 'win32'" }, 64 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 65 | { name = "iniconfig" }, 66 | { name = "packaging" }, 67 | { name = "pluggy" }, 68 | { name = "tomli", marker = "python_full_version < '3.11'" }, 69 | ] 70 | sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } 71 | wheels = [ 72 | { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, 73 | ] 74 | 75 | [[package]] 76 | name = "tomli" 77 | version = "2.0.2" 78 | source = { registry = "https://pypi.org/simple" } 79 | sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 } 80 | wheels = [ 81 | { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 }, 82 | ] 83 | 84 | [[package]] 85 | name = "uv" 86 | version = "0.5.1" 87 | source = { registry = "https://pypi.org/simple" } 88 | sdist = { url = "https://files.pythonhosted.org/packages/d2/ec/c05991014f8208c1f3556d7dfc96ef5a6b7da6062e489718f3a0f9a74fb8/uv-0.5.1.tar.gz", hash = "sha256:ad2dd8a994a8334a5d4b354589be4b8c4b3b2ebb7bb2f2976c8e21d2799f45a9", size = 2131866 } 89 | wheels = [ 90 | { url = "https://files.pythonhosted.org/packages/ba/33/9183528527cb8532369b678e1d12fb97e3777fed875f1d7ea6c75c570f64/uv-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:93f0a02ea9149f4e7e359ef92da6f221da2ecf458cda2af729a1f6fa8c3ed1d2", size = 13504611 }, 91 | { url = "https://files.pythonhosted.org/packages/6b/23/f0f8234624f720b5a856800d85a2220405182cfd798eefbda8b7868f9c4d/uv-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f66859e67d10ffff8b17c67c7ede207d67487cef20c3d17bc427b690f9dff795", size = 13482839 }, 92 | { url = "https://files.pythonhosted.org/packages/a7/ef/375bb00fa3e73cbb29ffd1d2a0cea3a52b4c0e67f68bce646a9ed0539f18/uv-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c4d209164448c8529e21aca4ef1e3da94303b1bf726924786feffd87ed93ab4a", size = 12503012 }, 93 | { url = "https://files.pythonhosted.org/packages/88/f1/244f0f5e0afdc86de3c12c62c51f594a8b900d8ae1bf7cc64d9d92147199/uv-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6ec61220d883751777cbabf0b076607cfbdeb812bc52c28722e897271461e589", size = 12787137 }, 94 | { url = "https://files.pythonhosted.org/packages/86/fc/74a2fbfb9fc5c1772c0f85718da3e79a05aa3bdd5db1d83851f33e119e01/uv-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73853b98bce9e118cda2d64360ddd7e0f79e237aca8cd2f28b6d5679400b239e", size = 13300628 }, 95 | { url = "https://files.pythonhosted.org/packages/0f/81/0c08c94a9c9f27adc11fd9c3f55d6244c3066cbdea1a9c636fcc1bf45d53/uv-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8dce5b6d6dea41db71fe8d9895167cc5abf3e7b28c016174b1b9a9aecb74d483", size = 13874679 }, 96 | { url = "https://files.pythonhosted.org/packages/eb/8c/2b3c642d71f18eff826a74329b23a269f2962b52d251854b645c98121e15/uv-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:aaa63053ff6dc4456e2ac2a9b6a8eda0cfaa1e0f861633d9e7315c7df9a0a525", size = 14437581 }, 97 | { url = "https://files.pythonhosted.org/packages/9e/65/bae32b05056ba6b5441a491ce17c8216ec92cfade62a7b44702758689646/uv-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac3fce68002e79f3c070f3e7d914e992f205f05af00bfffbe6c44d37aa39c86a", size = 14164878 }, 98 | { url = "https://files.pythonhosted.org/packages/51/1a/38edd7edf8fc73d1db87130f4ac62f2381166fabcc36288b63d82e43373c/uv-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72b54a3308e13a81aa2df19baea40611fc344c7556f75d2113f9b9b5a894355e", size = 18438644 }, 99 | { url = "https://files.pythonhosted.org/packages/be/24/b2f69c0adb60d4c5d3c5413454ab869ed275db52da4be02eaa9d1a149191/uv-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d1ec4a1bc19b523a84fc1bf2a92e9c4d982c831d3da450af71fc3057999d456", size = 13988358 }, 100 | { url = "https://files.pythonhosted.org/packages/43/80/1d887496fa3f2853dd670e43da90ee6efa8e020c82e3a1e2b41c19006d9d/uv-0.5.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:01c40f756e9536c05fdf3485c1dfe3da610c3169195bbe20fab03a4c4b7a0d98", size = 12970766 }, 101 | { url = "https://files.pythonhosted.org/packages/11/0c/5ba7bf4b5ca478cf8c9d660d7baa977af1cd3dda2981f20d0c8e436a48f2/uv-0.5.1-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:3db7513c804fb89dcde671ba917cc486cfb574408d6257e19b19ae6b55f5982f", size = 13274891 }, 102 | { url = "https://files.pythonhosted.org/packages/28/08/1ad7ef9ffd0f3c534cbcad47b57afdb785143ec46973faecc5c90de33025/uv-0.5.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4601d40b0c02aff9fb791efa5b6f4c7dbad0970e13ac679aa8fb07365f331354", size = 13609667 }, 103 | { url = "https://files.pythonhosted.org/packages/fc/e5/56d1457ac76bedce375c1adbfebeb281d94a24d35463350c7fc40f9413b8/uv-0.5.1-py3-none-musllinux_1_1_ppc64le.whl", hash = "sha256:821b6a9d591d3e951fbe81c53d32499d11500100d66b1c119e183f3d4a6cd07c", size = 15349628 }, 104 | { url = "https://files.pythonhosted.org/packages/c5/0a/956467ee8bfa59f698f384eef6e072b14b971d36b7ee6d9b8ed15cfed065/uv-0.5.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6a76765c3cc49268f3c6773bd89a0dacf8a91b040fc3faea6c527ef6f2308eba", size = 14110649 }, 105 | { url = "https://files.pythonhosted.org/packages/5e/94/d7546d96c0e447f56817be2210c4a9d75817d1072e526aaaf17f0dd59a3e/uv-0.5.1-py3-none-win32.whl", hash = "sha256:3ffb230be0f6552576da67a2737a32a6a640e4b3f42144088222a669802d7f10", size = 13423908 }, 106 | { url = "https://files.pythonhosted.org/packages/8c/b2/ac107886f60081f6cd2295c9faf3c312a02c5aca87e8c0b7589340a58224/uv-0.5.1-py3-none-win_amd64.whl", hash = "sha256:922685dcaa1c9b6663649b379f9bdbe5b87af230f512e69398efc51bd9d8b8eb", size = 15169543 }, 107 | ] 108 | 109 | [[package]] 110 | name = "uvtrick" 111 | version = "0.3.0" 112 | source = { virtual = "." } 113 | dependencies = [ 114 | { name = "cloudpickle" }, 115 | { name = "uv" }, 116 | ] 117 | 118 | [package.dev-dependencies] 119 | dev = [ 120 | { name = "pytest" }, 121 | ] 122 | 123 | [package.metadata] 124 | requires-dist = [ 125 | { name = "cloudpickle", specifier = ">=3.1.0" }, 126 | { name = "uv" }, 127 | ] 128 | 129 | [package.metadata.requires-dev] 130 | dev = [{ name = "pytest", specifier = ">=8.3.2" }] 131 | --------------------------------------------------------------------------------