This is an automated email from the ASF dual-hosted git repository.
linguini1 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/nuttx-apps.git
The following commit(s) were added to refs/heads/master by this push:
new 8ba84edb0 interpreters/python: Enable using `pip` to install Python
packages
8ba84edb0 is described below
commit 8ba84edb0ac6772ac67e4c78d14044435dd90d75
Author: Tiago Medicci <[email protected]>
AuthorDate: Fri May 8 13:54:17 2026 -0300
interpreters/python: Enable using `pip` to install Python packages
This commit enables using `pip` as a pre-compiled (pyc) built-in
distributed along with cpython.
Signed-off-by: Tiago Medicci <[email protected]>
---
interpreters/python/Kconfig | 17 +++
interpreters/python/Make.defs | 2 +
interpreters/python/Makefile | 38 +++++-
interpreters/python/Setup.local.in | 10 --
interpreters/python/config.site.in | 11 +-
.../0015-keep-ensurepip-in-stdlib-archive.patch | 25 ++++
...zone-offset-check-when-time-t-is-unsigned.patch | 21 ++++
...ip-keep-pydecimal-and-trim-tooling-extras.patch | 35 ++++++
.../0018-ignore-chmod-on-nuttx-like-wasi.patch | 25 ++++
interpreters/python/python_wrapper.c | 2 +
interpreters/python/repack_wheel_add_pyc.py | 139 +++++++++++++++++++++
11 files changed, 313 insertions(+), 12 deletions(-)
diff --git a/interpreters/python/Kconfig b/interpreters/python/Kconfig
index dfc3f5ee8..dfc5fd4cc 100644
--- a/interpreters/python/Kconfig
+++ b/interpreters/python/Kconfig
@@ -37,4 +37,21 @@ config INTERPRETERS_CPYTHON_PROGNAME
---help---
This is the name of the program that will be used from the nsh.
+config INTERPRETERS_CPYTHON_ENABLE_PIP
+ bool "Enable bundled pip"
+ default n
+ ---help---
+ Enable bundling pip into the CPython module image. When
enabled, the
+ build downloads the pip wheel and pre-installs it through a
+ site-packages .pth entry that points to ensurepip's bundled
wheel.
+ Disable this to skip pip wheel download/integration entirely.
+
+
+config INTERPRETERS_CPYTHON_PYTHONPATH
+ string "CPython Python path"
+ default "/tmp"
+ ---help---
+ This is the Python default search path for modules files. This
is
+ required to be a writable path.
+
endif
diff --git a/interpreters/python/Make.defs b/interpreters/python/Make.defs
index c02973e5a..dceeb5267 100644
--- a/interpreters/python/Make.defs
+++ b/interpreters/python/Make.defs
@@ -27,6 +27,8 @@ CPYTHON_VERSION_MINOR=$(basename $(CPYTHON_VERSION))
EXTRA_LIBPATHS += -L$(APPDIR)/interpreters/python/install/target
EXTRA_LIBS += -lpython$(CPYTHON_VERSION_MINOR)
+EXTRA_LIBS +=
$(APPDIR)/interpreters/python/build/target/Modules/_hacl/libHacl_Hash_SHA2.a
+EXTRA_LIBS +=
$(APPDIR)/interpreters/python/build/target/Modules/expat/libexpat.a
CONFIGURED_APPS += $(APPDIR)/interpreters/python
endif
diff --git a/interpreters/python/Makefile b/interpreters/python/Makefile
index 954f07b30..a339bbe02 100644
--- a/interpreters/python/Makefile
+++ b/interpreters/python/Makefile
@@ -84,6 +84,10 @@ $(CPYTHON_UNPACKNAME): $(CPYTHON_ZIP)
$(Q) patch -p1 -d $(CPYTHON_UNPACKNAME) <
patch$(DELIM)0012-hack-place-_PyRuntime-structure-into-PSRAM-bss-regio.patch
$(Q) patch -p1 -d $(CPYTHON_UNPACKNAME) <
patch$(DELIM)0013-transform-functions-used-by-NuttX-to-lowercase.patch
$(Q) patch -p1 -d $(CPYTHON_UNPACKNAME) <
patch$(DELIM)0014-insert-prefix-to-list_length-to-avoid-symbol-collisi.patch
+ $(Q) patch -p1 -d $(CPYTHON_UNPACKNAME) <
patch$(DELIM)0015-keep-ensurepip-in-stdlib-archive.patch
+ $(Q) patch -p1 -d $(CPYTHON_UNPACKNAME) <
patch$(DELIM)0016-fix-timezone-offset-check-when-time-t-is-unsigned.patch
+ $(Q) patch -p1 -d $(CPYTHON_UNPACKNAME) <
patch$(DELIM)0017-stdlib-zip-keep-pydecimal-and-trim-tooling-extras.patch
+ $(Q) patch -p1 -d $(CPYTHON_UNPACKNAME) <
patch$(DELIM)0018-ignore-chmod-on-nuttx-like-wasi.patch
$(HOSTPYTHON):
mkdir -p $(HOSTBUILD)
@@ -92,6 +96,7 @@ $(HOSTPYTHON):
cd $(HOSTBUILD) && $(CPYTHON_PATH)/configure \
--with-pydebug \
--prefix=$(HOSTINSTALL) \
+ --disable-test-modules \
)
$(MAKE) -C $(HOSTBUILD) install
@@ -152,7 +157,7 @@ $(TARGETBUILD)/Makefile: $(HOSTPYTHON) $(CONFIG_SITE)
$(SETUP_LOCAL)
AR="$(AR)" \
ARFLAGS=" " \
MACHDEP="$(MACHDEP)" \
- OPT="-g -O0 -Wall" \
+ OPT="-O3" \
CONFIG_SITE="$(CONFIG_SITE)" \
$(CPYTHON_PATH)/configure \
--prefix=${TARGETINSTALL} \
@@ -163,13 +168,44 @@ $(TARGETBUILD)/Makefile: $(HOSTPYTHON) $(CONFIG_SITE)
$(SETUP_LOCAL)
--without-mimalloc \
--without-pymalloc \
--disable-test-modules \
+ --with-ensurepip=no \
)
+ $(Q) sed -i 's/^#define HAVE_LIBB2 1/\/* #undef HAVE_LIBB2 *\//'
$(TARGETBUILD)/pyconfig.h
+ $(Q) sed -i 's/-lb2//g' $(TARGETBUILD)/Makefile
$(TARGETLIBPYTHON): $(TARGETBUILD)/Makefile
+ifeq ($(CONFIG_INTERPRETERS_CPYTHON_ENABLE_PIP),y)
+ $(Q) mkdir -p $(CPYTHON_PATH)/Lib/ensurepip/_bundled
+ $(Q) ( \
+ PIP_WHEEL_VERSION=$$($(HOSTPYTHON) -c "import ensurepip;
print(ensurepip._PIP_VERSION)"); \
+
PIP_WHEEL=$(CPYTHON_PATH)/Lib/ensurepip/_bundled/pip-$${PIP_WHEEL_VERSION}-py3-none-any.whl;
\
+ if [ ! -f "$${PIP_WHEEL}" ]; then \
+ echo "Fetching pip wheel $${PIP_WHEEL_VERSION} for
ensurepip bundle"; \
+ $(HOSTPYTHON) -m pip download --only-binary=:all:
--no-deps --dest $(CPYTHON_PATH)/Lib/ensurepip/_bundled
pip==$${PIP_WHEEL_VERSION}; \
+ fi; \
+ echo "Pre-compiling pip wheel with build Python (must match
embedded CPython version)"; \
+ $(HOSTPYTHON) $(CURDIR)/repack_wheel_add_pyc.py
"$${PIP_WHEEL}"; \
+ )
+endif
$(MAKE) -C $(TARGETBUILD) regen-frozen
$(MAKE) -C $(TARGETBUILD) libpython$(CPYTHON_VERSION_MINOR).a
wasm_stdlib
$(Q) ( cp $(TARGETBUILD)/libpython$(CPYTHON_VERSION_MINOR).a
$(TARGETLIBPYTHON) )
$(Q) $(UNPACK) $(TARGETMODULESPACK) -d
$(TARGETMODULES)/python$(CPYTHON_VERSION_MINOR)
+ifeq ($(CONFIG_INTERPRETERS_CPYTHON_ENABLE_PIP),y)
+ $(Q) mkdir -p
$(TARGETMODULES)/python$(CPYTHON_VERSION_MINOR)/site-packages
+ $(Q) ( \
+ set -e; \
+
BUNDLED_DIR=$(TARGETMODULES)/python$(CPYTHON_VERSION_MINOR)/ensurepip/_bundled;
\
+
SITE_PACKAGES=$(TARGETMODULES)/python$(CPYTHON_VERSION_MINOR)/site-packages; \
+ : > "$${SITE_PACKAGES}/bundled_wheels.pth"; \
+ for wheel in $${BUNDLED_DIR}/*.whl; do \
+ [ -f "$${wheel}" ] || continue; \
+ whl_name=$$(basename "$${wheel}"); \
+ echo "Pre-installing wheel via zipimport into target
sys.path: $${whl_name}"; \
+ echo "../ensurepip/_bundled/$${whl_name}" >>
"$${SITE_PACKAGES}/bundled_wheels.pth"; \
+ done; \
+ )
+endif
MODULE = $(CONFIG_INTERPRETERS_CPYTHON)
diff --git a/interpreters/python/Setup.local.in
b/interpreters/python/Setup.local.in
index 5ff7cd3fa..0bd116574 100644
--- a/interpreters/python/Setup.local.in
+++ b/interpreters/python/Setup.local.in
@@ -4,7 +4,6 @@
*disabled*
_asyncio
-_blake2
_bz2
_codecs_cn
_codecs_hk
@@ -15,19 +14,12 @@ _codecs_tw
_ctypes
_decimal
_elementtree
-_hashlib
_heapq
_interpchannels
_interpqueues
_lsprof
_lzma
-_md5
_multibytecodec
-_sha1
-_sha2
-_sha2
-_sha3
-_sha3
_sqlite3
_ssl
_statistics
@@ -41,9 +33,7 @@ _testlimitedcapi
_uuid
_xxtestfuzz
_zoneinfo
-mmap
pwd
-pyexpat
readline
resource
xxsubtype
diff --git a/interpreters/python/config.site.in
b/interpreters/python/config.site.in
index eb37e5a88..52c04725a 100644
--- a/interpreters/python/config.site.in
+++ b/interpreters/python/config.site.in
@@ -21,4 +21,13 @@ export ac_cv_func_pipe="yes"
export ac_cv_enable_strict_prototypes_warning="no"
export ac_cv_func_getnameinfo="yes"
export ac_cv_func_poll="yes"
-export ac_cv_func_gethostname="yes"
\ No newline at end of file
+export ac_cv_func_gethostname="yes"
+export ac_cv_func_lstat="yes"
+export ac_cv_func_readlink="yes"
+export ac_cv_func_realpath="yes"
+export ac_cv_func_getpid="yes"
+export ac_cv_func_utime="yes"
+export ac_cv_func_utimes="yes"
+export ac_cv_func_getuid="yes"
+export ac_cv_func_sysconf="yes"
+export ac_cv_func_umask="yes"
diff --git
a/interpreters/python/patch/0015-keep-ensurepip-in-stdlib-archive.patch
b/interpreters/python/patch/0015-keep-ensurepip-in-stdlib-archive.patch
new file mode 100644
index 000000000..a74e892ef
--- /dev/null
+++ b/interpreters/python/patch/0015-keep-ensurepip-in-stdlib-archive.patch
@@ -0,0 +1,25 @@
+--- a/Tools/wasm/wasm_assets.py
++++ b/Tools/wasm/wasm_assets.py
+@@ -40,7 +40,6 @@ OMIT_FILES = (
+ # regression tests
+ "test/",
+ # package management
+- "ensurepip/",
+ "venv/",
+ # other platforms
+ "_aix_support.py",
+@@ -148,6 +147,13 @@ def create_stdlib_zip(
+ if entry.name.endswith(".py") or entry.is_dir():
+ # writepy() writes .pyc files (bytecode).
+ pzf.writepy(entry, filterfunc=filterfunc)
++
++ # Preserve ensurepip wheel payloads so `python -m ensurepip` can
++ # bootstrap pip on targets that consume this stdlib zip archive.
++ bundled_wheels = args.srcdir_lib / "ensurepip" / "_bundled"
++ if bundled_wheels.is_dir():
++ for wheel in sorted(bundled_wheels.glob("*.whl")):
++ pzf.write(wheel, arcname=f"ensurepip/_bundled/{wheel.name}")
+
+
+ def detect_extension_modules(args: argparse.Namespace) -> Dict[str, bool]:
+
diff --git
a/interpreters/python/patch/0016-fix-timezone-offset-check-when-time-t-is-unsigned.patch
b/interpreters/python/patch/0016-fix-timezone-offset-check-when-time-t-is-unsigned.patch
new file mode 100644
index 000000000..81924297b
--- /dev/null
+++
b/interpreters/python/patch/0016-fix-timezone-offset-check-when-time-t-is-unsigned.patch
@@ -0,0 +1,21 @@
+--- a/Modules/timemodule.c
++++ b/Modules/timemodule.c
+@@ -1800,15 +1800,15 @@ static int
+ static const time_t YEAR = (365 * 24 + 6) * 3600;
+ time_t t;
+ struct tm p;
+- time_t janzone_t, julyzone_t;
++ long long janzone_t, julyzone_t;
+ char janname[10], julyname[10];
+ t = (time((time_t *)0) / YEAR) * YEAR;
+ _PyTime_localtime(t, &p);
+ get_zone(janname, 9, &p);
+- janzone_t = -get_gmtoff(t, &p);
++ janzone_t = -(long long)get_gmtoff(t, &p);
+ janname[9] = '\0';
+ t += YEAR/2;
+ _PyTime_localtime(t, &p);
+ get_zone(julyname, 9, &p);
+- julyzone_t = -get_gmtoff(t, &p);
++ julyzone_t = -(long long)get_gmtoff(t, &p);
+ julyname[9] = '\0';
diff --git
a/interpreters/python/patch/0017-stdlib-zip-keep-pydecimal-and-trim-tooling-extras.patch
b/interpreters/python/patch/0017-stdlib-zip-keep-pydecimal-and-trim-tooling-extras.patch
new file mode 100644
index 000000000..333bb983a
--- /dev/null
+++
b/interpreters/python/patch/0017-stdlib-zip-keep-pydecimal-and-trim-tooling-extras.patch
@@ -0,0 +1,35 @@
+--- a/Tools/wasm/wasm_assets.py
++++ b/Tools/wasm/wasm_assets.py
+@@ -47,13 +47,20 @@
+ # webbrowser
+ "antigravity.py",
+ "webbrowser.py",
+- # Pure Python implementations of C extensions
+- "_pydecimal.py",
++ # Pure Python implementations of C extensions.
++ # NOTE: keep "_pydecimal.py" so decimal.py can fall back to it when the
++ # _decimal C extension is not built (NuttX targets do not link libmpdec).
+ "_pyio.py",
+ # concurrent threading
+ "concurrent/futures/thread.py",
+ # Misc unused or large files
+ "pydoc_data/",
++ # Tooling/REPL extras not needed on a constrained embedded target.
++ "unittest/",
++ "_pyrepl/",
++ "idlelib/",
++ "turtledemo/",
++ "wsgiref/",
+ )
+
+ # Synchronous network I/O and protocols are not supported; for example,
+@@ -80,7 +87,8 @@
+ "_asyncio": ["asyncio/"],
+ "_curses": ["curses/"],
+ "_ctypes": ["ctypes/"],
+- "_decimal": ["decimal.py"],
++ # decimal.py is intentionally NOT omitted here: it ships a pure-Python
++ # fallback (_pydecimal) used when the _decimal C ext is unavailable.
+ "_dbm": ["dbm/ndbm.py"],
+ "_gdbm": ["dbm/gnu.py"],
+ "_json": ["json/"],
diff --git
a/interpreters/python/patch/0018-ignore-chmod-on-nuttx-like-wasi.patch
b/interpreters/python/patch/0018-ignore-chmod-on-nuttx-like-wasi.patch
new file mode 100644
index 000000000..4e2afb410
--- /dev/null
+++ b/interpreters/python/patch/0018-ignore-chmod-on-nuttx-like-wasi.patch
@@ -0,0 +1,25 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Tiago Medicci <[email protected]>
+Date: Thu, 7 May 2026 14:00:00 -0300
+Subject: [PATCH] posixmodule: ignore chmod on NuttX like WASI
+
+NuttX's tmpfs does not implement chstat, so chmod fails with ENOSYS.
+Apply the same workaround already used for WASI: silently succeed
+when HAVE_CHMOD is not defined.
+
+---
+ Modules/posixmodule.c | 4 ++--
+ 1 file changed, 2 insertions(+), 2 deletions(-)
+
+--- a/Modules/posixmodule.c
++++ b/Modules/posixmodule.c
+@@ -3608,8 +3608,8 @@
+ #ifdef HAVE_CHMOD
+ result = chmod(path->narrow, mode);
+-#elif defined(__wasi__)
+- // WASI SDK 15.0 does not support chmod.
++#elif defined(__wasi__) || defined(__NuttX__)
++ // WASI SDK 15.0 and NuttX do not fully support chmod.
+ // Ignore missing syscall for now.
+ result = 0;
+ #else
diff --git a/interpreters/python/python_wrapper.c
b/interpreters/python/python_wrapper.c
index 6617bae98..be7454514 100644
--- a/interpreters/python/python_wrapper.c
+++ b/interpreters/python/python_wrapper.c
@@ -198,5 +198,7 @@ int main(int argc, FAR char *argv[])
setenv("PYTHON_BASIC_REPL", "1", 1);
+ setenv("PYTHONPATH", CONFIG_INTERPRETERS_CPYTHON_PYTHONPATH, 1);
+
return py_bytesmain(argc, argv);
}
diff --git a/interpreters/python/repack_wheel_add_pyc.py
b/interpreters/python/repack_wheel_add_pyc.py
new file mode 100644
index 000000000..fc2d913f7
--- /dev/null
+++ b/interpreters/python/repack_wheel_add_pyc.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+#
+# Repack a wheel in-place: compile pip/*.py to legacy sibling *.pyc (compileall
+# -b: required for zipimport, which does not read PEP 3147 __pycache__/ names),
+# remove the .py sources, and rewrite *.dist-info/RECORD.
+
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import zipfile
+from pathlib import Path
+
+
+def wheel_record_hash(data: bytes) -> str:
+ digest = hashlib.sha256(data).digest()
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
+
+
+def wheel_has_pip_py_sources(zf: zipfile.ZipFile) -> bool:
+ return any(n.startswith("pip/") and n.endswith(".py") for n in
zf.namelist())
+
+
+def wheel_has_legacy_pip_bytecode(zf: zipfile.ZipFile) -> bool:
+ return "pip/__init__.pyc" in zf.namelist()
+
+
+def strip_pip_py_sources(pip_dir: Path) -> int:
+ """Remove pip/**/*.py after sibling legacy *.pyc exists (compileall -b
output)."""
+ removed = 0
+ for path in sorted(pip_dir.rglob("*.py")):
+ if not path.is_file():
+ continue
+ pyc = path.with_suffix(".pyc")
+ if not pyc.is_file():
+ rel = path.relative_to(pip_dir)
+ raise SystemExit(
+ f"missing legacy .pyc for pip/{rel.as_posix()}, refusing to
delete source"
+ )
+ path.unlink()
+ removed += 1
+ return removed
+
+
+def rebuild_record(root: Path) -> None:
+ dist_infos = sorted(root.glob("*.dist-info"))
+ if len(dist_infos) != 1:
+ raise SystemExit(
+ f"expected one *.dist-info, got {[p.name for p in dist_infos]}"
+ )
+ di = dist_infos[0]
+ record_path = di / "RECORD"
+ record_rel = f"{di.name}/RECORD"
+ lines: list[str] = []
+ for path in sorted(root.rglob("*")):
+ if not path.is_file():
+ continue
+ rel = path.relative_to(root).as_posix()
+ if rel == record_rel:
+ continue
+ body = path.read_bytes()
+ lines.append(f"{rel},sha256={wheel_record_hash(body)},{len(body)}")
+ lines.append(f"{record_rel},,")
+ record_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def repack(whl_path: Path, *, force: bool) -> None:
+ whl_path = whl_path.resolve()
+ if not whl_path.is_file():
+ raise SystemExit(f"missing wheel: {whl_path}")
+
+ with zipfile.ZipFile(whl_path) as zf:
+ has_py = wheel_has_pip_py_sources(zf)
+ if not has_py:
+ if not wheel_has_legacy_pip_bytecode(zf):
+ raise SystemExit(
+ "repack_wheel_add_pyc: wheel has no pip/*.py and no
pip/__init__.pyc "
+ "(corrupt or old tool output). Delete the bundled
pip-*.whl and rebuild."
+ )
+ if not force:
+ print(
+ f"repack_wheel_add_pyc: skip (pip already bytecode-only):
{whl_path.name}"
+ )
+ return
+
+ tmpdir = tempfile.mkdtemp(prefix="pip-whl-pyc-")
+ try:
+ root = Path(tmpdir)
+ with zipfile.ZipFile(whl_path) as zf:
+ zf.extractall(root)
+
+ pip_dir = root / "pip"
+ if not pip_dir.is_dir():
+ raise SystemExit("wheel has no pip/ top-level package")
+
+ subprocess.run(
+ [sys.executable, "-m", "compileall", "-q", "-f", "-b",
str(pip_dir)],
+ cwd=str(root),
+ check=True,
+ )
+ n_py = strip_pip_py_sources(pip_dir)
+ rebuild_record(root)
+
+ out_path = whl_path.with_suffix(whl_path.suffix + ".tmp")
+ with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED)
as out:
+ for path in sorted(root.rglob("*")):
+ if path.is_file():
+ arcname = path.relative_to(root).as_posix()
+ out.write(path, arcname)
+
+ out_path.replace(whl_path)
+ print(
+ f"repack_wheel_add_pyc: bytecode-only pip ({n_py} .py removed) ->
{whl_path.name}"
+ )
+ finally:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("wheel", type=Path, help="path to .whl (updated in place)")
+ ap.add_argument(
+ "-f",
+ "--force",
+ action="store_true",
+ help="repack even if pip is already .pyc-only",
+ )
+ args = ap.parse_args()
+ repack(args.wheel, force=args.force)
+
+
+if __name__ == "__main__":
+ main()