mix mob.enable pythonx adds 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:
  • 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.

    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

mix mob.new my_app --python
cd my_app
mix mob.deploy --native --device <udid>

In an existing project

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:

PlatformSourceCache key
iOSBeeWare's Python-Apple-support (~70 MB)python-apple-support-<vsn>/
AndroidChaquopy 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:

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:

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:

StepModule
Download + cache BeeWare bundleMobDev.PythonAppleSupport.ensure/0
Detect Pythonx in user's projectMobDev.NativeBuild.pythonx_in_project?/1
Install pythonx as OTP lib + cross-compile libpythonx.soMobDev.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-upMobDev.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.

StepModule
Download + cache Chaquopy distributionMobDev.PythonAndroidSupport.ensure/0
Detect Pythonx in user's projectMobDev.NativeBuild.pythonx_in_project?/1
Build stub libpython3.13.so for cross-linkMobDev.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 assetsMobDev.NativeBuild.bundle_python_android_assets/2
Symlink Pythonx NIF into nativeLibraryDirmob_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

PieceiOSAndroid
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 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 / erts-17.0). 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).