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:
- Portable Mode: With bundled Python, uv binary, and offline packages
- 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"→ usesvenvs/numeric/.venv"venv": null→ no venv (stdlib only)- No
"venv"field → ownapps/{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/passwdand/etc/shadoware not visible/proc/self/rootand/proc/1/rootdo not lead back to the host filesystem
Known limits:
- No network isolation. proot uses
ptracepath rewriting, not namespaces. Outbound connections from inside proot reach the internet unrestricted. /procis 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_AScaps the whole address space, including thread stacks. At 256 MB eventhreading.Thread()failed, which makes numpy and Streamlit unusable.RLIMIT_NPROCcounts 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/- Frameworkapps/- All app modulespython/- Portable Python 3.12bin/uv- uv package managerbin/proot-static- proot sandboxpypi/- 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)
| File | Size | Uploaded | |
|---|---|---|---|
| uvpy-0.7.0.tar.gz | 68.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|