Packages
mob_dev
0.6.13
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_apple_support.ex
defmodule MobDev.PythonAppleSupport do
@moduledoc """
Downloads and caches BeeWare's Python-Apple-support bundle so iOS
builds can embed CPython.
Mirrors the `MobDev.OtpDownloader` pattern: hashed URL + cached download
at `~/.mob/cache/python-apple-support-<version>/`, validated against
the expected `Python.xcframework` layout. Reused across projects.
Used by `MobDev.NativeBuild` whenever the user's project depends on
`:pythonx` — the build templates source `PYTHON_APPLE_SUPPORT` from
`extracted_dir/0` and bundle the framework + stdlib + lib-dynload
inside the `.app`.
## Scope
Only the bare CPython runtime + standard library + standard arch-specific
C extensions ship via this module. Third-party wheels (cryptography, RNS,
numpy, …) are out of scope; users who need those should produce their
own wheels with BeeWare's `mobile-forge` and drop them into their
project. See `guides/python_embedding.md`.
"""
# Pinned BeeWare release. Bump together with manual end-to-end validation
# on iOS sim + device — Python releases occasionally shift the lib-dynload
# layout or framework signing requirements.
@python_version "3.13"
@beeware_build "b13"
@release_tag "#{@python_version}-#{@beeware_build}"
@tarball_name "Python-#{@python_version}-iOS-support.#{@beeware_build}.tar.gz"
@base_url "https://github.com/beeware/Python-Apple-support/releases/download"
@doc """
Ensures the BeeWare Python-Apple-support bundle is cached and extracted.
Returns `{:ok, extracted_dir}` or `{:error, reason}`.
"""
@spec ensure() :: {:ok, String.t()} | {:error, term()}
def ensure do
dir = extracted_dir()
if valid_dir?(dir) do
{:ok, dir}
else
# Stale or partial — clean and re-download. Same rationale as
# OtpDownloader: previous failed extraction or schema bump.
if File.dir?(dir), do: File.rm_rf!(dir)
download_and_extract(dir)
end
end
@doc """
Returns the cached extraction directory path. May not exist if `ensure/0`
hasn't been called.
"""
@spec extracted_dir() :: String.t()
def extracted_dir do
Path.join(version_dir(), "extracted")
end
@doc """
Validates that the extracted bundle has the `Python.xcframework` layout
this module expects (both device and simulator slices, plus shared stdlib).
Public to enable testing (per AGENTS.md convention) and to let
`MobDev.NativeBuild` cheaply detect a partial cache.
"""
@spec valid_dir?(String.t()) :: boolean()
def valid_dir?(dir) do
File.dir?(Path.join([dir, "Python.xcframework", "ios-arm64", "Python.framework"])) and
File.dir?(
Path.join([dir, "Python.xcframework", "ios-arm64_x86_64-simulator", "Python.framework"])
) and
File.dir?(stdlib_path(dir))
end
@doc """
Path to `Python.framework` inside the given extracted bundle for the
named platform slice.
`slice` is one of `:ios_device` (`ios-arm64/`) or `:ios_simulator`
(`ios-arm64_x86_64-simulator/`).
"""
@spec framework_path(String.t(), :ios_device | :ios_simulator) :: String.t()
def framework_path(dir, :ios_device),
do: Path.join([dir, "Python.xcframework", "ios-arm64", "Python.framework"])
def framework_path(dir, :ios_simulator),
do: Path.join([dir, "Python.xcframework", "ios-arm64_x86_64-simulator", "Python.framework"])
@doc """
Path to the shared (slice-independent) Python standard library directory.
This is the pure-Python `os.py`, `urllib/`, etc. layout that goes under
`PYTHONHOME/lib/python3.13/`.
"""
@spec stdlib_path(String.t()) :: String.t()
def stdlib_path(dir) do
Path.join([dir, "Python.xcframework", "lib", "python#{@python_version}"])
end
@doc """
Path to the arch-specific C-extension dir (`_ctypes.so`, `_ssl.so`, …).
These live OUTSIDE the shared stdlib because they're per-slice.
"""
@spec lib_dynload_path(String.t(), :ios_device | :ios_simulator) :: String.t()
def lib_dynload_path(dir, :ios_device),
do:
Path.join([
dir,
"Python.xcframework",
"ios-arm64",
"lib-arm64",
"python#{@python_version}",
"lib-dynload"
])
def lib_dynload_path(dir, :ios_simulator),
do:
Path.join([
dir,
"Python.xcframework",
"ios-arm64_x86_64-simulator",
"lib-arm64",
"python#{@python_version}",
"lib-dynload"
])
@doc "URL the bundle is fetched from."
@spec download_url() :: String.t()
def download_url, do: "#{@base_url}/#{@release_tag}/#{@tarball_name}"
@doc "Tarball file name (used for caching the downloaded artifact)."
@spec tarball_name() :: String.t()
def tarball_name, do: @tarball_name
@doc "Pinned BeeWare release tag."
@spec release_tag() :: String.t()
def release_tag, do: @release_tag
@doc "Pinned Python version (`3.13`, `3.14`, …)."
@spec python_version() :: String.t()
def python_version, do: @python_version
# ── Private ─────────────────────────────────────────────────────────────────
defp version_dir do
Path.join(cache_dir(), "python-apple-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
tmp_file = Path.join(System.tmp_dir!(), @tarball_name)
IO.puts(" Downloading BeeWare Python-Apple-support #{@release_tag}...")
IO.puts(" URL: #{download_url()}")
File.mkdir_p!(Path.dirname(dest_dir))
with :ok <- download(download_url(), tmp_file),
:ok <- extract(tmp_file, dest_dir),
:ok <- verify_layout(dest_dir) do
File.rm(tmp_file)
IO.puts(" Cached at #{dest_dir}")
{:ok, dest_dir}
else
{:error, reason} ->
File.rm(tmp_file)
File.rm_rf(dest_dir)
{:error, reason}
end
end
defp download(url, dest), do: MobDev.Download.curl(url, dest)
defp extract(tarball, dest_dir) do
File.mkdir_p!(dest_dir)
# BeeWare's tarball doesn't have a single top-level directory — extract
# straight in. Verify happens afterward.
MobDev.Download.untar(tarball, dest_dir)
end
defp verify_layout(dir) do
if valid_dir?(dir) do
:ok
else
{:error,
"Python-Apple-support extraction at #{dir} is missing expected paths.\n" <>
" Expected Python.xcframework with ios-arm64 + ios-arm64_x86_64-simulator\n" <>
" slices and shared stdlib at lib/python#{@python_version}/.\n" <>
" Tarball may have an unexpected layout — report at\n" <>
" https://github.com/GenericJam/mob_dev/issues"}
end
end
end