Packages
mob_dev
0.7.31
0.7.31
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.33
0.6.32
0.6.31
0.6.30
0.6.29
0.6.28
0.6.27
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
guides/python_embedding.md
# Embedded CPython
`mix mob.enable pythonx` adds [Pythonx](https://hex.pm/packages/pythonx)
support to a Mob app and bundles a real CPython interpreter into both
the iOS and Android app artifacts. Once enabled you can call
`Pythonx.eval/2` from BEAM to run Python code that ships inside the
app — no network, no sandbox-escape required, same API on either
platform.
This guide is the contract between what Mob owns and what it doesn't.
Read the **scope** section before deciding to use it.
---
## Scope
Mob's Python support is deliberately narrow.
**In scope (Mob owns this):**
- A working CPython 3.13 interpreter on:
- iOS device + iOS simulator (BeeWare's
[`Python-Apple-support`](https://github.com/beeware/Python-Apple-support))
- Android arm64 emulator + arm64 device ([Chaquopy](https://chaquo.com/chaquopy/)'s
prebuilt distribution)
- The Python standard library (the pure-Python bits)
- Standard arch-specific C extensions: `_ssl`, `_ctypes`, `_hashlib`,
`_socket`, `_md5`, `_sha*`, `_decimal`, `_ctypes_test`, …
- Build pipeline: cross-compiling `libpythonx.so` (the Pythonx NIF)
for each target, bundling the interpreter + stdlib + dynload
extensions, codesigning on iOS, asset extraction on Android first
launch.
**Out of scope (Mob does not own this):**
- **Third-party wheels.** Anything beyond the standard library —
`cryptography`, `numpy`, `RNS`, etc. — requires a cross-compiled
wheel for the right target.
- iOS: [BeeWare's `mobile-forge`](https://github.com/beeware/mobile-forge)
ships some pre-built; for anything else you build the wheel
yourself.
- Android: Chaquopy has its own wheel pipeline (see
[Chaquopy's docs on package compatibility](https://chaquo.com/chaquopy/doc/current/android.html)).
Mob does not manage a wheel registry, does not know what wheels are
compatible, and does not field bug reports about specific Python
packages failing to import.
- **Android x86/x86_64.** The Android pipeline is wired for
`arm64-v8a` only. Most modern devices and the default Android
Studio emulator are arm64; if you specifically need to run on a
legacy x86 emulator you'll need to extend the build yourself.
- **App Store / Play Store review for Python apps.** The Mob
templates pass review for vanilla apps; CPython embedding adds
dynamic libraries store reviewers may flag. We've validated dev
signing on physical devices on both platforms; an actual
TestFlight or Play Console upload of a Pythonx-enabled app hasn't
been smoke-tested by the Mob team yet.
If you need wheels or non-arm64 Android, that's fine — you just can't
expect Mob to be the place that solves it for you.
---
## Quick start
### From scratch
```bash
mix mob.new my_app --python
cd my_app
mix mob.deploy --native --device <udid>
```
### In an existing project
```bash
cd my_app
mix mob.enable pythonx
mix deps.get
mix mob.deploy --native --device <udid>
```
The first `mob.deploy --native` downloads the per-platform CPython
distribution into `~/.mob/cache/` and reuses it across projects:
| Platform | Source | Cache key |
|---|---|---|
| iOS | BeeWare's `Python-Apple-support` (~70 MB) | `python-apple-support-<vsn>/` |
| Android | Chaquopy prebuilts (~30 MB after pruning) | `python-android-support-<vsn>/` |
`mix mob.enable pythonx` runs a freshness check on existing projects
and warns if `ios/build.sh`, `android/.../CMakeLists.txt`, or
`MainActivity.kt` are missing the build-time hooks the deploy
expects. If you see that warning, regenerate from the latest
`mob_new` archive or copy the bracketed regions across.
---
## Wiring it up
`mix mob.enable pythonx` writes:
- `lib/<app>/python_paths.ex` — a pure detection module that returns
`:desktop` / `{:ios, paths}` / `{:android, paths}` /
`{:partial, missing}` based on what artifacts it finds at runtime.
- A `:pythonx, :uv_init` block in `config/config.exs`. The same
config lands at compile and runtime, so Pythonx's
`validate_compile_env` check is satisfied unconditionally — no
env-var gate.
You still need to wire `Pythonx.init/4` into your app's `on_start/0`
for the mobile branches. The recipe:
```elixir
defmodule MyApp.App do
use Mob.App
@impl Mob.App
def navigation(_platform), do: stack(:main, root: MyApp.HomeScreen)
@impl Mob.App
def on_start do
case MyApp.PythonPaths.detect(to_string(:code.root_dir())) do
:desktop ->
# Desktop: uv handles venv setup at app start. This is the
# only branch that calls ensure_all_started — on device,
# `Pythonx.UvInit.init/1` would shell out to `uv` and fail.
{:ok, _} = Application.ensure_all_started(:pythonx)
{:ios, %{dl_path: dl, home_path: home}} ->
# iOS: bundle is at <App>.app/otp/python/. Pythonx.init/4
# loads the NIF directly against the bundled framework.
Pythonx.init(dl, home, dl, sys_paths: [])
{:android, %{dl_path: dl, home_path: home}} ->
# Android: MainActivity has unpacked Chaquopy's distribution
# to filesDir/python/ and exported MOB_PYTHON_HOME +
# MOB_PYTHON_DL before BEAM startup. Add stdlib explicitly
# because Chaquopy's layout doesn't auto-resolve it.
Pythonx.init(dl, home, dl,
sys_paths: [Path.join([home, "lib", "python3.13"])])
{:partial, missing} ->
# The directory exists but artifacts are missing. Means the
# build pipeline broke between cross-compile and bundling.
# Surface this — don't let the screen try to call into a
# half-initialized interpreter.
Logger.error("Python bundle incomplete; missing: #{inspect(missing)}")
end
Mob.Screen.start_root(MyApp.HomeScreen)
end
end
```
After `Pythonx.init/4` returns, `Pythonx.eval/2` is callable from any
screen. A minimal HomeScreen that proves the interpreter works:
```elixir
defmodule MyApp.HomeScreen do
use Mob.Screen
def mount(_params, _session, socket) do
version =
try do
{result, _} = Pythonx.eval("import sys; sys.version", %{})
Pythonx.decode(result) |> to_string() |> String.split("\n", parts: 2) |> hd()
rescue
e -> "eval failed: " <> Exception.message(e)
end
{:ok, Mob.Socket.assign(socket, :python_version, version)}
end
def render(assigns) do
~MOB"""
<Column padding={:space_lg}>
<Label text={"Python: " <> @python_version} />
</Column>
"""
end
end
```
---
## How it works
### iOS
Three pieces ship in your `<App>.app` bundle:
1. **`<App>.app/otp/python/Python.framework/Python`** — the CPython
interpreter binary (a Mach-O dylib with libpython statically
linked, plus libssl/libcrypto for `_ssl` / `_hashlib`).
2. **`<App>.app/otp/python/lib/python3.13/`** — the pure-Python
standard library (`os.py`, `urllib/`, `email/`, …) following the
`PYTHONHOME` contract.
3. **`<App>.app/otp/python/lib/python3.13/lib-dynload/*.so`** —
arch-specific compiled C extensions, codesigned individually with
your dev/distribution identity.
The fourth piece — **`<App>.app/otp/lib/pythonx-VSN/priv/libpythonx.so`** —
is the Pythonx NIF, cross-compiled for iphoneos/iphonesimulator arm64
during `mix mob.deploy --native`. It dlopens
`Python.framework/Python` at runtime when `Pythonx.init/4` is called.
`mix mob.deploy --native` orchestrates this:
| Step | Module |
|---|---|
| Download + cache BeeWare bundle | `MobDev.PythonAppleSupport.ensure/0` |
| Detect Pythonx in user's project | `MobDev.NativeBuild.pythonx_in_project?/1` |
| Install pythonx as OTP lib + cross-compile `libpythonx.so` | `MobDev.NativeBuild.maybe_setup_pythonx_device/5` (Mix-driven, calls `xcrun -sdk iphoneos clang++` directly) |
| Generate `enif_keepalive.c` (174 enif_* refs) | `MobDev.NativeBuild.generate_enif_keepalive/3` |
| Bundle framework + stdlib + lib-dynload into `<otp_root>/python/` | inside `maybe_setup_pythonx_device/5` |
| Codesign every dylib bottom-up | `MobDev.NativeBuild.codesign_ios_device_app/3` (`codesign` per `.so` + framework binary, then `.app`) |
### Android
Three pieces ship in your APK:
1. **`jniLibs/arm64-v8a/libpython3.13.so`** + **`libpythonx.so`** —
the CPython interpreter and the Pythonx NIF, packaged into the
APK's native lib directory. Android's installer auto-extracts to
the app's `nativeLibraryDir`. `libpythonx.so` was cross-compiled
with the NDK against a stub `libpython3.13.so` carrying the right
SONAME, so the dynamic loader's `NEEDED` entry resolves at
runtime.
2. **`assets/python/lib/python3.13/`** — the stdlib, packed into the
APK as assets. `MainActivity.extractPythonAssetsIfNeeded()`
unpacks to `filesDir/python/lib/python3.13/` on first launch
(idempotent via a `.extracted` marker).
3. **`assets/python/lib/python3.13/lib-dynload/<abi>/*.so`** —
arch-specific C extensions. Chaquopy ships them per-abi;
`MainActivity.flattenLibDynload()` picks the device's primary ABI
and moves the files up to `lib-dynload/` to match CPython's
expected flat layout.
`MainActivity.onCreate` exports `MOB_PYTHON_DL` (path to
`libpython3.13.so` in `nativeLibraryDir`) and `MOB_PYTHON_HOME` (path
to the extracted stdlib root) before calling `nativeStartBeam`. The
`<App>.PythonPaths.build_android_paths/0` function reads those vars
back and feeds them to `Pythonx.init/4`.
| Step | Module |
|---|---|
| Download + cache Chaquopy distribution | `MobDev.PythonAndroidSupport.ensure/0` |
| Detect Pythonx in user's project | `MobDev.NativeBuild.pythonx_in_project?/1` |
| Build stub `libpython3.13.so` for cross-link | `MobDev.NativeBuild.build_libpython_android_test_stub_so/1` |
| Cross-compile `libpythonx.so` (NDK) | `aarch64-linux-android28-clang++` |
| Generate `enif_keepalive.c` (NDK llvm-nm scan) | `MobDev.NativeBuild.generate_android_enif_keepalive/2` |
| Install Pythonx as OTP lib (mirrors exqlite) | `MobDev.NativeBuild.install_pythonx_otp_lib_android/2` |
| Bundle stdlib + lib-dynload as assets | `MobDev.NativeBuild.bundle_python_android_assets/2` |
| Symlink Pythonx NIF into nativeLibraryDir | `mob_beam.c` (at app launch) |
The build script's Pythonx work is gated on
`if [ -d "_build/dev/lib/pythonx" ]` — projects that have never run
`mix mob.enable pythonx` see the gate as a no-op and pay no overhead.
---
## Bundle size
| Piece | iOS | Android |
|---|---|---|
| Interpreter binary | ~5.2 MB (`Python.framework/Python`) | ~6 MB (`libpython3.13.so`) |
| Stdlib | ~61 MB | ~22 MB (Chaquopy ships pyc-only) |
| C extensions (lib-dynload) | ~3 MB (68 extensions) | ~2 MB (per-arch only) |
| `libpythonx.so` (Pythonx NIF) | ~150 KB | ~150 KB |
| **Total Python overhead** | **~70 MB** | **~30 MB** |
This is one-time, not per-feature. If your app already ships Python,
adding more Python code (your own `.py` files, additional imports
from stdlib) doesn't grow the bundle further.
A vanilla Mob app (no Python) is ~3 MB. Apply this only when you
actually want to call Python from BEAM.
---
## When not to use this
- **You only want one or two pure functions implemented in Python.**
Port them. The bundle cost dwarfs the productivity win for small
uses.
- **You want a Python web framework or async runtime.** BEAM is the
better runtime for that on Mob — Phoenix is a dep away.
- **You're targeting App Store / Play Store distribution and your
timeline is tight.** We've validated dev signing on physical
devices; store review of a CPython-bundled app is unproven by the
Mob team. Budget time for unknown rejection categories.
---
## Going further: third-party wheels
If you need a Python package beyond stdlib (the original push for
this feature was Reticulum, which depends on `cryptography`):
**iOS**
1. Use [BeeWare's `mobile-forge`](https://github.com/beeware/mobile-forge)
to cross-compile the wheel for iOS.
2. Place the wheel in your project at `priv/python_wheels/<name>.whl`.
3. Patch your `mix mob.deploy --native` flow to extract the wheel
into `<App>.app/otp/python/lib/python3.13/site-packages/` and
codesign any `.so` files inside it before the final app sign.
**Android**
1. Build the wheel via Chaquopy's own pipeline, or grab a prebuilt
one from their package index.
2. Place the wheel at `priv/python_wheels/<name>.whl`.
3. Extract into `assets/python/lib/python3.13/site-packages/` (so it
gets bundled at build time and unpacked by
`extractPythonAssetsIfNeeded`).
Mob does not currently script step 3 on either platform. You'll need
to maintain a post-`mob.deploy --native` patch step in your project
that handles the wheels you need. Each wheel is its own per-platform
compatibility problem; Mob explicitly does not own that surface.
If you find yourself building this for your own project and it
generalizes well, please share your approach upstream — we may
revisit the scope decision if there's a clean abstraction that
doesn't lock Mob into wheel ecosystem maintenance.
---
## Troubleshooting
**App crashes silently on launch with no Python output.** First check
for OTP version mismatch — your local Erlang must match the device
runtime's ERTS (currently OTP 29.1.2 / erts-17.1.1). `mise` reads
`.tool-versions`; if `mise current` disagrees with `mise exec -- erl`,
your shell hasn't picked up the project's pinned version.
**`Pythonx.eval` raises `ModuleNotFoundError: No module named '_ctypes'`.**
- *iOS:* the arch-specific `lib-dynload/` wasn't bundled. Check that
`<App>.app/otp/python/lib/python3.13/lib-dynload/` exists in your
build output — `mix mob.deploy --native` should print
`lib-dynload: 68 extensions` during the bundling step.
- *Android:* `flattenLibDynload` didn't run, or the device's primary
ABI didn't match what was bundled. Check
`filesDir/python/lib/python3.13/lib-dynload/` after first launch
and confirm `_ctypes.cpython-313.so` is present (flat, not nested
under `arm64-v8a/`).
**Python `Failed to import encodings`.** The stdlib path doesn't
match `PYTHONHOME`'s `lib/python3.13/` expectation. On Android, the
extracted assets must land at `filesDir/python/lib/python3.13/`
(*not* `filesDir/python/stdlib/`); the `MainActivity` template ships
the right layout — if you patched it, double-check.
**Build fails with `pythonx in deps but PYTHON_APPLE_SUPPORT not
set`.** You ran `bash ios/build_device.sh` directly. Use
`mix mob.deploy --native` instead — it calls
`MobDev.PythonAppleSupport.ensure/0` to download the BeeWare bundle
and exposes the path to the script.
**Android `dlopen` fails with `library "libpython3.13.so" not
found`.** The Pythonx NIF's `NEEDED` entry isn't resolving at
runtime. This usually means `libpython3.13.so` didn't get packaged
into `jniLibs/arm64-v8a/`. Check the APK with
`unzip -l build/outputs/apk/debug/app-debug.apk | grep libpython`.
**Codesign failure on `libpythonx.so` or a lib-dynload `.so` (iOS).**
Likely your signing identity doesn't match the team in your
provisioning profile. `mix mob.doctor` flags this; otherwise,
regenerate provisioning via `mix mob.provision`.
**Compile-env mismatch error: `the application :pythonx has a
different value set for key :uv_init during runtime compared to
compile time`.** Old projects had a `MOB_TARGET=ios`-gated
`:uv_init` block in `config/config.exs`. Mob no longer uses a gate —
delete the `if System.get_env("MOB_TARGET") in [nil, ""] do`
wrapper and leave the `config :pythonx, :uv_init, ...` block at
top level. The mobile-vs-desktop split lives in `on_start/0` now
(the desktop branch calls `Application.ensure_all_started(:pythonx)`,
mobile branches don't).