Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions .github/workflows/build-wheels-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,37 @@ jobs:
- name: Drop support-tree dep wheels
if: always()
shell: bash
# Drop the CPython support-tree dep wheels produced by make_dep_wheels.py
# iOS deps: bzip2, libffi, mpdecimal, openssl, xz
# Android deps: bzip2, libffi, openssl, sqlite, xz
run: rm -f dist/bzip2-* dist/libffi-* dist/mpdecimal-* dist/openssl-* dist/sqlite-* dist/xz-*
env:
PLATFORM: ${{ matrix.platform }}
PYTHON_SHORT: ${{ matrix.python_short }}
# The CPython support tree bundles native libs (openssl, xz, zstd, …) that
# make_dep_wheels.py wraps into `<lib>-<ver>-py3-none-<plat>.whl` so forge can
# resolve them as build deps — they must not be published as recipe wheels.
# Derive the set to drop from the support tree's VERSIONS manifest (the same
# source make_dep_wheels.py reads).
run: |
set -euo pipefail
if [[ "$PLATFORM" == "android" ]]; then
os_name="android"; support="${MOBILE_FORGE_ANDROID_SUPPORT_PATH:-}"
else
os_name="iOS"; support="${MOBILE_FORGE_IOS_SUPPORT_PATH:-}"
fi
versions="${support}/support/${PYTHON_SHORT}/${os_name}/VERSIONS"
if [[ ! -f "$versions" ]]; then
echo "::warning::No VERSIONS at '$versions' — skipping support-wheel drop"
exit 0
fi
echo "Dropping support-tree dep wheels declared in $versions"
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" == *:* ]] || continue
key="$(printf '%s' "${line%%:*}" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$key" in
"python version"|"build"|"min "*) continue ;;
esac
[[ -n "$key" ]] || continue
echo " - dropping dist/${key}-*"
rm -f "dist/${key}-"*
done < "$versions"

- name: Summarize built wheels
if: always()
Expand Down
131 changes: 131 additions & 0 deletions recipes/flet-libpq/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/bin/bash
# flet-libpq: PostgreSQL's libpq client library for the psycopg recipes. Ships:
# - opt/lib/libpq.so shared lib for psycopg v3's ctypes loader
# - opt/lib/libpq.a + libpgcommon.a + libpgport.a static+PIC, for psycopg2
# (compiled C-ext, static_libpq)
# - opt/include/... headers for the psycopg2 compile
# - opt/bin/pg_config relocatable shim psycopg2's setup.py queries
# TLS via the support-tree OpenSSL ($OPENSSL_DIR).
set -eu

NAME=pq
SRCROOT="$PWD"

# Point configure at the support-tree OpenSSL for TLS (libpq's --with-openssl).
export CPPFLAGS="${CPPFLAGS:-} -I$OPENSSL_DIR/include"
export LDFLAGS="${LDFLAGS:-} -L$OPENSSL_DIR/lib"

# -fPIC so the static .a objects fold cleanly into psycopg2's _psycopg.so.
export CFLAGS="${CFLAGS:-} -fPIC"
common_args="\
--without-readline \
--without-zlib \
--without-zstd \
--without-lz4 \
--without-icu \
--without-gssapi \
--with-openssl"

build_libpq() {
# Build libpq + its in-tree deps without the server. generated-headers runs
# host perl/sed to emit errcodes.h etc. (libpgcommon needs them).
make -C src/backend generated-headers
make -C src/common -j "$CPU_COUNT"
make -C src/port -j "$CPU_COUNT"
# all-lib builds the static + shared libpq but skips libpq-refs-stamp, the
# "libpq must not call exit()" sanity check. It false-positives on darwin/iOS
# (_atexit is a normal undefined import from libSystem; the check greps
# GNU-nm-style output). Pre-touch the stamp so `make install` sees it done.
make -C src/interfaces/libpq -j "$CPU_COUNT" all-lib
touch src/interfaces/libpq/libpq-refs-stamp
make -C src/interfaces/libpq install
make -C src/include install # libpq-fe.h, postgres_ext.h, pg_config*.h, ...

# psycopg2 (static_libpq) links libpq.a, which references symbols from
# libpgcommon/libpgport. libpq.a uses the PLAIN symbol names, which live in
# the *_shlib.a* variants (PostgreSQL namespaces the non-shlib archives'
# internals with a _private suffix); the _shlib archives are also PIC, so
# they fold cleanly into _psycopg.so. Ship those, renamed.
cp "$SRCROOT/src/common/libpgcommon_shlib.a" "$PREFIX/lib/libpgcommon.a"
cp "$SRCROOT/src/port/libpgport_shlib.a" "$PREFIX/lib/libpgport.a"
}

if [ "$CROSS_VENV_SDK" = "android" ]; then
# Android bionic only declares nl_langinfo() at API >= 26; we target API 24,
# so its declaration is hidden and chklocale.c hits an implicit-declaration
# (int-to-pointer) error under clang 18. Tell configure langinfo.h is absent
# so libpq falls back to its non-langinfo encoding detection.
ac_cv_header_langinfo_h=no \
./configure --host=$HOST_TRIPLET --prefix=$PREFIX $common_args
build_libpq

# Collapse the versioned .so + symlinks into a single unversioned libpq.so
# (Android extracts only files literally matching lib*.so; psycopg dlopens
# "libpq.so" by name).
cd "$PREFIX/lib"
real=$(readlink -f "lib$NAME.so")
tmp=$(mktemp); cp "$real" "$tmp"
rm -f "lib$NAME.so" "lib$NAME.so".*
mv "$tmp" "lib$NAME.so"; chmod 755 "lib$NAME.so"
cd - >/dev/null
else
# config.sub doesn't know Apple-mobile triplets — feed it an equivalent
# Darwin triplet (CC/CFLAGS from forge do the real targeting).
case $HOST_TRIPLET in
arm64-apple-ios) host=arm-apple-darwin23 ;;
arm64-apple-ios-simulator) host=aarch64-apple-darwin23 ;;
x86_64-apple-ios-simulator) host=x86_64-apple-darwin23 ;;
*) echo "Unknown iOS host triplet: $HOST_TRIPLET"; exit 1 ;;
esac
# forge's iOS CFLAGS/LDFLAGS embed framework search paths as -F "…" with
# literal quotes. PostgreSQL bakes the configure-time flags into
# config_info.c as C string literals (-DVAL_CFLAGS="…" etc.); the embedded
# quotes terminate the string early, so the path components parse as bare
# identifiers ("use of undeclared identifier 'Users'") and 3.12.13 as a
# float. Strip the quotes — the forge paths contain no spaces, so -F path
# still resolves.
export CFLAGS="$(printf '%s' "$CFLAGS" | tr -d '"')"
export CPPFLAGS="$(printf '%s' "$CPPFLAGS" | tr -d '"')"
export LDFLAGS="$(printf '%s' "$LDFLAGS" | tr -d '"')"
./configure --host=$host --prefix=$PREFIX $common_args
build_libpq

# PostgreSQL's Makefile.shlib emits a versioned darwin dylib; normalize to a
# single libpq.so the ctypes loader can dlopen.
cd "$PREFIX/lib"
real=$(ls lib$NAME.*.dylib lib$NAME.dylib 2>/dev/null | head -1)
if [ -n "$real" ]; then
cp "$real" "_tmp_lib$NAME"
rm -f "lib$NAME"*.dylib "lib$NAME.so"
mv "_tmp_lib$NAME" "lib$NAME.so"
install_name_tool -id "@rpath/lib$NAME.so" "lib$NAME.so" 2>/dev/null || true
fi
cd - >/dev/null
fi

# pg_config shim: psycopg2's setup.py shells out to pg_config to locate libpq.
# The real cross-built pg_config is a target binary that can't run on the build
# host, so ship a relocatable shell shim that reports paths relative to itself
# (opt/bin/pg_config -> opt/include, opt/lib).
mkdir -p "$PREFIX/bin"
cat > "$PREFIX/bin/pg_config" <<'PGC'
#!/bin/sh
here=$(cd "$(dirname "$0")/.." && pwd)
case "$1" in
--includedir) echo "$here/include" ;;
--includedir-server) echo "$here/include/postgresql/server" ;;
--libdir|--pkglibdir) echo "$here/lib" ;;
--bindir) echo "$here/bin" ;;
--version) echo "PostgreSQL 17.5" ;;
--cppflags|--cflags|--cflags_sl|--ldflags|--ldflags_ex|--ldflags_sl|--libs) echo "" ;;
*) echo "" ;;
esac
PGC
chmod 755 "$PREFIX/bin/pg_config"

# Keep opt/lib/{libpq.so,*.a}, opt/include, opt/bin/pg_config. Drop pkgconfig,
# libtool archives, versioned dylibs/sonames, and share/.
shopt -s nullglob
rm -rf "$PREFIX/share"
rm -rf "$PREFIX/lib/pkgconfig" "$PREFIX"/lib/*.la
rm -f "$PREFIX"/lib/lib$NAME.dylib "$PREFIX"/lib/lib$NAME.*.dylib
21 changes: 21 additions & 0 deletions recipes/flet-libpq/meta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% set version = "17.5" %}

package:
name: flet-libpq
version: '{{ version }}'

source:
# PostgreSQL ships a release tarball with a pre-generated ./configure
# (autotools); we build only src/interfaces/libpq (the client library).
url: https://ftp.postgresql.org/pub/source/v{{ version }}/postgresql-{{ version }}.tar.gz

build:
number: 1
script_env:
# OpenSSL (libpq TLS) comes from the python-build support tree, surfaced by
# the `openssl` host requirement at {platlib}/opt (same as cryptography).
OPENSSL_DIR: '{platlib}/opt'

requirements:
host:
- openssl ^3.0.12
28 changes: 28 additions & 0 deletions recipes/psycopg2/meta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package:
name: psycopg2
version: 2.9.12

build:
number: 1
script_env:
# psycopg2's setup.py shells out to pg_config to locate libpq. flet-libpq
# ships a relocatable shim at opt/bin/pg_config; mobile.patch teaches the
# setup to read its path from PG_CONFIG (it otherwise only checks PATH).
PG_CONFIG: '{platlib}/opt/bin/pg_config'

requirements:
host:
# openssl resolves libpq's TLS symbols (-lssl -lcrypto, added by setup.py's
# finalize_darwin since the build host is macOS). It lives in the base flet
# runtime, so it isn't promoted to Requires-Dist.
- openssl ^3.0.12
host_build:
# flet-libpq is STATICALLY folded into _psycopg.so (static_libpq=1,
# mobile.patch links libpq.a + libpgcommon.a + libpgport.a) — so it's a
# build-time-only dependency. host_build extracts it for the link without
# adding it to the wheel's Requires-Dist, so a psycopg2 app does not bundle
# the (unused) flet-libpq wheel at runtime.
- flet-libpq 17.5

patches:
- mobile.patch
40 changes: 40 additions & 0 deletions recipes/psycopg2/patches/mobile.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
--- a/setup.py 2026-06-17 21:43:57
+++ b/setup.py 2026-06-17 21:48:30
@@ -79,6 +79,8 @@
self.build_ext = build_ext
self.pg_config_exe = self.build_ext.pg_config
if not self.pg_config_exe:
+ self.pg_config_exe = os.environ.get("PG_CONFIG") # mobile-forge
+ if not self.pg_config_exe:
self.pg_config_exe = self.autodetect_pg_config_path()
if self.pg_config_exe is None:
sys.stderr.write("""
@@ -362,6 +364,17 @@
self.link_objects = []
self.link_objects.append(
os.path.join(pg_config_helper.query("libdir"), "libpq.a"))
+ # mobile-forge: libpq.a references libpgcommon/libpgport symbols,
+ # and the whole static stack references OpenSSL (libpq TLS). Add
+ # both archives and -lssl/-lcrypto here so the link resolves on
+ # every target (the cross sys.platform matches no finalize_*).
+ for _a in ("libpgcommon.a", "libpgport.a"):
+ self.link_objects.append(
+ os.path.join(pg_config_helper.query("libdir"), _a))
+ if self.libraries is None:
+ self.libraries = []
+ self.libraries.append("ssl")
+ self.libraries.append("crypto")
else:
self.libraries.append("pq")

--- a/setup.cfg 2026-06-17 21:43:57
+++ b/setup.cfg 2026-06-17 21:48:30
@@ -2,7 +2,7 @@
define = PSYCOPG_DEBUG
pg_config =
have_ssl = 0
-static_libpq = 0
+static_libpq = 1
libraries =

[metadata]
32 changes: 32 additions & 0 deletions recipes/psycopg2/tests/test_psycopg2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import pytest


def test_import():
"""Importing psycopg2 loads its compiled _psycopg extension (libpq + OpenSSL
statically linked) on-device and resolves all its symbols. A real query
needs a PostgreSQL server, so this proves the wheel imports and the C
extension initializes."""
import psycopg2
import psycopg2._psycopg # the compiled extension

assert psycopg2.__version__
assert callable(psycopg2.connect)


def test_exception_api():
"""psycopg2 exposes the DB-API exception hierarchy callers catch."""
import psycopg2

for exc in ("Error", "OperationalError", "DatabaseError", "InterfaceError"):
assert issubclass(getattr(psycopg2, exc), Exception)


def test_connect_refused():
"""Drives libpq's native connect path with no server needed: a closed local
port refuses immediately and psycopg2 must translate that into
OperationalError. Proves the statically-linked libpq actually *runs* on
device, not merely that the extension loaded."""
import psycopg2

with pytest.raises(psycopg2.OperationalError):
psycopg2.connect(host="127.0.0.1", port=1, connect_timeout=2)
11 changes: 11 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,17 @@ if [ ! -z "$MOBILE_FORGE_ANDROID_SUPPORT_PATH" ]; then
echo "MOBILE_FORGE_ANDROID_SUPPORT_PATH: $MOBILE_FORGE_ANDROID_SUPPORT_PATH"
fi

# In GitHub Actions, persist the resolved support-tree paths to the job environment so
# later workflow steps can read them — exports from a `source`d script don't survive
# across steps. Consumed by the "Drop support-tree dep wheels" step, which reads each
# tree's VERSIONS manifest to know which wheels to drop. No-op locally ($GITHUB_ENV unset).
if [ -n "${GITHUB_ENV:-}" ]; then
{
echo "MOBILE_FORGE_IOS_SUPPORT_PATH=$MOBILE_FORGE_IOS_SUPPORT_PATH"
echo "MOBILE_FORGE_ANDROID_SUPPORT_PATH=$MOBILE_FORGE_ANDROID_SUPPORT_PATH"
} >> "$GITHUB_ENV"
fi

echo
echo "You can now build packages with forge; e.g.:"
echo
Expand Down
5 changes: 5 additions & 0 deletions src/forge/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ def prepare(self, clean=True):

log(self.log_file, f"\n[{self.cross_venv}] Install forge host requirements")
self.install_requirements("host")
# host_build deps install into the cross env like host deps (so the build
# can link them), but are NOT promoted to the wheel's Requires-Dist (that
# loop below only walks "host"). For statically-linked native libs.
log(self.log_file, f"\n[{self.cross_venv}] Install forge host_build requirements")
self.install_requirements("host_build")
self.fix_host_tool_shims()

log(self.log_file, f"\n[{self.cross_venv}] Install forge build requirements")
Expand Down
Loading
Loading