Skip to main content

uvpy - Portable Python App Framework

Version 0.7.0 | Python 3.12

A fully portable, offline-capable CLI framework for modular Python apps.

Features

  • Portable: Bundled Python, no system dependencies
  • Offline: All packages from local pypi/ mirror
  • Isolated: Each app has its own venv (via uv)
  • Guarded: proot filesystem isolation + network restrictions for trusted app code (a safety net, not a security boundary - see Security Sandbox)
  • Modular: Apps as plugins in apps/

Dual Mode Operation

uvpy supports two modes:

  1. Portable Mode: With bundled Python, uv binary, and offline packages
  2. Installed Mode: pip install uvpy - uses system Python, apps from ./apps/

Quickstart

Portable Mode

# 1. Clone and install
git clone https://github.com/lepy/uvpy.git
cd uvpy
./install.sh   # Downloads Python (uv and proot ship with the repo)

# 2. Run an app
./uvpy hello_world
./uvpy --list-apps

Installed Mode

pip install uvpy
uvpy --list-apps
uvpy hello_world

Structure

uvpy/
├── uvpy              # Launcher (Linux/macOS)
├── uvpy.bat          # Launcher (Windows)
├── src/uvpy_lib/     # Package source
├── python/           # Portable Python 3.12 (auto-download)
├── apps/             # App modules
│   └── hello_world/  # Template app (stdlib only)
├── venvs/            # Shared virtual environments
│   ├── numeric/      # numpy, pandas, scipy, xarray, matplotlib
│   ├── headless/     # numpy, pandas, scipy, matplotlib (Agg)
│   └── minimal/      # numpy only
├── store/            # App store (versioned wheels)
│   ├── index.json    # App catalog
│   └── myapp/        # App wheels
├── pypi/             # Offline packages (.whl)
├── bin/              # uv + proot-static
└── build_minimal.sh  # Minimal deployment script

Commands

./uvpy --help              # Help
./uvpy --list-apps         # List apps
./uvpy <app>               # Run app

# Development workflow:
./uvpy dev myapp                       # Show app info and activation help
./uvpy dev myapp --shell               # Output shell activation (use with eval)
./uvpy dev myapp --interpreter         # Output Python path for IDE
./uvpy dev myapp --add requests        # Add dependency and install
./uvpy dev myapp --sync-deps           # Sync pip freeze to pyproject.toml
./uvpy dev --new myapp                 # Create new app (own venv)
./uvpy dev --new myapp --venv numeric  # Create new app (shared venv)

# App Store:
./uvpy store --list                    # List apps in store
./uvpy store --info myapp              # Show app details
./uvpy store --install myapp           # Install from store
./uvpy store --install myapp==1.0.0    # Install specific version
./uvpy release myapp                   # Build wheel and publish to store
./uvpy release myapp --version 1.2.0   # With version bump
./uvpy release myapp --tag             # Also create git tag
./uvpy store --remove myapp            # Remove from store

# Portable mode only:
./uvpy venv --list         # Show venv status
./uvpy venv <app>          # Create venv (offline)
./uvpy venv <app> --online # Create venv (online)

./uvpy download --list                  # Show packages in pypi/
./uvpy download --online numpy pandas   # Download packages (with deps)
./uvpy download --online sdata          # Source dists allowed by default
./uvpy download --online --wheels-only numpy  # Only wheels
./uvpy download --online --no-deps numpy      # Without dependencies

# Shared venvs (multiple apps can share one venv):
./uvpy venv --list-shared              # Show shared venvs
./uvpy venv --build numeric --online   # Build shared venv online
./uvpy venv --build --all-shared       # Build all shared venvs
./uvpy venv --new-shared myenv         # Create new shared venv template
./uvpy venv --info                     # Show app-venv assignments

# Create minimal deployments:
./uvpy deploy /tmp/my-deploy           # Deploy with hello_world (~55MB)
./uvpy deploy ./release --apps myapp   # Deploy with specific apps
./uvpy deploy ./bundle --all-apps --tarball  # All apps + tarball

# Create self-extracting archives:
./uvpy pack                            # Pack with hello_world
./uvpy pack --all-apps                 # Pack all apps
./uvpy pack -o myapp.run --apps dashboard  # Custom output + specific apps
./uvpy pack --format both              # Create .run and .tar.gz

# Run arbitrary scripts:
./uvpy run script.py                 # Run with base Python
./uvpy run script.py --app science   # Run with app's venv
./uvpy run script.py --workdir ./data  # Custom working directory

# Custom working directory for apps:
./uvpy myapp --workdir ~/.uvpy/projects/proj_a/jobs/001

Running Arbitrary Scripts

Run any Python script in the sandbox with uvpy run:

./uvpy run myscript.py                    # Base Python
./uvpy run myscript.py --app dashboard    # Use dashboard's venv
./uvpy run myscript.py --workdir ./output # Custom working directory
./uvpy run analysis.py -- --input data.csv  # Pass args to script

The --workdir flag specifies where the script operates. Inside the sandbox, this becomes /workdir with full read/write access to all subdirectories. Other paths like /home are not visible.

PEP 723 inline dependencies are auto-detected:

# /// script
# dependencies = ["numpy", "pandas>=2.0"]
# ///

import numpy as np
import pandas as pd
print(f"NumPy: {np.__version__}")
./uvpy run analysis.py  # Auto-creates temp venv with dependencies

Creating a New App

mkdir -p apps/myapp

apps/myapp/manifest.json:

{
  "name": "myapp",
  "version": "1.0.0",
  "description": "My application"
}

apps/myapp/pyproject.toml:

[project]
name = "myapp"
version = "1.0.0"
dependencies = ["numpy==1.26.4"]

apps/myapp/main.py:

def register(subparser):
    subparser.add_argument("--name", default="World")

def run(args):
    import numpy as np
    print(f"Hello {args.name}! NumPy version: {np.__version__}")
    return 0
./uvpy venv myapp    # Create venv
./uvpy myapp         # Run app

Development Workflow

The uvpy dev command provides IDE-friendly development:

# Create a new app
./uvpy dev --new analysis --venv numeric

# Activate venv in current shell
eval $(./uvpy dev analysis --shell)

# Get Python path for PyCharm/VSCode
./uvpy dev analysis --interpreter
# -> /home/user/uvpy/venvs/numeric/.venv/bin/python

# Add dependencies (updates pyproject.toml + installs)
./uvpy dev analysis --add scipy>=1.12
./uvpy dev analysis --add "pandas>=2.0,<3.0"

# Sync installed packages back to pyproject.toml
pip install requests  # In activated venv
./uvpy dev analysis --sync-deps

App Store

The app store allows versioning, distribution, and installation of apps as wheels.

Store structure:

store/
├── index.json           # App catalog
└── myapp/
    ├── myapp-1.0.0-py3-none-any.whl
    └── myapp-1.1.0-py3-none-any.whl

Publish an app:

./uvpy release myapp                    # Build wheel from current version
./uvpy release myapp --version 1.2.0    # Bump version first
./uvpy release myapp --version 1.2.0 --tag  # Also create git tag

Browse and install:

./uvpy store --list                     # List all apps
./uvpy store --info myapp               # Show versions and details
./uvpy store --install myapp            # Install latest
./uvpy store --install myapp==1.0.0     # Install specific version
./uvpy store --install myapp --force    # Overwrite existing

End-to-end workflow:

# 1. Create and develop
./uvpy dev --new analysis --venv numeric
eval $(./uvpy dev analysis --shell)
# ... develop in PyCharm/VSCode ...

# 2. Release
./uvpy release analysis --version 1.0.0

# 3. On another system
./uvpy store --install analysis
./uvpy analysis

Shared Virtual Environments

Shared venvs reduce package duplication by letting multiple apps use the same environment.

Predefined shared venvs:

Name Dependencies
numeric numpy, pandas, scipy, xarray, matplotlib
headless numpy, pandas, scipy, matplotlib (Agg backend)
minimal numpy only

Using a shared venv in your app:

In apps/myapp/manifest.json:

{
  "name": "myapp",
  "venv": "numeric"
}

Venv resolution:

  • "venv": "numeric" → uses venvs/numeric/.venv
  • "venv": null → no venv (stdlib only)
  • No "venv" field → own apps/{name}/.venv (backwards compatible)

Build shared venvs:

./uvpy venv --build --all-shared --online  # Build all online
./uvpy venv --build numeric                # Build from pypi/ (offline)
./uvpy venv --list-shared                  # Show status

Testing

make test          # run the suite (creates .venv_dev on first use)
make coverage      # with the coverage gate and a per-line report

The suite runs fully offline: autouse fixtures fail any test that resolves a hostname or waits on an interactive prompt, and fake_uvpy_root builds a throwaway portable universe so nothing touches the real repository. tests/test_sandbox_bypasses.py keeps one regression test per known sandbox bypass - the unfixed ones as xfail(strict=True), so a fix cannot land without turning its test green.

Documentation

Full documentation is built with MkDocs and works completely offline - no CDN, no web fonts, no analytics:

make mkdocs_serve      # live server on http://127.0.0.1:8940
make mkdocs_build      # static site into site/
make mkdocs_offline    # verify the built site loads nothing external

make help lists all targets. The rendered site/ directory is self-contained: copy it to an air-gapped machine and open site/index.html.

Security Sandbox

Threat model first: the sandbox is a safety net for trusted app code - it stops accidental network calls and telemetry from libraries such as Streamlit or Matplotlib. It is not a security boundary. Code that deliberately tries to escape will succeed. Never use uvpy to run untrusted or hostile code.

Findings below were measured against 0.4.2 with a real TCP listener on a non-loopback address; "escape" means data actually reached it. Each one has a regression test in tests/test_sandbox_bypasses.py.

Layer 1 - proot (filesystem)

bin/proot-static runs the app under an empty fake root with explicit bindings (/workdir, /app, /uvpy, /usr, /lib, /bin, /tmp, minimal /dev, /proc).

Verified working:

  • /home, ~/.ssh, /etc/passwd and /etc/shadow are not visible
  • /proc/self/root and /proc/1/root do not lead back to the host filesystem

Known limits:

  • No network isolation. proot uses ptrace path rewriting, not namespaces. Outbound connections from inside proot reach the internet unrestricted.
  • /proc is bound from the host, so all host PIDs are visible.
  • Linux only; on macOS and Windows this layer is absent entirely.
  • Without a working proot-static, uvpy refuses to run unless a human answers at a terminal (see "Honest failure mode" below).

Layer 2 - Python monkey patching

uvpy_lib.sandbox.activate() patches the socket layer, wraps urllib.request.urlopen, and sets telemetry-suppressing environment variables.

Blocked (the socket coverage was extended in 0.4.1):

Vector Result
socket.connect() blocked
socket.connect_ex() blocked (0.4.1)
socket.create_connection() blocked (0.4.1)
socket.getaddrinfo() - DNS blocked (0.4.1)
urllib.request.urlopen() / build_opener().open() blocked
requests / httpx blocked by the import hook (0.4.2)
sandbox.deactivate() from app code refused (0.4.2)
Lookalike hosts like 127.evil.example.com rejected (0.4.2)

Still bypassable in 0.4.2 - structural, not fixable by more monkey patching:

Vector Result
_socket.socket.connect() (C module, base class) not blocked
sandbox._original_socket_connect (module global) not blocked
subprocess / os.system child processes not blocked - patch is per-process
bash /dev/tcp redirection not blocked
UDP sendto() not covered
UVPY_NO_SANDBOX=1 documented opt-out

Layer 3 - resource limits (0.4.3)

uvpy_lib.limits bounds heap memory, CPU time and open file descriptors with setrlimit, applied in the app process before any app code runs. Measured 4 of 4 in the resource category of tools/sandbox_bench, with nothing over-blocked.

Limit rlimit Default Manifest key
Heap memory RLIMIT_DATA 4096 MB memory_mb
CPU time RLIMIT_CPU unbounded cpu_seconds
Open files RLIMIT_NOFILE 1024 open_files
{"name": "report", "limits": {"memory_mb": 512, "cpu_seconds": 600}}

Soft and hard limit are set to the same value, so app code cannot raise its own limit back up. UVPY_NO_LIMITS=1 switches the layer off.

Two rlimits are deliberately not used, both because the benchmark showed they do more harm than good:

  • RLIMIT_AS caps the whole address space, including thread stacks. At 256 MB even threading.Thread() failed, which makes numpy and Streamlit unusable.
  • RLIMIT_NPROC counts per UID, not per app. Under an account already running 993 threads, any limit is either useless or fatal. The number of processes and threads therefore stays unbounded - a known gap with no rootless fix.

Layer 4 - capabilities (0.5.0)

An app declares what it needs; anything undeclared is refused.

{"name": "dashboard", "capabilities": {
  "network": {"listen": ["127.0.0.1:8501"]},
  "subprocess": true
}}

This inverts the previous default. Before 0.5.0 an app got whatever the Python layer let through - loopback, subprocesses, and every bind on every interface. An app with no capabilities block now gets no network, no subprocess and nothing outside its working directory.

Key Default Enforced by Strength
network.connect nothing Python patch on socket soft
network.listen nothing Python patch on socket.bind soft
subprocess false Python patch on subprocess, os.system, os.exec*, os.fork soft
filesystem.read / .write workdir only proot bind list hard

--security-info prints that strength column rather than leaving it implied. Soft means the accident is stopped and the attacker is not; hard network rules wait for seccomp and Landlock.

Measured against the same attack matrix (tools/sandbox_bench, 35 attacks):

Mechanism Handled as intended Escapes Over-blocked
what uvpy did before 0.5.0 14 21 0
an app that declares nothing 17 17 1
an app that declares loopback 18 17 0
the same, plus seccomp (0.6.0) 19 15 1

process.fork and process.spawn_subprocess escaped every earlier version and now fail. The one over-blocked cell is localhost_connect for an app that never asked for localhost - two lines in its manifest fix it, which is the point.

uvpy run <script.py> has no manifest and keeps the loopback profile.

Layer 5 - seccomp-bpf (0.6.0)

For an app that declares no network at all, a BPF filter makes the kernel refuse socket(AF_INET|AF_INET6, ...). That closes the two escapes no Python patch can:

Vector Python patches seccomp
_socket.socket.connect (C module, S2) escapes refused
sandbox._original_socket_connect (S3b) escapes refused
child processes (S4) escape refused - the filter is inherited
AF_UNIX, files allowed allowed

The filter is hand-written BPF over ctypes - no libseccomp, nothing to compile, which is what an offline bundle needs. It is installed in the app process after the imports and before the app's own code: some libraries reach for a licence server or a telemetry endpoint while loading, and a filter in front of that would break the import rather than the app.

It is all-or-nothing by construction. A BPF program may not dereference pointers, and the destination of connect() sits behind a pointer to struct sockaddr. "AF_INET yes or no" is expressible; "only 127.0.0.1" is not. So an app that declares any endpoint keeps the soft Python layer, and --security-info shows soft for it while an app that declares nothing shows hard. That distinction is the reason V2 needed the capability model first.

x86_64 only. The filter denies every syscall whose architecture does not match - correct against an unknown architecture, and the reason an untested one is a reason not to install anything rather than to guess. UVPY_NO_SECCOMP=1 switches it off.

Honest failure mode (0.4.3)

--security-info used to print Sandbox Status: ACTIVE whenever the Python patches were installed, whether or not the filesystem layer existed. It now reports each layer separately, and names what none of them cover:

Active isolation layers:

  [MISSING] proot (filesystem)
            Not available - the app can read and write everything this account can.
  [active ] Python patches (network)
            socket.connect, getaddrinfo, urllib and requests/httpx restricted to loopback.
  [active ] Resource limits
            heap memory: 4096 MB; open files: 1024

Not covered, even with every layer active:
  - Direct calls into the C module `_socket` bypass the Python patches.
  ...

A missing layer no longer runs by default. --require-sandbox always aborts; otherwise uvpy asks at a terminal and refuses when nobody can answer, which is what a pipeline looks like. UVPY_REQUIRE_SANDBOX=0 is the deliberate way through, and the run still says it is unprotected.

Situation Behaviour
--require-sandbox or UVPY_REQUIRE_SANDBOX=1 abort
Terminal ask
No terminal (CI, pipe, cron) abort
UVPY_REQUIRE_SANDBOX=0 run unprotected, with a warning

Refusals now name the mechanism and the rule instead of just the verdict:

[uvpy Sandbox] Network access blocked: example.org:443
  mechanism: Python patch (socket.connect)
  rule:      no connect endpoints declared in manifest.json
  to allow:  add "example.org:443" to capabilities.network.connect in manifest.json

Coverage per entry point

Entry point proot Python sandbox Resource limits Capabilities seccomp
uvpy <app> yes (Linux, if proot-static works) yes - activated in the app process yes (0.4.3) yes (0.5.0) yes (0.6.0, if no network declared)
uvpy run <script.py> yes yes (since 0.4.2) yes (0.4.3, defaults) loopback profile no - the profile declares network
pip install uvpy (installed mode) no yes (since 0.4.1) yes (0.4.3) yes (0.5.0) yes (0.6.0)

uvpy run applies the restrictions since 0.4.2. Previously sandbox.activate() ran in the parent while the script ran in a subprocess, which never inherits the patch - the script reached the internet while --security-info reported ACTIVE. The script is now started through uvpy_lib.script_runner, which activates the sandbox in the process that executes it.

Supply chain

The launcher and install.sh verify the SHA256 checksum of the downloaded Python against the published .sha256 file and abort on mismatch (UVPY_SKIP_HASH=1 disables it, with a warning). uvpy store --install rejects wheel paths that escape the target directory.

Using the sandbox directly

from uvpy_lib import activate_sandbox
activate_sandbox()

If you need a real boundary, run uvpy inside a container, a VM, or a user namespace sandbox (bubblewrap, systemd-nspawn) with a network policy enforced from outside.

Platform Compatibility

One bundle works on all major Linux distributions:

Binary Min. glibc RHEL 8 (2.28) RHEL 9 (2.34) Ubuntu 24.04 (2.39)
Python 2.17
uv 2.17
proot-static - (static)
uvpy_lib.so (compiled bundle only) glibc of the build host only if built on ≤ 2.28 only if built on ≤ 2.34

make smoke_rhel (also a CI job) checks this in ubi8 and ubi9 containers, offline and as an unprivileged user: the binaries start, apps run, proot hides the host and seccomp refuses AF_INET. A container runs on the host kernel, so this covers the RHEL userland (glibc, libraries), not the RHEL 8/9 kernels.

The Nuitka-compiled uvpy_lib.so from build_compiled.sh links against the glibc of the machine it is built on: built on Ubuntu 24.04 it needs glibc 2.38 and runs on neither RHEL. The script names the archive after the glibc it actually needs and warns when that rules out RHEL 8 or 9. make compiled_rhel (also a CI job) builds it in a ubi8 container instead - the result needs glibc 2.17 - and smoke-tests the unpacked archive on RHEL 8 and 9.

Deployment

Minimal Deployment with Python

Create a deployment bundle with Python included:

# Using the deploy command
./uvpy deploy /tmp/my-deploy --verbose
./uvpy deploy ./release --apps myapp --tarball

# Or using the shell script (runs build_compiled.sh first)
./build_minimal.sh                # -> uvpy0.3.1-py3.12.12-x86_64.tar.gz
./build_minimal.sh --no-python    # Without Python (~55MB)

Output: uvpy<version>-py<python-version>-<arch>.tar.gz

Bundle contents:

uvpy0.3.1-py3.12.12-x86_64/
├── uvpy              # Launcher
├── bin/              # uv, proot-static, uvpy_lib.so
├── apps/             # App modules
├── pypi/             # Offline packages
└── python/           # Clean Python 3.12 (~330MB)

Build options:

Option Description
--no-python Exclude Python from bundle
--output DIR Output directory (default: ./dist)
--name NAME Override archive name

Deploy command options:

Option Description
--apps <app> ... Include specific apps
--all-apps Include all apps
--tarball Create .tar.gz archive
--with-python Include Python
--compiled Use uvpy_lib.so if available

Self-Extracting Archive (pack)

Create a portable .run file that extracts and runs on any Linux machine:

./uvpy pack                              # Default: hello_world app
./uvpy pack --all-apps                   # All apps
./uvpy pack -o myapp.run --apps dashboard science  # Specific apps
./uvpy pack --format both                # Create .run and .tar.gz
./uvpy pack --all-apps --include-shared-venvs  # With pre-built shared venvs
./uvpy pack --all-apps --venv numeric          # Only specific shared venv
./uvpy pack --no-python                  # Exclude Python (target needs Python 3.12)

Install on target machine:

scp uvpy-0.3.1-x86_64.run server:
ssh server
./uvpy-0.3.1-x86_64.run /opt/uvpy        # Extract to /opt/uvpy
/opt/uvpy/uvpy --version
/opt/uvpy/uvpy hello_world

Pack options:

Option Description
-o, --output FILE Output file name
--format FORMAT run (default), tar.gz, or both
--apps <app> ... Include specific apps
--all-apps Include all apps
--no-python Exclude Python
--no-pypi Exclude pypi/ packages
--include-shared-venvs Include pre-built shared venvs
--venv <name> ... Include specific shared venvs
--compiled Prefer uvpy_lib.so if available

Compiled Bundle (Nuitka)

Create a bundle with compiled .so modules for faster startup and code protection:

./build_compiled.sh

Auto-setup: The script automatically downloads Python 3.12 and installs Nuitka if not present. Only gcc is required (sudo apt install build-essential).

Output: uvpy-compiled-<version>-linux-<arch>-glibc<version>.tar.gz

The bundle contains a fresh Python download without Nuitka or build dependencies in site-packages/.

Full Bundle (~280MB)

For a complete bundle with all packages:

./bundle.sh uvpy-linux-x86_64

Full bundle contents:

  • src/uvpy_lib/ - Framework
  • apps/ - All app modules
  • python/ - Portable Python 3.12
  • bin/uv - uv package manager
  • bin/proot-static - proot sandbox
  • pypi/ - Offline packages (.whl)
  • uvpy - Launcher script

Deploy to Server

scp uvpy-minimal.tar.gz server:
ssh server
tar xzf uvpy-minimal.tar.gz
cd uvpy-minimal
./uvpy hello_world   # Python auto-downloads on first run

License

MIT License - see LICENSE

Release files for uvpy 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for uvpy 0.7.0
File Size Uploaded
uvpy-0.7.0.tar.gz 68.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for uvpy 0.7.0
File Interpreter ABI Platform
uvpy-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 142.2 kB

Release files / uvpy-0.7.0.tar.gz

Download URL uvpy-0.7.0.tar.gz
Size 68.9 kB
Tags Source
SHA-256 checksum
How to use checksums
0076bf48097473c9232024e6a409565f10d4c256729cf0ce6f7848d8bc0538c1
BLAKE2b-256 checksum
How to use checksums
44d3b6acd166c7870ca40b4f09b6166805f69126bf69261c18d48fd55fbf1f91
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / uvpy-0.7.0-py3-none-any.whl

Download URL uvpy-0.7.0-py3-none-any.whl
Size 73.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
64421cc0fbd8bf0714092edcdf17c18d93fc158483b0466fe9ce99bd21d71c33
BLAKE2b-256 checksum
How to use checksums
4d3be19db9c40d17e91d8ba81ed3b66d78afa17862d4a54922fe8b0cc36ea366
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

0.8.0

2 release files

This release

0.7.0 This release

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page