Packages
mob_dev
0.6.11
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
lib/mob_dev/python_android_support.ex
defmodule MobDev.PythonAndroidSupport do
@moduledoc """
Downloads and caches a Chaquopy CPython distribution so Android builds
can embed Python.
## Why Chaquopy
Chaquopy is currently the only actively-maintained source of pre-built
CPython binaries for Android. BeeWare's `Python-Android-support`
(the iOS sibling we already use) hasn't shipped a Python 3.11+ release.
Chaquopy is Apache 2.0 (since 2025), publishes to Maven Central, and
ships exactly the .so files we need:
target-VSN-arm64-v8a.zip:
jniLibs/arm64-v8a/libpython3.13.so ← interpreter
jniLibs/arm64-v8a/libcrypto_python.so ← OpenSSL crypto
jniLibs/arm64-v8a/libssl_python.so ← OpenSSL SSL
jniLibs/arm64-v8a/libsqlite3_python.so ← bundled SQLite
lib-dynload/arm64-v8a/*.so ← C extensions
include/python3.13/ ← headers (NIF compile)
target-VSN-stdlib.zip:
os.py, urllib/, email/, … ← shared pure-Python
target-VSN-x86_64.zip:
Same as arm64-v8a but for x86_64 emulator slice.
We only use these binaries — Chaquopy's Java<->Python bridge is bypassed.
Pythonx's NIF dlopens libpython3.13.so directly via the same path
contract as iOS, just with Android paths.
## Architectures
* `arm64-v8a` — modern Android phones. Required.
* `x86_64` — Android emulators on Intel/AMD development machines.
Required for sim development.
* `armeabi-v7a` (32-bit) — NOT supported. Chaquopy dropped 32-bit
Android Python a few releases back. iOS-era 32-bit Android phones
(~2017 and earlier) cannot run Pythonx-enabled Mob apps.
## Mirrors PythonAppleSupport
Same caching pattern: `~/.mob/cache/python-android-support-<version>/`,
`valid_dir?/1` for layout validation, downloads on-demand via
`MobDev.NativeBuild` when Pythonx is in the user's project.
"""
@python_version "3.13"
# Chaquopy's target-VSN versioning is `<python>.<patch>-<chaquopy-rev>`.
# Bump together with manual end-to-end validation on emulator + device.
@chaquopy_target "3.13.9-0"
@release_tag @chaquopy_target
@base_url "https://repo1.maven.org/maven2/com/chaquo/python/target"
@abis ~w(arm64-v8a x86_64)
@doc """
Ensures Chaquopy's Python distribution is cached and extracted.
Returns `{:ok, extracted_dir}` or `{:error, reason}`.
Three artifacts get downloaded: per-abi binary zips for `arm64-v8a`
and `x86_64`, plus the shared stdlib zip.
"""
@spec ensure() :: {:ok, String.t()} | {:error, term()}
def ensure do
dir = extracted_dir()
if valid_dir?(dir) do
{:ok, dir}
else
if File.dir?(dir), do: File.rm_rf!(dir)
File.mkdir_p!(dir)
download_and_extract(dir)
end
end
@doc """
Returns the cached extraction directory path.
"""
@spec extracted_dir() :: String.t()
def extracted_dir do
Path.join(version_dir(), "extracted")
end
@doc """
Validates the extracted bundle has the expected layout: per-abi
jniLibs/<abi>/libpython3.13.so, lib-dynload/<abi>/, headers, and
the shared stdlib.
Public for testing (per AGENTS.md convention).
"""
@spec valid_dir?(String.t()) :: boolean()
def valid_dir?(dir) do
File.dir?(stdlib_dir(dir)) and
Enum.all?(@abis, fn abi ->
File.regular?(libpython_path(dir, abi)) and
File.dir?(lib_dynload_dir(dir, abi))
end)
end
@doc """
Path to libpython3.13.so for a given ABI.
"""
@spec libpython_path(String.t(), String.t()) :: String.t()
def libpython_path(dir, abi) do
Path.join([jni_libs_dir(dir, abi), "libpython#{@python_version}.so"])
end
@doc """
Per-abi `jniLibs/<abi>` subtree containing libpython.so + its
bundled OpenSSL/SQLite dependencies.
"""
@spec jni_libs_dir(String.t(), String.t()) :: String.t()
def jni_libs_dir(dir, abi) do
Path.join([dir, abi, "jniLibs", abi])
end
@doc """
Per-abi `lib-dynload/<abi>` subtree with arch-specific Python C
extensions (_ssl, _ctypes, _hashlib, …).
"""
@spec lib_dynload_dir(String.t(), String.t()) :: String.t()
def lib_dynload_dir(dir, abi) do
Path.join([dir, abi, "lib-dynload", abi])
end
@doc """
Shared (slice-independent) Python standard library directory.
"""
@spec stdlib_dir(String.t()) :: String.t()
def stdlib_dir(dir) do
Path.join(dir, "stdlib")
end
@doc """
Per-abi C headers for cross-compiling NIFs (pythonx, etc.) against
the bundled libpython.
"""
@spec headers_dir(String.t(), String.t()) :: String.t()
def headers_dir(dir, abi) do
Path.join([dir, abi, "include", "python#{@python_version}"])
end
@doc "URL the per-variant artifact is fetched from."
@spec download_url(String.t()) :: String.t()
def download_url(variant) do
"#{@base_url}/#{@chaquopy_target}/#{tarball_name(variant)}"
end
@doc "Tarball file name for a given variant (`arm64-v8a` / `x86_64` / `stdlib`)."
@spec tarball_name(String.t()) :: String.t()
def tarball_name(variant), do: "target-#{@chaquopy_target}-#{variant}.zip"
@doc "Pinned Chaquopy target version (`3.13.9-0`, …)."
@spec release_tag() :: String.t()
def release_tag, do: @release_tag
@doc "Pinned Python version (`3.13`)."
@spec python_version() :: String.t()
def python_version, do: @python_version
@doc "Supported ABIs (the per-arch artifacts we extract)."
@spec abis() :: [String.t()]
def abis, do: @abis
# ── Private ─────────────────────────────────────────────────────────────────
defp version_dir do
Path.join(cache_dir(), "python-android-support-#{@release_tag}")
end
defp cache_dir do
System.get_env("MOB_CACHE_DIR") ||
Path.join([System.get_env("HOME"), ".mob", "cache"])
end
defp download_and_extract(dest_dir) do
File.mkdir_p!(dest_dir)
# Each abi extracts to <dest_dir>/<abi>/, stdlib to <dest_dir>/stdlib/.
with :ok <- download_and_extract_abi("arm64-v8a", dest_dir),
:ok <- download_and_extract_abi("x86_64", dest_dir),
:ok <- download_and_extract_stdlib(dest_dir),
:ok <- verify_layout(dest_dir) do
IO.puts(" Cached at #{dest_dir}")
{:ok, dest_dir}
else
{:error, reason} ->
File.rm_rf(dest_dir)
{:error, reason}
end
end
defp download_and_extract_abi(abi, dest_dir) do
url = download_url(abi)
abi_dir = Path.join(dest_dir, abi)
File.mkdir_p!(abi_dir)
tmp_zip = Path.join(System.tmp_dir!(), tarball_name(abi))
IO.puts(" Downloading Chaquopy Python #{abi} (#{@release_tag})...")
IO.puts(" URL: #{url}")
with :ok <- download(url, tmp_zip),
:ok <- extract_zip(tmp_zip, abi_dir) do
File.rm(tmp_zip)
:ok
else
err ->
File.rm(tmp_zip)
err
end
end
defp download_and_extract_stdlib(dest_dir) do
url = download_url("stdlib")
stdlib = stdlib_dir(dest_dir)
File.mkdir_p!(stdlib)
tmp_zip = Path.join(System.tmp_dir!(), tarball_name("stdlib"))
IO.puts(" Downloading Chaquopy Python stdlib (#{@release_tag})...")
with :ok <- download(url, tmp_zip),
:ok <- extract_zip(tmp_zip, stdlib) do
File.rm(tmp_zip)
:ok
else
err ->
File.rm(tmp_zip)
err
end
end
defp download(url, dest), do: MobDev.Download.curl(url, dest)
defp extract_zip(zip, dest_dir) do
case System.cmd("unzip", ["-q", "-o", zip, "-d", dest_dir], stderr_to_stdout: true) do
{_, 0} -> :ok
{out, rc} -> {:error, "unzip failed (exit #{rc}): #{String.trim(out)}"}
end
end
defp verify_layout(dir) do
if valid_dir?(dir) do
:ok
else
{:error,
"Chaquopy Python extraction at #{dir} is missing expected paths.\n" <>
" Expected jniLibs/<abi>/libpython#{@python_version}.so for arm64-v8a + x86_64,\n" <>
" lib-dynload/<abi>/, and shared stdlib/.\n" <>
" Maven artifact may have an unexpected layout — report at\n" <>
" https://github.com/GenericJam/mob_dev/issues"}
end
end
end