Metadata-Version: 2.5
Name: uvpy
Version: 0.7.0
Summary: Portable Python App Framework with isolated venvs via uv
Project-URL: Homepage, https://github.com/lepy/uvpy
Project-URL: Repository, https://github.com/lepy/uvpy
Project-URL: Issues, https://github.com/lepy/uvpy/issues
Author: uvpy contributors
License-Expression: MIT
License-File: LICENSE
Keywords: cli,framework,offline,portable,sandbox,uv,venv
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Topic :: System :: Software Distribution
Requires-Python: >=3.10
Provides-Extra: build
Requires-Dist: nuitka<3.0,>=2.8; extra == 'build'
Requires-Dist: ordered-set>=4.1; extra == 'build'
Requires-Dist: patchelf>=0.17; extra == 'build'
Requires-Dist: zstandard>=0.22; extra == 'build'
Provides-Extra: dev
Requires-Dist: coverage[toml]>=7.10; extra == 'dev'
Requires-Dist: pytest-cov>=7.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# 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](#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

```bash
# 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

```bash
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

```bash
./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`:

```bash
./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:

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

import numpy as np
import pandas as pd
print(f"NumPy: {np.__version__}")
```

```bash
./uvpy run analysis.py  # Auto-creates temp venv with dependencies
```

## Creating a New App

```bash
mkdir -p apps/myapp
```

**apps/myapp/manifest.json:**
```json
{
  "name": "myapp",
  "version": "1.0.0",
  "description": "My application"
}
```

**apps/myapp/pyproject.toml:**
```toml
[project]
name = "myapp"
version = "1.0.0"
dependencies = ["numpy==1.26.4"]
```

**apps/myapp/main.py:**
```python
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
```

```bash
./uvpy venv myapp    # Create venv
./uvpy myapp         # Run app
```

## Development Workflow

The `uvpy dev` command provides IDE-friendly development:

```bash
# 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:**
```bash
./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:**
```bash
./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:**
```bash
# 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`:
```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:**
```bash
./uvpy venv --build --all-shared --online  # Build all online
./uvpy venv --build numeric                # Build from pypi/ (offline)
./uvpy venv --list-shared                  # Show status
```

## Testing

```bash
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:

```bash
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` |

```json
{"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.

```json
{"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

```python
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:

```bash
# 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:

```bash
./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:**
```bash
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:

```bash
./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:

```bash
./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

```bash
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](LICENSE)
